From d8def9a798de9b24aecc89d6478a07da16d2abab Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 18 Jul 2026 21:57:26 -0500 Subject: [PATCH 001/275] feat(daemon): add timed DND and inline replies Summary: add timed DND and inline replies. Scope: daemon. --- crates/unixnotis-core/src/control/proxy.rs | 4 + crates/unixnotis-core/src/control/state.rs | 2 + crates/unixnotis-core/src/model/mod.rs | 2 + .../unixnotis-core/src/model/notification.rs | 7 + crates/unixnotis-core/src/model/reply.rs | 21 +++ .../src/model/tests/notification.rs | 3 +- .../unixnotis-core/src/model/tests/reply.rs | 12 ++ .../src/daemon/control/dnd.rs | 34 ++++- .../src/daemon/control/mod.rs | 1 + .../src/daemon/control/query.rs | 1 + .../src/daemon/control/reply.rs | 65 +++++++++ .../src/daemon/control/server.rs | 20 +++ .../src/daemon/control/tests/mod.rs | 1 + .../src/daemon/control/tests/reply.rs | 138 ++++++++++++++++++ .../src/daemon/control/tests/server.rs | 93 ++++++++++++ .../src/daemon/notifications/payload.rs | 31 +++- .../src/daemon/notifications/sender.rs | 2 +- .../notifications/server/capabilities.rs | 1 + .../daemon/notifications/server/interface.rs | 8 + .../notifications/tests/capabilities.rs | 20 ++- .../src/daemon/notifications/tests/flow.rs | 1 + .../src/daemon/notifications/tests/payload.rs | 59 ++++++++ .../unixnotis-daemon/src/daemon/state/dnd.rs | 39 +++++ .../unixnotis-daemon/src/daemon/state/mod.rs | 1 + .../src/daemon/state/model.rs | 9 ++ .../src/daemon/state/signals.rs | 1 + .../src/daemon/state/tests/cache.rs | 23 +++ .../src/daemon/state/tests/notifications.rs | 1 + .../src/daemon/state/tests/signals.rs | 4 +- crates/unixnotis-daemon/src/dnd_expiration.rs | 82 +++++++++++ crates/unixnotis-daemon/src/main.rs | 1 + crates/unixnotis-daemon/src/runtime/daemon.rs | 5 + crates/unixnotis-daemon/src/store/core.rs | 33 ++++- crates/unixnotis-daemon/src/store/dnd.rs | 61 ++++++-- .../unixnotis-daemon/src/store/lifecycle.rs | 3 +- crates/unixnotis-daemon/src/store/state.rs | 6 +- .../unixnotis-daemon/src/store/tests/dnd.rs | 138 +++++++++++++++++- .../unixnotis-daemon/src/store/tests/mod.rs | 93 +----------- .../unixnotis-daemon/src/store/tests/reply.rs | 56 +++++++ .../src/store/tests/support.rs | 96 ++++++++++++ crates/unixnotis-daemon/src/store/types.rs | 6 + .../src/tests/dnd_expiration.rs | 42 ++++++ crates/unixnotis-daemon/src/tests/expire.rs | 1 + 43 files changed, 1111 insertions(+), 116 deletions(-) create mode 100644 crates/unixnotis-core/src/model/reply.rs create mode 100644 crates/unixnotis-core/src/model/tests/reply.rs create mode 100644 crates/unixnotis-daemon/src/daemon/control/reply.rs create mode 100644 crates/unixnotis-daemon/src/daemon/control/tests/reply.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/dnd.rs create mode 100644 crates/unixnotis-daemon/src/dnd_expiration.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/reply.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/support.rs create mode 100644 crates/unixnotis-daemon/src/tests/dnd_expiration.rs diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 25fd679ad..a93cdda2a 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -32,6 +32,8 @@ trait Control { fn toggle_panel(&self) -> zbus::Result<()>; /// Update the Do Not Disturb state fn set_dnd(&self, enabled: bool) -> zbus::Result<()>; + /// Enable Do Not Disturb until one future Unix timestamp + fn set_dnd_until(&self, expires_at: i64) -> zbus::Result<()>; /// Toggle the Do Not Disturb state atomically in the daemon fn toggle_dnd(&self) -> zbus::Result<()>; /// Register an inhibitor and return its token @@ -44,6 +46,8 @@ trait Control { fn dismiss(&self, id: u32) -> zbus::Result<()>; /// Invoke an action key for a notification fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; + /// Submit text for an explicitly advertised inline-reply action + fn reply_notification(&self, id: u32, reply_text: &str) -> zbus::Result<()>; /// Clear active notifications and saved history fn clear_all(&self) -> zbus::Result<()>; /// Clear active notifications without deleting saved history diff --git a/crates/unixnotis-core/src/control/state.rs b/crates/unixnotis-core/src/control/state.rs index b48553b97..9b573e565 100644 --- a/crates/unixnotis-core/src/control/state.rs +++ b/crates/unixnotis-core/src/control/state.rs @@ -7,6 +7,8 @@ use zbus::zvariant::Type; #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct ControlState { pub dnd_enabled: bool, + /// Unix timestamp in seconds, or zero for an indefinite/disabled state + pub dnd_expires_at: i64, pub history_count: u32, /// True when at least one active inhibitor suppresses popups pub inhibited: bool, diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index 9dfd52e7d..1ef7a1dcf 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -3,9 +3,11 @@ // Keep the public model surface small by splitting large helpers into files. mod image; mod notification; +mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. pub use image::{ImageData, NotificationImage}; pub use notification::{Notification, NotificationView}; +pub use reply::InlineReply; pub use types::{Action, Urgency}; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 98496281c..3e4a1dc4a 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; use super::image::NotificationImage; +use super::reply::InlineReply; use super::types::{Action, Urgency}; /// Full notification record stored by the daemon @@ -22,6 +23,8 @@ pub struct Notification { pub body: String, // Optional actions supplied by the app pub actions: Vec, + // Reply metadata exists only for an explicit KDE-compatible action + pub inline_reply: InlineReply, // Raw hints preserved for storage and downstream consumers pub hints: HashMap, // Derived urgency used for styling and escalation @@ -55,6 +58,7 @@ impl Notification { summary: notification_plain_text(&self.summary), body: notification_plain_text(&self.body), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), urgency: self.urgency.as_u8(), // Center and popup policy both need the transient bit to stay in sync is_transient: self.is_transient, @@ -73,6 +77,7 @@ impl Notification { summary: notification_plain_text(&self.summary), body: notification_plain_text(&self.body), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), urgency: self.urgency.as_u8(), // History policy still depends on the transient bit in panel rows is_transient: self.is_transient, @@ -96,6 +101,7 @@ impl Notification { summary: self.summary.clone(), body: self.body.clone(), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), // Keep history entries lightweight by dropping raw hint payloads hints: HashMap::new(), urgency: self.urgency, @@ -258,6 +264,7 @@ pub struct NotificationView { pub summary: String, pub body: String, pub actions: Vec, + pub inline_reply: InlineReply, pub urgency: u8, // Close handling needs this flag so history policy stays shared pub is_transient: bool, diff --git a/crates/unixnotis-core/src/model/reply.rs b/crates/unixnotis-core/src/model/reply.rs new file mode 100644 index 000000000..f1c64d82a --- /dev/null +++ b/crates/unixnotis-core/src/model/reply.rs @@ -0,0 +1,21 @@ +//! Inline reply metadata shared by the daemon and notification UIs + +use serde::{Deserialize, Serialize}; +use zbus::zvariant::Type; + +/// KDE-compatible reply controls attached to one notification action +#[derive(Debug, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct InlineReply { + // False keeps the D-Bus structure stable when no reply action exists + pub available: bool, + // Label comes from the matching action pair + pub label: String, + // Optional KDE hints use empty strings when the sender omits them + pub placeholder: String, + pub submit_label: String, + pub submit_icon: String, +} + +#[cfg(test)] +#[path = "tests/reply.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index c9ab0f5a5..43254e708 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -4,7 +4,7 @@ use chrono::Utc; use zbus::zvariant::Value; use super::{Notification, NotificationImage}; -use crate::{Action, ImageData, Urgency}; +use crate::{Action, ImageData, InlineReply, Urgency}; fn notification_with_image(image: NotificationImage) -> Notification { let mut hints = HashMap::new(); @@ -23,6 +23,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: InlineReply::default(), hints, urgency: Urgency::Critical, category: Some("email".to_string()), diff --git a/crates/unixnotis-core/src/model/tests/reply.rs b/crates/unixnotis-core/src/model/tests/reply.rs new file mode 100644 index 000000000..abad21bc4 --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/reply.rs @@ -0,0 +1,12 @@ +use super::InlineReply; + +#[test] +fn inline_reply_default_is_unavailable_and_carries_no_display_text() { + let reply = InlineReply::default(); + + assert!(!reply.available); + assert!(reply.label.is_empty()); + assert!(reply.placeholder.is_empty()); + assert!(reply.submit_label.is_empty()); + assert!(reply.submit_icon.is_empty()); +} diff --git a/crates/unixnotis-daemon/src/daemon/control/dnd.rs b/crates/unixnotis-daemon/src/daemon/control/dnd.rs index 9289a8001..7c60937fb 100644 --- a/crates/unixnotis-daemon/src/daemon/control/dnd.rs +++ b/crates/unixnotis-daemon/src/daemon/control/dnd.rs @@ -7,8 +7,11 @@ use tracing::{debug, warn}; use super::ControlServer; +const MAX_DND_DURATION_SECONDS: i64 = 366 * 24 * 60 * 60; + impl ControlServer { pub(super) async fn apply_dnd_state(&self, enabled: bool) -> zbus::fdo::Result<()> { + let _write_guard = self.state.lock_dnd_write().await; let write = { let mut store = self.state.store.lock().await; // Set request mutates once under lock and records rollback guards @@ -17,7 +20,24 @@ impl ControlServer { self.finalize_dnd_write(write).await } + pub(super) async fn apply_dnd_until(&self, expires_at: i64) -> zbus::fdo::Result<()> { + let _write_guard = self.state.lock_dnd_write().await; + let now = chrono::Utc::now().timestamp(); + let duration = expires_at.saturating_sub(now); + if duration <= 0 || duration > MAX_DND_DURATION_SECONDS { + return Err(zbus::fdo::Error::InvalidArgs( + "DND expiration must be within the next 366 days".to_string(), + )); + } + let write = { + let mut store = self.state.store.lock().await; + store.set_dnd_until(expires_at) + }; + self.finalize_dnd_write(write).await + } + pub(super) async fn apply_toggle_dnd(&self) -> zbus::fdo::Result<()> { + let _write_guard = self.state.lock_dnd_write().await; let write = { let mut store = self.state.store.lock().await; // Toggle computation and write stay in one critical section @@ -26,10 +46,20 @@ impl ControlServer { self.finalize_dnd_write(write).await } + pub(crate) async fn apply_dnd_expiration(&self, expires_at: i64) -> zbus::fdo::Result<()> { + let _write_guard = self.state.lock_dnd_write().await; + let write = { + let mut store = self.state.store.lock().await; + // The store rejects stale deadlines that were replaced while the task slept + store.expire_dnd_if_current(expires_at, chrono::Utc::now().timestamp()) + }; + self.finalize_dnd_write(write).await + } + async fn finalize_dnd_write(&self, write: DndWrite) -> zbus::fdo::Result<()> { if let Some(store) = write.persist.as_ref() { // Persist outside the main store lock to avoid blocking notify paths on I/O - if let Err(err) = store.persist(write.current) { + if let Err(err) = store.persist(write.current, write.current_expires_at) { warn!(?err, "failed to persist do-not-disturb state"); // Only rollback if this failing write is still the latest in-memory value let mut state = self.state.store.lock().await; @@ -54,6 +84,8 @@ impl ControlServer { } } if write.changed { + // Scheduling follows durable commit so failed writes keep the previous timer + self.state.schedule_dnd_expiration(write.current_expires_at); // Mutation is already committed; signal fanout is best-effort if let Err(err) = self.state.emit_state_changed().await { warn!( diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 4f650ac79..0227dcb76 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -5,6 +5,7 @@ mod dnd; mod inhibit; mod panel; mod query; +mod reply; mod sanitize; mod server; mod watch; diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 39852a170..9c82de8f5 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -16,6 +16,7 @@ impl ControlServer { // Cheap state snapshot Ok(ControlState { dnd_enabled: store.dnd_enabled(), + dnd_expires_at: store.dnd_expires_at().unwrap_or(0), history_count: store.history_len() as u32, inhibited: store.inhibited(), inhibitor_count: store.inhibitor_count(), diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs new file mode 100644 index 000000000..d20d074fd --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -0,0 +1,65 @@ +//! KDE-compatible inline reply handling for active notifications + +use unixnotis_core::util; +use zbus::SignalContext; + +use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; + +use super::ControlServer; + +pub(super) const MAX_REPLY_TEXT_BYTES: usize = 4 * 1024; + +impl ControlServer { + pub(super) async fn submit_inline_reply( + &self, + id: u32, + reply_text: &str, + ) -> zbus::fdo::Result<()> { + // Text validation happens before any notification lookup or signal work + let reply_text = sanitize_reply_text(reply_text)?; + let is_resident = { + // Keep the store lock only for the live-action eligibility snapshot + let store = self.state.store.lock().await; + store.active_inline_reply_target(id).ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification is not live or does not support inline reply".to_string(), + ) + })? + }; + + // Emit only after all live-state and text checks have passed + let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) + .map_err(to_fdo_error)?; + NotificationServer::notification_replied(&context, id, &reply_text) + .await + .map_err(to_fdo_error)?; + + if !is_resident { + // Non-resident replies leave no stale action behind in active or history lists + self.state + .dismiss_from_panel(id) + .await + .map_err(to_fdo_error)?; + } + // Resident notifications remain active for later updates from the sender + Ok(()) + } +} + +pub(super) fn sanitize_reply_text(reply_text: &str) -> zbus::fdo::Result { + // Display controls and line breaks are removed because GtkEntry is single-line + let reply_text = util::sanitize_inline_display_text(reply_text); + let reply_text = reply_text.trim(); + if reply_text.is_empty() { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text cannot be empty".to_string(), + )); + } + if reply_text.len() > MAX_REPLY_TEXT_BYTES { + // Byte limits match the D-Bus payload and remain stable across Unicode text + return Err(zbus::fdo::Error::InvalidArgs(format!( + "reply text exceeds {MAX_REPLY_TEXT_BYTES} bytes" + ))); + } + Ok(reply_text.to_string()) +} diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 19efc6676..99ba1db19 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -135,6 +135,15 @@ impl ControlServer { self.apply_dnd_state(enabled).await } + pub(super) async fn set_dnd_until( + &self, + expires_at: i64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "SetDndUntil").await?; + self.apply_dnd_until(expires_at).await + } + async fn toggle_dnd(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ToggleDnd").await?; self.apply_toggle_dnd().await @@ -188,6 +197,17 @@ impl ControlServer { .map_err(to_fdo_error) } + pub(super) async fn reply_notification( + &self, + id: u32, + reply_text: &str, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "ReplyNotification") + .await?; + self.submit_inline_reply(id, reply_text).await + } + pub(super) async fn clear_all( &self, #[zbus(header)] header: Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index 9ae383e17..f258b4171 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,3 +1,4 @@ mod clear; +mod reply; mod sanitize; mod server; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs new file mode 100644 index 000000000..1cb8b495b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; +use std::time::Duration; + +use chrono::Utc; +use futures_util::TryStreamExt; +use unixnotis_core::{InlineReply, Notification, NotificationImage, Urgency}; +use zbus::message::Type; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +use super::super::reply::{sanitize_reply_text, MAX_REPLY_TEXT_BYTES}; +use super::super::ControlServer; +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::test_support::daemon_state_for_test; + +#[test] +fn sanitize_reply_text_keeps_normal_text_and_trims_outer_spacing() { + assert_eq!( + sanitize_reply_text(" See you soon ").expect("valid reply"), + "See you soon" + ); +} + +#[test] +fn sanitize_reply_text_rejects_empty_control_only_and_oversized_values() { + assert!(sanitize_reply_text(" \n\t ").is_err()); + assert!(sanitize_reply_text("\u{202e}").is_err()); + assert!(sanitize_reply_text(&"x".repeat(MAX_REPLY_TEXT_BYTES + 1)).is_err()); +} + +#[tokio::test] +async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { + let state = daemon_state_for_test(false).await; + let mut stream = reply_signal_stream(&state).await; + let id = { + let mut store = state.store.lock().await; + store.insert(reply_notification(false), 0).notification.id + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, " On my way ") + .await + .expect("submit live inline reply"); + + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(text, "On my way"); + assert!(state.store.lock().await.list_active().is_empty()); + assert!(state.store.lock().await.list_history().is_empty()); +} + +#[tokio::test] +async fn submit_inline_reply_keeps_resident_notification_live() { + let state = daemon_state_for_test(false).await; + let mut stream = reply_signal_stream(&state).await; + let id = { + let mut store = state.store.lock().await; + store.insert(reply_notification(true), 0).notification.id + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, "Another update") + .await + .expect("submit resident inline reply"); + + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(text, "Another update"); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +fn reply_notification(is_resident: bool) -> Notification { + Notification { + id: 0, + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: vec![unixnotis_core::Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }], + inline_reply: InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }, + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +async fn reply_signal_stream(state: &crate::daemon::DaemonState) -> MessageStream { + let receiver = Connection::session().await.expect("receiver session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("signal sender") + .path(NOTIFICATIONS_OBJECT_PATH) + .expect("notification object path") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("NotificationReplied") + .expect("reply member") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(4)) + .await + .expect("reply signal stream") +} + +async fn next_reply_signal(stream: &mut MessageStream) -> (u32, String) { + let signal = tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("reply signal should arrive before timeout") + .expect("reply signal stream should stay open") + .expect("reply signal"); + signal + .body() + .deserialize::<(u32, String)>() + .expect("reply signal body") +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 5f30612ed..27edf6a71 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -19,6 +19,7 @@ fn notification(summary: &str) -> Notification { summary: summary.to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::::new(), urgency: Urgency::Normal, category: None, @@ -154,6 +155,72 @@ async fn apply_toggle_dnd_persists_successful_state_change() { assert!(persisted.contains("\"dnd_enabled\":true")); } +#[tokio::test] +async fn apply_timed_dnd_validates_and_persists_a_future_deadline() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-timed-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + let server = ControlServer::new(state.clone()); + let expires_at = Utc::now().timestamp() + 3_600; + + server + .apply_dnd_until(expires_at) + .await + .expect("timed DND should persist"); + + let store = state.store.lock().await; + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + drop(store); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted timed DND state"); + assert!(persisted.contains(&format!("\"expires_at\":{expires_at}"))); +} + +#[tokio::test] +async fn apply_timed_dnd_rejects_past_and_excessive_deadlines_without_mutation() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + let now = Utc::now().timestamp(); + + assert!(server.apply_dnd_until(now - 1).await.is_err()); + assert!(server + .apply_dnd_until(now + 367 * 24 * 60 * 60) + .await + .is_err()); + + let store = state.store.lock().await; + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); +} + +#[tokio::test] +async fn dnd_updates_wait_for_the_prior_persistence_commit() { + let state = daemon_state_for_test(false).await; + let guard = state.lock_dnd_write().await; + let server = ControlServer::new(state.clone()); + let mut update = Box::pin(server.apply_dnd_state(true)); + + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut update) + .await + .is_err(), + "later DND update should wait for the current writer" + ); + assert!(!state.store.lock().await.dnd_enabled()); + + drop(guard); + tokio::time::timeout(Duration::from_millis(500), update) + .await + .expect("DND update should resume after the prior commit") + .expect("DND update should succeed"); + assert!(state.store.lock().await.dnd_enabled()); +} + #[tokio::test] async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; @@ -227,3 +294,29 @@ async fn invoke_action_rejects_unauthorized_sender_before_signal_emit() { .await .expect_err("unauthorized action should fail"); } + +#[tokio::test] +async fn timed_dnd_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + let message = control_header_message("SetDndUntil"); + + server + .set_dnd_until(Utc::now().timestamp() + 600, message.header()) + .await + .expect_err("unauthorized timed DND should fail"); + + assert!(!state.store.lock().await.dnd_enabled()); +} + +#[tokio::test] +async fn inline_reply_rejects_unauthorized_sender_before_live_state_lookup() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state); + let message = control_header_message("ReplyNotification"); + + server + .reply_notification(7, "private text", message.header()) + .await + .expect_err("unauthorized inline reply should fail"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 0edf19233..91d983fda 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthChar; -use unixnotis_core::{util, Action, Config, Notification, NotificationImage, Urgency}; +use unixnotis_core::{util, Action, Config, InlineReply, Notification, NotificationImage, Urgency}; use zbus::zvariant::{OwnedValue, Value}; use super::limits::{ @@ -64,6 +64,8 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { .and_then(|value| bool::try_from(value).ok()) .unwrap_or(false); let image = NotificationImage::from_hints(&app_name, &app_icon, &hints); + let actions = parse_actions(actions); + let inline_reply = parse_inline_reply(&actions, &hints); // Clean text before storing it let app_name = util::sanitize_inline_display_text(&app_name); let summary = util::sanitize_display_text(&summary); @@ -90,7 +92,8 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { &truncate_utf8_bytes(&body, MAX_BODY_BYTES), MAX_CONTIGUOUS_TOKEN_CHARS, ), - actions: parse_actions(actions), + actions, + inline_reply, // Keep only needed hints hints: sanitize_hints_for_storage(hints), urgency, @@ -134,6 +137,30 @@ pub(super) fn resolve_expiration(config: &Config, notification: &Notification) - Some(Instant::now() + Duration::from_millis(timeout_ms)) } +fn parse_inline_reply(actions: &[Action], hints: &HashMap) -> InlineReply { + let Some(action) = actions.iter().find(|action| action.key == "inline-reply") else { + // Reply hints without the protocol action cannot create a text control + return InlineReply::default(); + }; + + InlineReply { + available: true, + label: action.label.clone(), + placeholder: reply_hint_text(hints, "x-kde-reply-placeholder-text"), + submit_label: reply_hint_text(hints, "x-kde-reply-submit-button-text"), + submit_icon: reply_hint_text(hints, "x-kde-reply-submit-button-icon-name"), + } +} + +fn reply_hint_text(hints: &HashMap, key: &str) -> String { + let Some(value) = hints.get(key).and_then(owned_to_string) else { + return String::new(); + }; + // Reply controls are single-line GTK widgets, so layout controls are removed here + let clean = util::sanitize_inline_display_text(&value); + truncate_utf8_bytes(&clean, MAX_HINT_STRING_BYTES) +} + fn parse_actions(raw: Vec) -> Vec { // Actions come in key and label pairs let mut actions = Vec::with_capacity(raw.len().min(MAX_ACTIONS)); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs index 552d6334f..5a88d9eaa 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs @@ -9,7 +9,7 @@ use zbus::fdo::DBusProxy; use zbus::message::Header; use zbus::Connection; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub(super) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks pub(super) sender_name: Option, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs index 5da4b3a75..94a589ca6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs @@ -2,6 +2,7 @@ pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { // Capabilities are static except for optional sound support let mut caps = vec![ "actions".to_string(), + "inline-reply".to_string(), "body".to_string(), "body-markup".to_string(), "icon-static".to_string(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index eb9c8b73b..87fc05fab 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -99,4 +99,12 @@ impl NotificationServer { id: u32, action_key: &str, ) -> zbus::Result<()>; + + #[zbus(signal)] + // KDE-compatible senders receive the entered text through this extension signal + pub(crate) async fn notification_replied( + ctx: &SignalContext<'_>, + id: u32, + reply_text: &str, + ) -> zbus::Result<()>; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs index b762cb239..ced0c53ae 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs @@ -4,7 +4,16 @@ use super::notification_capabilities; fn notification_capabilities_without_sound_keeps_static_contract() { let caps = notification_capabilities(false); - assert_eq!(caps, ["actions", "body", "body-markup", "icon-static"]); + assert_eq!( + caps, + [ + "actions", + "inline-reply", + "body", + "body-markup", + "icon-static" + ] + ); } #[test] @@ -13,6 +22,13 @@ fn notification_capabilities_adds_sound_only_when_backend_supports_it() { assert_eq!( caps, - ["actions", "body", "body-markup", "icon-static", "sound"] + [ + "actions", + "inline-reply", + "body", + "body-markup", + "icon-static", + "sound" + ] ); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs index 82b6046ed..2666d92f0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs @@ -26,6 +26,7 @@ fn notification_with_id(id: u32) -> Arc { summary: "summary".to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index 62675a8e1..8b72493c3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -74,6 +74,63 @@ fn build_notification_strips_display_spoofing_controls() { assert_eq!(notification.actions[0].label, "Open"); } +#[test] +fn build_notification_collects_inline_reply_action_and_kde_labels() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Write a reply").expect("placeholder value"), + ); + hints.insert( + "x-kde-reply-submit-button-text".to_string(), + string_to_owned_value("Send now").expect("submit label value"), + ); + hints.insert( + "x-kde-reply-submit-button-icon-name".to_string(), + string_to_owned_value("mail-send-symbolic").expect("submit icon value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints, + sender: SenderMetadata::default(), + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!(notification.inline_reply.label, "Reply"); + assert_eq!(notification.inline_reply.placeholder, "Write a reply"); + assert_eq!(notification.inline_reply.submit_label, "Send now"); + assert_eq!(notification.inline_reply.submit_icon, "mail-send-symbolic"); +} + +#[test] +fn build_notification_ignores_reply_hints_without_explicit_action() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Decoy reply").expect("placeholder value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["default".to_string(), "Open".to_string()], + hints, + sender: SenderMetadata::default(), + expire_timeout: 0, + }); + + assert!(!notification.inline_reply.available); + assert!(notification.inline_reply.placeholder.is_empty()); +} + #[test] fn parse_actions_caps_pairs() { let mut raw = Vec::new(); @@ -166,6 +223,7 @@ fn resolve_expiration_respects_protocol_and_config_rules() { summary: "summary".to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::new(), urgency: Urgency::Normal, category: None, @@ -217,6 +275,7 @@ fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_ summary: "summary".to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/state/dnd.rs b/crates/unixnotis-daemon/src/daemon/state/dnd.rs new file mode 100644 index 000000000..c42f0959c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/dnd.rs @@ -0,0 +1,39 @@ +//! Timed DND scheduler ownership for shared daemon state + +use std::sync::atomic::Ordering; + +use tokio::sync::MutexGuard; +use tracing::warn; + +use crate::dnd_expiration::DndExpirationScheduler; + +use super::DaemonState; + +impl DaemonState { + pub(in crate::daemon) async fn lock_dnd_write(&self) -> MutexGuard<'_, ()> { + // One writer keeps disk state and the scheduled deadline in the same order + self.dnd_write_lock.lock().await + } + + pub fn set_dnd_scheduler(&self, scheduler: DndExpirationScheduler) { + if self.dnd_scheduler.set(scheduler).is_err() { + warn!("DND scheduler was already installed; ignoring duplicate initialization"); + return; + } + self.dnd_scheduler_missing_warned + .store(false, Ordering::SeqCst); + } + + pub(crate) fn schedule_dnd_expiration(&self, expires_at: Option) { + let Some(scheduler) = self.dnd_scheduler.get() else { + if !self + .dnd_scheduler_missing_warned + .swap(true, Ordering::SeqCst) + { + warn!("DND scheduler is unavailable during live daemon operation"); + } + return; + }; + scheduler.schedule(expires_at); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/mod.rs b/crates/unixnotis-daemon/src/daemon/state/mod.rs index 90143ea83..6952f5791 100644 --- a/crates/unixnotis-daemon/src/daemon/state/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/mod.rs @@ -1,6 +1,7 @@ //! Shared daemon state and signal fanout coordination mod cache; +mod dnd; mod model; mod notifications; mod runtime; diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 3f0ec7f7f..81e4ca690 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -5,6 +5,7 @@ use tokio::sync::Mutex; use unixnotis_core::{Config, ControlState, PopupGateState}; use zbus::Connection; +use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; use crate::store::NotificationStore; @@ -25,6 +26,11 @@ pub struct DaemonState { pub(in crate::daemon::state) scheduler: OnceLock, // Warn once if scheduler-backed operations happen before install pub(in crate::daemon::state) scheduler_missing_warned: AtomicBool, + // Timed DND has one coalesced wall-clock deadline + pub(in crate::daemon::state) dnd_scheduler: OnceLock, + pub(in crate::daemon::state) dnd_scheduler_missing_warned: AtomicBool, + // DND persistence and timer replacement must commit in mutation order + pub(in crate::daemon::state) dnd_write_lock: Mutex<()>, // Cache the last control-state snapshot so no-op signals can be skipped pub(in crate::daemon) last_emitted_state: StdMutex>, // Popup UIs only care about the gate, not panel history counters @@ -63,6 +69,9 @@ impl DaemonState { popups_running: AtomicBool::new(false), scheduler: OnceLock::new(), scheduler_missing_warned: AtomicBool::new(false), + dnd_scheduler: OnceLock::new(), + dnd_scheduler_missing_warned: AtomicBool::new(false), + dnd_write_lock: Mutex::new(()), last_emitted_state: StdMutex::new(None), last_emitted_popup_gate: StdMutex::new(None), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), diff --git a/crates/unixnotis-daemon/src/daemon/state/signals.rs b/crates/unixnotis-daemon/src/daemon/state/signals.rs index 96ab7fa2b..183677289 100644 --- a/crates/unixnotis-daemon/src/daemon/state/signals.rs +++ b/crates/unixnotis-daemon/src/daemon/state/signals.rs @@ -166,6 +166,7 @@ pub(in crate::daemon::state) fn control_state_from_store( // Panel consumers still need history and inhibitor counters in one snapshot ControlState { dnd_enabled: store.dnd_enabled(), + dnd_expires_at: store.dnd_expires_at().unwrap_or(0), history_count: store.history_len() as u32, inhibited: store.inhibited(), inhibitor_count: store.inhibitor_count(), diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs index 703e69dd3..582e7b572 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs @@ -9,6 +9,7 @@ fn cached_state_emits_first_value_then_suppresses_duplicates() { let cache = Mutex::new(None); let state = ControlState { dnd_enabled: false, + dnd_expires_at: 0, history_count: 1, inhibited: false, inhibitor_count: 0, @@ -43,6 +44,7 @@ fn cached_state_emits_after_counter_change() { let cache = Mutex::new(None); let first = ControlState { dnd_enabled: false, + dnd_expires_at: 0, history_count: 0, inhibited: false, inhibitor_count: 0, @@ -57,6 +59,27 @@ fn cached_state_emits_after_counter_change() { assert!(!should_emit_cached(&cache, &changed)); } +#[test] +fn cached_state_emits_when_only_the_dnd_deadline_changes() { + let cache = Mutex::new(None); + let indefinite = ControlState { + dnd_enabled: true, + dnd_expires_at: 0, + history_count: 0, + inhibited: false, + inhibitor_count: 0, + }; + assert!(should_emit_cached(&cache, &indefinite)); + + let timed = ControlState { + dnd_expires_at: 500, + ..indefinite + }; + + assert!(should_emit_cached(&cache, &timed)); + assert!(!should_emit_cached(&cache, &timed)); +} + #[test] fn cached_state_recovers_from_poisoned_mutex() { let cache = Mutex::new(None); diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs index 77b1b13ae..b8f08c64d 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs @@ -16,6 +16,7 @@ fn notification(summary: &str) -> Notification { summary: summary.to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs b/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs index a4e28fb9d..3bd03f0dd 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs @@ -80,6 +80,7 @@ async fn assert_no_signal(stream: &mut MessageStream) { fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { let state = ControlState { dnd_enabled: true, + dnd_expires_at: 0, history_count: 99, inhibited: false, inhibitor_count: 12, @@ -95,12 +96,13 @@ fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { fn control_state_from_store_reads_dnd_history_and_inhibitors() { let mut store = NotificationStore::new(Config::default()); - store.set_dnd(true); + store.set_dnd_until(500); store.add_inhibitor(":1.test".to_string(), "focus".to_string(), 0); let state = control_state_from_store(&store); assert!(state.dnd_enabled); + assert_eq!(state.dnd_expires_at, 500); assert!(state.inhibited); assert_eq!(state.inhibitor_count, 1); assert_eq!(state.history_count, 0); diff --git a/crates/unixnotis-daemon/src/dnd_expiration.rs b/crates/unixnotis-daemon/src/dnd_expiration.rs new file mode 100644 index 000000000..53a979bb2 --- /dev/null +++ b/crates/unixnotis-daemon/src/dnd_expiration.rs @@ -0,0 +1,82 @@ +//! Single-deadline scheduler for timed Do Not Disturb state + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::watch; +use tracing::warn; + +use crate::daemon::{ControlServer, DaemonState}; + +const MAX_CLOCK_RECHECK: Duration = Duration::from_mins(1); +const PERSIST_RETRY_DELAY: Duration = Duration::from_secs(5); + +/// Coalescing scheduler handle for the one active DND deadline +#[derive(Clone)] +pub struct DndExpirationScheduler { + sender: watch::Sender>, +} + +impl DndExpirationScheduler { + pub fn start(state: Arc) -> Self { + // A watch channel keeps only the newest deadline during rapid menu changes + let (sender, mut receiver) = watch::channel(None); + tokio::spawn(async move { + loop { + let expires_at = *receiver.borrow_and_update(); + let Some(expires_at) = expires_at else { + // No deadline means indefinite or disabled DND + if receiver.changed().await.is_err() { + break; + } + continue; + }; + + let delay = delay_until_recheck(chrono::Utc::now().timestamp(), expires_at); + if delay.is_zero() { + // The store verifies this is still the current deadline before mutating + let server = ControlServer::new(state.clone()); + if let Err(err) = server.apply_dnd_expiration(expires_at).await { + warn!( + ?err, + expires_at, "failed to expire timed do-not-disturb state" + ); + // A persistence outage must not create a tight retry loop + tokio::time::sleep(PERSIST_RETRY_DELAY).await; + } + continue; + } + + tokio::select! { + changed = receiver.changed() => { + if changed.is_err() { + break; + } + } + () = tokio::time::sleep(delay) => { + // Wall time is checked again so clock adjustments cannot skip expiry + } + } + } + }); + + Self { sender } + } + + pub fn schedule(&self, expires_at: Option) { + // Replacing the watch value cancels the previous logical deadline + self.sender.send_replace(expires_at); + } +} + +fn delay_until_recheck(now: i64, expires_at: i64) -> Duration { + let remaining = expires_at.saturating_sub(now); + if remaining <= 0 { + return Duration::ZERO; + } + Duration::from_secs(remaining as u64).min(MAX_CLOCK_RECHECK) +} + +#[cfg(test)] +#[path = "tests/dnd_expiration.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index 25c0e2a36..4c2f40d9d 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -28,6 +28,7 @@ mod child_process; mod cli; mod daemon; mod dbus_owner; +mod dnd_expiration; mod expire; mod runtime; mod sound; diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 998e12c7e..ae6770d0a 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -16,6 +16,7 @@ use crate::daemon::{ ControlServer, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, }; use crate::dbus_owner::log_current_owner; +use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; use unixnotis_core::{Config, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH}; @@ -32,6 +33,10 @@ pub(super) async fn run_daemon( let state = DaemonState::new(connection.clone(), config, sound_settings, args.trial); let scheduler = ExpirationScheduler::start(state.clone()); state.set_scheduler(scheduler.clone()); + let dnd_scheduler = DndExpirationScheduler::start(state.clone()); + state.set_dnd_scheduler(dnd_scheduler); + let dnd_expires_at = state.store.lock().await.dnd_expires_at(); + state.schedule_dnd_expiration(dnd_expires_at); connection .object_server() diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs index f60176caf..2ab50e844 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -19,12 +19,26 @@ impl NotificationStore { ) -> Self { // Config default is used unless a valid persisted value overrides it let mut dnd_enabled = config.general.dnd_default; + let mut dnd_expires_at = None; if let Some(store) = dnd_state_store.as_ref() { match store.load() { Ok(Some(state)) if state.version == DND_STATE_VERSION => { // Versioned state prevents accidental decode of incompatible formats dnd_enabled = state.dnd_enabled; - debug!(dnd_enabled, "loaded persisted do-not-disturb state"); + dnd_expires_at = state.dnd_enabled.then_some(state.expires_at).flatten(); + // A deadline that passed while the daemon was stopped must not revive DND + if dnd_expires_at.is_some_and(|expires_at| expires_at <= unix_now_seconds()) { + dnd_enabled = false; + dnd_expires_at = None; + if let Err(err) = store.persist(false, None) { + warn!(?err, "failed to clear expired do-not-disturb state"); + } + } + debug!( + dnd_enabled, + ?dnd_expires_at, + "loaded persisted do-not-disturb state" + ); } Ok(Some(state)) => { // Unknown version is ignored but logged for troubleshooting @@ -45,6 +59,7 @@ impl NotificationStore { // IDs start at 1 to preserve protocol expectations next_id: 1, dnd_enabled, + dnd_expires_at, dnd_revision: 0, config, active: IndexMap::new(), @@ -92,6 +107,17 @@ impl NotificationStore { .map(|notification| notification.to_view()) } + pub fn active_inline_reply_target(&self, id: u32) -> Option { + let notification = self.active.get(&id)?; + // Both fields must agree so malformed internal data cannot widen reply access + let has_reply_action = notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + (notification.inline_reply.available && has_reply_action) + .then_some(notification.is_resident) + } + pub fn history_len(&self) -> usize { // Exposed for diagnostics and test assertions self.history.len() @@ -102,3 +128,8 @@ impl NotificationStore { self.history.clear(); } } + +fn unix_now_seconds() -> i64 { + // Chrono handles pre-epoch clocks without panicking + chrono::Utc::now().timestamp() +} diff --git a/crates/unixnotis-daemon/src/store/dnd.rs b/crates/unixnotis-daemon/src/store/dnd.rs index 8a4b00b01..a631e75cb 100644 --- a/crates/unixnotis-daemon/src/store/dnd.rs +++ b/crates/unixnotis-daemon/src/store/dnd.rs @@ -5,52 +5,85 @@ impl NotificationStore { self.dnd_enabled } + pub const fn dnd_expires_at(&self) -> Option { + self.dnd_expires_at + } + pub fn set_dnd(&mut self, enabled: bool) -> DndWrite { - // Shared mutation path keeps set and toggle behavior aligned - self.write_dnd(enabled) + // A plain set always means indefinite when enabled + self.write_dnd(enabled, None) + } + + pub fn set_dnd_until(&mut self, expires_at: i64) -> DndWrite { + // Validation happens at the control boundary before this state mutation + self.write_dnd(true, Some(expires_at)) } pub fn toggle_dnd(&mut self) -> DndWrite { // Toggle and write happen under one lock at the call site - self.write_dnd(!self.dnd_enabled) + self.write_dnd(!self.dnd_enabled, None) + } + + pub fn expire_dnd_if_current(&mut self, expires_at: i64, now: i64) -> DndWrite { + if !self.dnd_enabled || self.dnd_expires_at != Some(expires_at) || expires_at > now { + // A replaced or not-yet-due schedule cannot alter current state + return self.unchanged_dnd_write(); + } + self.write_dnd(false, None) } - pub(crate) const fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { + pub(crate) fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { // No-op writes do not need rollback if !write.changed { return false; } // Guarded rollback avoids clobbering newer successful writes - if self.dnd_revision != write.revision || self.dnd_enabled != write.current { + if self.dnd_revision != write.revision + || self.dnd_enabled != write.current + || self.dnd_expires_at != write.current_expires_at + { return false; } self.dnd_enabled = write.previous; + self.dnd_expires_at = write.previous_expires_at; // Rollback is also a state transition self.dnd_revision = self.dnd_revision.saturating_add(1); true } - fn write_dnd(&mut self, enabled: bool) -> DndWrite { + fn write_dnd(&mut self, enabled: bool, expires_at: Option) -> DndWrite { + // Disabled DND cannot retain a deadline + let expires_at = enabled.then_some(expires_at).flatten(); let previous = self.dnd_enabled; - if previous == enabled { + let previous_expires_at = self.dnd_expires_at; + if previous == enabled && previous_expires_at == expires_at { // Returning unchanged avoids unnecessary disk writes and state signals - return DndWrite { - changed: false, - previous, - current: previous, - revision: self.dnd_revision, - persist: None, - }; + return self.unchanged_dnd_write(); } self.dnd_enabled = enabled; + self.dnd_expires_at = expires_at; self.dnd_revision = self.dnd_revision.saturating_add(1); // Persist outside the store lock so notification flow stays responsive DndWrite { changed: true, previous, + previous_expires_at, current: enabled, + current_expires_at: expires_at, revision: self.dnd_revision, persist: self.dnd_state_store.clone(), } } + + const fn unchanged_dnd_write(&self) -> DndWrite { + DndWrite { + changed: false, + previous: self.dnd_enabled, + previous_expires_at: self.dnd_expires_at, + current: self.dnd_enabled, + current_expires_at: self.dnd_expires_at, + revision: self.dnd_revision, + persist: None, + } + } } diff --git a/crates/unixnotis-daemon/src/store/lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs index 8beaa986d..2690b8d69 100644 --- a/crates/unixnotis-daemon/src/store/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/lifecycle.rs @@ -171,7 +171,7 @@ impl NotificationStore { self.history.evict_to_limit(self.config.history.max_entries); } - const fn should_show_popup(&self, notification: &Notification) -> bool { + fn should_show_popup(&self, notification: &Notification) -> bool { // Rule-level popup suppression is highest priority if notification.suppress_popup { return false; @@ -181,6 +181,7 @@ impl NotificationStore { notification.urgency as u8, &ControlState { dnd_enabled: self.dnd_enabled, + dnd_expires_at: self.dnd_expires_at.unwrap_or(0), history_count: 0, inhibited: self.inhibited, inhibitor_count: self.inhibitor_count, diff --git a/crates/unixnotis-daemon/src/store/state.rs b/crates/unixnotis-daemon/src/store/state.rs index f76706cdc..c341e42e8 100644 --- a/crates/unixnotis-daemon/src/store/state.rs +++ b/crates/unixnotis-daemon/src/store/state.rs @@ -18,6 +18,8 @@ pub(super) const DND_STATE_FILE: &str = "state.json"; pub(super) struct PersistedDndState { pub(super) version: u32, pub(super) dnd_enabled: bool, + #[serde(default)] + pub(super) expires_at: Option, pub(super) updated_at: Option, } @@ -48,10 +50,12 @@ impl DndStateStore { Ok(Some(parsed)) } - pub(crate) fn persist(&self, enabled: bool) -> io::Result<()> { + pub(crate) fn persist(&self, enabled: bool, expires_at: Option) -> io::Result<()> { let payload = PersistedDndState { version: DND_STATE_VERSION, dnd_enabled: enabled, + // Disabled state never keeps a stale deadline on disk + expires_at: enabled.then_some(expires_at).flatten(), updated_at: Some(Utc::now().to_rfc3339()), }; let body = serde_json::to_vec(&payload)?; diff --git a/crates/unixnotis-daemon/src/store/tests/dnd.rs b/crates/unixnotis-daemon/src/store/tests/dnd.rs index 0d6353340..c441eca46 100644 --- a/crates/unixnotis-daemon/src/store/tests/dnd.rs +++ b/crates/unixnotis-daemon/src/store/tests/dnd.rs @@ -87,6 +87,29 @@ fn dnd_state_persists_on_change() { cleanup_temp_dir(&state_dir); } +#[test] +fn timed_dnd_persists_the_absolute_deadline() { + let state_dir = make_temp_state_dir("dnd-timed-write"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = chrono::Utc::now().timestamp() + 3_600; + let write = store.set_dnd_until(expires_at); + write + .persist + .as_ref() + .expect("timed DND state store") + .persist(write.current, write.current_expires_at) + .expect("persist timed DND"); + + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + let persisted: PersistedDndState = + serde_json::from_slice(&std::fs::read(path).expect("read timed DND state")) + .expect("parse timed DND state"); + + assert!(persisted.dnd_enabled); + assert_eq!(persisted.expires_at, Some(expires_at)); + cleanup_temp_dir(&state_dir); +} + #[cfg(unix)] #[test] fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { @@ -101,7 +124,7 @@ fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); let error = state_store - .persist(true) + .persist(true, None) .expect_err("state symlink should be rejected"); assert_ne!(error.kind(), std::io::ErrorKind::NotFound); @@ -112,6 +135,103 @@ fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { cleanup_temp_dir(&state_dir); } +#[test] +fn future_timed_dnd_is_loaded_with_its_deadline() { + let state_dir = make_temp_state_dir("dnd-future-deadline"); + let expires_at = chrono::Utc::now().timestamp() + 3_600; + let state = PersistedDndState { + version: DND_STATE_VERSION, + dnd_enabled: true, + expires_at: Some(expires_at), + updated_at: None, + }; + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write( + &path, + serde_json::to_vec(&state).expect("serialize timed state"), + ) + .expect("write timed state"); + + let store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn expired_timed_dnd_is_disabled_during_startup_and_cleared_on_disk() { + let state_dir = make_temp_state_dir("dnd-expired-deadline"); + let state = PersistedDndState { + version: DND_STATE_VERSION, + dnd_enabled: true, + expires_at: Some(chrono::Utc::now().timestamp() - 1), + updated_at: None, + }; + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write( + &path, + serde_json::to_vec(&state).expect("serialize expired state"), + ) + .expect("write expired state"); + + let store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let persisted: PersistedDndState = + serde_json::from_slice(&std::fs::read(&path).expect("read corrected persisted state")) + .expect("parse corrected state"); + + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + assert!(!persisted.dnd_enabled); + assert_eq!(persisted.expires_at, None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn plain_dnd_enable_replaces_a_timed_deadline_with_indefinite_state() { + let state_dir = make_temp_state_dir("dnd-timed-to-indefinite"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = chrono::Utc::now().timestamp() + 600; + + let timed = store.set_dnd_until(expires_at); + assert!(timed.changed); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + + let indefinite = store.set_dnd(true); + assert!(indefinite.changed); + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn expiration_mutation_requires_the_current_due_deadline() { + let state_dir = make_temp_state_dir("dnd-current-expiration"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = 500; + store.set_dnd_until(expires_at); + + assert!( + !store + .expire_dnd_if_current(expires_at + 1, expires_at) + .changed + ); + assert!( + !store + .expire_dnd_if_current(expires_at, expires_at - 1) + .changed + ); + assert!(store.dnd_enabled()); + + let expired = store.expire_dnd_if_current(expires_at, expires_at); + assert!(expired.changed); + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + #[test] fn dnd_toggle_flips_state_in_one_store_mutation() { let state_dir = make_temp_state_dir("dnd-toggle"); @@ -191,3 +311,19 @@ fn dnd_rollback_restores_state_when_write_is_still_current() { cleanup_temp_dir(&state_dir); } + +#[test] +fn failed_timed_write_rollback_restores_the_previous_deadline() { + let state_dir = make_temp_state_dir("dnd-timed-rollback"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let original = chrono::Utc::now().timestamp() + 600; + let replacement = original + 600; + store.set_dnd_until(original); + + let write = store.set_dnd_until(replacement); + assert!(store.rollback_dnd_write_if_current(&write)); + + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(original)); + cleanup_temp_dir(&state_dir); +} diff --git a/crates/unixnotis-daemon/src/store/tests/mod.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs index 5209f7d9d..9d2b5651a 100644 --- a/crates/unixnotis-daemon/src/store/tests/mod.rs +++ b/crates/unixnotis-daemon/src/store/tests/mod.rs @@ -9,99 +9,12 @@ use std::sync::Arc; use unixnotis_core::{CloseReason, Config, InhibitMode, Notification, NotificationImage, Urgency}; use zbus::zvariant::OwnedValue; -impl NotificationStore { - pub(crate) fn new_with_state_dir(config: Config, state_dir: std::path::PathBuf) -> Self { - // Isolated persistence roots keep tests away from the live XDG state directory - let state_store = Some(super::DndStateStore::from_state_dir(state_dir)); - Self::new_with_state_store(config, state_store) - } -} - mod dnd; mod inhibit; mod lifecycle; mod ownership; +mod reply; mod rules; +mod support; -pub(super) fn make_notification(summary: &str) -> Notification { - Notification { - id: 0, - app_name: "TestApp".to_string(), - app_icon: String::new(), - summary: summary.to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 0, - received_at: Utc::now(), - sender_name: Some(":1.test".to_string()), - sender_pid: Some(1234), - sender_start_time: Some(555), - sender_executable: Some("/usr/bin/test-app".to_string()), - } -} - -pub(super) fn make_notification_with_sender( - summary: &str, - sender: &str, - pid: u32, - start_time: u64, -) -> Notification { - let mut notification = make_notification(summary); - notification.sender_name = Some(sender.to_string()); - notification.sender_pid = Some(pid); - notification.sender_start_time = Some(start_time); - notification -} - -pub(super) fn make_store_with_limits(max_active: usize, max_entries: usize) -> NotificationStore { - let mut config = Config::default(); - // Test helper uses explicit limits so each case isolates one policy branch - config.history.max_active = max_active; - config.history.max_entries = max_entries; - NotificationStore::new(config) -} - -pub(super) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .map_or(0, |duration| duration.as_nanos()); - path.push(format!("unixnotis-test-{label}-{pid}-{nanos}")); - std::fs::create_dir_all(&path).expect("create temp state dir"); - path -} - -pub(super) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { - let state = PersistedDndState { - version, - dnd_enabled: enabled, - updated_at: Some("2025-01-01T00:00:00Z".to_string()), - }; - let payload = serde_json::to_string(&state).expect("serialize state"); - let path = dir.join("unixnotis").join(DND_STATE_FILE); - std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); - std::fs::write(&path, payload).expect("write state"); -} - -pub(super) fn cleanup_temp_dir(dir: &std::path::Path) { - let _ = std::fs::remove_dir_all(dir); -} - -pub(super) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { - let write = store.set_dnd(enabled); - if let Some(state_store) = write.persist.as_ref() { - state_store - .persist(write.current) - .expect("persist dnd state"); - } - write.changed -} +use support::*; diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/reply.rs new file mode 100644 index 000000000..7e6946c34 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/reply.rs @@ -0,0 +1,56 @@ +use unixnotis_core::{Action, CloseReason, InlineReply}; + +use super::{make_notification, make_store_with_limits}; + +#[test] +fn active_inline_reply_target_requires_a_live_explicit_reply_action() { + let mut store = make_store_with_limits(12, 20); + let ordinary_id = store + .insert(make_notification("ordinary"), 0) + .notification + .id; + let mut reply = make_notification("reply"); + reply.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let reply_id = store.insert(reply, 0).notification.id; + + assert_eq!(store.active_inline_reply_target(ordinary_id), None); + assert_eq!(store.active_inline_reply_target(reply_id), Some(false)); +} + +#[test] +fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { + let mut store = make_store_with_limits(12, 20); + let mut reply = make_notification("resident reply"); + reply.inline_reply.available = true; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + reply.is_resident = true; + let id = store.insert(reply, 0).notification.id; + + assert_eq!(store.active_inline_reply_target(id), Some(true)); + + store.close(id, CloseReason::Expired); + + assert_eq!(store.active_inline_reply_target(id), None); + assert!(store.list_history().iter().any(|view| view.id == id)); +} + +#[test] +fn inline_reply_metadata_without_the_protocol_action_is_rejected() { + let mut store = make_store_with_limits(12, 20); + let mut malformed = make_notification("metadata only"); + malformed.inline_reply.available = true; + let id = store.insert(malformed, 0).notification.id; + + assert_eq!(store.active_inline_reply_target(id), None); +} diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/tests/support.rs new file mode 100644 index 000000000..deba3bac5 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/support.rs @@ -0,0 +1,96 @@ +//! Shared notification and persistence fixtures for store tests + +use super::*; + +impl NotificationStore { + pub(crate) fn new_with_state_dir(config: Config, state_dir: std::path::PathBuf) -> Self { + // Isolated persistence roots keep tests away from the live XDG state directory + let state_store = Some(super::super::DndStateStore::from_state_dir(state_dir)); + Self::new_with_state_store(config, state_store) + } +} + +pub(super) fn make_notification(summary: &str) -> Notification { + Notification { + id: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +pub(super) fn make_notification_with_sender( + summary: &str, + sender: &str, + pid: u32, + start_time: u64, +) -> Notification { + let mut notification = make_notification(summary); + notification.sender_name = Some(sender.to_string()); + notification.sender_pid = Some(pid); + notification.sender_start_time = Some(start_time); + notification +} + +pub(super) fn make_store_with_limits(max_active: usize, max_entries: usize) -> NotificationStore { + let mut config = Config::default(); + // Test helper uses explicit limits so each case isolates one policy branch + config.history.max_active = max_active; + config.history.max_entries = max_entries; + NotificationStore::new(config) +} + +pub(super) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + path.push(format!("unixnotis-test-{label}-{pid}-{nanos}")); + std::fs::create_dir_all(&path).expect("create temp state dir"); + path +} + +pub(super) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { + let state = PersistedDndState { + version, + dnd_enabled: enabled, + expires_at: None, + updated_at: Some("2025-01-01T00:00:00Z".to_string()), + }; + let payload = serde_json::to_string(&state).expect("serialize state"); + let path = dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write(&path, payload).expect("write state"); +} + +pub(super) fn cleanup_temp_dir(dir: &std::path::Path) { + let _ = std::fs::remove_dir_all(dir); +} + +pub(super) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { + let write = store.set_dnd(enabled); + if let Some(state_store) = write.persist.as_ref() { + state_store + .persist(write.current, write.current_expires_at) + .expect("persist dnd state"); + } + write.changed +} diff --git a/crates/unixnotis-daemon/src/store/types.rs b/crates/unixnotis-daemon/src/store/types.rs index af02166fd..78d9d65c0 100644 --- a/crates/unixnotis-daemon/src/store/types.rs +++ b/crates/unixnotis-daemon/src/store/types.rs @@ -21,6 +21,8 @@ pub struct NotificationStore { pub(super) expirations: HashMap, // Effective DND switch after loading persisted state pub(super) dnd_enabled: bool, + // Wall-clock deadline survives daemon restarts; None means indefinite + pub(super) dnd_expires_at: Option, // Monotonic in-memory revision for DND writes pub(super) dnd_revision: u64, // Optional persistence layer for DND; absent store keeps behavior in-memory @@ -54,8 +56,12 @@ pub struct DndWrite { pub(crate) changed: bool, // Value seen before this write pub(crate) previous: bool, + // Deadline paired with the previous switch value + pub(crate) previous_expires_at: Option, // Value written by this operation pub(crate) current: bool, + // Deadline paired with the current switch value + pub(crate) current_expires_at: Option, // Monotonic revision captured for guarded rollback pub(crate) revision: u64, // Persistence backend used outside the store lock diff --git a/crates/unixnotis-daemon/src/tests/dnd_expiration.rs b/crates/unixnotis-daemon/src/tests/dnd_expiration.rs new file mode 100644 index 000000000..67b35f363 --- /dev/null +++ b/crates/unixnotis-daemon/src/tests/dnd_expiration.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use super::{delay_until_recheck, DndExpirationScheduler, MAX_CLOCK_RECHECK}; +use crate::test_support::daemon_state_for_test; + +#[test] +fn delay_until_recheck_returns_zero_for_due_and_past_deadlines() { + assert_eq!(delay_until_recheck(100, 100), Duration::ZERO); + assert_eq!(delay_until_recheck(101, 100), Duration::ZERO); +} + +#[test] +fn delay_until_recheck_caps_long_waits_for_wall_clock_changes() { + assert_eq!(delay_until_recheck(100, 110), Duration::from_secs(10)); + assert_eq!(delay_until_recheck(100, 10_000), MAX_CLOCK_RECHECK); +} + +#[tokio::test] +async fn scheduler_disables_dnd_when_the_current_deadline_is_due() { + let state = daemon_state_for_test(false).await; + let expires_at = chrono::Utc::now().timestamp(); + { + let mut store = state.store.lock().await; + store.set_dnd_until(expires_at); + } + let scheduler = DndExpirationScheduler::start(state.clone()); + state.set_dnd_scheduler(scheduler.clone()); + + scheduler.schedule(Some(expires_at)); + + tokio::time::timeout(Duration::from_millis(500), async { + loop { + if !state.store.lock().await.dnd_enabled() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("due DND deadline should be processed promptly"); + assert_eq!(state.store.lock().await.dnd_expires_at(), None); +} diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 7674b1224..67f04c912 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -144,6 +144,7 @@ fn make_notification(summary: &str) -> Notification { summary: summary.to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), hints: HashMap::::new(), urgency: Urgency::Normal, category: None, From dd09f147e3f10ad7f53427e7125c70954d11dddf Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 18 Jul 2026 21:57:45 -0500 Subject: [PATCH 002/275] feat(center): add timed DND and inline reply controls Summary: add timed DND and inline reply controls. Scope: center. --- Cargo.lock | 1 + crates/unixnotis-center/Cargo.toml | 1 + .../unixnotis-center/src/control/commands.rs | 39 ++- crates/unixnotis-center/src/control/model.rs | 40 ++- .../src/control/tests/commands.rs | 29 +- .../src/control/tests/events.rs | 1 + .../src/control/tests/model.rs | 17 ++ .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/init/constructor.rs | 2 + .../src/ui/notifications/model/tests/item.rs | 1 + .../notifications/row/notification/build.rs | 6 + .../ui/notifications/row/notification/mod.rs | 4 + .../notifications/row/notification/reply.rs | 278 ++++++++++++++++++ .../notifications/row/notification/state.rs | 8 + .../row/notification/tests/actions.rs | 51 ++++ .../row/notification/tests/reply.rs | 222 ++++++++++++++ .../row/notification/tests/support.rs | 1 + .../notifications/row/notification/update.rs | 92 ++++-- .../src/ui/notifications/row/tests/group.rs | 1 + .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + .../src/ui/panel/action_widgets.rs | 28 +- .../unixnotis-center/src/ui/panel/actions.rs | 19 +- crates/unixnotis-center/src/ui/panel/build.rs | 3 + crates/unixnotis-center/src/ui/panel/dnd.rs | 126 ++++++++ crates/unixnotis-center/src/ui/panel/mod.rs | 2 + .../unixnotis-center/src/ui/panel/reload.rs | 3 + .../src/ui/panel/tests/actions.rs | 19 +- .../src/ui/panel/tests/dnd.rs | 27 ++ .../src/ui/panel/tests/reload.rs | 3 + crates/unixnotis-center/src/ui/panel/types.rs | 3 + .../src/ui/panel/visibility.rs | 15 + crates/unixnotis-center/src/ui/state.rs | 2 + .../unixnotis-center/src/ui/tests/command.rs | 2 +- crates/unixnotis-core/assets/panel.css | 18 ++ crates/unixnotis-popups/src/ui/entry/build.rs | 23 +- .../src/ui/entry/tests/build.rs | 20 +- .../src/ui/icons/tests/resolver/support.rs | 1 + .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/events.rs | 1 + 40 files changed, 1058 insertions(+), 56 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs create mode 100644 crates/unixnotis-center/src/ui/panel/dnd.rs create mode 100644 crates/unixnotis-center/src/ui/panel/tests/dnd.rs diff --git a/Cargo.lock b/Cargo.lock index 3474ccee2..3cd170e35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3519,6 +3519,7 @@ dependencies = [ "anyhow", "async-channel", "blake3", + "chrono", "clap", "crossbeam-channel", "fast_image_resize", diff --git a/crates/unixnotis-center/Cargo.toml b/crates/unixnotis-center/Cargo.toml index 454dac1b6..f532609d4 100644 --- a/crates/unixnotis-center/Cargo.toml +++ b/crates/unixnotis-center/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true anyhow.workspace = true async-channel.workspace = true blake3.workspace = true +chrono.workspace = true clap.workspace = true crossbeam-channel.workspace = true fast_image_resize.workspace = true diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index 4d6f6f3b0..83b56cdbe 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -22,11 +22,21 @@ pub async fn handle_command( // Per-row actions still map straight to the daemon methods UiCommand::Dismiss(id) => proxy.dismiss(id).await, UiCommand::InvokeAction { id, action_key } => proxy.invoke_action(id, &action_key).await, + UiCommand::Reply { id, text, outcome } => { + let result = proxy.reply_notification(id, &text).await; + let reply_result = match &result { + Ok(()) => Ok(()), + Err(err) => Err(err.to_string()), + }; + let _ = outcome.send(reply_result); + result + } // Daemon invalidation now drives refresh for every client, not just the caller // Keeping the caller path thin avoids reintroducing one-client-only fixes later UiCommand::ClearAll => proxy.clear_all().await, // State and visibility commands remain safe to replay after reconnect UiCommand::SetDnd(enabled) => proxy.set_dnd(enabled).await, + UiCommand::SetDndUntil(expires_at) => proxy.set_dnd_until(expires_at).await, UiCommand::ClosePanel => proxy.close_panel().await, } } @@ -52,20 +62,38 @@ pub fn stash_offline_commands( } fn enqueue_offline_command(offline: &mut VecDeque, command: UiCommand) -> bool { + let command = match command { + UiCommand::Reply { outcome, .. } => { + // Reply text is live-only and must never survive a D-Bus generation change + let _ = outcome.send(Err("notification service is unavailable".to_string())); + return false; + } + command => command, + }; match &command { // Close and clear are one-shot intents, so one buffered copy is enough UiCommand::ClearAll | UiCommand::ClosePanel => { - if offline.iter().any(|queued| queued == &command) { + let duplicate = offline.iter().any(|queued| { + matches!( + (queued, &command), + (UiCommand::ClearAll, UiCommand::ClearAll) + | (UiCommand::ClosePanel, UiCommand::ClosePanel) + ) + }); + if duplicate { // Duplicate one-shot replay adds no user value after reconnect return false; } } // DND should replay only the newest requested state after reconnect - UiCommand::SetDnd(_) => { + UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_) => { // Older states are stale once a newer DND request exists - offline.retain(|queued| !matches!(queued, UiCommand::SetDnd(_))); + offline.retain(|queued| { + !matches!(queued, UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_)) + }); } UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } => {} + UiCommand::Reply { .. } => unreachable!("reply commands return before queueing"), } if offline.len() >= MAX_OFFLINE_COMMANDS { @@ -104,7 +132,10 @@ pub fn drop_stale_offline_commands(offline: &mut VecDeque) { offline.retain(|command| { matches!( command, - UiCommand::ClearAll | UiCommand::SetDnd(_) | UiCommand::ClosePanel + UiCommand::ClearAll + | UiCommand::SetDnd(_) + | UiCommand::SetDndUntil(_) + | UiCommand::ClosePanel ) }); let dropped = before.saturating_sub(offline.len()); diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index d2607dbb7..ac1a8e1db 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -1,5 +1,7 @@ //! Shared UI event and command types for the center D-Bus runtime. +use std::fmt; + use unixnotis_core::{CloseReason, ControlState, Margins, NotificationView, PanelRequest}; use crate::media::MediaInfo; @@ -35,15 +37,49 @@ pub enum UiEvent { } /// Commands sent from GTK handlers to the D-Bus runtime. -#[derive(Debug, Clone, PartialEq, Eq)] pub enum UiCommand { Dismiss(u32), - InvokeAction { id: u32, action_key: String }, + InvokeAction { + id: u32, + action_key: String, + }, + Reply { + id: u32, + text: String, + outcome: tokio::sync::oneshot::Sender>, + }, ClearAll, SetDnd(bool), + SetDndUntil(i64), ClosePanel, } +impl fmt::Debug for UiCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dismiss(id) => formatter.debug_tuple("Dismiss").field(id).finish(), + Self::InvokeAction { id, action_key } => formatter + .debug_struct("InvokeAction") + .field("id", id) + .field("action_key", action_key) + .finish(), + Self::Reply { id, .. } => formatter + .debug_struct("Reply") + .field("id", id) + // Typed message content must never enter diagnostic logs + .field("text", &"[redacted]") + .finish_non_exhaustive(), + Self::ClearAll => formatter.write_str("ClearAll"), + Self::SetDnd(enabled) => formatter.debug_tuple("SetDnd").field(enabled).finish(), + Self::SetDndUntil(expires_at) => formatter + .debug_tuple("SetDndUntil") + .field(expires_at) + .finish(), + Self::ClosePanel => formatter.write_str("ClosePanel"), + } + } +} + #[cfg(test)] #[path = "tests/model.rs"] mod tests; diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index 36493b869..ce770ed17 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -39,8 +39,8 @@ fn enqueue_offline_command_drops_duplicate_one_shot_commands() { assert!(!enqueue_offline_command(&mut offline, UiCommand::ClearAll)); assert_eq!(offline.len(), 2); - assert_eq!(offline[0], UiCommand::ClosePanel); - assert_eq!(offline[1], UiCommand::ClearAll); + assert!(matches!(offline[0], UiCommand::ClosePanel)); + assert!(matches!(offline[1], UiCommand::ClearAll)); } #[test] @@ -53,9 +53,30 @@ fn enqueue_offline_command_keeps_latest_dnd_state_only() { )); assert!(enqueue_offline_command( &mut offline, - UiCommand::SetDnd(false) + UiCommand::SetDndUntil(500) )); assert_eq!(offline.len(), 1); - assert_eq!(offline[0], UiCommand::SetDnd(false)); + assert!(matches!(offline[0], UiCommand::SetDndUntil(500))); +} + +#[test] +fn enqueue_offline_command_rejects_live_reply_text_and_reports_failure() { + let mut offline = VecDeque::new(); + let (outcome, mut result) = tokio::sync::oneshot::channel(); + + assert!(!enqueue_offline_command( + &mut offline, + UiCommand::Reply { + id: 7, + text: "Still there?".to_string(), + outcome, + } + )); + + assert!(offline.is_empty()); + assert!(matches!( + result.try_recv(), + Ok(Err(message)) if message.contains("unavailable") + )); } diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index aff0ccb58..8319d4b85 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -9,6 +9,7 @@ fn notification(id: u32) -> NotificationView { summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index d0bbadf2a..1911b3376 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -10,3 +10,20 @@ fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); assert!(matches!(UiEvent::ConfigReload, UiEvent::ConfigReload)); } + +#[test] +fn reply_command_debug_output_redacts_the_typed_message() { + let (outcome, _result) = tokio::sync::oneshot::channel(); + let command = UiCommand::Reply { + id: 9, + text: "private reply text".to_string(), + outcome, + }; + + let rendered = format!("{command:?}"); + + assert!(rendered.contains("Reply")); + assert!(rendered.contains('9')); + assert!(rendered.contains("[redacted]")); + assert!(!rendered.contains("private reply text")); +} diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index f8c2ca473..5bd71194d 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -76,6 +76,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { summary: String::new(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage { diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 12adac16e..a9f47ce85 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -36,6 +36,7 @@ impl UiState { list.set_empty_layout(has_visible_widget_section(&panel)); panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); + panel::connect_dnd_menu(&panel, init.command_tx.clone()); panel::connect_clear_button(&panel.clear_action_button, init.command_tx.clone()); panel::connect_clear_button(&panel.clear_header_button, init.command_tx.clone()); panel::connect_close_button(&panel, init.command_tx.clone()); @@ -63,6 +64,7 @@ impl UiState { icon_resolver, widget_icon_resolver, dnd_guard, + dnd_expiration_source: None, search_toggle_guard, panel_visible: false, panel_visible_flag, diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 1306fcf9c..f6fafd16c 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -13,6 +13,7 @@ fn notification(id: u32) -> Rc { summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index f84b47255..23c848a2a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -14,6 +14,7 @@ use unixnotis_core::css::hooks; use crate::control::UiCommand; use crate::ui::try_send_command; +use super::reply::build_inline_reply; use super::state::NotificationRowWidgets; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -153,6 +154,7 @@ pub(in crate::ui::notifications) fn build_notification_row( let actions_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); // Action buttons are added on demand during row updates actions_box.add_css_class("unixnotis-notification-actions"); + let inline_reply = build_inline_reply(command_tx.clone()); // Keep the card tree fully built up front // Row refreshes then only replace content instead of rebuilding containers @@ -161,6 +163,7 @@ pub(in crate::ui::notifications) fn build_notification_row( card.append(&body_row); card.append(&footer); card.append(&actions_box); + card.append(&inline_reply.revealer); let stack_ghost_1 = build_stack_ghost(1); let stack_ghost_2 = build_stack_ghost(2); @@ -209,8 +212,11 @@ pub(in crate::ui::notifications) fn build_notification_row( footer_left, footer_right, actions_box, + inline_reply, notify_id, + action_cache_id: Cell::new(0), action_cache: RefCell::new(Vec::new()), + reply_cache: RefCell::new((unixnotis_core::InlineReply::default(), false)), icon_sig: RefCell::new(None), }, ) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 2e5e04731..703fb1d80 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -13,6 +13,10 @@ mod labels_tests; #[cfg(test)] #[path = "tests/metadata.rs"] mod metadata_tests; +mod reply; +#[cfg(test)] +#[path = "tests/reply.rs"] +mod reply_tests; #[cfg(test)] #[path = "tests/stack.rs"] mod stack_tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs new file mode 100644 index 000000000..264179854 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs @@ -0,0 +1,278 @@ +//! Reusable inline reply form for live KDE-compatible notifications + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use tokio::sync::mpsc; +use unixnotis_core::InlineReply; + +use crate::control::UiCommand; +use crate::ui::try_send_command; + +const DEFAULT_PLACEHOLDER: &str = "Type a reply…"; +const DEFAULT_SUBMIT_LABEL: &str = "Send"; +// Button text stays compact even when the sender provides a long custom hint +const MAX_SUBMIT_LABEL_CHARS: usize = 20; +// GTK limits characters while the protocol boundary limits encoded bytes +const MAX_REPLY_CHARS: i32 = 4 * 1024; +const MAX_REPLY_BYTES: usize = 4 * 1024; + +pub(super) struct InlineReplyWidgets { + // The form is retained with the recycled row and revealed only on explicit action + pub(super) revealer: gtk::Revealer, + pub(super) entry: gtk::Entry, + pub(super) send_button: gtk::Button, + // Notification identity prevents a recycled row from leaking a prior draft + bound_id: Rc>, + // One shared gate covers button and Enter submissions + submitted: Rc>, +} + +pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineReplyWidgets { + // Build the hidden form once so row updates only change state and metadata + let revealer = gtk::Revealer::new(); + revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_reveal_child(false); + + let row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + row.add_css_class("unixnotis-inline-reply"); + + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_max_length(MAX_REPLY_CHARS); + entry.set_placeholder_text(Some(DEFAULT_PLACEHOLDER)); + entry.add_css_class("unixnotis-inline-reply-entry"); + + let send_button = gtk::Button::with_label(DEFAULT_SUBMIT_LABEL); + send_button.set_sensitive(false); + send_button.add_css_class("unixnotis-notification-action"); + send_button.add_css_class("unixnotis-inline-reply-send"); + + row.append(&entry); + row.append(&send_button); + revealer.set_child(Some(&row)); + + let bound_id = Rc::new(Cell::new(0)); + let submitted = Rc::new(Cell::new(false)); + + let changed_button = send_button.clone(); + let changed_submitted = submitted.clone(); + entry.connect_changed(move |entry| { + // Sensitivity mirrors the daemon byte limit before any command is queued + let text = entry.text(); + let text = text.trim(); + let too_long = text.len() > MAX_REPLY_BYTES; + entry.set_tooltip_text(too_long.then_some("Reply text must be no larger than 4 KiB")); + let valid = !text.is_empty() && !too_long; + changed_button.set_sensitive(valid && !changed_submitted.get()); + }); + + let submit_entry = entry.clone(); + let submit_revealer = revealer.clone(); + let submit_button = send_button.clone(); + let submit_id = bound_id.clone(); + let submit_gate = submitted.clone(); + let submit_tx = command_tx.clone(); + // Mouse submission shares the exact same guarded path as keyboard activation + send_button.connect_clicked(move |_| { + submit_reply( + &submit_entry, + &submit_revealer, + &submit_button, + &submit_id, + &submit_gate, + &submit_tx, + ); + }); + + let activate_revealer = revealer.clone(); + let activate_button = send_button.clone(); + let activate_id = bound_id.clone(); + let activate_gate = submitted.clone(); + // GtkEntry emits activate for Enter without needing a separate key handler + entry.connect_activate(move |entry| { + submit_reply( + entry, + &activate_revealer, + &activate_button, + &activate_id, + &activate_gate, + &command_tx, + ); + }); + + let key_revealer = revealer.clone(); + let key_entry = entry.clone(); + let key_submitted = submitted.clone(); + let key_controller = gtk::EventControllerKey::new(); + // Escape owns draft cancellation while other keys continue through GTK + key_controller.connect_key_pressed(move |_, key, _, _| { + if key != gtk::gdk::Key::Escape { + return gtk::glib::Propagation::Proceed; + } + cancel_inline_reply(&key_entry, &key_revealer, &key_submitted) + }); + entry.add_controller(key_controller); + + InlineReplyWidgets { + revealer, + entry, + send_button, + bound_id, + submitted, + } +} + +pub(super) fn configure_inline_reply( + widgets: &InlineReplyWidgets, + id: u32, + reply: &InlineReply, + is_active: bool, +) { + // History rows keep metadata for display but never expose a live reply control + let available = is_active && reply.available; + if widgets.bound_id.get() != id { + // Recycled rows never carry typed drafts to another notification + widgets.entry.set_text(""); + widgets.revealer.set_reveal_child(false); + widgets.submitted.set(false); + widgets.bound_id.set(id); + } + if !available { + // History and ordinary actions never expose a stale reply field + widgets.entry.set_text(""); + widgets.revealer.set_reveal_child(false); + widgets.entry.set_sensitive(true); + widgets.send_button.set_sensitive(false); + widgets.submitted.set(false); + return; + } + + // KDE hints customize only presentation and never change reply eligibility + let placeholder = if reply.placeholder.is_empty() { + DEFAULT_PLACEHOLDER + } else { + &reply.placeholder + }; + widgets.entry.set_placeholder_text(Some(placeholder)); + update_submit_content( + &widgets.send_button, + &reply.submit_label, + &reply.submit_icon, + ); +} + +pub(super) fn connect_inline_reply_button(button: >k::Button, widgets: &InlineReplyWidgets) { + let revealer = widgets.revealer.clone(); + let entry = widgets.entry.clone(); + let bound_id = widgets.bound_id.clone(); + let submitted = widgets.submitted.clone(); + button.connect_clicked(move |_| { + // Zero is the unbound sentinel and in-flight work cannot reopen the form + if bound_id.get() == 0 || submitted.get() { + return; + } + revealer.set_reveal_child(true); + entry.grab_focus(); + }); +} + +fn submit_reply( + entry: >k::Entry, + revealer: >k::Revealer, + button: >k::Button, + bound_id: &Rc>, + submitted: &Rc>, + command_tx: &mpsc::Sender, +) { + // Trim once so UI validation and the transmitted payload use the same content + let text = entry.text().trim().to_string(); + let id = bound_id.get(); + // replace(true) closes the race between Enter and a near-simultaneous click + if id == 0 || text.is_empty() || text.len() > MAX_REPLY_BYTES || submitted.replace(true) { + return; + } + + entry.set_sensitive(false); + button.set_sensitive(false); + // A one-shot response lets the GTK task restore the draft after transport failure + let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel(); + try_send_command( + command_tx, + UiCommand::Reply { + id, + text, + outcome: outcome_tx, + }, + ); + + let result_entry = entry.clone(); + let result_revealer = revealer.clone(); + let result_button = button.clone(); + let result_id = bound_id.clone(); + let result_submitted = submitted.clone(); + // The local main-context task is allowed to touch GTK widgets directly + gtk::glib::MainContext::default().spawn_local(async move { + let succeeded = matches!(outcome_rx.await, Ok(Ok(()))); + if result_id.get() != id || !result_submitted.get() { + // A recycled row already owns different notification state + return; + } + result_submitted.set(false); + result_entry.set_sensitive(true); + if succeeded { + // Successful replies leave no draft behind in the reusable row + result_entry.set_text(""); + result_revealer.set_reveal_child(false); + result_button.set_sensitive(false); + } else { + // Keep the draft available for correction or retry + result_button.set_sensitive(!result_entry.text().trim().is_empty()); + result_entry.grab_focus(); + } + }); +} + +pub(super) fn cancel_inline_reply( + entry: >k::Entry, + revealer: >k::Revealer, + submitted: &Cell, +) -> gtk::glib::Propagation { + if submitted.get() { + // An in-flight reply cannot be canceled into a second submission + return gtk::glib::Propagation::Proceed; + } + // Canceling an idle draft restores the original action row + entry.set_text(""); + revealer.set_reveal_child(false); + gtk::glib::Propagation::Stop +} + +fn update_submit_content(button: >k::Button, label: &str, icon_name: &str) { + // Rebuild the tiny child box because KDE may change hints on replacement + let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); + if !icon_name.is_empty() { + let icon = gtk::Image::from_icon_name(icon_name); + content.append(&icon); + } + let label = if label.is_empty() { + DEFAULT_SUBMIT_LABEL + } else { + label + }; + let label = gtk::Label::new(Some(clamp_submit_label(label).as_ref())); + content.append(&label); + button.set_child(Some(&content)); +} + +fn clamp_submit_label(label: &str) -> std::borrow::Cow<'_, str> { + // Character indexes preserve UTF-8 boundaries while enforcing visual length + let Some((cut, _)) = label.char_indices().nth(MAX_SUBMIT_LABEL_CHARS) else { + return std::borrow::Cow::Borrowed(label); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&label[..cut]); + bounded.push('…'); + std::borrow::Cow::Owned(bounded) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index b65e752a6..43362a084 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -8,6 +8,8 @@ use std::rc::Rc; use unixnotis_core::NotificationView; +use super::reply::InlineReplyWidgets; + pub(in crate::ui::notifications) struct NotificationRowWidgets { // Styled notification card inside the ListView row wrapper pub(super) card: gtk::Box, @@ -36,10 +38,16 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) footer_right: gtk::Label, // Container for optional action buttons pub(super) actions_box: gtk::Box, + // Live-only reply form is kept outside the action button cache + pub(super) inline_reply: InlineReplyWidgets, // Current notification id bound to this reused row widget pub(super) notify_id: Rc>, + // Recycled rows must rebuild action closures when the notification id changes + pub(super) action_cache_id: Cell, // Last rendered action signature for cheap no-op detection pub(super) action_cache: RefCell>, + // Reply metadata and live state are cached separately from ordinary actions + pub(super) reply_cache: RefCell<(unixnotis_core::InlineReply, bool)>, // Last rendered icon signature so decode work only happens on a real change pub(super) icon_sig: RefCell>, } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs index 36cebf542..f808ac55f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs @@ -114,3 +114,54 @@ fn update_notification_row_action_button_sends_command_once_per_click_window() { button.emit_clicked(); assert!(command_rx.try_recv().is_err()); } + +#[gtk::test] +fn recycled_action_button_targets_the_new_notification_id() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut first = sample_notification(); + first.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let mut second = first.clone(); + second.id = 2; + + update_notification_row( + &row, + &row_data( + Rc::new(first), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + update_notification_row( + &row, + &row_data( + Rc::new(second), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .expect("recycled action button") + .downcast::() + .expect("child should be action button"); + button.emit_clicked(); + + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { id: 2, action_key }) if action_key == "open" + )); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs new file mode 100644 index 000000000..5fcc0160c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs @@ -0,0 +1,222 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use super::reply::cancel_inline_reply; +use super::test_support::{row_data, sample_notification, RowFlags}; +use super::update::update_notification_row; +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +#[gtk::test] +fn inline_reply_is_available_only_for_a_live_explicit_reply_action() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = super::build::build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Duplicate reply".to_string(), + }, + ]; + notification.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + placeholder: "Write back".to_string(), + submit_label: "Send now".to_string(), + submit_icon: String::new(), + }; + + update_notification_row( + &row, + &row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + let button = row + .actions_box + .first_child() + .expect("reply action") + .downcast::() + .expect("reply child should be a button"); + assert!(button.next_sibling().is_none()); + button.emit_clicked(); + assert!(row.inline_reply.revealer.reveals_child()); + assert_eq!( + row.inline_reply.entry.placeholder_text().as_deref(), + Some("Write back") + ); + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.actions_box.first_child().is_none()); +} + +#[gtk::test] +fn inline_reply_submit_sends_text_once_and_hides_after_success() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = super::build::build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("On my way"); + row.inline_reply.entry.emit_activate(); + row.inline_reply.send_button.emit_clicked(); + + let UiCommand::Reply { id, text, outcome } = command_rx.try_recv().expect("reply command") + else { + panic!("expected inline reply command"); + }; + assert_eq!(id, 1); + assert_eq!(text, "On my way"); + assert!(command_rx.try_recv().is_err()); + outcome.send(Ok(())).expect("reply result receiver"); + + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.inline_reply.entry.text().is_empty()); +} + +#[gtk::test] +fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = super::build::build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + row.inline_reply.entry.set_text(" "); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.set_text(&"🙂".repeat(1_025)); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.set_text("Try again"); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err("temporary failure".to_string())) + .expect("reply result receiver"); + + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert_eq!(row.inline_reply.entry.text(), "Try again"); + assert!(row.inline_reply.entry.is_sensitive()); + assert!(row.inline_reply.send_button.is_sensitive()); +} + +#[gtk::test] +fn inline_reply_escape_clears_an_idle_draft_and_collapses_the_form() { + init_gtk(); + let entry = gtk::Entry::new(); + let revealer = gtk::Revealer::new(); + let submitted = Cell::new(false); + entry.set_text("Unsent draft"); + revealer.set_reveal_child(true); + + assert_eq!( + cancel_inline_reply(&entry, &revealer, &submitted), + gtk::glib::Propagation::Stop + ); + assert!(entry.text().is_empty()); + assert!(!revealer.reveals_child()); +} + +#[gtk::test] +fn inline_reply_submit_label_is_bounded_without_splitting_unicode() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = super::build::build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply.submit_label = "界".repeat(22); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let content = row + .inline_reply + .send_button + .child() + .expect("submit content") + .downcast::() + .expect("submit content box"); + let label = content + .last_child() + .expect("submit label") + .downcast::() + .expect("submit label widget"); + assert_eq!(label.text(), format!("{}…", "界".repeat(20))); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 7be490bd8..84616b86f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -19,6 +19,7 @@ pub(super) fn sample_notification() -> NotificationView { summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: Urgency::Normal as u8, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs index 61b9927a0..92b5f627c 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs @@ -3,7 +3,6 @@ //! This file owns the repeated update rules for reused notification rows use std::borrow::Cow; -use std::cell::RefCell; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; @@ -18,6 +17,7 @@ use crate::ui::panel::input::ClickCooldown; use crate::ui::try_send_command; use super::super::super::item::RowData; +use super::reply::{configure_inline_reply, connect_inline_reply_button}; use super::state::{ IconSignature, NotificationRowWidgets, OptionalLabelState, MAX_ACTION_LABEL_CHARS, MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, @@ -87,16 +87,9 @@ pub(in crate::ui::notifications) fn update_notification_row( hooks::panel_card::HAS_BODY, has_visible_text(¬ification.body), ); - set_class_state( - card, - hooks::panel_card::HAS_ACTIONS, - !notification.actions.is_empty(), - ); - set_class_state( - card, - hooks::panel_card::NO_ACTIONS, - notification.actions.is_empty(), - ); + let has_actions = visible_action_count(notification, data.is_active) > 0; + set_class_state(card, hooks::panel_card::HAS_ACTIONS, has_actions); + set_class_state(card, hooks::panel_card::NO_ACTIONS, !has_actions); let has_thumbnail = data.presentation.show_thumbnail && notification_has_thumbnail(notification); set_class_state(card, hooks::panel_card::HAS_THUMBNAIL, has_thumbnail); @@ -109,12 +102,7 @@ pub(in crate::ui::notifications) fn update_notification_row( update_body_label(&row.body_label, ¬ification.body); row.notify_id.set(notification.id); - update_actions( - &row.actions_box, - &row.action_cache, - command_tx, - notification, - ); + update_actions(row, command_tx, notification, data.is_active); // Icon decode and apply is skipped when the icon signature is unchanged // Text and action changes should not trigger another icon pipeline round @@ -207,10 +195,11 @@ fn update_metadata_labels( set_label_visible_if_changed(&row.footer_left, true); set_label_text_if_changed(&row.footer_left, footer_left); - let footer_right = if notification.actions.is_empty() { + let action_count = visible_action_count(notification, data.is_active); + let footer_right = if action_count == 0 { Cow::Borrowed("") } else { - Cow::Owned(format!("{} ACTIONS", notification.actions.len())) + Cow::Owned(format!("{action_count} ACTIONS")) }; set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); @@ -319,20 +308,30 @@ fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { } fn update_actions( - actions_box: >k::Box, - cache: &RefCell>, + row: &NotificationRowWidgets, command_tx: &mpsc::Sender, notification: &NotificationView, + is_active: bool, ) { + configure_inline_reply( + &row.inline_reply, + notification.id, + ¬ification.inline_reply, + is_active, + ); // Fast path: skip button rebuild when the action set is unchanged // This avoids tearing down buttons during no-op refresh passes { - let cached = cache.borrow(); - if cached.len() == notification.actions.len() + let cached = row.action_cache.borrow(); + let reply_cached = row.reply_cache.borrow(); + if row.action_cache_id.get() == notification.id + && cached.len() == notification.actions.len() && cached .iter() .zip(notification.actions.iter()) .all(|((key, label), action)| key == &action.key && label == &action.label) + && reply_cached.0 == notification.inline_reply + && reply_cached.1 == is_active { return; } @@ -341,25 +340,47 @@ fn update_actions( { // Cache the current action signature for the next update cycle // Reserve once so the cache grows with the current action count - let mut cached = cache.borrow_mut(); + let mut cached = row.action_cache.borrow_mut(); cached.clear(); cached.reserve(notification.actions.len()); for action in ¬ification.actions { cached.push((action.key.clone(), action.label.clone())); } + row.action_cache_id.set(notification.id); + *row.reply_cache.borrow_mut() = (notification.inline_reply.clone(), is_active); } // Refresh action buttons only when the action list changes - while let Some(child) = actions_box.first_child() { + while let Some(child) = row.actions_box.first_child() { // Remove old buttons before rebuilding the new set - actions_box.remove(&child); + row.actions_box.remove(&child); } - if notification.actions.is_empty() { + if visible_action_count(notification, is_active) == 0 { // No buttons should remain when the sender drops all actions return; } + let mut reply_button_added = false; for action in ¬ification.actions { + if action.key == "inline-reply" { + if reply_button_added || !is_active || !notification.inline_reply.available { + continue; + } + reply_button_added = true; + let label = if !notification.inline_reply.label.is_empty() { + notification.inline_reply.label.as_str() + } else if !action.label.is_empty() { + action.label.as_str() + } else { + "Reply" + }; + let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + connect_inline_reply_button(&button, &row.inline_reply); + row.actions_box.append(&button); + continue; + } // Bound action text so one long label cannot stretch the whole row // Clamp before button creation so GTK never measures the oversized string let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); @@ -385,6 +406,21 @@ fn update_actions( }, ); }); - actions_box.append(&button); + row.actions_box.append(&button); } } + +fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { + let regular = notification + .actions + .iter() + .filter(|action| action.key != "inline-reply") + .count(); + let reply = is_active + && notification.inline_reply.available + && notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + regular + usize::from(reply) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 2b849727d..41003718f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -17,6 +17,7 @@ fn notification(app_name: &str) -> Rc { summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 0fe57b006..cee41fb86 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -13,6 +13,7 @@ fn make_view(is_transient: bool) -> NotificationView { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient, image: NotificationImage::default(), @@ -26,6 +27,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 6ed5e883d..00bc79dc3 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -57,6 +57,7 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/panel/action_widgets.rs b/crates/unixnotis-center/src/ui/panel/action_widgets.rs index f47fba24a..557fb35df 100644 --- a/crates/unixnotis-center/src/ui/panel/action_widgets.rs +++ b/crates/unixnotis-center/src/ui/panel/action_widgets.rs @@ -7,8 +7,11 @@ use unixnotis_core::{ pub(super) struct PanelActionWidgets { pub(super) group: gtk::Box, + pub(super) dnd_group: gtk::Box, pub(super) focus_toggle: gtk::ToggleButton, pub(super) dnd_toggle: gtk::ToggleButton, + pub(super) dnd_status: gtk::Label, + pub(super) dnd_menu: gtk::MenuButton, pub(super) clear_button: gtk::Button, pub(super) search_toggle: gtk::ToggleButton, pub(super) close_button: gtk::Button, @@ -30,6 +33,20 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { let focus_toggle = build_toggle_action(hooks::panel_action::FOCUS, &config.focus_action); let dnd_toggle = build_toggle_action(hooks::panel_action::PRIMARY, &config.dnd_action); + let dnd_status = gtk::Label::new(None); + dnd_status.add_css_class(hooks::panel_action::LABEL); + dnd_status.set_visible(false); + + let dnd_menu = gtk::MenuButton::new(); + configure_action_button(&dnd_menu, hooks::panel_action::PRIMARY, true); + dnd_menu.set_icon_name("pan-down-symbolic"); + dnd_menu.set_tooltip_text(Some("Choose a Do Not Disturb duration")); + + let dnd_group = gtk::Box::new(gtk::Orientation::Horizontal, 2); + // One ordered child keeps the toggle, countdown, and duration arrow together + dnd_group.append(&dnd_toggle); + dnd_group.append(&dnd_status); + dnd_group.append(&dnd_menu); let clear_button = build_button_action(hooks::panel_action::MUTED, &resolved_clear_action(config)); @@ -46,7 +63,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { append_ordered_actions( &action_primary, &focus_toggle, - &dnd_toggle, + &dnd_group, &clear_button, &search_toggle, &close_button, @@ -58,8 +75,11 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { row: actions, widgets: PanelActionWidgets { group: action_primary, + dnd_group, focus_toggle, dnd_toggle, + dnd_status, + dnd_menu, clear_button, search_toggle, close_button, @@ -108,7 +128,7 @@ pub(in crate::ui::panel) fn apply_panel_action_config( append_ordered_actions( &widgets.group, &widgets.focus_toggle, - &widgets.dnd_toggle, + &widgets.dnd_group, &widgets.clear_button, &widgets.search_toggle, &widgets.close_button, @@ -214,7 +234,7 @@ fn configure_action_button(button: &impl IsA, role_class: &str, ico fn append_ordered_actions( group: >k::Box, focus_toggle: >k::ToggleButton, - dnd_toggle: >k::ToggleButton, + dnd_group: >k::Box, clear_button: >k::Button, search_toggle: >k::ToggleButton, close_button: >k::Button, @@ -224,7 +244,7 @@ fn append_ordered_actions( for action in order { let child: gtk::Widget = match action { PanelActionId::Widgets => focus_toggle.clone().upcast(), - PanelActionId::Dnd => dnd_toggle.clone().upcast(), + PanelActionId::Dnd => dnd_group.clone().upcast(), PanelActionId::Clear => clear_button.clone().upcast(), PanelActionId::Search => search_toggle.clone().upcast(), PanelActionId::Close => close_button.clone().upcast(), diff --git a/crates/unixnotis-center/src/ui/panel/actions.rs b/crates/unixnotis-center/src/ui/panel/actions.rs index 026656617..b210f81f9 100644 --- a/crates/unixnotis-center/src/ui/panel/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/actions.rs @@ -34,14 +34,27 @@ pub(in crate::ui) fn connect_dnd_toggle( dnd_guard: Rc>, command_tx: tokio::sync::mpsc::Sender, ) { - panel.dnd_toggle.connect_toggled(move |button| { + connect_dnd_button(&panel.dnd_toggle, dnd_guard, command_tx); +} + +fn connect_dnd_button( + button: >k::ToggleButton, + dnd_guard: Rc>, + command_tx: tokio::sync::mpsc::Sender, +) { + button.connect_toggled(move |button| { if dnd_guard.get() { // Daemon-driven state sync should not echo another DND command return; } - debug!(enabled = button.is_active(), "dnd toggled"); - try_send_command(&command_tx, UiCommand::SetDnd(button.is_active())); + let requested = button.is_active(); + // Keep the durable daemon state visible until the command commits successfully + dnd_guard.set(true); + button.set_active(!requested); + dnd_guard.set(false); + debug!(enabled = requested, "dnd toggled"); + try_send_command(&command_tx, UiCommand::SetDnd(requested)); }); } diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index 7379d48a9..471599012 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -107,6 +107,7 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg header_top: header.top, header_action_row: header.action_row, header_action_group: header.actions.group, + dnd_action_group: header.actions.dnd_group, notification_container: sections.notification_container, notification_header_row: sections.notification_header_row, notification_header: sections.notification_header, @@ -115,6 +116,8 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg footer_label: sections.footer, focus_toggle: header.actions.focus_toggle, dnd_toggle: header.actions.dnd_toggle, + dnd_status: header.actions.dnd_status, + dnd_menu: header.actions.dnd_menu, clear_action_button: header.actions.clear_button, clear_header_button: sections.clear_header_button, close_button: header.actions.close_button, diff --git a/crates/unixnotis-center/src/ui/panel/dnd.rs b/crates/unixnotis-center/src/ui/panel/dnd.rs new file mode 100644 index 000000000..425957e3c --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/dnd.rs @@ -0,0 +1,126 @@ +//! Timed Do Not Disturb menu and compact countdown formatting + +use std::time::Duration; + +use chrono::{Days, Local, NaiveDate, NaiveTime, TimeZone, Utc}; +use gtk::prelude::*; + +use crate::control::UiCommand; +use crate::ui::try_send_command; + +use super::PanelWidgets; + +const MORNING_HOUR: u32 = 8; + +pub(in crate::ui) fn connect_dnd_menu( + panel: &PanelWidgets, + command_tx: tokio::sync::mpsc::Sender, +) { + // The menu button owns this popover after setup + let popover = gtk::Popover::new(); + let choices = gtk::Box::new(gtk::Orientation::Vertical, 2); + + // Common relative choices share one absolute-deadline command path + for (label, seconds) in [ + ("30 minutes", 30 * 60), + ("1 hour", 60 * 60), + ("2 hours", 2 * 60 * 60), + ] { + let button = gtk::Button::with_label(label); + let tx = command_tx.clone(); + let menu = popover.clone(); + button.connect_clicked(move |_| { + // Saturation keeps an abnormal system clock from wrapping the deadline + let expires_at = Utc::now().timestamp().saturating_add(seconds); + try_send_command(&tx, UiCommand::SetDndUntil(expires_at)); + menu.popdown(); + }); + choices.append(&button); + } + + // Morning follows the next calendar day rather than a fixed 24-hour duration + let morning = gtk::Button::with_label("Until tomorrow morning"); + let morning_tx = command_tx.clone(); + let morning_menu = popover.clone(); + morning.connect_clicked(move |_| { + if let Some(expires_at) = next_morning_deadline() { + try_send_command(&morning_tx, UiCommand::SetDndUntil(expires_at)); + } else { + tracing::warn!("could not resolve the next local 08:00 DND deadline"); + } + morning_menu.popdown(); + }); + choices.append(&morning); + + // Indefinite enablement deliberately replaces any existing timed deadline + let indefinite = gtk::Button::with_label("Indefinitely"); + let indefinite_menu = popover.clone(); + indefinite.connect_clicked(move |_| { + try_send_command(&command_tx, UiCommand::SetDnd(true)); + indefinite_menu.popdown(); + }); + choices.append(&indefinite); + + popover.set_child(Some(&choices)); + panel.dnd_menu.set_popover(Some(&popover)); +} + +pub(in crate::ui) fn update_dnd_status(label: >k::Label, expires_at: i64) { + // One helper keeps immediate and timer-driven label updates identical + let text = format_dnd_remaining(expires_at, Utc::now().timestamp()); + label.set_visible(!text.is_empty()); + label.set_text(&text); +} + +pub(in crate::ui) fn start_dnd_countdown( + label: >k::Label, + expires_at: i64, +) -> gtk::glib::SourceId { + // GTK owns the callback on its main context while UiState owns the source id + let label = label.clone(); + gtk::glib::timeout_add_local(Duration::from_secs(30), move || { + update_dnd_status(&label, expires_at); + // The next daemon state update owns source removal, even after the label reaches zero + gtk::glib::ControlFlow::Continue + }) +} + +fn format_dnd_remaining(expires_at: i64, now: i64) -> String { + let remaining = expires_at.saturating_sub(now); + if remaining <= 0 { + return String::new(); + } + // Round upward so a positive remainder never appears as zero minutes + let minutes = (remaining.saturating_add(59)) / 60; + if minutes < 60 { + return format!("· {minutes}m"); + } + let hours = minutes / 60; + let trailing_minutes = minutes % 60; + if trailing_minutes == 0 { + format!("· {hours}h") + } else { + format!("· {hours}h {trailing_minutes}m") + } +} + +fn next_morning_deadline() -> Option { + let now = Local::now(); + // Construct the local clock value separately from the next calendar date + let morning = NaiveTime::from_hms_opt(MORNING_HOUR, 0, 0)?; + let date = tomorrow_date(now.date_naive())?; + match Local.from_local_datetime(&date.and_time(morning)) { + chrono::LocalResult::Single(value) => Some(value.timestamp()), + // The earliest occurrence is sufficient because the whole date is in the future + chrono::LocalResult::Ambiguous(first, _) => Some(first.timestamp()), + chrono::LocalResult::None => None, + } +} + +const fn tomorrow_date(today: NaiveDate) -> Option { + today.checked_add_days(Days::new(1)) +} + +#[cfg(test)] +#[path = "tests/dnd.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index f2cf5b9ec..6e60fdd07 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -6,6 +6,7 @@ mod action_widgets; mod actions; mod autoclose; mod build; +mod dnd; mod header; pub(in crate::ui) mod input; mod keyboard; @@ -29,6 +30,7 @@ pub use self::sections::{notification_header_row_visible, WIDGET_REVEAL_TRANSITI pub use self::types::PanelWidgets; pub(in crate::ui) use actions::{connect_clear_button, connect_close_button, connect_dnd_toggle}; pub(in crate::ui) use autoclose::connect_auto_close; +pub(in crate::ui) use dnd::connect_dnd_menu; pub(in crate::ui) use keyboard::connect_keyboard_shortcuts; pub(in crate::ui) use search::{ connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, diff --git a/crates/unixnotis-center/src/ui/panel/reload.rs b/crates/unixnotis-center/src/ui/panel/reload.rs index 1ef9da667..a22c18c4c 100644 --- a/crates/unixnotis-center/src/ui/panel/reload.rs +++ b/crates/unixnotis-center/src/ui/panel/reload.rs @@ -9,8 +9,11 @@ pub fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { &panel.header_top, &super::action_widgets::PanelActionWidgets { group: panel.header_action_group.clone(), + dnd_group: panel.dnd_action_group.clone(), focus_toggle: panel.focus_toggle.clone(), dnd_toggle: panel.dnd_toggle.clone(), + dnd_status: panel.dnd_status.clone(), + dnd_menu: panel.dnd_menu.clone(), clear_button: panel.clear_action_button.clone(), search_toggle: panel.search_toggle.clone(), close_button: panel.close_button.clone(), diff --git a/crates/unixnotis-center/src/ui/panel/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/tests/actions.rs index 29eef35ef..18f3f1b41 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/actions.rs @@ -1,6 +1,9 @@ +use std::cell::Cell; +use std::rc::Rc; + use gtk::prelude::*; -use super::connect_clear_button; +use super::{connect_clear_button, connect_dnd_button}; use crate::control::UiCommand; #[gtk::test] @@ -15,3 +18,17 @@ fn clear_button_sends_once_while_click_guard_is_active() { assert!(matches!(command_rx.try_recv(), Ok(UiCommand::ClearAll))); assert!(command_rx.try_recv().is_err()); } + +#[gtk::test] +fn dnd_toggle_waits_for_daemon_state_before_changing_visual_state() { + let button = gtk::ToggleButton::new(); + let guard = Rc::new(Cell::new(false)); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + connect_dnd_button(&button, guard, command_tx); + + button.set_active(true); + + assert!(!button.is_active()); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); + assert!(command_rx.try_recv().is_err()); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/tests/dnd.rs new file mode 100644 index 000000000..a3d557bbb --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/dnd.rs @@ -0,0 +1,27 @@ +use chrono::NaiveDate; + +use super::{format_dnd_remaining, tomorrow_date}; + +#[test] +fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { + assert_eq!(format_dnd_remaining(100, 100), ""); + assert_eq!(format_dnd_remaining(99, 100), ""); + assert_eq!(format_dnd_remaining(101, 100), "· 1m"); + assert_eq!(format_dnd_remaining(100 + 47 * 60, 100), "· 47m"); +} + +#[test] +fn remaining_time_keeps_hours_compact_without_losing_partial_hour() { + assert_eq!(format_dnd_remaining(100 + 60 * 60, 100), "· 1h"); + assert_eq!( + format_dnd_remaining(100 + 2 * 60 * 60 + 5 * 60, 100), + "· 2h 5m" + ); +} + +#[test] +fn morning_choice_uses_the_next_local_eight_oclock() { + let today = NaiveDate::from_ymd_opt(2026, 7, 18).expect("valid date"); + + assert_eq!(tomorrow_date(today), NaiveDate::from_ymd_opt(2026, 7, 19)); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/reload.rs b/crates/unixnotis-center/src/ui/panel/tests/reload.rs index 0b179642f..6b0f25bd9 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/reload.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/reload.rs @@ -58,6 +58,7 @@ fn panel_widgets(config: &PanelConfig) -> PanelWidgets { header_top: header.top, header_action_row: header.action_row, header_action_group: header.actions.group, + dnd_action_group: header.actions.dnd_group, notification_container: sections.notification_container, notification_header_row: sections.notification_header_row, notification_header: sections.notification_header, @@ -66,6 +67,8 @@ fn panel_widgets(config: &PanelConfig) -> PanelWidgets { footer_label: sections.footer, focus_toggle: header.actions.focus_toggle, dnd_toggle: header.actions.dnd_toggle, + dnd_status: header.actions.dnd_status, + dnd_menu: header.actions.dnd_menu, clear_action_button: header.actions.clear_button, clear_header_button: sections.clear_header_button, close_button: header.actions.close_button, diff --git a/crates/unixnotis-center/src/ui/panel/types.rs b/crates/unixnotis-center/src/ui/panel/types.rs index 511fe299e..3b6c5ab19 100644 --- a/crates/unixnotis-center/src/ui/panel/types.rs +++ b/crates/unixnotis-center/src/ui/panel/types.rs @@ -25,6 +25,7 @@ pub struct PanelWidgets { pub header_top: gtk::Box, pub header_action_row: gtk::Box, pub header_action_group: gtk::Box, + pub dnd_action_group: gtk::Box, pub notification_container: gtk::Box, pub notification_header_row: gtk::Box, pub notification_header: gtk::Label, @@ -33,6 +34,8 @@ pub struct PanelWidgets { pub footer_label: gtk::Label, pub focus_toggle: gtk::ToggleButton, pub dnd_toggle: gtk::ToggleButton, + pub dnd_status: gtk::Label, + pub dnd_menu: gtk::MenuButton, pub clear_action_button: gtk::Button, pub clear_header_button: gtk::Button, pub close_button: gtk::Button, diff --git a/crates/unixnotis-center/src/ui/panel/visibility.rs b/crates/unixnotis-center/src/ui/panel/visibility.rs index ce8a4af65..73d6227a0 100644 --- a/crates/unixnotis-center/src/ui/panel/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/visibility.rs @@ -42,10 +42,25 @@ impl UiState { } pub(in crate::ui) fn update_state(&mut self, state: unixnotis_core::ControlState) { + if let Some(source) = self.dnd_expiration_source.take() { + source.remove(); + } // Avoid re-entrant DND toggles while applying daemon state self.dnd_guard.set(true); self.panel.dnd_toggle.set_active(state.dnd_enabled); self.dnd_guard.set(false); + let expires_at = state + .dnd_enabled + .then_some(state.dnd_expires_at) + .filter(|expires_at| *expires_at > 0) + .unwrap_or(0); + super::dnd::update_dnd_status(&self.panel.dnd_status, expires_at); + if expires_at > 0 { + self.dnd_expiration_source = Some(super::dnd::start_dnd_countdown( + &self.panel.dnd_status, + expires_at, + )); + } } pub(in crate::ui) fn refresh_counts(&mut self) { diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 1eed37207..1302edf66 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -26,6 +26,8 @@ pub struct UiState { // Widget assets are resolved relative to the active config file root pub(super) widget_icon_resolver: IconAssetResolver, pub(super) dnd_guard: Rc>, + // One countdown source updates the compact DND deadline label + pub(super) dnd_expiration_source: Option, pub(super) search_toggle_guard: Rc>, pub(super) panel_visible: bool, pub(super) panel_visible_flag: Arc, diff --git a/crates/unixnotis-center/src/ui/tests/command.rs b/crates/unixnotis-center/src/ui/tests/command.rs index 4ba5020c6..d032fd9f9 100644 --- a/crates/unixnotis-center/src/ui/tests/command.rs +++ b/crates/unixnotis-center/src/ui/tests/command.rs @@ -7,7 +7,7 @@ fn available_command_queue_receives_the_original_command() { try_send_command(&command_tx, UiCommand::SetDnd(true)); - assert_eq!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true))); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); } #[test] diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 0e364dcda..405b53b44 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -522,6 +522,24 @@ entry selection { border-color: alpha(@unixnotis-accent, 0.5); } +.unixnotis-inline-reply { + margin-top: 4px; +} + +.unixnotis-inline-reply-entry { + color: @unixnotis-text; + background-color: alpha(@unixnotis-surface-strong, 0.9); + border: 1px solid alpha(@unixnotis-accent, 0.25); + border-radius: 10px; + padding: 5px 9px; + min-height: 28px; +} + +.unixnotis-inline-reply-entry:focus { + border-color: alpha(@unixnotis-accent, 0.7); + box-shadow: 0 0 16px -12px @unixnotis-glow-cyan; +} + /* Restrained default composition * * Navy remains the visual identity while flat surfaces and spacing carry hierarchy diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 193f51d01..d41734c7b 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -3,7 +3,7 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; -use unixnotis_core::{hooks, NotificationView, Urgency}; +use unixnotis_core::{hooks, Action, NotificationView, Urgency}; use super::super::window::refresh_popup_input_region; use super::super::UiState; @@ -76,6 +76,7 @@ impl UiState { // Critical rows keep the shared urgency class at the root root.add_css_class(hooks::shared_state::CRITICAL); } + let has_popup_actions = notification.actions.iter().any(popup_action_is_visible); // State classes make popup theming less dependent on child selector tricks set_class_state( &root, @@ -87,11 +88,7 @@ impl UiState { hooks::popup_card::HAS_BODY, has_visible_text(¬ification.body), ); - set_class_state( - &root, - hooks::popup_card::HAS_ACTIONS, - !notification.actions.is_empty(), - ); + set_class_state(&root, hooks::popup_card::HAS_ACTIONS, has_popup_actions); // Header keeps icon, app name, and close in one stable row let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); @@ -153,10 +150,15 @@ impl UiState { root.append(&body); // Action buttons are only built when the payload exposes actions - if !notification.actions.is_empty() { + if has_popup_actions { let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); actions.add_css_class("unixnotis-popup-actions"); - for action in notification.actions.iter().take(MAX_POPUP_ACTIONS) { + for action in notification + .actions + .iter() + .filter(|action| popup_action_is_visible(action)) + .take(MAX_POPUP_ACTIONS) + { // Button labels are clamped before GTK measures them let button = gtk::Button::with_label( clamp_label_text(&action.label, POPUP_ACTION_LABEL_MAX_CHARS).as_ref(), @@ -273,6 +275,11 @@ fn widget_type_blocks_default_action(widget_type: gtk::glib::Type) -> bool { widget_type.is_a(gtk::Button::static_type()) } +fn popup_action_is_visible(action: &Action) -> bool { + // Inline reply needs a text field, so it is available in the panel instead of popup buttons + action.key != "inline-reply" +} + fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { if enabled { // Skip duplicate adds so repeated rebuilds do not churn the class list diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index 418b2cff3..e981e8d94 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -1,5 +1,8 @@ -use super::{popup_header_spacer_expands, widget_type_blocks_default_action}; +use super::{ + popup_action_is_visible, popup_header_spacer_expands, widget_type_blocks_default_action, +}; use gtk::glib::prelude::StaticType; +use unixnotis_core::Action; #[test] fn popup_header_spacer_expands_to_hold_close_alignment() { @@ -18,3 +21,18 @@ fn default_card_action_is_allowed_for_plain_content_widgets() { // Plain card content may use the notification default action assert!(!widget_type_blocks_default_action(gtk::Label::static_type())); } + +#[test] +fn popup_actions_hide_inline_reply_but_keep_regular_buttons() { + let reply = Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }; + let open = Action { + key: "default".to_string(), + label: "Open".to_string(), + }; + + assert!(!popup_action_is_visible(&reply)); + assert!(popup_action_is_visible(&open)); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index b43dc2436..4c5a57505 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -25,6 +25,7 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView summary: String::new(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, image: NotificationImage { diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 21f29b349..bd7c59b4a 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -13,6 +13,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), urgency: urgency as u8, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-popups/src/ui/state/tests/events.rs b/crates/unixnotis-popups/src/ui/state/tests/events.rs index 899b78243..2cc8b2213 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/events.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/events.rs @@ -6,6 +6,7 @@ use super::super::events::apply_popup_gate; fn popup_gate_update_changes_policy_without_replacing_runtime_counts() { let mut state = ControlState { dnd_enabled: false, + dnd_expires_at: 0, inhibited: false, history_count: 42, inhibitor_count: 3, From 6ff953fff8d573fd7889b60569f7d1c138825fd8 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 18 Jul 2026 21:58:02 -0500 Subject: [PATCH 003/275] feat(cli): add timed DND deadlines Summary: add timed DND deadlines. Scope: cli. --- README.md | 10 +- crates/noticenterctl/src/app/runner.rs | 2 + crates/noticenterctl/src/cli/command.rs | 22 +++ crates/noticenterctl/src/cli/dnd.rs | 127 ++++++++++++++++++ crates/noticenterctl/src/cli/mod.rs | 2 + crates/noticenterctl/src/cli/tests/args.rs | 52 ++++++- crates/noticenterctl/src/cli/tests/dnd.rs | 43 ++++++ crates/noticenterctl/src/cli/tests/mod.rs | 1 + crates/noticenterctl/src/dbus/client.rs | 10 ++ crates/noticenterctl/src/dbus/commands.rs | 25 +++- .../noticenterctl/src/dbus/tests/commands.rs | 51 +++++++ .../noticenterctl/src/dbus/tests/support.rs | 5 + .../src/doctor/checks/tests/dbus.rs | 1 + .../src/output/tests/notifications.rs | 1 + 14 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 crates/noticenterctl/src/cli/dnd.rs create mode 100644 crates/noticenterctl/src/cli/tests/dnd.rs diff --git a/README.md b/README.md index 1885c76f3..697a13c13 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ git clone https://github.com/locainin/UnixNotis.wiki.git ## Features - Freedesktop.org notification daemon with history, rules, sound, and DND. -- Persistent DND state across daemon restarts. +- Persistent and timed DND state across daemon restarts. +- KDE-compatible inline replies in the control-center panel for live notifications that advertise reply support. - Control-center panel with widgets, notification list, and media controls. - Toast popup UI with configurable timeouts and styling. - D-Bus inhibit API for programmatic popup suppression. @@ -92,6 +93,13 @@ noticenterctl doctor --config "$HOME/path/to/config.toml" noticenterctl css-check --config "$HOME/path/to/config.toml" ``` +Timed DND can use a relative duration or the next occurrence of a local clock time: + +```sh +noticenterctl dnd on --for 30m +noticenterctl dnd on --until 08:00 +``` + Verbose systemd reports include a sanitized, bounded window of up to 30 user-journal lines. Review verbose output before posting it because application metadata can still be present. Dinit, runit, s6-rc, manual, and unknown launches report service status without pretending that diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index 167230dc0..731e1c239 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -13,6 +13,8 @@ pub fn run() -> Result<()> { // Parse CLI arguments before any daemon work starts let args = Args::parse(); let command = args.command; + // Semantic checks happen before runtime and D-Bus setup + command.validate()?; if command.is_synchronous() { // Preset and CSS work should not pay for an unused asynchronous runtime diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index 03b174ca1..79465f2a7 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -4,6 +4,7 @@ use clap::Subcommand; use super::args::{DndState, DoctorServiceManagerArg, PresetCommand}; use super::{DebugLevelArg, InhibitScopeArg}; +use super::{DndClockTime, DndDuration}; #[derive(Subcommand, Debug)] pub enum Command { @@ -20,6 +21,10 @@ pub enum Command { Dnd { #[arg(value_enum)] state: DndState, + #[arg(long = "for", value_name = "DURATION", conflicts_with = "until")] + for_duration: Option, + #[arg(long, value_name = "HH:MM", conflicts_with = "for_duration")] + until: Option, }, // Clear active notifications and saved history Clear, @@ -79,6 +84,23 @@ pub enum Command { } impl Command { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + if let Self::Dnd { + state, + for_duration, + until, + } = self + { + let has_deadline = for_duration.is_some() || until.is_some(); + if has_deadline && !matches!(state, DndState::On) { + return Err(anyhow::anyhow!( + "--for and --until are valid only with `dnd on`" + )); + } + } + Ok(()) + } + pub(crate) const fn is_local_only(&self) -> bool { // Local-only commands should not fail just because D-Bus is unavailable matches!( diff --git a/crates/noticenterctl/src/cli/dnd.rs b/crates/noticenterctl/src/cli/dnd.rs new file mode 100644 index 000000000..c4dd6e90f --- /dev/null +++ b/crates/noticenterctl/src/cli/dnd.rs @@ -0,0 +1,127 @@ +//! Timed Do Not Disturb command value parsing and deadline resolution + +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use chrono::{Days, Local, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc}; + +// Relative durations stay bounded so persisted deadlines remain operationally useful +const MAX_DND_DURATION_SECONDS: u64 = 365 * 24 * 60 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DndDuration { + seconds: u64, +} + +impl DndDuration { + pub fn deadline(self) -> Result { + // The daemon receives one absolute timestamp so restarts do not reset the duration + Utc::now() + .timestamp() + .checked_add_unsigned(self.seconds) + .ok_or_else(|| anyhow!("DND duration exceeds the supported timestamp range")) + } +} + +impl FromStr for DndDuration { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + // The final ASCII byte selects the only supported duration unit + let Some(unit) = value.as_bytes().last().copied() else { + return Err("duration cannot be empty".to_string()); + }; + let multiplier = match unit { + b's' => 1, + b'm' => 60, + b'h' => 60 * 60, + b'd' => 24 * 60 * 60, + _ => return Err("duration must end in s, m, h, or d".to_string()), + }; + // Supported suffixes are one-byte ASCII, so this boundary is always valid + let digits = &value[..value.len() - 1]; + let amount = digits + .parse::() + .map_err(|_| "duration must start with a positive integer".to_string())?; + // Checked multiplication rejects large values before the policy bound is applied + let seconds = amount + .checked_mul(multiplier) + .ok_or_else(|| "duration is too large".to_string())?; + if seconds == 0 || seconds > MAX_DND_DURATION_SECONDS { + return Err("duration must be between 1 second and 365 days".to_string()); + } + Ok(Self { seconds }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DndClockTime { + time: NaiveTime, +} + +impl DndClockTime { + pub fn deadline(self) -> Result { + // Resolve against the machine timezone because HH:MM is a local wall-clock value + let now = Local::now(); + let now_timestamp = now.timestamp(); + if let Some(deadline) = local_deadline_after(now.date_naive(), self.time, now_timestamp) { + // A repeated local hour may still have a second future occurrence today + return Ok(deadline); + } + + // A missing or elapsed occurrence today advances by one calendar day + let tomorrow = tomorrow_date(now.date_naive())?; + local_deadline_after(tomorrow, self.time, now_timestamp) + .ok_or_else(|| anyhow!("requested local time does not exist on the next calendar date")) + } +} + +impl FromStr for DndClockTime { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + let bytes = value.as_bytes(); + // Exact width avoids accepting locale-specific or partly parsed clock forms + if bytes.len() != 5 + || bytes[2] != b':' + || !bytes[..2].iter().all(u8::is_ascii_digit) + || !bytes[3..].iter().all(u8::is_ascii_digit) + { + return Err("time must use 24-hour HH:MM format".to_string()); + } + let time = NaiveTime::parse_from_str(value, "%H:%M") + .map_err(|_| "time must use 24-hour HH:MM format".to_string())?; + Ok(Self { time }) + } +} + +fn tomorrow_date(today: NaiveDate) -> Result { + // Calendar addition remains correct across daylight-saving offset changes + today + .checked_add_days(Days::new(1)) + .ok_or_else(|| anyhow!("next DND date exceeds the supported calendar range")) +} + +fn local_deadline_after(date: NaiveDate, time: NaiveTime, after: i64) -> Option { + let local = Local.from_local_datetime(&date.and_time(time)); + match local { + LocalResult::Single(value) => future_timestamp(after, Some(value.timestamp()), None), + // Repeated hours expose both absolute instants for future filtering + LocalResult::Ambiguous(first, second) => { + future_timestamp(after, Some(first.timestamp()), Some(second.timestamp())) + } + // A skipped wall-clock time has no deadline on this date + LocalResult::None => None, + } +} + +pub(super) fn future_timestamp(after: i64, first: Option, second: Option) -> Option { + // Select by absolute time so repeated wall-clock hours remain correct + [first, second] + .into_iter() + .flatten() + .filter(|candidate| *candidate > after) + .min() +} diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs index ff6919278..4cbebdbfb 100644 --- a/crates/noticenterctl/src/cli/mod.rs +++ b/crates/noticenterctl/src/cli/mod.rs @@ -2,10 +2,12 @@ mod args; mod command; +mod dnd; pub use args::{Args, DndState, DoctorServiceManagerArg, PresetCommand}; pub use args::{DebugLevelArg, InhibitScopeArg}; pub use command::Command; +pub use dnd::{DndClockTime, DndDuration}; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs index a838b1c7e..fd93720cb 100644 --- a/crates/noticenterctl/src/cli/tests/args.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -36,13 +36,63 @@ fn parses_dnd_toggle() { // Confirms the value enum accepts the toggle state for DND commands let args = Args::try_parse_from(["noticenterctl", "dnd", "toggle"]).expect("parse args"); match args.command { - Command::Dnd { state } => { + Command::Dnd { + state, + for_duration, + until, + } => { assert!(matches!(state, DndState::Toggle)); + assert!(for_duration.is_none()); + assert!(until.is_none()); } other => panic!("unexpected command: {other:?}"), } } +#[test] +fn parses_timed_dnd_duration_and_clock_deadline() { + let duration = + Args::try_parse_from(["noticenterctl", "dnd", "on", "--for", "30m"]).expect("duration"); + assert!(matches!( + duration.command, + Command::Dnd { + state: DndState::On, + for_duration: Some(_), + until: None, + } + )); + + let until = + Args::try_parse_from(["noticenterctl", "dnd", "on", "--until", "08:00"]).expect("clock"); + assert!(matches!( + until.command, + Command::Dnd { + state: DndState::On, + for_duration: None, + until: Some(_), + } + )); +} + +#[test] +fn timed_dnd_options_conflict_and_require_on_state_semantically() { + assert!(Args::try_parse_from([ + "noticenterctl", + "dnd", + "on", + "--for", + "30m", + "--until", + "08:00" + ]) + .is_err()); + + let command = Args::try_parse_from(["noticenterctl", "dnd", "off", "--for", "30m"]) + .expect("syntax should parse") + .command; + assert!(command.validate().is_err()); +} + #[test] fn parses_explicit_clear_variants() { for (name, expected) in [ diff --git a/crates/noticenterctl/src/cli/tests/dnd.rs b/crates/noticenterctl/src/cli/tests/dnd.rs new file mode 100644 index 000000000..8a3c1d11a --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/dnd.rs @@ -0,0 +1,43 @@ +use std::str::FromStr; + +use super::super::dnd::{future_timestamp, DndClockTime, DndDuration}; + +#[test] +fn duration_parser_accepts_supported_units_and_rejects_invalid_bounds() { + assert!(DndDuration::from_str("30m").is_ok()); + assert!(DndDuration::from_str("1h").is_ok()); + assert!(DndDuration::from_str("2d").is_ok()); + assert!(DndDuration::from_str("0m").is_err()); + assert!(DndDuration::from_str("30").is_err()); + assert!(DndDuration::from_str("366d").is_err()); +} + +#[test] +fn clock_parser_requires_exact_twenty_four_hour_time() { + assert!(DndClockTime::from_str("08:00").is_ok()); + assert!(DndClockTime::from_str("23:59").is_ok()); + assert!(DndClockTime::from_str("24:00").is_err()); + assert!(DndClockTime::from_str("8:00").is_err()); + assert!(DndClockTime::from_str("08:0").is_err()); + assert!(DndClockTime::from_str("8am").is_err()); +} + +#[test] +fn clock_deadline_resolves_to_a_future_occurrence() { + let now = chrono::Utc::now().timestamp(); + let deadline = DndClockTime::from_str("08:00") + .expect("valid clock") + .deadline() + .expect("next local occurrence"); + + assert!(deadline > now); + assert!(deadline <= now + 2 * 24 * 60 * 60); +} + +#[test] +fn future_timestamp_selects_the_next_absolute_occurrence() { + assert_eq!(future_timestamp(100, Some(200), None), Some(200)); + assert_eq!(future_timestamp(150, Some(100), Some(200)), Some(200)); + assert_eq!(future_timestamp(50, Some(200), Some(100)), Some(100)); + assert_eq!(future_timestamp(200, Some(100), Some(200)), None); +} diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs index 5c448cf44..ad7ba9253 100644 --- a/crates/noticenterctl/src/cli/tests/mod.rs +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -1,2 +1,3 @@ mod args; mod command; +mod dnd; diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index 023315db6..90ff113a5 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -44,6 +44,9 @@ pub trait ControlClient { // Turn do-not-disturb on or off directly fn set_dnd(&self, enabled: bool) -> ControlFuture<'_, ()>; + // Enable do-not-disturb until one absolute deadline + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()>; + // Flip do-not-disturb to the opposite of what it is now fn toggle_dnd(&self) -> ControlFuture<'_, ()>; @@ -116,6 +119,13 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::set_dnd(self, enabled))) } + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()> { + // Absolute timestamps keep CLI and panel deadlines consistent across daemon restarts + Box::pin(run_control_call(ControlProxy::set_dnd_until( + self, expires_at, + ))) + } + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { // Ask the daemon to flip do-not-disturb without the caller needing to know its current value Box::pin(run_control_call(ControlProxy::toggle_dnd(self))) diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index e0606f8b3..ce1c207c6 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -19,6 +19,8 @@ pub(super) async fn handle_command_with_debug_logs( command: Command, mut follow_logs: impl FnMut() -> Result<()>, ) -> Result<()> { + // Keep library-level dispatch safe even when a caller bypasses the CLI runner + command.validate()?; // CLI forwards work to the daemon match command { Command::TogglePanel => { @@ -74,10 +76,27 @@ pub(super) async fn handle_command_with_debug_logs( let notifications = client.list_history().await?; print_notifications("history", ¬ifications, allow_full)?; } - Command::Dnd { state } => match state { + Command::Dnd { + state, + for_duration, + until, + } => match state { DndState::On => { - // Explicit enable avoids ambiguous scripts - client.set_dnd(true).await?; + let expires_at = match (for_duration, until) { + (Some(duration), None) => Some(duration.deadline()?), + (None, Some(clock)) => Some(clock.deadline()?), + (None, None) => None, + // Clap rejects this pair, but keep dispatch defensive for direct tests + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!("--for and --until cannot be used together")); + } + }; + if let Some(expires_at) = expires_at { + client.set_dnd_until(expires_at).await?; + } else { + // Explicit enable without timing means indefinite DND + client.set_dnd(true).await?; + } } DndState::Off => { // Explicit disable avoids ambiguous scripts diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs index b8fcf18f7..72a49a92c 100644 --- a/crates/noticenterctl/src/dbus/tests/commands.rs +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -101,18 +101,24 @@ async fn dnd_commands_dispatch_to_matching_control_calls() { ( Command::Dnd { state: DndState::On, + for_duration: None, + until: None, }, RecordedCall::SetDnd(true), ), ( Command::Dnd { state: DndState::Off, + for_duration: None, + until: None, }, RecordedCall::SetDnd(false), ), ( Command::Dnd { state: DndState::Toggle, + for_duration: None, + until: None, }, RecordedCall::ToggleDnd, ), @@ -127,6 +133,51 @@ async fn dnd_commands_dispatch_to_matching_control_calls() { } } +#[tokio::test] +async fn timed_dnd_dispatches_one_future_absolute_deadline() { + use std::str::FromStr; + + let client = RecordingControlClient::default(); + let before = chrono::Utc::now().timestamp(); + handle_command( + &client, + Command::Dnd { + state: DndState::On, + for_duration: Some(crate::cli::DndDuration::from_str("30m").expect("valid duration")), + until: None, + }, + ) + .await + .expect("dispatch timed DND"); + let after = chrono::Utc::now().timestamp(); + + let calls = client.take_calls(); + let [RecordedCall::SetDndUntil(expires_at)] = calls.as_slice() else { + panic!("expected one timed DND call, got {calls:?}"); + }; + assert!(*expires_at >= before + 30 * 60); + assert!(*expires_at <= after + 30 * 60); +} + +#[tokio::test] +async fn timed_dnd_dispatch_rejects_non_on_state_without_calling_control() { + use std::str::FromStr; + + let client = RecordingControlClient::default(); + let result = handle_command( + &client, + Command::Dnd { + state: DndState::Off, + for_duration: Some(crate::cli::DndDuration::from_str("30m").expect("valid duration")), + until: None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(client.take_calls().is_empty()); +} + #[tokio::test] async fn notification_commands_dispatch_to_matching_control_calls() { let cases = [ diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs index a8b255b9f..616e593c3 100644 --- a/crates/noticenterctl/src/dbus/tests/support.rs +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -17,6 +17,7 @@ pub(super) enum RecordedCall { ListActive, ListHistory, SetDnd(bool), + SetDndUntil(i64), ToggleDnd, Inhibit { reason: String, scope: u32 }, Uninhibit(u64), @@ -106,6 +107,10 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::SetDnd(enabled), ()) } + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()> { + self.record(RecordedCall::SetDndUntil(expires_at), ()) + } + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { self.record(RecordedCall::ToggleDnd, ()) } diff --git a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs index e788a0be5..72775eccd 100644 --- a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs +++ b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs @@ -81,6 +81,7 @@ impl TestControl { Ok(ControlState { dnd_enabled: true, + dnd_expires_at: 0, history_count: 4, inhibited: false, inhibitor_count: 2, diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 365cc67fe..5df0799bc 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -14,6 +14,7 @@ fn sample_notification() -> NotificationView { key: "open".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), urgency: 1, is_transient: false, // CLI formatting only needs the lightweight transport fields From e63484ac720b1e785840b5fb8de3e5e199d770de Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 18 Jul 2026 21:58:18 -0500 Subject: [PATCH 004/275] refactor: keep module roots focused Summary: keep module roots focused. Scope: repository. --- .../css_check/geometry/parse/lengths/edges.rs | 101 ++++++ .../geometry/parse/lengths/expression.rs | 241 +++++++++++++ .../css_check/geometry/parse/lengths/mod.rs | 336 +----------------- .../unixnotis-center/src/media/runtime/mod.rs | 73 +--- .../src/media/runtime/signal.rs | 17 + .../src/media/runtime/startup.rs | 52 +++ .../src/ui/widgets/stats/group.rs | 43 +++ .../src/ui/widgets/stats/mod.rs | 111 +----- .../src/ui/widgets/stats/state.rs | 70 ++++ 9 files changed, 547 insertions(+), 497 deletions(-) create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs create mode 100644 crates/unixnotis-center/src/media/runtime/signal.rs create mode 100644 crates/unixnotis-center/src/media/runtime/startup.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/group.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/state.rs diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs new file mode 100644 index 000000000..feabc80f2 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs @@ -0,0 +1,101 @@ +//! CSS edge shorthand and single-length entry points + +use super::super::super::model::{HorizontalEdges, VerticalEdges}; +use super::tokenize::split_css_value_tokens; +use super::{parse_length_expression, CssCustomProperties, ResolvedCssValue}; + +// Length parsing stays local to the geometry parser so calc and var rules do not leak outward +pub(in crate::css_check::geometry) fn set_edge( + edge: &mut f32, + value: &str, + custom_properties: &CssCustomProperties, +) { + if let Some(parsed) = parse_single_length(value, custom_properties) { + *edge = parsed; + } +} + +pub(in crate::css_check::geometry) fn parse_box_edges( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + // CSS shorthands map to left and right edges based on token count + let values = parse_length_tokens(value, custom_properties); + match values.as_slice() { + [] => None, + [all] => Some(HorizontalEdges { + left: *all, + right: *all, + }), + [vertical, horizontal] => { + let _ = vertical; + Some(HorizontalEdges { + left: *horizontal, + right: *horizontal, + }) + } + [_, right, _, left] => Some(HorizontalEdges { + left: *left, + right: *right, + }), + [_, right, _] => Some(HorizontalEdges { + left: *right, + right: *right, + }), + _ => None, + } +} + +pub(in crate::css_check::geometry) fn parse_box_vertical_edges( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + // CSS shorthands map to top and bottom edges based on token count + let values = parse_length_tokens(value, custom_properties); + match values.as_slice() { + [] => None, + [all] => Some(VerticalEdges { + top: *all, + bottom: *all, + }), + [vertical, _horizontal] => Some(VerticalEdges { + top: *vertical, + bottom: *vertical, + }), + [top, _horizontal, bottom] => Some(VerticalEdges { + top: *top, + bottom: *bottom, + }), + [top, _, bottom, _left] => Some(VerticalEdges { + top: *top, + bottom: *bottom, + }), + _ => None, + } +} + +pub(in crate::css_check::geometry) fn parse_single_length( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + let trimmed = value.trim(); + if let Some(parsed) = parse_length_expression(trimmed, custom_properties, 0) { + return parsed.into_length(); + } + + // Fall back to the first token so old shorthand behavior stays intact + split_css_value_tokens(trimmed) + .into_iter() + .find_map(|token| parse_length_expression(token, custom_properties, 0)) + .and_then(ResolvedCssValue::into_length) +} + +fn parse_length_tokens(value: &str, custom_properties: &CssCustomProperties) -> Vec { + // Four tokens are enough for the full CSS box shorthand + split_css_value_tokens(value) + .into_iter() + .filter_map(|token| parse_length_expression(token, custom_properties, 0)) + .filter_map(ResolvedCssValue::into_length) + .take(4) + .collect() +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs new file mode 100644 index 000000000..d8f035974 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs @@ -0,0 +1,241 @@ +//! Typed arithmetic parser for CSS length expressions + +use super::tokenize::consume_balanced_group; +use super::units::parse_atomic_value; +use super::CssCustomProperties; + +pub(in crate::css_check::geometry::parse) fn parse_length_expression( + value: &str, + custom_properties: &CssCustomProperties, + depth: usize, +) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || depth > 8 { + // Recursion limits keep broken variable loops from spinning forever + return None; + } + + LengthExpressionParser::new(trimmed, custom_properties, depth).parse() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::css_check::geometry::parse) enum ResolvedCssValue { + // Length values may participate in compatible arithmetic and become geometry + Length(f32), + // Scalars are valid only as intermediate scale or divisor values + Scalar(f32), +} + +impl ResolvedCssValue { + pub(super) const fn into_length(self) -> Option { + match self { + Self::Length(value) => Some(value), + // Plain scalars only make sense while calc math is still in progress + Self::Scalar(_) => None, + } + } + + fn add(self, rhs: Self) -> Option { + // Addition cannot mix a dimensioned length with a scalar + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left + right)), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left + right)), + _ => None, + } + } + + fn subtract(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left - right)), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left - right)), + _ => None, + } + } + + fn multiply(self, rhs: Self) -> Option { + // Multiplication accepts one dimensioned side at most + match (self, rhs) { + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left * right)), + (Self::Length(length), Self::Scalar(scale)) + | (Self::Scalar(scale), Self::Length(length)) => Some(Self::Length(length * scale)), + _ => None, + } + } + + fn divide(self, rhs: Self) -> Option { + // Only scalar divisors preserve a valid CSS length dimension + match (self, rhs) { + (_, Self::Scalar(divisor)) if divisor.abs() < f32::EPSILON => None, + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left / right)), + (Self::Length(length), Self::Scalar(divisor)) => Some(Self::Length(length / divisor)), + _ => None, + } + } + + fn apply_sign(self, sign: f32) -> Self { + match self { + Self::Length(value) => Self::Length(value * sign), + Self::Scalar(value) => Self::Scalar(value * sign), + } + } + + pub(super) const fn min_with(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.min(right))), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.min(right))), + _ => None, + } + } + + pub(super) const fn max_with(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.max(right))), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.max(right))), + _ => None, + } + } + + pub(super) fn clamp_between(self, lower: Self, upper: Self) -> Option { + // clamp() keeps the value inside the two bounds once all three share one type + lower.max_with(self)?.min_with(upper) + } +} + +struct LengthExpressionParser<'a> { + input: &'a str, + cursor: usize, + // Resolved custom properties are passed in so var() can stay local to the tracked selector + custom_properties: &'a CssCustomProperties, + // Depth keeps broken recursive tokens from looping forever + depth: usize, +} + +impl<'a> LengthExpressionParser<'a> { + const fn new(input: &'a str, custom_properties: &'a CssCustomProperties, depth: usize) -> Self { + Self { + input, + cursor: 0, + custom_properties, + depth, + } + } + + fn parse(mut self) -> Option { + let value = self.parse_additive_expression()?; + self.skip_whitespace(); + // Partial parses are rejected so geometry only trusts whole expressions + (self.cursor == self.input.len()).then_some(value) + } + + fn parse_additive_expression(&mut self) -> Option { + let mut value = self.parse_multiplicative_expression()?; + loop { + self.skip_whitespace(); + if self.consume_char('+') { + // Addition stays left-associative like normal CSS calc evaluation + value = value.add(self.parse_multiplicative_expression()?)?; + continue; + } + if self.consume_char('-') { + value = value.subtract(self.parse_multiplicative_expression()?)?; + continue; + } + break; + } + Some(value) + } + + fn parse_multiplicative_expression(&mut self) -> Option { + // Multiplication binds more tightly than the additive parser above it + let mut value = self.parse_factor()?; + loop { + self.skip_whitespace(); + if self.consume_char('*') { + value = value.multiply(self.parse_factor()?)?; + continue; + } + if self.consume_char('/') { + value = value.divide(self.parse_factor()?)?; + continue; + } + break; + } + Some(value) + } + + fn parse_factor(&mut self) -> Option { + self.skip_whitespace(); + + // Repeated unary signs are folded before reading a group or atomic token + let mut sign = 1.0_f32; + loop { + if self.consume_char('+') { + self.skip_whitespace(); + continue; + } + if self.consume_char('-') { + sign *= -1.0; + self.skip_whitespace(); + continue; + } + break; + } + + if self.consume_char('(') { + let value = self.parse_additive_expression()?; + self.skip_whitespace(); + self.consume_char(')').then_some(value.apply_sign(sign)) + } else { + let token = self.consume_token()?; + parse_atomic_value(token, self.custom_properties, self.depth + 1) + .map(|value| value.apply_sign(sign)) + } + } + + fn consume_token(&mut self) -> Option<&'a str> { + self.skip_whitespace(); + // Cursor positions stay on UTF-8 boundaries because non-ASCII bytes are token content + let start = self.cursor; + let bytes = self.input.as_bytes(); + + while self.cursor < bytes.len() { + let byte = bytes[self.cursor]; + if byte.is_ascii_whitespace() || matches!(byte, b'+' | b'-' | b'*' | b'/' | b')') { + break; + } + + if byte == b'(' { + // Nested groups are consumed whole so inner operators do not split the token + self.cursor = consume_balanced_group(self.input, self.cursor)?; + continue; + } + + self.cursor += 1; + } + + (self.cursor > start).then(|| self.input[start..self.cursor].trim()) + } + + fn skip_whitespace(&mut self) { + // Character iteration handles every Unicode whitespace boundary safely + while let Some(ch) = self.input[self.cursor..].chars().next() { + if ch.is_whitespace() { + self.cursor += ch.len_utf8(); + } else { + break; + } + } + } + + fn consume_char(&mut self, expected: char) -> bool { + // Operators are consumed only when the next complete character matches + let Some(ch) = self.input[self.cursor..].chars().next() else { + return false; + }; + if ch != expected { + return false; + } + self.cursor += ch.len_utf8(); + true + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs index 9a7de6d49..faf4b37d6 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs @@ -1,337 +1,19 @@ -use super::super::model::{HorizontalEdges, VerticalEdges}; -use super::CssCustomProperties; +//! CSS length parsing split by shorthand, expression, token, and function logic +mod edges; +mod expression; mod resolve_calc; mod resolve_compare; mod resolve_var; mod tokenize; mod units; +use super::CssCustomProperties; + +pub(in super::super) use edges::set_edge; +pub(in super::super) use edges::{parse_box_edges, parse_box_vertical_edges, parse_single_length}; +pub(super) use expression::{parse_length_expression, ResolvedCssValue}; + #[cfg(test)] #[path = "tests/cases.rs"] mod tests; - -use self::tokenize::{consume_balanced_group, split_css_value_tokens}; -use self::units::parse_atomic_value; - -// Length parsing stays local to the geometry parser so calc and var rules do not leak outward -pub(in super::super) fn set_edge( - edge: &mut f32, - value: &str, - custom_properties: &CssCustomProperties, -) { - if let Some(parsed) = parse_single_length(value, custom_properties) { - *edge = parsed; - } -} - -pub(in super::super) fn parse_box_edges( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - // CSS shorthands map to left and right edges based on token count - let values = parse_length_tokens(value, custom_properties); - match values.as_slice() { - [] => None, - [all] => Some(HorizontalEdges { - left: *all, - right: *all, - }), - [vertical, horizontal] => { - let _ = vertical; - Some(HorizontalEdges { - left: *horizontal, - right: *horizontal, - }) - } - [_, right, _, left] => Some(HorizontalEdges { - left: *left, - right: *right, - }), - [_, right, _] => Some(HorizontalEdges { - left: *right, - right: *right, - }), - _ => None, - } -} - -pub(in super::super) fn parse_box_vertical_edges( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - // CSS shorthands map to top and bottom edges based on token count - let values = parse_length_tokens(value, custom_properties); - match values.as_slice() { - [] => None, - [all] => Some(VerticalEdges { - top: *all, - bottom: *all, - }), - [vertical, _horizontal] => Some(VerticalEdges { - top: *vertical, - bottom: *vertical, - }), - [top, _horizontal, bottom] => Some(VerticalEdges { - top: *top, - bottom: *bottom, - }), - [top, _, bottom, _left] => Some(VerticalEdges { - top: *top, - bottom: *bottom, - }), - _ => None, - } -} - -pub(in super::super) fn parse_single_length( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - let trimmed = value.trim(); - if let Some(parsed) = parse_length_expression(trimmed, custom_properties, 0) { - return parsed.into_length(); - } - - // Fall back to the first token so old shorthand behavior stays intact - split_css_value_tokens(trimmed) - .into_iter() - .find_map(|token| parse_length_expression(token, custom_properties, 0)) - .and_then(ResolvedCssValue::into_length) -} - -fn parse_length_tokens(value: &str, custom_properties: &CssCustomProperties) -> Vec { - // Four tokens are enough for the full CSS box shorthand - split_css_value_tokens(value) - .into_iter() - .filter_map(|token| parse_length_expression(token, custom_properties, 0)) - .filter_map(ResolvedCssValue::into_length) - .take(4) - .collect() -} - -pub(super) fn parse_length_expression( - value: &str, - custom_properties: &CssCustomProperties, - depth: usize, -) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() || depth > 8 { - // Recursion limits keep broken variable loops from spinning forever - return None; - } - - LengthExpressionParser::new(trimmed, custom_properties, depth).parse() -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) enum ResolvedCssValue { - Length(f32), - Scalar(f32), -} - -impl ResolvedCssValue { - const fn into_length(self) -> Option { - match self { - Self::Length(value) => Some(value), - // Plain scalars only make sense while calc math is still in progress - Self::Scalar(_) => None, - } - } - - fn add(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left + right)), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left + right)), - _ => None, - } - } - - fn subtract(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left - right)), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left - right)), - _ => None, - } - } - - fn multiply(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left * right)), - (Self::Length(length), Self::Scalar(scale)) - | (Self::Scalar(scale), Self::Length(length)) => Some(Self::Length(length * scale)), - _ => None, - } - } - - fn divide(self, rhs: Self) -> Option { - match (self, rhs) { - (_, Self::Scalar(divisor)) if divisor.abs() < f32::EPSILON => None, - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left / right)), - (Self::Length(length), Self::Scalar(divisor)) => Some(Self::Length(length / divisor)), - _ => None, - } - } - - fn apply_sign(self, sign: f32) -> Self { - match self { - Self::Length(value) => Self::Length(value * sign), - Self::Scalar(value) => Self::Scalar(value * sign), - } - } - - const fn min_with(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.min(right))), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.min(right))), - _ => None, - } - } - - const fn max_with(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.max(right))), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.max(right))), - _ => None, - } - } - - fn clamp_between(self, lower: Self, upper: Self) -> Option { - // clamp() keeps the value inside the two bounds once all three share one type - lower.max_with(self)?.min_with(upper) - } -} - -struct LengthExpressionParser<'a> { - input: &'a str, - cursor: usize, - // Resolved custom properties are passed in so var() can stay local to the tracked selector - custom_properties: &'a CssCustomProperties, - // Depth keeps broken recursive tokens from looping forever - depth: usize, -} - -impl<'a> LengthExpressionParser<'a> { - const fn new(input: &'a str, custom_properties: &'a CssCustomProperties, depth: usize) -> Self { - Self { - input, - cursor: 0, - custom_properties, - depth, - } - } - - fn parse(mut self) -> Option { - let value = self.parse_additive_expression()?; - self.skip_whitespace(); - // Partial parses are rejected so geometry only trusts whole expressions - (self.cursor == self.input.len()).then_some(value) - } - - fn parse_additive_expression(&mut self) -> Option { - let mut value = self.parse_multiplicative_expression()?; - loop { - self.skip_whitespace(); - if self.consume_char('+') { - // Addition stays left-associative like normal CSS calc evaluation - value = value.add(self.parse_multiplicative_expression()?)?; - continue; - } - if self.consume_char('-') { - value = value.subtract(self.parse_multiplicative_expression()?)?; - continue; - } - break; - } - Some(value) - } - - fn parse_multiplicative_expression(&mut self) -> Option { - let mut value = self.parse_factor()?; - loop { - self.skip_whitespace(); - if self.consume_char('*') { - value = value.multiply(self.parse_factor()?)?; - continue; - } - if self.consume_char('/') { - value = value.divide(self.parse_factor()?)?; - continue; - } - break; - } - Some(value) - } - - fn parse_factor(&mut self) -> Option { - self.skip_whitespace(); - - let mut sign = 1.0_f32; - loop { - if self.consume_char('+') { - self.skip_whitespace(); - continue; - } - if self.consume_char('-') { - sign *= -1.0; - self.skip_whitespace(); - continue; - } - break; - } - - if self.consume_char('(') { - let value = self.parse_additive_expression()?; - self.skip_whitespace(); - self.consume_char(')').then_some(value.apply_sign(sign)) - } else { - let token = self.consume_token()?; - parse_atomic_value(token, self.custom_properties, self.depth + 1) - .map(|value| value.apply_sign(sign)) - } - } - - fn consume_token(&mut self) -> Option<&'a str> { - self.skip_whitespace(); - let start = self.cursor; - let bytes = self.input.as_bytes(); - - while self.cursor < bytes.len() { - let byte = bytes[self.cursor]; - if byte.is_ascii_whitespace() || matches!(byte, b'+' | b'-' | b'*' | b'/' | b')') { - break; - } - - if byte == b'(' { - // Nested groups are consumed whole so inner operators do not split the token - self.cursor = consume_balanced_group(self.input, self.cursor)?; - continue; - } - - self.cursor += 1; - } - - (self.cursor > start).then(|| self.input[start..self.cursor].trim()) - } - - fn skip_whitespace(&mut self) { - while let Some(ch) = self.input[self.cursor..].chars().next() { - if ch.is_whitespace() { - self.cursor += ch.len_utf8(); - } else { - break; - } - } - } - - fn consume_char(&mut self, expected: char) -> bool { - let Some(ch) = self.input[self.cursor..].chars().next() else { - return false; - }; - if ch != expected { - return false; - } - self.cursor += ch.len_utf8(); - true - } -} diff --git a/crates/unixnotis-center/src/media/runtime/mod.rs b/crates/unixnotis-center/src/media/runtime/mod.rs index 58f22f12a..32b7c7912 100644 --- a/crates/unixnotis-center/src/media/runtime/mod.rs +++ b/crates/unixnotis-center/src/media/runtime/mod.rs @@ -1,4 +1,4 @@ -//! Media task startup and runtime orchestration +//! Media runtime module wiring mod cache; mod dispatch; @@ -6,76 +6,17 @@ mod r#loop; mod owner; mod refresh; mod schedule; +mod signal; mod snapshot; +mod startup; mod state; -use tokio::sync::mpsc; -use unixnotis_core::MediaConfig; - -use crate::control::UiEvent; - -use super::api::MediaHandle; +pub(super) use signal::{MediaRefreshOrigin, MediaSignal}; +#[cfg(test)] +use startup::normalize_media_config; +pub(super) use startup::start_media_task; -pub(super) const MEDIA_COMMAND_CAPACITY: usize = 32; pub(super) const MEDIA_SIGNAL_CAPACITY: usize = 256; -pub(super) fn start_media_task( - runtime: &tokio::runtime::Handle, - config: MediaConfig, - sender: async_channel::Sender, -) -> Option { - if !config.enabled { - // Disabled media means no background work and no command channel - return None; - } - - // Lowercase tokens once so the hot path can stay allocation-free - let config = normalize_media_config(config); - // The command channel stays small because button presses arrive in short bursts - let (command_tx, command_rx) = mpsc::channel(MEDIA_COMMAND_CAPACITY); - // The runtime task owns player state and feeds snapshots back to the UI - runtime.spawn(r#loop::run_event_loop(config, sender, command_rx)); - - Some(MediaHandle::connected(command_tx, runtime.clone())) -} - -fn normalize_media_config(mut config: MediaConfig) -> MediaConfig { - // Lowercase these token lists once so the hot path can use plain contains checks - config.allowlist = config - .allowlist - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - // Browser family matching uses the same lowercase path - config.browser_tokens = config - .browser_tokens - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - // Denylist entries follow the same normalized form - config.denylist = config - .denylist - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - config -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum MediaRefreshOrigin { - // Native bus traffic can justify one bounded fallback sweep - Bus, - // Synthetic retries never re-arm themselves because that would become polling - Fallback, -} - -#[derive(Debug)] -pub(super) enum MediaSignal { - PropertiesChanged { - bus_name: String, - origin: MediaRefreshOrigin, - }, -} - #[cfg(test)] mod tests; diff --git a/crates/unixnotis-center/src/media/runtime/signal.rs b/crates/unixnotis-center/src/media/runtime/signal.rs new file mode 100644 index 000000000..efd2bde78 --- /dev/null +++ b/crates/unixnotis-center/src/media/runtime/signal.rs @@ -0,0 +1,17 @@ +//! Internal signals that drive bounded media refresh work + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::media) enum MediaRefreshOrigin { + // Native bus traffic can justify one bounded fallback sweep + Bus, + // Synthetic retries never re-arm themselves because that would become polling + Fallback, +} + +#[derive(Debug)] +pub(in crate::media) enum MediaSignal { + PropertiesChanged { + bus_name: String, + origin: MediaRefreshOrigin, + }, +} diff --git a/crates/unixnotis-center/src/media/runtime/startup.rs b/crates/unixnotis-center/src/media/runtime/startup.rs new file mode 100644 index 000000000..83ca39421 --- /dev/null +++ b/crates/unixnotis-center/src/media/runtime/startup.rs @@ -0,0 +1,52 @@ +//! Media runtime startup and one-time configuration normalization + +use tokio::sync::mpsc; +use unixnotis_core::MediaConfig; + +use crate::control::UiEvent; + +use super::super::api::MediaHandle; + +const MEDIA_COMMAND_CAPACITY: usize = 32; + +pub(in crate::media) fn start_media_task( + runtime: &tokio::runtime::Handle, + config: MediaConfig, + sender: async_channel::Sender, +) -> Option { + if !config.enabled { + // Disabled media means no background work and no command channel + return None; + } + + // Lowercase tokens once so the hot path can stay allocation-free + let config = normalize_media_config(config); + // The command channel stays small because button presses arrive in short bursts + let (command_tx, command_rx) = mpsc::channel(MEDIA_COMMAND_CAPACITY); + // The runtime task owns player state and feeds snapshots back to the UI + runtime.spawn(super::r#loop::run_event_loop(config, sender, command_rx)); + + Some(MediaHandle::connected(command_tx, runtime.clone())) +} + +pub(super) fn normalize_media_config(mut config: MediaConfig) -> MediaConfig { + // Lowercase these token lists once so the hot path can use plain contains checks + config.allowlist = config + .allowlist + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + // Browser family matching uses the same lowercase path + config.browser_tokens = config + .browser_tokens + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + // Denylist entries follow the same normalized form + config.denylist = config + .denylist + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + config +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/group.rs b/crates/unixnotis-center/src/ui/widgets/stats/group.rs new file mode 100644 index 000000000..8139aebd6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/group.rs @@ -0,0 +1,43 @@ +//! Shared refresh grouping for cards backed by the same built-in reader + +use std::collections::HashMap; +use std::time::Instant; + +use super::{BuiltinStat, BuiltinStatKey, StatItem}; + +pub(super) struct BuiltinRefreshGroup { + // One live builtin reader is enough for all cards that point at the same source + pub(super) stat: BuiltinStat, + // Every item in the group receives the same sampled value and updated reader state + pub(super) items: Vec, +} + +pub(super) fn collect_builtin_groups( + items: &[StatItem], + now: Instant, + force: bool, +) -> HashMap { + let mut groups: HashMap = HashMap::new(); + + for item in items { + let Some((key, stat)) = item.take_builtin_refresh(now, force) else { + continue; + }; + + // Keep one reader per unique builtin source, then fan the result out to every card + match groups.get_mut(&key) { + Some(group) => group.items.push(item.clone()), + None => { + groups.insert( + key, + BuiltinRefreshGroup { + stat, + items: vec![item.clone()], + }, + ); + } + } + } + + groups +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs index db604ba3f..44a91cc4c 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs @@ -1,116 +1,19 @@ -//! Statistic widgets and refresh orchestration +//! Statistic widget module wiring mod build; mod card; mod css; +mod group; +mod state; mod stats_builtin; #[cfg(test)] #[path = "tests/grid.rs"] mod tests; mod worker; -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::rc::Rc; -use std::time::Instant; - -use unixnotis_core::StatWidgetConfig; - +use self::group::{collect_builtin_groups, BuiltinRefreshGroup}; +pub use self::state::StatGrid; +use self::state::StatItem; +use self::state::{apply_cached_value, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome}; use self::stats_builtin::{BuiltinStat, BuiltinStatKey}; use super::utils::RefreshBackoff; - -pub struct StatGrid { - // FlowBox root is embedded by the panel widget tree - root: gtk::FlowBox, - // Per-stat item state is retained for refresh scheduling - items: Vec, -} - -#[derive(Clone)] -struct StatItem { - // Raw config is retained for command and plugin selection plus labels - config: StatWidgetConfig, - // Root card inserted into the grid - root: gtk::Box, - // Render target for the latest stat value - value_label: gtk::Label, - // Optional builtin reader reused across refresh calls - builtin: Rc>>, - // Guard prevents overlapping command or builtin reads - inflight: Rc>, - // Cached value avoids unnecessary relayout for unchanged results - last_value: Rc>>, - // Backoff reduces repeated reads when the value is stable - refresh_backoff: Rc>, -} - -struct BuiltinStatJob { - // Builtin reader variant to execute on the worker thread - stat: BuiltinStat, - // One-shot response channel used to return the sampled value - respond: async_channel::Sender<(BuiltinStat, String)>, -} - -struct BuiltinStatWorker { - // Bounded queue feeding the dedicated builtin worker thread - tx: crossbeam_channel::Sender, - // True when worker startup failed and callers should read inline - inline_fallback: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum BuiltinSubmitOutcome { - // Job was accepted by the worker queue - Submitted, - // Queue is healthy but currently saturated - QueueFull, - // Worker is unavailable and caller must use inline fallback - WorkerUnavailable, -} - -fn apply_cached_value(label: >k::Label, cache: &Rc>>) { - if let Some(value) = cache.borrow().as_ref() { - if label.text().as_str() != value { - label.set_text(value); - } - } else if label.text().as_str() != "n/a" { - label.set_text("n/a"); - } -} - -struct BuiltinRefreshGroup { - // One live builtin reader is enough for all cards that point at the same source - stat: BuiltinStat, - // Every item in the group receives the same sampled value and updated reader state - items: Vec, -} - -fn collect_builtin_groups( - items: &[StatItem], - now: Instant, - force: bool, -) -> HashMap { - let mut groups: HashMap = HashMap::new(); - - for item in items { - let Some((key, stat)) = item.take_builtin_refresh(now, force) else { - continue; - }; - - // Keep one reader per unique builtin source, then fan the result out to every card - match groups.get_mut(&key) { - Some(group) => group.items.push(item.clone()), - None => { - groups.insert( - key, - BuiltinRefreshGroup { - stat, - items: vec![item.clone()], - }, - ); - } - } - } - - groups -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/state.rs b/crates/unixnotis-center/src/ui/widgets/stats/state.rs new file mode 100644 index 000000000..6edc83bed --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/state.rs @@ -0,0 +1,70 @@ +//! Retained widget and worker state for statistic cards + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use unixnotis_core::StatWidgetConfig; + +use super::super::utils::RefreshBackoff; +use super::BuiltinStat; + +pub struct StatGrid { + // FlowBox root is embedded by the panel widget tree + pub(super) root: gtk::FlowBox, + // Per-stat item state is retained for refresh scheduling + pub(super) items: Vec, +} + +#[derive(Clone)] +pub(super) struct StatItem { + // Raw config is retained for command and plugin selection plus labels + pub(super) config: StatWidgetConfig, + // Root card inserted into the grid + pub(super) root: gtk::Box, + // Render target for the latest stat value + pub(super) value_label: gtk::Label, + // Optional builtin reader reused across refresh calls + pub(super) builtin: Rc>>, + // Guard prevents overlapping command or builtin reads + pub(super) inflight: Rc>, + // Cached value avoids unnecessary relayout for unchanged results + pub(super) last_value: Rc>>, + // Backoff reduces repeated reads when the value is stable + pub(super) refresh_backoff: Rc>, +} + +pub(super) struct BuiltinStatJob { + // Builtin reader variant to execute on the worker thread + pub(super) stat: BuiltinStat, + // One-shot response channel used to return the sampled value + pub(super) respond: async_channel::Sender<(BuiltinStat, String)>, +} + +pub(super) struct BuiltinStatWorker { + // Bounded queue feeding the dedicated builtin worker thread + pub(super) tx: crossbeam_channel::Sender, + // True when worker startup failed and callers should read inline + pub(super) inline_fallback: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum BuiltinSubmitOutcome { + // Job was accepted by the worker queue + Submitted, + // Queue is healthy but currently saturated + QueueFull, + // Worker is unavailable and caller must use inline fallback + WorkerUnavailable, +} + +pub(super) fn apply_cached_value(label: >k::Label, cache: &Rc>>) { + if let Some(value) = cache.borrow().as_ref() { + // Stable values avoid an unnecessary GTK property update + if label.text().as_str() != value { + label.set_text(value); + } + } else if label.text().as_str() != "n/a" { + // Missing samples share one predictable fallback label + label.set_text("n/a"); + } +} From f508debb0878272399b1e692786fb7eb7cdd362e Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 18 Jul 2026 23:59:40 -0500 Subject: [PATCH 005/275] fix(daemon): make inline replies generation safe Summary: make inline replies generation safe. Scope: daemon. --- .../src/daemon/control/reply.rs | 85 ++++++++-- .../src/daemon/control/tests/mod.rs | 1 - .../src/daemon/control/tests/reply.rs | 160 ++++++++++++++++-- .../src/daemon/state/notifications.rs | 29 +++- .../src/daemon/state/tests/notifications.rs | 30 ++++ crates/unixnotis-daemon/src/store/core.rs | 8 +- .../unixnotis-daemon/src/store/lifecycle.rs | 16 ++ .../unixnotis-daemon/src/store/tests/reply.rs | 46 ++++- 8 files changed, 341 insertions(+), 34 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index d20d074fd..d8a14b5e1 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -1,6 +1,9 @@ //! KDE-compatible inline reply handling for active notifications -use unixnotis_core::util; +use std::future::Future; + +use unixnotis_core::Notification; +use zbus::fdo::DBusProxy; use zbus::SignalContext; use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; @@ -8,6 +11,7 @@ use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH} use super::ControlServer; pub(super) const MAX_REPLY_TEXT_BYTES: usize = 4 * 1024; +const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; impl ControlServer { pub(super) async fn submit_inline_reply( @@ -15,10 +19,24 @@ impl ControlServer { id: u32, reply_text: &str, ) -> zbus::fdo::Result<()> { + self.submit_inline_reply_with_post_emit(id, reply_text, || std::future::ready(())) + .await + } + + async fn submit_inline_reply_with_post_emit( + &self, + id: u32, + reply_text: &str, + post_emit: F, + ) -> zbus::fdo::Result<()> + where + F: FnOnce() -> Fut, + Fut: Future, + { // Text validation happens before any notification lookup or signal work - let reply_text = sanitize_reply_text(reply_text)?; - let is_resident = { - // Keep the store lock only for the live-action eligibility snapshot + let reply_text = validate_reply_text(reply_text)?; + let target = { + // Keep the Arc so later cleanup can distinguish a same-ID replacement let store = self.state.store.lock().await; store.active_inline_reply_target(id).ok_or_else(|| { zbus::fdo::Error::InvalidArgs( @@ -26,29 +44,54 @@ impl ControlServer { ) })? }; + self.ensure_reply_sender_is_live(&target).await?; // Emit only after all live-state and text checks have passed let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) .map_err(to_fdo_error)?; - NotificationServer::notification_replied(&context, id, &reply_text) + NotificationServer::notification_replied(&context, id, reply_text) .await .map_err(to_fdo_error)?; + // The test seam models an application replacing the row while handling the signal + post_emit().await; - if !is_resident { - // Non-resident replies leave no stale action behind in active or history lists + if !target.is_resident { + // Cleanup applies only if the exact replied generation is still active self.state - .dismiss_from_panel(id) + .dismiss_active_if_current(id, &target) .await .map_err(to_fdo_error)?; } // Resident notifications remain active for later updates from the sender Ok(()) } + + async fn ensure_reply_sender_is_live(&self, target: &Notification) -> zbus::fdo::Result<()> { + let sender = target + .sender_name + .as_deref() + .ok_or_else(application_unavailable_error)?; + let bus_name = zbus::names::BusName::try_from(sender).map_err(|error| { + // Stored sender names should always be unique D-Bus names from message headers + tracing::debug!(?error, "inline reply target has an invalid sender name"); + application_unavailable_error() + })?; + let proxy = DBusProxy::new(self.state.connection()) + .await + .map_err(to_fdo_error)?; + let has_owner = proxy + .name_has_owner(bus_name) + .await + .map_err(|err| zbus::fdo::Error::Failed(err.to_string()))?; + if !has_owner { + return Err(application_unavailable_error()); + } + Ok(()) + } } -pub(super) fn sanitize_reply_text(reply_text: &str) -> zbus::fdo::Result { - // Display controls and line breaks are removed because GtkEntry is single-line - let reply_text = util::sanitize_inline_display_text(reply_text); +pub(super) fn validate_reply_text(reply_text: &str) -> zbus::fdo::Result<&str> { + // Outer spacing is not message content, while interior Unicode remains byte-for-byte intact let reply_text = reply_text.trim(); if reply_text.is_empty() { return Err(zbus::fdo::Error::InvalidArgs( @@ -61,5 +104,23 @@ pub(super) fn sanitize_reply_text(reply_text: &str) -> zbus::fdo::Result "reply text exceeds {MAX_REPLY_TEXT_BYTES} bytes" ))); } - Ok(reply_text.to_string()) + if reply_text.contains('\0') { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text contains an embedded NUL".to_string(), + )); + } + if reply_text.contains(['\r', '\n']) { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text must contain one line".to_string(), + )); + } + Ok(reply_text) } + +fn application_unavailable_error() -> zbus::fdo::Error { + zbus::fdo::Error::Failed(APPLICATION_UNAVAILABLE.to_string()) +} + +#[cfg(test)] +#[path = "tests/reply.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index f258b4171..9ae383e17 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,4 +1,3 @@ mod clear; -mod reply; mod sanitize; mod server; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 1cb8b495b..f86aa3cda 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -8,33 +8,63 @@ use zbus::message::Type; use zbus::zvariant::OwnedValue; use zbus::{Connection, MatchRule, MessageStream}; -use super::super::reply::{sanitize_reply_text, MAX_REPLY_TEXT_BYTES}; use super::super::ControlServer; +use super::{validate_reply_text, MAX_REPLY_TEXT_BYTES}; use crate::daemon::NOTIFICATIONS_OBJECT_PATH; use crate::test_support::daemon_state_for_test; #[test] -fn sanitize_reply_text_keeps_normal_text_and_trims_outer_spacing() { +fn validate_reply_text_keeps_message_content_and_trims_outer_spacing() { assert_eq!( - sanitize_reply_text(" See you soon ").expect("valid reply"), + validate_reply_text(" See you soon ").expect("valid reply"), "See you soon" ); } #[test] -fn sanitize_reply_text_rejects_empty_control_only_and_oversized_values() { - assert!(sanitize_reply_text(" \n\t ").is_err()); - assert!(sanitize_reply_text("\u{202e}").is_err()); - assert!(sanitize_reply_text(&"x".repeat(MAX_REPLY_TEXT_BYTES + 1)).is_err()); +fn validate_reply_text_preserves_unicode_and_bidirectional_content_exactly() { + let messages = [ + "مرحبًا، سأصل قريبًا", + "שלום, אגיע בקרוב", + "Reply 👩🏽‍💻 cafe\u{301}", + "English \u{2067}مرحبا שלום\u{2069} English", + ]; + + for message in messages { + assert_eq!( + validate_reply_text(message).expect("valid Unicode"), + message + ); + } +} + +#[test] +fn validate_reply_text_accepts_exact_byte_limit() { + let reply = "🙂".repeat(MAX_REPLY_TEXT_BYTES / "🙂".len()); + + assert_eq!(reply.len(), MAX_REPLY_TEXT_BYTES); + assert_eq!(validate_reply_text(&reply).expect("exact limit"), reply); +} + +#[test] +fn validate_reply_text_rejects_empty_oversized_nul_and_multiline_values() { + assert!(validate_reply_text(" \n\t ").is_err()); + assert!(validate_reply_text(&"x".repeat(MAX_REPLY_TEXT_BYTES + 1)).is_err()); + assert!(validate_reply_text("before\0after").is_err()); + assert!(validate_reply_text("line one\nline two").is_err()); } #[tokio::test] async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state).await; let id = { let mut store = state.store.lock().await; - store.insert(reply_notification(false), 0).notification.id + store + .insert(reply_notification(false, &sender), 0) + .notification + .id }; ControlServer::new(state.clone()) @@ -52,10 +82,14 @@ async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { #[tokio::test] async fn submit_inline_reply_keeps_resident_notification_live() { let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state).await; let id = { let mut store = state.store.lock().await; - store.insert(reply_notification(true), 0).notification.id + store + .insert(reply_notification(true, &sender), 0) + .notification + .id }; ControlServer::new(state.clone()) @@ -69,7 +103,106 @@ async fn submit_inline_reply_keeps_resident_notification_live() { assert_eq!(state.store.lock().await.list_active().len(), 1); } -fn reply_notification(is_resident: bool) -> Notification { +#[tokio::test] +async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state).await; + let messages = [ + "مرحبًا، سأصل قريبًا".to_string(), + "שלום, אגיע בקרוב".to_string(), + "Reply 👩🏽‍💻 cafe\u{301}".to_string(), + "English \u{2067}مرحبا שלום\u{2069} English".to_string(), + "🙂".repeat(MAX_REPLY_TEXT_BYTES / "🙂".len()), + ]; + + for message in messages { + let id = { + let mut store = state.store.lock().await; + store + .insert(reply_notification(true, &sender), 0) + .notification + .id + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, &message) + .await + .expect("submit exact reply text"); + + let (signal_id, signal_text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(signal_text, message); + } +} + +#[tokio::test] +async fn reply_listener_replacement_survives_generation_safe_dismissal() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state).await; + let id = { + let mut store = state.store.lock().await; + store + .insert(reply_notification(false, &sender), 0) + .notification + .id + }; + let replacement_state = state.clone(); + let replacement_sender = sender.clone(); + + ControlServer::new(state.clone()) + .submit_inline_reply_with_post_emit(id, "yes", move || async move { + // This models the sender updating the same row while handling the reply signal + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!((signal_id, text.as_str()), (id, "yes")); + let mut replacement = reply_notification(false, &replacement_sender); + replacement.summary = "Reply received".to_string(); + let outcome = replacement_state.store.lock().await.insert(replacement, id); + assert!(outcome.replaced); + }) + .await + .expect("reply with replacement"); + + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("same-ID replacement should remain active"); + assert_eq!(active.summary, "Reply received"); +} + +#[tokio::test] +async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let id = { + let mut store = state.store.lock().await; + store + .insert(reply_notification(false, &sender), 0) + .notification + .id + }; + sender.close().await.expect("close sender connection"); + + let error = ControlServer::new(state.clone()) + .submit_inline_reply(id, "Anyone there?") + .await + .expect_err("closed sender must reject replies"); + + assert!(error + .to_string() + .contains("The application is no longer available")); + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_some()); +} + +fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { Notification { id: 0, app_name: "Messages".to_string(), @@ -95,7 +228,12 @@ fn reply_notification(is_resident: bool) -> Notification { image: NotificationImage::default(), expire_timeout: 0, received_at: Utc::now(), - sender_name: Some(":1.test".to_string()), + sender_name: Some( + sender + .unique_name() + .expect("sender connection unique name") + .to_string(), + ), sender_pid: Some(1234), sender_start_time: Some(555), sender_executable: Some("/usr/bin/test-app".to_string()), diff --git a/crates/unixnotis-daemon/src/daemon/state/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/notifications.rs index baf9bd763..9df26b847 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notifications.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; + use tracing::warn; -use unixnotis_core::CloseReason; +use unixnotis_core::{CloseReason, Notification}; use super::DaemonState; @@ -48,4 +50,29 @@ impl DaemonState { } Ok(()) } + + pub async fn dismiss_active_if_current( + &self, + id: u32, + expected: &Arc, + ) -> zbus::Result { + let removed = { + // Object identity prevents an older action from deleting a same-ID replacement + let mut store = self.store.lock().await; + store.dismiss_active_if_current(id, expected) + }; + if !removed { + return Ok(false); + } + + // Only the matching active generation owns this expiration timer + self.cancel_expiration(id); + if let Err(err) = self.emit_dismiss_fanout(id, true).await { + warn!( + ?err, + id, "generation-safe dismiss committed but one or more D-Bus signals failed" + ); + } + Ok(true) + } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs index b8f08c64d..1c85fe450 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs @@ -113,6 +113,36 @@ async fn dismiss_from_panel_missing_id_is_noop() { assert!(receiver.try_recv().is_err()); } +#[tokio::test] +async fn generation_safe_dismiss_keeps_replacement_and_its_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (id, original) = { + let mut store = state.store.lock().await; + let original = store.insert(notification("original"), 0).notification; + let id = original.id; + let replacement = store.insert(notification("replacement"), id); + assert!(replacement.replaced); + (id, original) + }; + + let removed = state + .dismiss_active_if_current(id, &original) + .await + .expect("stale generation dismiss should remain a no-op"); + + assert!(!removed); + assert!(receiver.try_recv().is_err()); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active"); + assert_eq!(active.summary, "replacement"); +} + #[tokio::test] async fn close_notification_removes_active_notification_and_cancels_timer() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs index 2ab50e844..892c8efa8 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; +use std::sync::Arc; use indexmap::IndexMap; use tracing::{debug, warn}; -use unixnotis_core::{Config, NotificationView}; +use unixnotis_core::{Config, Notification, NotificationView}; use super::{DndStateStore, HistoryStore, NotificationStore, DND_STATE_VERSION}; @@ -107,15 +108,14 @@ impl NotificationStore { .map(|notification| notification.to_view()) } - pub fn active_inline_reply_target(&self, id: u32) -> Option { + pub fn active_inline_reply_target(&self, id: u32) -> Option> { let notification = self.active.get(&id)?; // Both fields must agree so malformed internal data cannot widen reply access let has_reply_action = notification .actions .iter() .any(|action| action.key == "inline-reply"); - (notification.inline_reply.available && has_reply_action) - .then_some(notification.is_resident) + (notification.inline_reply.available && has_reply_action).then(|| Arc::clone(notification)) } pub fn history_len(&self) -> usize { diff --git a/crates/unixnotis-daemon/src/store/lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs index 2690b8d69..85404ba98 100644 --- a/crates/unixnotis-daemon/src/store/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/lifecycle.rs @@ -94,6 +94,22 @@ impl NotificationStore { } } + pub fn dismiss_active_if_current(&mut self, id: u32, expected: &Arc) -> bool { + // A replacement can reuse the numeric ID but never the same Arc allocation + let is_current = self + .active + .get(&id) + .is_some_and(|active| Arc::ptr_eq(active, expected)); + if !is_current { + // Keep a replacement that arrived while an earlier action was in flight + return false; + } + + self.active.shift_remove(&id); + self.expirations.remove(&id); + true + } + pub fn drain_active_ids(&mut self) -> Vec { // Drain in one pass so callers do not need repeated lookups let ids = self.active.keys().rev().copied().collect(); diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/reply.rs index 7e6946c34..965ce8842 100644 --- a/crates/unixnotis-daemon/src/store/tests/reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/reply.rs @@ -21,8 +21,12 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { }); let reply_id = store.insert(reply, 0).notification.id; - assert_eq!(store.active_inline_reply_target(ordinary_id), None); - assert_eq!(store.active_inline_reply_target(reply_id), Some(false)); + assert!(store.active_inline_reply_target(ordinary_id).is_none()); + let target = store + .active_inline_reply_target(reply_id) + .expect("reply target"); + assert_eq!(target.id, reply_id); + assert!(!target.is_resident); } #[test] @@ -37,11 +41,16 @@ fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { reply.is_resident = true; let id = store.insert(reply, 0).notification.id; - assert_eq!(store.active_inline_reply_target(id), Some(true)); + assert!( + store + .active_inline_reply_target(id) + .expect("resident reply target") + .is_resident + ); store.close(id, CloseReason::Expired); - assert_eq!(store.active_inline_reply_target(id), None); + assert!(store.active_inline_reply_target(id).is_none()); assert!(store.list_history().iter().any(|view| view.id == id)); } @@ -52,5 +61,32 @@ fn inline_reply_metadata_without_the_protocol_action_is_rejected() { malformed.inline_reply.available = true; let id = store.insert(malformed, 0).notification.id; - assert_eq!(store.active_inline_reply_target(id), None); + assert!(store.active_inline_reply_target(id).is_none()); +} + +#[test] +fn generation_safe_reply_dismissal_keeps_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let mut original = make_notification("original"); + original.inline_reply.available = true; + original.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let original = store.insert(original, 0).notification; + let id = original.id; + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + + assert!(!store.dismiss_active_if_current(id, &original)); + assert_eq!( + store + .active_notification_view(id) + .expect("replacement should remain active") + .summary, + "replacement" + ); + assert!(store.dismiss_active_if_current(id, &replacement.notification)); + assert!(store.active_notification_view(id).is_none()); } From 8f6da7551d3fc149db8c0080f12f2873c759f5e2 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 00:00:09 -0500 Subject: [PATCH 006/275] refactor(center): organize panel reply components Summary: organize panel reply components. Scope: center. --- crates/noticenterctl/src/cli/dnd.rs | 4 +- .../unixnotis-center/src/media/runtime/mod.rs | 2 - .../src/media/runtime/tests/startup.rs | 2 +- .../unixnotis-center/src/ui/init/builders.rs | 16 +- .../src/ui/init/constructor.rs | 4 +- .../src/ui/init/tests/constructor.rs | 2 +- .../unixnotis-center/src/ui/media/config.rs | 12 +- .../notifications/row/notification/reply.rs | 102 ++++++-- .../row/notification/tests/reply.rs | 236 +++++++++++++++++- .../unixnotis-center/src/ui/panel/actions.rs | 78 ------ crates/unixnotis-center/src/ui/panel/apply.rs | 27 ++ .../src/ui/panel/{ => behavior}/autoclose.rs | 6 +- .../src/ui/panel/{ => behavior}/input.rs | 0 .../src/ui/panel/{ => behavior}/keyboard.rs | 32 ++- .../src/ui/panel/behavior/mod.rs | 9 + .../panel/{ => behavior}/tests/autoclose.rs | 0 .../ui/panel/{ => behavior}/tests/input.rs | 0 .../ui/panel/{ => behavior}/tests/keyboard.rs | 38 ++- .../panel/{ => behavior}/tests/visibility.rs | 0 .../src/ui/panel/{ => behavior}/visibility.rs | 77 +----- .../src/ui/panel/{sections.rs => body.rs} | 40 +-- crates/unixnotis-center/src/ui/panel/build.rs | 53 +--- .../src/ui/panel/{ => geometry}/layout.rs | 19 +- .../src/ui/panel/geometry/mod.rs | 7 + .../src/ui/panel/{ => geometry}/monitor.rs | 4 +- .../ui/panel/{ => geometry}/tests/layout.rs | 0 .../{action_widgets.rs => header/actions.rs} | 104 ++++++-- .../src/ui/panel/{ => header}/dnd.rs | 59 ++++- .../src/ui/panel/{header.rs => header/mod.rs} | 30 ++- .../src/ui/panel/header/search.rs | 194 ++++++++++++++ .../tests/action_signals.rs} | 0 .../tests/actions.rs} | 0 .../src/ui/panel/header/tests/dnd.rs | 75 ++++++ .../src/ui/panel/{ => header}/tests/header.rs | 0 .../tests/search.rs} | 0 .../tests/search_signals.rs} | 0 crates/unixnotis-center/src/ui/panel/mod.rs | 44 ++-- .../unixnotis-center/src/ui/panel/notice.rs | 8 +- .../unixnotis-center/src/ui/panel/reload.rs | 37 --- .../unixnotis-center/src/ui/panel/search.rs | 133 ---------- .../src/ui/panel/search_widgets.rs | 52 ---- crates/unixnotis-center/src/ui/panel/state.rs | 77 ++++++ .../ui/panel/tests/{reload.rs => apply.rs} | 55 +--- .../ui/panel/tests/{sections.rs => body.rs} | 0 .../src/ui/panel/tests/dnd.rs | 27 -- .../src/ui/panel/tests/timing.rs | 10 - .../unixnotis-center/src/ui/panel/timing.rs | 13 - crates/unixnotis-center/src/ui/panel/types.rs | 45 ---- .../unixnotis-center/src/ui/panel/widgets.rs | 17 ++ .../unixnotis-center/src/ui/reload/config.rs | 82 +++--- .../src/ui/reload/tests/config.rs | 103 ++++---- crates/unixnotis-center/src/ui/state.rs | 4 +- .../src/ui/widget_builders.rs | 24 +- 53 files changed, 1168 insertions(+), 795 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/panel/actions.rs create mode 100644 crates/unixnotis-center/src/ui/panel/apply.rs rename crates/unixnotis-center/src/ui/panel/{ => behavior}/autoclose.rs (93%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/input.rs (100%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/keyboard.rs (82%) create mode 100644 crates/unixnotis-center/src/ui/panel/behavior/mod.rs rename crates/unixnotis-center/src/ui/panel/{ => behavior}/tests/autoclose.rs (100%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/tests/input.rs (100%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/tests/keyboard.rs (60%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/tests/visibility.rs (100%) rename crates/unixnotis-center/src/ui/panel/{ => behavior}/visibility.rs (76%) rename crates/unixnotis-center/src/ui/panel/{sections.rs => body.rs} (89%) rename crates/unixnotis-center/src/ui/panel/{ => geometry}/layout.rs (93%) create mode 100644 crates/unixnotis-center/src/ui/panel/geometry/mod.rs rename crates/unixnotis-center/src/ui/panel/{ => geometry}/monitor.rs (94%) rename crates/unixnotis-center/src/ui/panel/{ => geometry}/tests/layout.rs (100%) rename crates/unixnotis-center/src/ui/panel/{action_widgets.rs => header/actions.rs} (75%) rename crates/unixnotis-center/src/ui/panel/{ => header}/dnd.rs (72%) rename crates/unixnotis-center/src/ui/panel/{header.rs => header/mod.rs} (79%) create mode 100644 crates/unixnotis-center/src/ui/panel/header/search.rs rename crates/unixnotis-center/src/ui/panel/{tests/actions.rs => header/tests/action_signals.rs} (100%) rename crates/unixnotis-center/src/ui/panel/{tests/action_widgets.rs => header/tests/actions.rs} (100%) create mode 100644 crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs rename crates/unixnotis-center/src/ui/panel/{ => header}/tests/header.rs (100%) rename crates/unixnotis-center/src/ui/panel/{tests/search_widgets.rs => header/tests/search.rs} (100%) rename crates/unixnotis-center/src/ui/panel/{tests/search.rs => header/tests/search_signals.rs} (100%) delete mode 100644 crates/unixnotis-center/src/ui/panel/reload.rs delete mode 100644 crates/unixnotis-center/src/ui/panel/search.rs delete mode 100644 crates/unixnotis-center/src/ui/panel/search_widgets.rs create mode 100644 crates/unixnotis-center/src/ui/panel/state.rs rename crates/unixnotis-center/src/ui/panel/tests/{reload.rs => apply.rs} (53%) rename crates/unixnotis-center/src/ui/panel/tests/{sections.rs => body.rs} (100%) delete mode 100644 crates/unixnotis-center/src/ui/panel/tests/dnd.rs delete mode 100644 crates/unixnotis-center/src/ui/panel/tests/timing.rs delete mode 100644 crates/unixnotis-center/src/ui/panel/timing.rs delete mode 100644 crates/unixnotis-center/src/ui/panel/types.rs create mode 100644 crates/unixnotis-center/src/ui/panel/widgets.rs diff --git a/crates/noticenterctl/src/cli/dnd.rs b/crates/noticenterctl/src/cli/dnd.rs index c4dd6e90f..312bf1c08 100644 --- a/crates/noticenterctl/src/cli/dnd.rs +++ b/crates/noticenterctl/src/cli/dnd.rs @@ -43,7 +43,7 @@ impl FromStr for DndDuration { let digits = &value[..value.len() - 1]; let amount = digits .parse::() - .map_err(|_| "duration must start with a positive integer".to_string())?; + .map_err(|_error| "duration must start with a positive integer".to_string())?; // Checked multiplication rejects large values before the policy bound is applied let seconds = amount .checked_mul(multiplier) @@ -92,7 +92,7 @@ impl FromStr for DndClockTime { return Err("time must use 24-hour HH:MM format".to_string()); } let time = NaiveTime::parse_from_str(value, "%H:%M") - .map_err(|_| "time must use 24-hour HH:MM format".to_string())?; + .map_err(|_error| "time must use 24-hour HH:MM format".to_string())?; Ok(Self { time }) } } diff --git a/crates/unixnotis-center/src/media/runtime/mod.rs b/crates/unixnotis-center/src/media/runtime/mod.rs index 32b7c7912..417f3b590 100644 --- a/crates/unixnotis-center/src/media/runtime/mod.rs +++ b/crates/unixnotis-center/src/media/runtime/mod.rs @@ -12,8 +12,6 @@ mod startup; mod state; pub(super) use signal::{MediaRefreshOrigin, MediaSignal}; -#[cfg(test)] -use startup::normalize_media_config; pub(super) use startup::start_media_task; pub(super) const MEDIA_SIGNAL_CAPACITY: usize = 256; diff --git a/crates/unixnotis-center/src/media/runtime/tests/startup.rs b/crates/unixnotis-center/src/media/runtime/tests/startup.rs index da647fe7a..c91611d68 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/startup.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/startup.rs @@ -2,8 +2,8 @@ use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; use crate::media::MediaCommand; -use super::super::normalize_media_config; use super::super::r#loop::drain_stale_media_commands; +use super::super::startup::normalize_media_config; #[test] fn normalize_media_config_lowercases_all_matching_lists() { diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index e1c92b503..cc2fb0866 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -25,7 +25,7 @@ pub(super) fn build_notification_list( // Notification list owns row virtualization and icon resolution // Startup only passes the resolved policy and shared channels notifications::NotificationList::new( - panel.scroller.clone(), + panel.sections.scroller.clone(), init.command_tx.clone(), init.event_tx.clone(), icon_resolver, @@ -40,7 +40,7 @@ pub(super) fn build_media_widget( let panel_width = panel::requested_panel_width(&panel.root); let media = init.media_handle.as_ref().map(|handle| { media::MediaWidget::new( - &panel.media_container, + &panel.sections.media_container, handle.clone(), panel_width, &init.config.media, @@ -49,7 +49,7 @@ pub(super) fn build_media_widget( if media.is_none() { // Hidden container keeps layout stable without reserving blank media space - panel.media_container.set_visible(false); + panel.sections.media_container.set_visible(false); } media } @@ -101,9 +101,9 @@ pub(super) fn icon_resolver_for_widgets( pub(super) fn has_visible_widget_section(panel: &panel::PanelWidgets) -> bool { // Empty-state spacing depends on whether any upper panel section is visible - panel.quick_controls.get_visible() - || panel.media_container.get_visible() - || panel.toggle_container.get_visible() - || panel.stat_container.get_visible() - || panel.card_container.get_visible() + panel.sections.quick_controls.get_visible() + || panel.sections.media_container.get_visible() + || panel.sections.toggle_container.get_visible() + || panel.sections.stat_container.get_visible() + || panel.sections.card_container.get_visible() } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index a9f47ce85..fb5687c5c 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -37,8 +37,8 @@ impl UiState { panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); panel::connect_dnd_menu(&panel, init.command_tx.clone()); - panel::connect_clear_button(&panel.clear_action_button, init.command_tx.clone()); - panel::connect_clear_button(&panel.clear_header_button, init.command_tx.clone()); + panel::connect_clear_button(&panel.header.actions.clear_button, init.command_tx.clone()); + panel::connect_clear_button(&panel.sections.clear_header_button, init.command_tx.clone()); panel::connect_close_button(&panel, init.command_tx.clone()); panel::connect_widget_collapse_toggle(&panel, init.event_tx.clone()); panel::connect_filter_entry(&panel, init.event_tx.clone()); diff --git a/crates/unixnotis-center/src/ui/init/tests/constructor.rs b/crates/unixnotis-center/src/ui/init/tests/constructor.rs index 5e4b69dc2..b1225c090 100644 --- a/crates/unixnotis-center/src/ui/init/tests/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/tests/constructor.rs @@ -55,7 +55,7 @@ fn constructor_builds_disabled_optional_sections_without_reserving_space() { }); assert!(state.media.is_none()); - assert!(!state.panel.media_container.get_visible()); + assert!(!state.panel.sections.media_container.get_visible()); assert!(state.volume.is_none()); assert!(state.brightness.is_none()); assert!(state.toggles.is_none()); diff --git a/crates/unixnotis-center/src/ui/media/config.rs b/crates/unixnotis-center/src/ui/media/config.rs index 60e29cf08..b6ccbbbd6 100644 --- a/crates/unixnotis-center/src/ui/media/config.rs +++ b/crates/unixnotis-center/src/ui/media/config.rs @@ -17,7 +17,7 @@ impl UiState { return; } - self.panel.media_container.set_visible(true); + self.panel.sections.media_container.set_visible(true); // The resolved request stays stable even when a child reports a wider natural allocation let panel_width = super::super::panel::requested_panel_width(&self.panel.root); if self.media_layout_changed(config) { @@ -29,7 +29,7 @@ impl UiState { } fn disable_media_widget(&mut self) { - self.panel.media_container.set_visible(false); + self.panel.sections.media_container.set_visible(false); self.clear_media_container(); self.media = None; debug!("media disabled"); @@ -61,7 +61,7 @@ impl UiState { debug!("media widget rebuilt for layout change"); let mut media = widget::MediaWidget::new( - &self.panel.media_container, + &self.panel.sections.media_container, handle.clone(), panel_width, &config.media, @@ -83,7 +83,7 @@ impl UiState { (None, Some(handle)) => { debug!("media widget created"); let media = widget::MediaWidget::new( - &self.panel.media_container, + &self.panel.sections.media_container, handle.clone(), panel_width, &config.media, @@ -99,8 +99,8 @@ impl UiState { fn clear_media_container(&self) { // Rebuilds remove old children one by one so GTK releases the shell cleanly - while let Some(child) = self.panel.media_container.first_child() { - self.panel.media_container.remove(&child); + while let Some(child) = self.panel.sections.media_container.first_child() { + self.panel.sections.media_container.remove(&child); } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs index 264179854..d28c07aba 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use gtk::prelude::*; use tokio::sync::mpsc; -use unixnotis_core::InlineReply; +use unixnotis_core::{util, InlineReply}; use crate::control::UiCommand; use crate::ui::try_send_command; @@ -17,12 +17,15 @@ const MAX_SUBMIT_LABEL_CHARS: usize = 20; // GTK limits characters while the protocol boundary limits encoded bytes const MAX_REPLY_CHARS: i32 = 4 * 1024; const MAX_REPLY_BYTES: usize = 4 * 1024; +const MAX_REPLY_ERROR_CHARS: usize = 180; +const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; pub(super) struct InlineReplyWidgets { // The form is retained with the recycled row and revealed only on explicit action pub(super) revealer: gtk::Revealer, pub(super) entry: gtk::Entry, pub(super) send_button: gtk::Button, + pub(super) error_label: gtk::Label, // Notification identity prevents a recycled row from leaking a prior draft bound_id: Rc>, // One shared gate covers button and Enter submissions @@ -35,8 +38,9 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); revealer.set_reveal_child(false); - let row = gtk::Box::new(gtk::Orientation::Horizontal, 6); - row.add_css_class("unixnotis-inline-reply"); + let form = gtk::Box::new(gtk::Orientation::Vertical, 4); + form.add_css_class("unixnotis-inline-reply"); + let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); let entry = gtk::Entry::new(); entry.set_hexpand(true); @@ -49,16 +53,28 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR send_button.add_css_class("unixnotis-notification-action"); send_button.add_css_class("unixnotis-inline-reply-send"); - row.append(&entry); - row.append(&send_button); - revealer.set_child(Some(&row)); + let error_label = gtk::Label::new(None); + error_label.set_xalign(0.0); + error_label.set_wrap(true); + error_label.set_visible(false); + error_label.add_css_class("error"); + error_label.add_css_class("unixnotis-inline-reply-error"); + + input_row.append(&entry); + input_row.append(&send_button); + form.append(&input_row); + form.append(&error_label); + revealer.set_child(Some(&form)); let bound_id = Rc::new(Cell::new(0)); let submitted = Rc::new(Cell::new(false)); let changed_button = send_button.clone(); let changed_submitted = submitted.clone(); + let changed_error = error_label.clone(); entry.connect_changed(move |entry| { + // Editing starts a fresh attempt, so an older transport error no longer applies + clear_reply_error(&changed_error); // Sensitivity mirrors the daemon byte limit before any command is queued let text = entry.text(); let text = text.trim(); @@ -71,6 +87,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR let submit_entry = entry.clone(); let submit_revealer = revealer.clone(); let submit_button = send_button.clone(); + let submit_error = error_label.clone(); let submit_id = bound_id.clone(); let submit_gate = submitted.clone(); let submit_tx = command_tx.clone(); @@ -80,6 +97,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR &submit_entry, &submit_revealer, &submit_button, + &submit_error, &submit_id, &submit_gate, &submit_tx, @@ -88,6 +106,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR let activate_revealer = revealer.clone(); let activate_button = send_button.clone(); + let activate_error = error_label.clone(); let activate_id = bound_id.clone(); let activate_gate = submitted.clone(); // GtkEntry emits activate for Enter without needing a separate key handler @@ -96,6 +115,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR entry, &activate_revealer, &activate_button, + &activate_error, &activate_id, &activate_gate, &command_tx, @@ -104,6 +124,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR let key_revealer = revealer.clone(); let key_entry = entry.clone(); + let key_error = error_label.clone(); let key_submitted = submitted.clone(); let key_controller = gtk::EventControllerKey::new(); // Escape owns draft cancellation while other keys continue through GTK @@ -111,7 +132,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR if key != gtk::gdk::Key::Escape { return gtk::glib::Propagation::Proceed; } - cancel_inline_reply(&key_entry, &key_revealer, &key_submitted) + cancel_inline_reply(&key_entry, &key_revealer, &key_error, &key_submitted) }); entry.add_controller(key_controller); @@ -119,6 +140,7 @@ pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineR revealer, entry, send_button, + error_label, bound_id, submitted, } @@ -134,14 +156,18 @@ pub(super) fn configure_inline_reply( let available = is_active && reply.available; if widgets.bound_id.get() != id { // Recycled rows never carry typed drafts to another notification + widgets.submitted.set(false); + widgets.entry.set_sensitive(true); widgets.entry.set_text(""); + widgets.send_button.set_sensitive(false); + clear_reply_error(&widgets.error_label); widgets.revealer.set_reveal_child(false); - widgets.submitted.set(false); widgets.bound_id.set(id); } if !available { // History and ordinary actions never expose a stale reply field widgets.entry.set_text(""); + clear_reply_error(&widgets.error_label); widgets.revealer.set_reveal_child(false); widgets.entry.set_sensitive(true); widgets.send_button.set_sensitive(false); @@ -182,6 +208,7 @@ fn submit_reply( entry: >k::Entry, revealer: >k::Revealer, button: >k::Button, + error_label: >k::Label, bound_id: &Rc>, submitted: &Rc>, command_tx: &mpsc::Sender, @@ -196,6 +223,7 @@ fn submit_reply( entry.set_sensitive(false); button.set_sensitive(false); + clear_reply_error(error_label); // A one-shot response lets the GTK task restore the draft after transport failure let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel(); try_send_command( @@ -210,26 +238,34 @@ fn submit_reply( let result_entry = entry.clone(); let result_revealer = revealer.clone(); let result_button = button.clone(); + let result_error = error_label.clone(); let result_id = bound_id.clone(); let result_submitted = submitted.clone(); // The local main-context task is allowed to touch GTK widgets directly gtk::glib::MainContext::default().spawn_local(async move { - let succeeded = matches!(outcome_rx.await, Ok(Ok(()))); + let result = outcome_rx + .await + .unwrap_or_else(|_| Err("notification service did not return a result".to_string())); if result_id.get() != id || !result_submitted.get() { // A recycled row already owns different notification state return; } result_submitted.set(false); result_entry.set_sensitive(true); - if succeeded { - // Successful replies leave no draft behind in the reusable row - result_entry.set_text(""); - result_revealer.set_reveal_child(false); - result_button.set_sensitive(false); - } else { - // Keep the draft available for correction or retry - result_button.set_sensitive(!result_entry.text().trim().is_empty()); - result_entry.grab_focus(); + match result { + Ok(()) => { + // Successful replies leave no draft behind in the reusable row + result_entry.set_text(""); + clear_reply_error(&result_error); + result_revealer.set_reveal_child(false); + result_button.set_sensitive(false); + } + Err(error) => { + // Keep the draft available for correction or retry + result_button.set_sensitive(!result_entry.text().trim().is_empty()); + show_reply_error(&result_error, &error); + result_entry.grab_focus(); + } } }); } @@ -237,6 +273,7 @@ fn submit_reply( pub(super) fn cancel_inline_reply( entry: >k::Entry, revealer: >k::Revealer, + error_label: >k::Label, submitted: &Cell, ) -> gtk::glib::Propagation { if submitted.get() { @@ -245,10 +282,39 @@ pub(super) fn cancel_inline_reply( } // Canceling an idle draft restores the original action row entry.set_text(""); + clear_reply_error(error_label); revealer.set_reveal_child(false); gtk::glib::Propagation::Stop } +fn clear_reply_error(label: >k::Label) { + label.set_text(""); + label.set_visible(false); +} + +fn show_reply_error(label: >k::Label, error: &str) { + // Known liveness failures use a short stable message instead of a D-Bus error prefix + let message = if error.contains(APPLICATION_UNAVAILABLE) { + APPLICATION_UNAVAILABLE.to_string() + } else { + util::sanitize_inline_display_text(error) + }; + let message = clamp_error_message(&message); + label.set_text(&format!("Could not send: {message}")); + label.set_visible(true); +} + +fn clamp_error_message(message: &str) -> std::borrow::Cow<'_, str> { + // Remote error text is display-only and must not create an unbounded row + let Some((cut, _)) = message.char_indices().nth(MAX_REPLY_ERROR_CHARS) else { + return std::borrow::Cow::Borrowed(message); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&message[..cut]); + bounded.push('…'); + std::borrow::Cow::Owned(bounded) +} + fn update_submit_content(button: >k::Button, label: &str, icon_name: &str) { // Rebuild the tiny child box because KDE may change hints on replacement let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs index 5fcc0160c..795927d83 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs @@ -4,12 +4,13 @@ use std::rc::Rc; use gtk::prelude::*; use unixnotis_core::{Action, InlineReply}; -use super::reply::cancel_inline_reply; +use super::reply::{build_inline_reply, cancel_inline_reply, configure_inline_reply}; use super::test_support::{row_data, sample_notification, RowFlags}; use super::update::update_notification_row; use crate::control::UiCommand; use crate::ui::icons::IconResolver; use crate::ui::notifications::test_support::init_gtk; +use crate::ui::panel::behavior::keyboard::editable_has_focus; #[gtk::test] fn inline_reply_is_available_only_for_a_live_explicit_reply_action() { @@ -114,6 +115,7 @@ fn inline_reply_submit_sends_text_once_and_hides_after_success() { } assert!(!row.inline_reply.revealer.reveals_child()); assert!(row.inline_reply.entry.text().is_empty()); + assert!(!row.inline_reply.error_label.is_visible()); } #[gtk::test] @@ -143,9 +145,14 @@ fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { row.inline_reply.entry.set_text(" "); assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); row.inline_reply.entry.set_text(&"🙂".repeat(1_025)); assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); row.inline_reply.entry.set_text("Try again"); + assert!(row.inline_reply.send_button.is_sensitive()); row.inline_reply.send_button.emit_clicked(); let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { panic!("expected inline reply command"); @@ -161,6 +168,54 @@ fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { assert_eq!(row.inline_reply.entry.text(), "Try again"); assert!(row.inline_reply.entry.is_sensitive()); assert!(row.inline_reply.send_button.is_sensitive()); + assert!(row.inline_reply.error_label.is_visible()); + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: temporary failure" + ); + + row.inline_reply.entry.set_text("Try once more"); + assert!(!row.inline_reply.error_label.is_visible()); + assert!(row.inline_reply.error_label.text().is_empty()); +} + +#[gtk::test] +fn inline_reply_accepts_exact_byte_limit_and_blocks_changes_during_submission() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + configure_inline_reply(&widgets, 41, &reply, true); + let exact_limit = "🙂".repeat(1_024); + + widgets.entry.set_text(&exact_limit); + assert!(widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + let pending = command_rx.try_recv().expect("exact-limit reply command"); + let UiCommand::Reply { text, .. } = pending else { + panic!("expected inline reply command"); + }; + assert_eq!(text, exact_limit); + + widgets.entry.set_text("Changed while pending"); + assert!(!widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn inline_reply_does_not_submit_before_binding_a_notification() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + + widgets.entry.set_text("Not bound"); + widgets.entry.emit_activate(); + + assert!(command_rx.try_recv().is_err()); } #[gtk::test] @@ -168,16 +223,186 @@ fn inline_reply_escape_clears_an_idle_draft_and_collapses_the_form() { init_gtk(); let entry = gtk::Entry::new(); let revealer = gtk::Revealer::new(); + let error_label = gtk::Label::new(Some("Could not send")); let submitted = Cell::new(false); entry.set_text("Unsent draft"); revealer.set_reveal_child(true); + error_label.set_visible(true); assert_eq!( - cancel_inline_reply(&entry, &revealer, &submitted), + cancel_inline_reply(&entry, &revealer, &error_label, &submitted), gtk::glib::Propagation::Stop ); assert!(entry.text().is_empty()); assert!(!revealer.reveals_child()); + assert!(error_label.text().is_empty()); + assert!(!error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_key_controller_cancels_only_escape() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + widgets.entry.set_text("Unsent draft"); + widgets.revealer.set_reveal_child(true); + let controllers = widgets.entry.observe_controllers(); + let controller = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index)) + .find_map(|object| object.downcast::().ok()) + .expect("inline reply key controller"); + + let proceed = controller.emit_by_name::( + "key-pressed", + &[>k::gdk::Key::a, &0_u32, >k::gdk::ModifierType::empty()], + ); + assert!(!proceed); + assert_eq!(widgets.entry.text(), "Unsent draft"); + + let stop = controller.emit_by_name::( + "key-pressed", + &[ + >k::gdk::Key::Escape, + &0_u32, + >k::gdk::ModifierType::empty(), + ], + ); + assert!(stop); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn inline_reply_entry_focus_is_recognized_as_editable_panel_input() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (root, row) = super::build::build_notification_row(command_tx); + let window = gtk::Window::new(); + window.set_child(Some(&root)); + window.set_visible(true); + + row.inline_reply.entry.grab_focus(); + + assert!(editable_has_focus(&window)); +} + +#[gtk::test] +fn inline_reply_dead_sender_error_uses_the_stable_user_message() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = super::build::build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("Hello?"); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err( + "org.freedesktop.DBus.Error.Failed: The application is no longer available".to_string(), + )) + .expect("reply result receiver"); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: The application is no longer available" + ); + assert!(row.inline_reply.error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_rebind_clears_draft_and_prior_error() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + configure_inline_reply(&widgets, 41, &reply, true); + widgets.entry.set_text("Old draft"); + widgets.send_button.emit_clicked(); + let _pending_reply = command_rx.try_recv().expect("pending reply command"); + assert!(!widgets.entry.is_sensitive()); + widgets.error_label.set_text("Could not send: old error"); + widgets.error_label.set_visible(true); + widgets.revealer.set_reveal_child(true); + + configure_inline_reply(&widgets, 42, &reply, true); + + assert!(widgets.entry.text().is_empty()); + assert!(widgets.error_label.text().is_empty()); + assert!(!widgets.error_label.is_visible()); + assert!(!widgets.revealer.reveals_child()); + assert!(widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); +} + +#[gtk::test] +fn stale_reply_result_cannot_change_a_new_inflight_reply() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + configure_inline_reply(&widgets, 41, &reply, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + configure_inline_reply(&widgets, 42, &reply, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + first_outcome + .send(Err("stale failure".to_string())) + .expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.error_label.is_visible()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); } #[gtk::test] @@ -220,3 +445,10 @@ fn inline_reply_submit_label_is_bounded_without_splitting_unicode() { .expect("submit label widget"); assert_eq!(label.text(), format!("{}…", "界".repeat(20))); } + +fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/actions.rs b/crates/unixnotis-center/src/ui/panel/actions.rs deleted file mode 100644 index b210f81f9..000000000 --- a/crates/unixnotis-center/src/ui/panel/actions.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Panel action signal wiring - -use std::cell::Cell; -use std::rc::Rc; -use std::time::Duration; - -use gtk::prelude::*; -use tracing::debug; - -use super::super::try_send_command; -use super::input::ClickCooldown; -use super::timing::CONTROL_CLICK_GUARD_MS; -use super::PanelWidgets; -use crate::control::UiCommand; - -pub(in crate::ui) fn connect_clear_button( - button: >k::Button, - command_tx: tokio::sync::mpsc::Sender, -) { - let clear_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); - button.connect_clicked(move |_| { - if !clear_gate.try_start() { - return; - } - - debug!("clear all clicked"); - // Non-blocking send avoids UI stalls on D-Bus backpressure - try_send_command(&command_tx, UiCommand::ClearAll); - }); -} - -pub(in crate::ui) fn connect_dnd_toggle( - panel: &PanelWidgets, - dnd_guard: Rc>, - command_tx: tokio::sync::mpsc::Sender, -) { - connect_dnd_button(&panel.dnd_toggle, dnd_guard, command_tx); -} - -fn connect_dnd_button( - button: >k::ToggleButton, - dnd_guard: Rc>, - command_tx: tokio::sync::mpsc::Sender, -) { - button.connect_toggled(move |button| { - if dnd_guard.get() { - // Daemon-driven state sync should not echo another DND command - return; - } - - let requested = button.is_active(); - // Keep the durable daemon state visible until the command commits successfully - dnd_guard.set(true); - button.set_active(!requested); - dnd_guard.set(false); - debug!(enabled = requested, "dnd toggled"); - try_send_command(&command_tx, UiCommand::SetDnd(requested)); - }); -} - -pub(in crate::ui) fn connect_close_button( - panel: &PanelWidgets, - command_tx: tokio::sync::mpsc::Sender, -) { - let close_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); - panel.close_button.connect_clicked(move |_| { - if !close_gate.try_start() { - return; - } - - debug!("close panel clicked"); - try_send_command(&command_tx, UiCommand::ClosePanel); - }); -} - -#[cfg(test)] -#[path = "tests/actions.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/apply.rs b/crates/unixnotis-center/src/ui/panel/apply.rs new file mode 100644 index 000000000..b93142e70 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/apply.rs @@ -0,0 +1,27 @@ +//! Panel reload helpers for structure and action chrome + +use unixnotis_core::{PanelConfig, PanelSection}; + +use super::widgets::PanelWidgets; + +pub fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { + super::header::actions::apply_panel_action_config( + &panel.header.top, + &panel.header.actions, + config, + ); + super::header::actions::apply_clear_button_config(&panel.sections.clear_header_button, config); +} + +pub fn apply_reloaded_body_order(panel: &PanelWidgets, order: &[PanelSection]) { + super::body::apply_panel_body_section_order( + &panel.sections.body_stack, + &panel.sections.widget_revealer, + &panel.sections.notification_container, + order, + ); +} + +#[cfg(test)] +#[path = "tests/apply.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/autoclose.rs b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs similarity index 93% rename from crates/unixnotis-center/src/ui/panel/autoclose.rs rename to crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs index 01160256e..936fc1b8b 100644 --- a/crates/unixnotis-center/src/ui/panel/autoclose.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs @@ -5,11 +5,9 @@ use std::sync::Arc; use gtk::prelude::*; -use super::super::hyprland; -use super::super::try_send_command; -use super::super::UiStateInit; -use super::PanelWidgets; use crate::control::UiCommand; +use crate::ui::panel::PanelWidgets; +use crate::ui::{hyprland, try_send_command, UiStateInit}; fn connect_blur_close( command_tx: tokio::sync::mpsc::Sender, diff --git a/crates/unixnotis-center/src/ui/panel/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/input.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/input.rs rename to crates/unixnotis-center/src/ui/panel/behavior/input.rs diff --git a/crates/unixnotis-center/src/ui/panel/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs similarity index 82% rename from crates/unixnotis-center/src/ui/panel/keyboard.rs rename to crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs index 3eaf60e64..cf57d11db 100644 --- a/crates/unixnotis-center/src/ui/panel/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs @@ -3,9 +3,9 @@ use gtk::gdk; use gtk::prelude::*; -use super::super::try_send_command; -use super::PanelWidgets; use crate::control::UiCommand; +use crate::ui::panel::PanelWidgets; +use crate::ui::try_send_command; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum KeyboardPanelAction { @@ -31,8 +31,13 @@ pub(super) fn keyboard_action_for( key: gdk::Key, state: gdk::ModifierType, search_open: bool, - search_has_focus: bool, + editable_has_focus: bool, ) -> KeyboardPanelAction { + if editable_has_focus { + // Editable widgets own typing and Escape while a draft or query has focus + return KeyboardPanelAction::Continue; + } + if key == gdk::Key::Escape { return if search_open { KeyboardPanelAction::CloseSearch @@ -55,11 +60,11 @@ pub(super) fn keyboard_action_for( return KeyboardPanelAction::ToggleWidgets; } - if !search_has_focus && key == gdk::Key::j { + if key == gdk::Key::j { return KeyboardPanelAction::ScrollDown; } - if !search_has_focus && key == gdk::Key::k { + if key == gdk::Key::k { return KeyboardPanelAction::ScrollUp; } @@ -70,11 +75,12 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( panel: &PanelWidgets, command_tx: tokio::sync::mpsc::Sender, ) { - let focus_toggle = panel.focus_toggle.clone(); - let search_toggle = panel.search_toggle.clone(); - let search_revealer = panel.search_revealer.clone(); - let search_entry = panel.search_entry.clone(); - let scroller = panel.scroller.clone(); + let focus_toggle = panel.header.actions.focus_toggle.clone(); + let search_toggle = panel.header.actions.search_toggle.clone(); + let search_revealer = panel.header.search.revealer.clone(); + let search_entry = panel.header.search.entry.clone(); + let scroller = panel.sections.scroller.clone(); + let window = panel.window.clone(); let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(move |_, key, _, state| { @@ -82,7 +88,7 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( key, state, search_revealer.reveals_child(), - search_entry.has_focus(), + editable_has_focus(&window), ); match action { KeyboardPanelAction::CloseSearch => { @@ -124,6 +130,10 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( #[path = "tests/keyboard.rs"] mod tests; +pub(in crate::ui) fn editable_has_focus(window: &impl IsA) -> bool { + gtk::prelude::RootExt::focus(window.as_ref()).is_some_and(|widget| widget.is::()) +} + fn reveal_and_focus_search( search_toggle: >k::ToggleButton, search_revealer: >k::Revealer, diff --git a/crates/unixnotis-center/src/ui/panel/behavior/mod.rs b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs new file mode 100644 index 000000000..c4cc61b17 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs @@ -0,0 +1,9 @@ +//! Panel interaction behavior grouped away from widget construction + +mod autoclose; +pub(in crate::ui) mod input; +pub(in crate::ui) mod keyboard; +mod visibility; + +pub(in crate::ui) use autoclose::connect_auto_close; +pub(in crate::ui) use keyboard::connect_keyboard_shortcuts; diff --git a/crates/unixnotis-center/src/ui/panel/tests/autoclose.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/autoclose.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/autoclose.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/autoclose.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/input.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs similarity index 60% rename from crates/unixnotis-center/src/ui/panel/tests/keyboard.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs index 938e86441..cc3fe5725 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs @@ -1,6 +1,7 @@ use gtk::gdk; +use gtk::prelude::*; -use super::{keyboard_action_for, KeyboardPanelAction}; +use super::{editable_has_focus, keyboard_action_for, KeyboardPanelAction}; #[test] fn escape_closes_search_before_panel() { @@ -42,10 +43,14 @@ fn ctrl_w_toggles_widget_section() { keyboard_action_for(gdk::Key::w, gdk::ModifierType::CONTROL_MASK, false, false), KeyboardPanelAction::ToggleWidgets ); + assert_eq!( + keyboard_action_for(gdk::Key::w, gdk::ModifierType::empty(), false, false), + KeyboardPanelAction::Continue + ); } #[test] -fn vim_scroll_keys_do_not_steal_text_entry_input() { +fn vim_scroll_keys_do_not_steal_editable_input() { let state = gdk::ModifierType::empty(); assert_eq!( @@ -66,6 +71,24 @@ fn vim_scroll_keys_do_not_steal_text_entry_input() { ); } +#[test] +fn all_panel_shortcuts_continue_while_an_editable_has_focus() { + for (key, state) in [ + (gdk::Key::Escape, gdk::ModifierType::empty()), + (gdk::Key::slash, gdk::ModifierType::empty()), + (gdk::Key::j, gdk::ModifierType::empty()), + (gdk::Key::k, gdk::ModifierType::empty()), + (gdk::Key::f, gdk::ModifierType::CONTROL_MASK), + (gdk::Key::l, gdk::ModifierType::CONTROL_MASK), + (gdk::Key::w, gdk::ModifierType::CONTROL_MASK), + ] { + assert_eq!( + keyboard_action_for(key, state, true, true), + KeyboardPanelAction::Continue + ); + } +} + #[test] fn unrelated_keys_continue_to_gtk() { assert_eq!( @@ -73,3 +96,14 @@ fn unrelated_keys_continue_to_gtk() { KeyboardPanelAction::Continue ); } + +#[gtk::test] +fn noneditable_focus_does_not_suppress_panel_shortcuts() { + let window = gtk::Window::new(); + let button = gtk::Button::with_label("Focus target"); + window.set_child(Some(&button)); + window.set_visible(true); + button.grab_focus(); + + assert!(!editable_has_focus(&window)); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/visibility.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/visibility.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/visibility.rs diff --git a/crates/unixnotis-center/src/ui/panel/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs similarity index 76% rename from crates/unixnotis-center/src/ui/panel/visibility.rs rename to crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index 73d6227a0..bd5f9d2e8 100644 --- a/crates/unixnotis-center/src/ui/panel/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -12,72 +12,9 @@ use unixnotis_core::{PanelAction, PanelDebugLevel, PanelRequest}; use crate::control::UiCommand; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; -use super::super::{try_send_command, UiState}; +use crate::ui::{try_send_command, UiState}; impl UiState { - pub const fn panel_is_visible(&self) -> bool { - self.panel_visible - } - - pub(in crate::ui) const fn has_any_widgets(&self) -> bool { - self.volume.is_some() - || self.brightness.is_some() - || self.toggles.is_some() - || self.stats.is_some() - || self.cards.is_some() - || (self.media.is_some() && self.config.media.enabled) - } - - pub(in crate::ui) fn set_widgets_collapsed(&mut self, collapsed: bool) { - self.widgets_collapsed = collapsed; - if self.panel.focus_toggle.is_active() != collapsed { - // Mirror external collapse requests into the header toggle state - self.panel.focus_toggle.set_active(collapsed); - } - if self.panel.widget_revealer.reveals_child() == collapsed { - self.panel.widget_revealer.set_reveal_child(!collapsed); - } - self.list - .set_empty_layout(!collapsed && self.has_any_widgets()); - } - - pub(in crate::ui) fn update_state(&mut self, state: unixnotis_core::ControlState) { - if let Some(source) = self.dnd_expiration_source.take() { - source.remove(); - } - // Avoid re-entrant DND toggles while applying daemon state - self.dnd_guard.set(true); - self.panel.dnd_toggle.set_active(state.dnd_enabled); - self.dnd_guard.set(false); - let expires_at = state - .dnd_enabled - .then_some(state.dnd_expires_at) - .filter(|expires_at| *expires_at > 0) - .unwrap_or(0); - super::dnd::update_dnd_status(&self.panel.dnd_status, expires_at); - if expires_at > 0 { - self.dnd_expiration_source = Some(super::dnd::start_dnd_countdown( - &self.panel.dnd_status, - expires_at, - )); - } - } - - pub(in crate::ui) fn refresh_counts(&mut self) { - if !self.panel_visible { - // Skip label updates while hidden to avoid unnecessary UI work - // Counts are refreshed on the next open to keep the header accurate - return; - } - // Header count always reflects total active + history entries - let total = self.list.total_count(); - if self.last_count == Some(total) { - return; - } - self.last_count = Some(total); - self.panel.header_count.set_text(&format!("{total}")); - } - pub(in crate::ui) fn apply_panel_request(&mut self, request: PanelRequest) { let requested_visibility = panel_visibility_for_action(self.panel_visible, request.action); // Request-driven changes always flow through set_visible for consistent side effects @@ -154,10 +91,10 @@ impl UiState { // Only hit the compositor once per open when the cache is empty // Keeps open latency stable while avoiding repeated IPC work if self.config.panel.respect_work_area && self.work_area.is_none() { - self.work_area = super::super::hyprland::reserved_work_area_sync( + self.work_area = crate::ui::hyprland::reserved_work_area_sync( self.config.panel.output.as_deref(), ); - super::apply_panel_config(&self.panel, &self.config, self.work_area); + crate::ui::panel::apply_panel_config(&self.panel, &self.config, self.work_area); } // Only show the window after geometry is correct to avoid visible jitter self.panel.window.set_visible(true); @@ -177,15 +114,15 @@ impl UiState { // Hide first so any teardown work does not trigger visible reflow self.panel.window.set_visible(false); // Reset transient search UI so each open starts from the full notification list - if self.panel.search_toggle.is_active() { + if self.panel.header.actions.search_toggle.is_active() { // Programmatic close should not be treated as a user click self.search_toggle_guard.set(true); - self.panel.search_toggle.set_active(false); + self.panel.header.actions.search_toggle.set_active(false); self.search_toggle_guard.set(false); } - if !self.panel.search_entry.text().is_empty() { + if !self.panel.header.search.entry.text().is_empty() { // Clearing text also removes any active list filter - self.panel.search_entry.set_text(""); + self.panel.header.search.entry.set_text(""); } // Disable watch-based polling when hidden to reduce background load if let Some(volume) = self.volume.as_ref() { diff --git a/crates/unixnotis-center/src/ui/panel/sections.rs b/crates/unixnotis-center/src/ui/panel/body.rs similarity index 89% rename from crates/unixnotis-center/src/ui/panel/sections.rs rename to crates/unixnotis-center/src/ui/panel/body.rs index 61f62a348..c2245f7ac 100644 --- a/crates/unixnotis-center/src/ui/panel/sections.rs +++ b/crates/unixnotis-center/src/ui/panel/body.rs @@ -1,4 +1,4 @@ -//! Panel widget stack and scroller construction +//! Panel body, widget stack, and notification list construction use gtk::prelude::*; use gtk::Align; @@ -7,27 +7,27 @@ use unixnotis_core::{ WidgetDensity, }; -use super::action_widgets::build_clear_button; +use super::header::actions::build_clear_button; pub const WIDGET_REVEAL_TRANSITION_MS: u64 = 180; -pub(super) struct PanelSectionWidgets { - pub(super) body_stack: gtk::Box, - pub(super) widget_revealer: gtk::Revealer, - pub(super) widget_stack: gtk::Box, - pub(super) quick_controls: gtk::Box, - pub(super) toggle_container: gtk::Box, - pub(super) stat_container: gtk::Box, - pub(super) card_container: gtk::Box, - pub(super) scroller: gtk::ScrolledWindow, - pub(super) notification_container: gtk::Box, - pub(super) notification_header_row: gtk::Box, - pub(super) notification_header: gtk::Label, - pub(super) clear_header_button: gtk::Button, - pub(super) toggle_section_header: gtk::Label, - pub(super) stat_section_header: gtk::Label, - pub(super) footer: gtk::Label, - pub(super) media_container: gtk::Box, +pub(in crate::ui) struct PanelSectionWidgets { + pub(in crate::ui) body_stack: gtk::Box, + pub(in crate::ui) widget_revealer: gtk::Revealer, + pub(in crate::ui) widget_stack: gtk::Box, + pub(in crate::ui) quick_controls: gtk::Box, + pub(in crate::ui) toggle_container: gtk::Box, + pub(in crate::ui) stat_container: gtk::Box, + pub(in crate::ui) card_container: gtk::Box, + pub(in crate::ui) scroller: gtk::ScrolledWindow, + pub(in crate::ui) notification_container: gtk::Box, + pub(in crate::ui) notification_header_row: gtk::Box, + pub(in crate::ui) notification_header: gtk::Label, + pub(in crate::ui) clear_header_button: gtk::Button, + pub(in crate::ui) toggle_section_header: gtk::Label, + pub(in crate::ui) stat_section_header: gtk::Label, + pub(in crate::ui) footer: gtk::Label, + pub(in crate::ui) media_container: gtk::Box, } pub(super) fn build_panel_sections( @@ -239,5 +239,5 @@ pub const fn notification_header_row_visible(config: &PanelConfig) -> bool { } #[cfg(test)] -#[path = "tests/sections.rs"] +#[path = "tests/body.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index 471599012..f85fd44dc 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -6,10 +6,10 @@ use gtk::prelude::*; use gtk4_layer_shell::{Layer, LayerShell}; use unixnotis_core::{css::hooks, Config}; +use super::body::build_panel_sections; use super::header::build_panel_header; use super::notice::build_reload_notice; -use super::sections::build_panel_sections; -use super::types::PanelWidgets; +use super::widgets::PanelWidgets; pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidgets { let window = gtk::ApplicationWindow::new(app); @@ -26,23 +26,24 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window.init_layer_shell(); window.set_namespace(Some("unixnotis-panel")); window.set_layer(Layer::Overlay); - super::layout::apply_anchor(&window, config.panel.anchor, config.panel.margin); + super::geometry::apply_anchor(&window, config.panel.anchor, config.panel.margin); window.set_exclusive_zone(0); - window.set_keyboard_mode(super::layout::map_keyboard_mode( + window.set_keyboard_mode(super::geometry::map_keyboard_mode( config.panel.keyboard_interactivity, )); let monitor = if let Some(output) = config.panel.output.as_ref() { // Named outputs fall back to the compositor default when the monitor disappears - super::monitor::find_monitor(output).or_else(super::monitor::default_monitor) + super::geometry::monitor::find_monitor(output) + .or_else(super::geometry::monitor::default_monitor) } else { - super::monitor::default_monitor() + super::geometry::monitor::default_monitor() }; if let Some(monitor) = monitor.as_ref() { window.set_monitor(Some(monitor)); } - let (width, height) = super::layout::resolve_panel_size(config, monitor.as_ref(), None); + let (width, height) = super::geometry::resolve_panel_size(config, monitor.as_ref(), None); // Default size guides the compositor while size request constrains GTK children window.set_default_size(width, height); if height > 0 { @@ -89,41 +90,9 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window, surface: overlay, root, - body_stack: sections.body_stack, - widget_revealer: sections.widget_revealer, - widget_stack: sections.widget_stack, - quick_controls: sections.quick_controls, - toggle_container: sections.toggle_container, - stat_container: sections.stat_container, - card_container: sections.card_container, - scroller: sections.scroller, - media_container: sections.media_container, - search_revealer: header.search.revealer, - search_entry: header.search.entry, - search_toggle: header.actions.search_toggle, - header_title: header.title, - header_subtitle: header.subtitle, - header_count: header.count, - header_top: header.top, - header_action_row: header.action_row, - header_action_group: header.actions.group, - dnd_action_group: header.actions.dnd_group, - notification_container: sections.notification_container, - notification_header_row: sections.notification_header_row, - notification_header: sections.notification_header, - toggle_section_header: sections.toggle_section_header, - stat_section_header: sections.stat_section_header, - footer_label: sections.footer, - focus_toggle: header.actions.focus_toggle, - dnd_toggle: header.actions.dnd_toggle, - dnd_status: header.actions.dnd_status, - dnd_menu: header.actions.dnd_menu, - clear_action_button: header.actions.clear_button, - clear_header_button: sections.clear_header_button, - close_button: header.actions.close_button, - reload_notice_revealer: reload_notice.revealer, - reload_notice_shell: reload_notice.shell, - reload_notice_label: reload_notice.label, + header, + sections, + reload_notice, } } diff --git a/crates/unixnotis-center/src/ui/panel/layout.rs b/crates/unixnotis-center/src/ui/panel/geometry/layout.rs similarity index 93% rename from crates/unixnotis-center/src/ui/panel/layout.rs rename to crates/unixnotis-center/src/ui/panel/geometry/layout.rs index 11d36151d..e8bd0b178 100644 --- a/crates/unixnotis-center/src/ui/panel/layout.rs +++ b/crates/unixnotis-center/src/ui/panel/geometry/layout.rs @@ -9,7 +9,8 @@ use unixnotis_core::{ Anchor, Config, Margins, PanelKeyboardInteractivity, PANEL_RUNTIME_WIDTH_MIN, }; -use super::types::PanelWidgets; +use super::super::widgets::PanelWidgets; +use super::monitor; // Keep panel width reasonable on narrow displays to avoid dominating screen real estate const PANEL_WIDTH_MONITOR_RATIO_CAP: f32 = 0.32; @@ -26,7 +27,7 @@ fn normalize_panel_width_request(width_request: i32) -> i32 { width_request.max(1) } -pub(super) fn resolve_panel_size( +pub(in crate::ui::panel) fn resolve_panel_size( config: &Config, monitor: Option<&gdk::Monitor>, reserved: Option, @@ -37,7 +38,11 @@ pub(super) fn resolve_panel_size( (width, height) } -pub(super) fn apply_anchor(window: &impl IsA, anchor: Anchor, margin: Margins) { +pub(in crate::ui::panel) fn apply_anchor( + window: &impl IsA, + anchor: Anchor, + margin: Margins, +) { for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] { window.set_anchor(edge, false); } @@ -88,9 +93,9 @@ pub(super) fn apply_anchor(window: &impl IsA, anchor: Anchor, margi pub fn apply_panel_config(panel: &PanelWidgets, config: &Config, reserved: Option) { let monitor = if let Some(output) = config.panel.output.as_ref() { - super::monitor::find_monitor(output).or_else(super::monitor::default_monitor) + monitor::find_monitor(output).or_else(monitor::default_monitor) } else { - super::monitor::default_monitor() + monitor::default_monitor() }; if let Some(monitor) = monitor.as_ref() { panel.window.set_monitor(Some(monitor)); @@ -117,7 +122,9 @@ pub fn apply_panel_config(panel: &PanelWidgets, config: &Config, reserved: Optio // margins, so only the outer shell receives an exact width request } -pub(super) const fn map_keyboard_mode(mode: PanelKeyboardInteractivity) -> KeyboardMode { +pub(in crate::ui::panel) const fn map_keyboard_mode( + mode: PanelKeyboardInteractivity, +) -> KeyboardMode { match mode { PanelKeyboardInteractivity::None => KeyboardMode::None, PanelKeyboardInteractivity::OnDemand => KeyboardMode::OnDemand, diff --git a/crates/unixnotis-center/src/ui/panel/geometry/mod.rs b/crates/unixnotis-center/src/ui/panel/geometry/mod.rs new file mode 100644 index 000000000..f7a0a0237 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/geometry/mod.rs @@ -0,0 +1,7 @@ +//! Panel geometry module wiring + +mod layout; +pub(super) mod monitor; + +pub(super) use layout::{apply_anchor, map_keyboard_mode, resolve_panel_size}; +pub use layout::{apply_panel_config, requested_panel_width}; diff --git a/crates/unixnotis-center/src/ui/panel/monitor.rs b/crates/unixnotis-center/src/ui/panel/geometry/monitor.rs similarity index 94% rename from crates/unixnotis-center/src/ui/panel/monitor.rs rename to crates/unixnotis-center/src/ui/panel/geometry/monitor.rs index 2d500ef23..6678ce030 100644 --- a/crates/unixnotis-center/src/ui/panel/monitor.rs +++ b/crates/unixnotis-center/src/ui/panel/geometry/monitor.rs @@ -5,7 +5,7 @@ use gtk::gdk; use gtk::gdk::prelude::*; -pub(super) fn default_monitor() -> Option { +pub(in crate::ui::panel) fn default_monitor() -> Option { let display = gdk::Display::default()?; let monitors = display.monitors(); let mut best: Option = None; @@ -36,7 +36,7 @@ pub(super) fn default_monitor() -> Option { item.downcast::().ok() } -pub(super) fn find_monitor(output: &str) -> Option { +pub(in crate::ui::panel) fn find_monitor(output: &str) -> Option { let display = gdk::Display::default()?; let monitors = display.monitors(); for index in 0..monitors.n_items() { diff --git a/crates/unixnotis-center/src/ui/panel/tests/layout.rs b/crates/unixnotis-center/src/ui/panel/geometry/tests/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/layout.rs rename to crates/unixnotis-center/src/ui/panel/geometry/tests/layout.rs diff --git a/crates/unixnotis-center/src/ui/panel/action_widgets.rs b/crates/unixnotis-center/src/ui/panel/header/actions.rs similarity index 75% rename from crates/unixnotis-center/src/ui/panel/action_widgets.rs rename to crates/unixnotis-center/src/ui/panel/header/actions.rs index 557fb35df..74a1a7f9f 100644 --- a/crates/unixnotis-center/src/ui/panel/action_widgets.rs +++ b/crates/unixnotis-center/src/ui/panel/header/actions.rs @@ -1,20 +1,32 @@ -//! Panel action row construction +//! Panel action construction and signal wiring + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; use gtk::prelude::*; +use tracing::debug; use unixnotis_core::{ css::hooks, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelConfig, }; -pub(super) struct PanelActionWidgets { - pub(super) group: gtk::Box, - pub(super) dnd_group: gtk::Box, - pub(super) focus_toggle: gtk::ToggleButton, - pub(super) dnd_toggle: gtk::ToggleButton, - pub(super) dnd_status: gtk::Label, - pub(super) dnd_menu: gtk::MenuButton, - pub(super) clear_button: gtk::Button, - pub(super) search_toggle: gtk::ToggleButton, - pub(super) close_button: gtk::Button, +use crate::control::UiCommand; +use crate::ui::panel::behavior::input::ClickCooldown; +use crate::ui::panel::PanelWidgets; +use crate::ui::try_send_command; + +const CONTROL_CLICK_GUARD_MS: u64 = 180; + +pub(in crate::ui) struct PanelActionWidgets { + pub(in crate::ui) group: gtk::Box, + pub(in crate::ui) dnd_group: gtk::Box, + pub(in crate::ui) focus_toggle: gtk::ToggleButton, + pub(in crate::ui) dnd_toggle: gtk::ToggleButton, + pub(in crate::ui) dnd_status: gtk::Label, + pub(in crate::ui) dnd_menu: gtk::MenuButton, + pub(in crate::ui) clear_button: gtk::Button, + pub(in crate::ui) search_toggle: gtk::ToggleButton, + pub(in crate::ui) close_button: gtk::Button, } pub(super) struct PanelActionArea { @@ -87,7 +99,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { } } -pub(super) fn build_clear_button(config: &PanelConfig) -> gtk::Button { +pub(in crate::ui::panel) fn build_clear_button(config: &PanelConfig) -> gtk::Button { build_button_action(hooks::panel_action::MUTED, &resolved_clear_action(config)) } @@ -279,6 +291,70 @@ fn resolved_clear_action(config: &PanelConfig) -> PanelActionConfig { action } +pub(in crate::ui) fn connect_clear_button( + button: >k::Button, + command_tx: tokio::sync::mpsc::Sender, +) { + let clear_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); + button.connect_clicked(move |_| { + if !clear_gate.try_start() { + return; + } + + debug!("clear all clicked"); + // Non-blocking send avoids UI stalls on D-Bus backpressure + try_send_command(&command_tx, UiCommand::ClearAll); + }); +} + +pub(in crate::ui) fn connect_dnd_toggle( + panel: &PanelWidgets, + dnd_guard: Rc>, + command_tx: tokio::sync::mpsc::Sender, +) { + connect_dnd_button(&panel.header.actions.dnd_toggle, dnd_guard, command_tx); +} + +fn connect_dnd_button( + button: >k::ToggleButton, + dnd_guard: Rc>, + command_tx: tokio::sync::mpsc::Sender, +) { + button.connect_toggled(move |button| { + if dnd_guard.get() { + // Daemon-driven state sync should not echo another DND command + return; + } + + let requested = button.is_active(); + // Keep the durable daemon state visible until the command commits successfully + dnd_guard.set(true); + button.set_active(!requested); + dnd_guard.set(false); + debug!(enabled = requested, "dnd toggled"); + try_send_command(&command_tx, UiCommand::SetDnd(requested)); + }); +} + +pub(in crate::ui) fn connect_close_button( + panel: &PanelWidgets, + command_tx: tokio::sync::mpsc::Sender, +) { + let close_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); + panel.header.actions.close_button.connect_clicked(move |_| { + if !close_gate.try_start() { + return; + } + + debug!("close panel clicked"); + try_send_command(&command_tx, UiCommand::ClosePanel); + }); +} + +#[cfg(test)] +#[path = "tests/actions.rs"] +mod construction_tests; + #[cfg(test)] -#[path = "tests/action_widgets.rs"] -mod tests; +#[path = "tests/action_signals.rs"] +mod signal_tests; diff --git a/crates/unixnotis-center/src/ui/panel/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/dnd.rs similarity index 72% rename from crates/unixnotis-center/src/ui/panel/dnd.rs rename to crates/unixnotis-center/src/ui/panel/header/dnd.rs index 425957e3c..ecb2cd1db 100644 --- a/crates/unixnotis-center/src/ui/panel/dnd.rs +++ b/crates/unixnotis-center/src/ui/panel/header/dnd.rs @@ -1,17 +1,41 @@ //! Timed Do Not Disturb menu and compact countdown formatting +use std::cell::Cell; +use std::rc::Rc; use std::time::Duration; use chrono::{Days, Local, NaiveDate, NaiveTime, TimeZone, Utc}; use gtk::prelude::*; use crate::control::UiCommand; +use crate::ui::panel::PanelWidgets; use crate::ui::try_send_command; -use super::PanelWidgets; - const MORNING_HOUR: u32 = 8; +pub(in crate::ui) struct DndCountdown { + source: Option, + active: Rc>, +} + +impl DndCountdown { + fn remove_active_source(&mut self) { + if self.active.replace(false) { + // GLib removal is valid only while the callback still owns a live source + if let Some(source) = self.source.take() { + source.remove(); + } + } + } +} + +impl Drop for DndCountdown { + fn drop(&mut self) { + // Dropping panel state must not leave a callback retaining the countdown label + self.remove_active_source(); + } +} + pub(in crate::ui) fn connect_dnd_menu( panel: &PanelWidgets, command_tx: tokio::sync::mpsc::Sender, @@ -62,7 +86,7 @@ pub(in crate::ui) fn connect_dnd_menu( choices.append(&indefinite); popover.set_child(Some(&choices)); - panel.dnd_menu.set_popover(Some(&popover)); + panel.header.actions.dnd_menu.set_popover(Some(&popover)); } pub(in crate::ui) fn update_dnd_status(label: >k::Label, expires_at: i64) { @@ -72,17 +96,32 @@ pub(in crate::ui) fn update_dnd_status(label: >k::Label, expires_at: i64) { label.set_text(&text); } -pub(in crate::ui) fn start_dnd_countdown( - label: >k::Label, - expires_at: i64, -) -> gtk::glib::SourceId { +pub(in crate::ui) fn start_dnd_countdown(label: >k::Label, expires_at: i64) -> DndCountdown { // GTK owns the callback on its main context while UiState owns the source id let label = label.clone(); - gtk::glib::timeout_add_local(Duration::from_secs(30), move || { + let active = Rc::new(Cell::new(true)); + let callback_active = active.clone(); + let source = gtk::glib::timeout_add_local(Duration::from_secs(30), move || { update_dnd_status(&label, expires_at); - // The next daemon state update owns source removal, even after the label reaches zero + let flow = countdown_control_flow(expires_at, Utc::now().timestamp()); + if flow == gtk::glib::ControlFlow::Break { + // Mark the ID inactive before GLib destroys it after this callback + callback_active.set(false); + } + flow + }); + DndCountdown { + source: Some(source), + active, + } +} + +const fn countdown_control_flow(expires_at: i64, now: i64) -> gtk::glib::ControlFlow { + if expires_at <= now { + gtk::glib::ControlFlow::Break + } else { gtk::glib::ControlFlow::Continue - }) + } } fn format_dnd_remaining(expires_at: i64, now: i64) -> String { diff --git a/crates/unixnotis-center/src/ui/panel/header.rs b/crates/unixnotis-center/src/ui/panel/header/mod.rs similarity index 79% rename from crates/unixnotis-center/src/ui/panel/header.rs rename to crates/unixnotis-center/src/ui/panel/header/mod.rs index 79c0e5338..bbe3ace11 100644 --- a/crates/unixnotis-center/src/ui/panel/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header/mod.rs @@ -1,21 +1,25 @@ -//! Panel header construction +//! Panel header construction and component grouping + +pub(in crate::ui) mod actions; +pub(in crate::ui) mod dnd; +pub(in crate::ui) mod search; use gtk::prelude::*; use gtk::Align; use unixnotis_core::{css::hooks, PanelConfig}; -use super::action_widgets::{action_order_contains_close, build_panel_actions, PanelActionWidgets}; -use super::search_widgets::{build_panel_search, PanelSearchWidgets}; - -pub(super) struct PanelHeaderWidgets { - pub(super) root: gtk::Box, - pub(super) top: gtk::Box, - pub(super) action_row: gtk::Box, - pub(super) title: gtk::Label, - pub(super) subtitle: gtk::Label, - pub(super) count: gtk::Label, - pub(super) search: PanelSearchWidgets, - pub(super) actions: PanelActionWidgets, +use self::actions::{action_order_contains_close, build_panel_actions, PanelActionWidgets}; +use self::search::{build_panel_search, PanelSearchWidgets}; + +pub(in crate::ui) struct PanelHeaderWidgets { + pub(in crate::ui) root: gtk::Box, + pub(in crate::ui) top: gtk::Box, + pub(in crate::ui) action_row: gtk::Box, + pub(in crate::ui) title: gtk::Label, + pub(in crate::ui) subtitle: gtk::Label, + pub(in crate::ui) count: gtk::Label, + pub(in crate::ui) search: PanelSearchWidgets, + pub(in crate::ui) actions: PanelActionWidgets, } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs new file mode 100644 index 000000000..7fb41be79 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -0,0 +1,194 @@ +//! Panel search construction, filtering, and reveal wiring + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use async_channel::TrySendError; +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelConfig}; + +use crate::control::UiEvent; +use crate::ui::panel::behavior::input::{ClickCooldown, LatestBoolEventGate}; +use crate::ui::panel::{PanelWidgets, WIDGET_REVEAL_TRANSITION_MS}; + +pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; +const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; + +pub(in crate::ui) struct PanelSearchWidgets { + pub(in crate::ui) revealer: gtk::Revealer, + pub(in crate::ui) entry: gtk::SearchEntry, +} + +pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { + let search_shell = gtk::Box::new(gtk::Orientation::Horizontal, 6); + search_shell.add_css_class(hooks::panel_shell::SEARCH_SHELL); + search_shell.set_hexpand(true); + + let leading_accent = gtk::Box::new(gtk::Orientation::Vertical, 0); + leading_accent.add_css_class(hooks::panel_shell::SEARCH_ACCENT); + leading_accent.add_css_class(hooks::panel_shell::TICK_TOP_LEFT); + + let star_accent = gtk::Label::new(Some("*")); + star_accent.add_css_class(hooks::panel_shell::SEARCH_STAR); + + let search_entry = gtk::SearchEntry::new(); + search_entry.add_css_class(hooks::panel_shell::SEARCH); + // Placeholder text keeps the intent obvious before the first query + search_entry.set_placeholder_text(Some(&config.search_placeholder)); + search_entry.set_hexpand(true); + search_entry.set_tooltip_text(Some("Type to filter notifications")); + search_shell.append(&leading_accent); + search_shell.append(&search_entry); + search_shell.append(&star_accent); + + let search_revealer = gtk::Revealer::new(); + search_revealer.add_css_class(hooks::panel_shell::SEARCH_REVEALER); + // Slide-down matches the rest of the panel reveal motion + search_revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + search_revealer.set_transition_duration(SEARCH_REVEAL_TRANSITION_MS as u32); + // Keep search hidden until the user asks for it so notifications keep the space + search_revealer.set_reveal_child(config.search_visible); + search_revealer.set_child(Some(&search_shell)); + + PanelSearchWidgets { + revealer: search_revealer, + entry: search_entry, + } +} + +pub(in crate::ui) fn connect_widget_collapse_toggle( + panel: &PanelWidgets, + event_tx: async_channel::Sender, +) { + let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); + let collapse_click_gate = + ClickCooldown::new(Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS)); + let accepted_collapsed = Rc::new(Cell::new(false)); + // Restore guard prevents a rejected click rollback from re-entering this handler + let collapse_restore = Rc::new(Cell::new(false)); + + panel + .header + .actions + .focus_toggle + .connect_toggled(move |button| { + if collapse_restore.replace(false) { + return; + } + + let collapsed = button.is_active(); + // Ignore clicks while the previous reveal animation is still changing layout + if !collapse_click_gate.try_start() { + let accepted = accepted_collapsed.get(); + if collapsed != accepted { + // Roll back only the rejected edge so the UI mirrors the running transition + collapse_restore.set(true); + button.set_active(accepted); + } + return; + } + + accepted_collapsed.set(collapsed); + // Disable the control until GTK finishes the matching reveal transition + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once( + Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), + move || { + button_enable.set_sensitive(true); + }, + ); + collapse_gate.request_widgets_collapsed(&event_tx, collapsed); + }); +} + +pub(in crate::ui) fn connect_filter_entry( + panel: &PanelWidgets, + event_tx: async_channel::Sender, +) { + // SearchChanged covers typing, clear actions, and programmatic text resets + panel + .header + .search + .entry + .connect_search_changed(move |entry| { + send_filter_event(&event_tx, entry.text().to_string()); + }); +} + +pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filter: String) { + let event = UiEvent::FilterChanged(filter); + match event_tx.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + // Search changes are small and should retry instead of disappearing under bursts + let event_tx = event_tx.clone(); + gtk::glib::MainContext::default().spawn_local(async move { + let _ = event_tx.send(event).await; + }); + } + Err(TrySendError::Closed(_)) => {} // A closed UI channel means shutdown already owns the pending filter state + } +} + +pub(in crate::ui) fn connect_search_toggle( + panel: &PanelWidgets, + search_toggle_guard: Rc>, +) { + let search_revealer = panel.header.search.revealer.clone(); + let search_entry = panel.header.search.entry.clone(); + let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); + let accepted_search_reveal = Rc::new(Cell::new(false)); + // Programmatic rollback must not be mistaken for a fresh user click + let search_restore = Rc::new(Cell::new(false)); + + panel + .header + .actions + .search_toggle + .connect_toggled(move |button| { + if search_toggle_guard.get() || search_restore.replace(false) { + return; + } + + let reveal = button.is_active(); + if !search_click_gate.try_start() { + let accepted = accepted_search_reveal.get(); + if reveal != accepted { + // Keep the visual toggle synced with the accepted revealer state + search_restore.set(true); + button.set_active(accepted); + } + return; + } + + accepted_search_reveal.set(reveal); + // Freeze the toggle while its revealer animates to the accepted state + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once( + Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), + move || { + button_enable.set_sensitive(true); + }, + ); + search_revealer.set_reveal_child(reveal); + if reveal { + // Selecting existing text makes the next query replace it immediately + search_entry.grab_focus(); + search_entry.select_region(0, -1); + } else if !search_entry.text().is_empty() { + // Closing search restores the full notification list + search_entry.set_text(""); + } + }); +} + +#[cfg(test)] +#[path = "tests/search.rs"] +mod construction_tests; + +#[cfg(test)] +#[path = "tests/search_signals.rs"] +mod signal_tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/header/tests/action_signals.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/actions.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/action_signals.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/action_widgets.rs b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/action_widgets.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/actions.rs diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs new file mode 100644 index 000000000..d2a871890 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs @@ -0,0 +1,75 @@ +use std::cell::Cell; +use std::rc::Rc; + +use chrono::NaiveDate; + +use super::{countdown_control_flow, format_dnd_remaining, tomorrow_date, DndCountdown}; + +#[test] +fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { + assert_eq!(format_dnd_remaining(100, 100), ""); + assert_eq!(format_dnd_remaining(99, 100), ""); + assert_eq!(format_dnd_remaining(101, 100), "· 1m"); + assert_eq!(format_dnd_remaining(100 + 47 * 60, 100), "· 47m"); +} + +#[test] +fn remaining_time_keeps_hours_compact_without_losing_partial_hour() { + assert_eq!(format_dnd_remaining(100 + 60 * 60, 100), "· 1h"); + assert_eq!( + format_dnd_remaining(100 + 2 * 60 * 60 + 5 * 60, 100), + "· 2h 5m" + ); +} + +#[test] +fn morning_choice_uses_the_next_local_eight_oclock() { + let today = NaiveDate::from_ymd_opt(2026, 7, 18).expect("valid date"); + + assert_eq!(tomorrow_date(today), NaiveDate::from_ymd_opt(2026, 7, 19)); +} + +#[test] +fn countdown_stops_at_the_deadline_and_continues_only_while_future() { + assert_eq!( + countdown_control_flow(100, 99), + gtk::glib::ControlFlow::Continue + ); + assert_eq!( + countdown_control_flow(100, 100), + gtk::glib::ControlFlow::Break + ); + assert_eq!( + countdown_control_flow(100, 101), + gtk::glib::ControlFlow::Break + ); +} + +#[gtk::test] +fn dropping_countdown_removes_its_live_source() { + let callback_runs = Rc::new(Cell::new(0)); + let countdown = test_countdown(callback_runs.clone()); + + drop(countdown); + drain_main_context(); + + assert_eq!(callback_runs.get(), 0); +} + +fn test_countdown(callback_runs: Rc>) -> DndCountdown { + let source = gtk::glib::idle_add_local(move || { + callback_runs.set(callback_runs.get() + 1); + gtk::glib::ControlFlow::Break + }); + DndCountdown { + source: Some(source), + active: Rc::new(Cell::new(true)), + } +} + +fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/header.rs b/crates/unixnotis-center/src/ui/panel/header/tests/header.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/header.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/header.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/search_widgets.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/search_widgets.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/search.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/search.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/search.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index 6e60fdd07..a56b5e5a1 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -2,36 +2,28 @@ //! //! The folder root stays focused on module wiring and the public panel surface -mod action_widgets; -mod actions; -mod autoclose; +mod apply; +pub(in crate::ui) mod behavior; +mod body; mod build; -mod dnd; +mod geometry; mod header; -pub(in crate::ui) mod input; -mod keyboard; -mod layout; -mod monitor; mod notice; -mod reload; -mod search; -mod search_widgets; -mod sections; -mod timing; -mod types; -mod visibility; +mod state; +mod widgets; +pub use self::apply::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; +pub use self::body::apply_widget_density; +pub use self::body::{notification_header_row_visible, WIDGET_REVEAL_TRANSITION_MS}; pub use self::build::build_panel_widgets; -pub use self::layout::{apply_panel_config, requested_panel_width}; -pub use self::reload::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; -pub use self::search_widgets::SEARCH_REVEAL_TRANSITION_MS; -pub use self::sections::apply_widget_density; -pub use self::sections::{notification_header_row_visible, WIDGET_REVEAL_TRANSITION_MS}; -pub use self::types::PanelWidgets; -pub(in crate::ui) use actions::{connect_clear_button, connect_close_button, connect_dnd_toggle}; -pub(in crate::ui) use autoclose::connect_auto_close; -pub(in crate::ui) use dnd::connect_dnd_menu; -pub(in crate::ui) use keyboard::connect_keyboard_shortcuts; -pub(in crate::ui) use search::{ +pub use self::geometry::{apply_panel_config, requested_panel_width}; +pub use self::widgets::PanelWidgets; +pub(in crate::ui) use behavior::input; +pub(in crate::ui) use behavior::{connect_auto_close, connect_keyboard_shortcuts}; +pub(in crate::ui) use header::actions::{ + connect_clear_button, connect_close_button, connect_dnd_toggle, +}; +pub(in crate::ui) use header::dnd::{connect_dnd_menu, DndCountdown}; +pub(in crate::ui) use header::search::{ connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, }; diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index 7e8878dd9..1759830ae 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -3,10 +3,10 @@ use gtk::prelude::*; use unixnotis_core::css::hooks; -pub(super) struct ReloadNoticeWidgets { - pub(super) revealer: gtk::Revealer, - pub(super) shell: gtk::Box, - pub(super) label: gtk::Label, +pub(in crate::ui) struct ReloadNoticeWidgets { + pub(in crate::ui) revealer: gtk::Revealer, + pub(in crate::ui) shell: gtk::Box, + pub(in crate::ui) label: gtk::Label, } pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { diff --git a/crates/unixnotis-center/src/ui/panel/reload.rs b/crates/unixnotis-center/src/ui/panel/reload.rs deleted file mode 100644 index a22c18c4c..000000000 --- a/crates/unixnotis-center/src/ui/panel/reload.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Panel reload helpers for structure and action chrome - -use unixnotis_core::{PanelConfig, PanelSection}; - -use super::types::PanelWidgets; - -pub fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { - super::action_widgets::apply_panel_action_config( - &panel.header_top, - &super::action_widgets::PanelActionWidgets { - group: panel.header_action_group.clone(), - dnd_group: panel.dnd_action_group.clone(), - focus_toggle: panel.focus_toggle.clone(), - dnd_toggle: panel.dnd_toggle.clone(), - dnd_status: panel.dnd_status.clone(), - dnd_menu: panel.dnd_menu.clone(), - clear_button: panel.clear_action_button.clone(), - search_toggle: panel.search_toggle.clone(), - close_button: panel.close_button.clone(), - }, - config, - ); - super::action_widgets::apply_clear_button_config(&panel.clear_header_button, config); -} - -pub fn apply_reloaded_body_order(panel: &PanelWidgets, order: &[PanelSection]) { - super::sections::apply_panel_body_section_order( - &panel.body_stack, - &panel.widget_revealer, - &panel.notification_container, - order, - ); -} - -#[cfg(test)] -#[path = "tests/reload.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/search.rs b/crates/unixnotis-center/src/ui/panel/search.rs deleted file mode 100644 index e470a1399..000000000 --- a/crates/unixnotis-center/src/ui/panel/search.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Search, filter, and widget-collapse wiring - -use std::cell::Cell; -use std::rc::Rc; -use std::time::Duration; - -use async_channel::TrySendError; -use gtk::prelude::*; - -use super::input::{ClickCooldown, LatestBoolEventGate}; -use super::timing::WIDGETS_TOGGLE_COALESCE_MS; -use super::{PanelWidgets, SEARCH_REVEAL_TRANSITION_MS, WIDGET_REVEAL_TRANSITION_MS}; -use crate::control::UiEvent; - -pub(in crate::ui) fn connect_widget_collapse_toggle( - panel: &PanelWidgets, - event_tx: async_channel::Sender, -) { - let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); - let collapse_click_gate = - ClickCooldown::new(Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS)); - let accepted_collapsed = Rc::new(Cell::new(false)); - // Restore guard prevents a rejected click rollback from re-entering this handler - let collapse_restore = Rc::new(Cell::new(false)); - - panel.focus_toggle.connect_toggled(move |button| { - if collapse_restore.replace(false) { - return; - } - - let collapsed = button.is_active(); - // Ignore clicks while the previous reveal animation is still changing layout - if !collapse_click_gate.try_start() { - let accepted = accepted_collapsed.get(); - if collapsed != accepted { - // Roll back only the rejected edge so the UI mirrors the running transition - collapse_restore.set(true); - button.set_active(accepted); - } - return; - } - - accepted_collapsed.set(collapsed); - // Disable the control until GTK finishes the matching reveal transition - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - collapse_gate.request_widgets_collapsed(&event_tx, collapsed); - }); -} - -pub(in crate::ui) fn connect_filter_entry( - panel: &PanelWidgets, - event_tx: async_channel::Sender, -) { - // SearchChanged covers typing, clear actions, and programmatic text resets - panel.search_entry.connect_search_changed(move |entry| { - send_filter_event(&event_tx, entry.text().to_string()); - }); -} - -#[cfg(test)] -#[path = "tests/search.rs"] -mod tests; - -pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filter: String) { - let event = UiEvent::FilterChanged(filter); - match event_tx.try_send(event) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - // Search changes are small and should retry instead of disappearing under bursts - let event_tx = event_tx.clone(); - gtk::glib::MainContext::default().spawn_local(async move { - let _ = event_tx.send(event).await; - }); - } - Err(TrySendError::Closed(_)) => {} // A closed UI channel means shutdown already owns the pending filter state - } -} - -pub(in crate::ui) fn connect_search_toggle( - panel: &PanelWidgets, - search_toggle_guard: Rc>, -) { - let search_revealer = panel.search_revealer.clone(); - let search_entry = panel.search_entry.clone(); - let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); - let accepted_search_reveal = Rc::new(Cell::new(false)); - // Programmatic rollback must not be mistaken for a fresh user click - let search_restore = Rc::new(Cell::new(false)); - - panel.search_toggle.connect_toggled(move |button| { - if search_toggle_guard.get() || search_restore.replace(false) { - return; - } - - let reveal = button.is_active(); - if !search_click_gate.try_start() { - let accepted = accepted_search_reveal.get(); - if reveal != accepted { - // Keep the visual toggle synced with the accepted revealer state - search_restore.set(true); - button.set_active(accepted); - } - return; - } - - accepted_search_reveal.set(reveal); - // Freeze the toggle while its revealer animates to the accepted state - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - search_revealer.set_reveal_child(reveal); - if reveal { - // Selecting existing text makes the next query replace it immediately - search_entry.grab_focus(); - search_entry.select_region(0, -1); - } else if !search_entry.text().is_empty() { - // Closing search restores the full notification list - search_entry.set_text(""); - } - }); -} diff --git a/crates/unixnotis-center/src/ui/panel/search_widgets.rs b/crates/unixnotis-center/src/ui/panel/search_widgets.rs deleted file mode 100644 index ff5db7ff2..000000000 --- a/crates/unixnotis-center/src/ui/panel/search_widgets.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Panel search row construction - -use gtk::prelude::*; -use unixnotis_core::{css::hooks, PanelConfig}; - -pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; - -pub(super) struct PanelSearchWidgets { - pub(super) revealer: gtk::Revealer, - pub(super) entry: gtk::SearchEntry, -} - -pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { - let search_shell = gtk::Box::new(gtk::Orientation::Horizontal, 6); - search_shell.add_css_class(hooks::panel_shell::SEARCH_SHELL); - search_shell.set_hexpand(true); - - let leading_accent = gtk::Box::new(gtk::Orientation::Vertical, 0); - leading_accent.add_css_class(hooks::panel_shell::SEARCH_ACCENT); - leading_accent.add_css_class(hooks::panel_shell::TICK_TOP_LEFT); - - let star_accent = gtk::Label::new(Some("*")); - star_accent.add_css_class(hooks::panel_shell::SEARCH_STAR); - - let search_entry = gtk::SearchEntry::new(); - search_entry.add_css_class(hooks::panel_shell::SEARCH); - // Placeholder text keeps the intent obvious before the first query - search_entry.set_placeholder_text(Some(&config.search_placeholder)); - search_entry.set_hexpand(true); - search_entry.set_tooltip_text(Some("Type to filter notifications")); - search_shell.append(&leading_accent); - search_shell.append(&search_entry); - search_shell.append(&star_accent); - - let search_revealer = gtk::Revealer::new(); - search_revealer.add_css_class(hooks::panel_shell::SEARCH_REVEALER); - // Slide-down matches the rest of the panel reveal motion - search_revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - search_revealer.set_transition_duration(SEARCH_REVEAL_TRANSITION_MS as u32); - // Keep search hidden until the user asks for it so notifications keep the space - search_revealer.set_reveal_child(config.search_visible); - search_revealer.set_child(Some(&search_shell)); - - PanelSearchWidgets { - revealer: search_revealer, - entry: search_entry, - } -} - -#[cfg(test)] -#[path = "tests/search_widgets.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/state.rs b/crates/unixnotis-center/src/ui/panel/state.rs new file mode 100644 index 000000000..de1621b60 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/state.rs @@ -0,0 +1,77 @@ +//! Panel content and daemon-state synchronization + +use gtk::prelude::*; + +use crate::ui::UiState; + +impl UiState { + pub const fn panel_is_visible(&self) -> bool { + self.panel_visible + } + + pub(in crate::ui) const fn has_any_widgets(&self) -> bool { + self.volume.is_some() + || self.brightness.is_some() + || self.toggles.is_some() + || self.stats.is_some() + || self.cards.is_some() + || (self.media.is_some() && self.config.media.enabled) + } + + pub(in crate::ui) fn set_widgets_collapsed(&mut self, collapsed: bool) { + self.widgets_collapsed = collapsed; + if self.panel.header.actions.focus_toggle.is_active() != collapsed { + // Mirror external collapse requests into the header toggle state + self.panel.header.actions.focus_toggle.set_active(collapsed); + } + if self.panel.sections.widget_revealer.reveals_child() == collapsed { + self.panel + .sections + .widget_revealer + .set_reveal_child(!collapsed); + } + self.list + .set_empty_layout(!collapsed && self.has_any_widgets()); + } + + pub(in crate::ui) fn update_state(&mut self, state: unixnotis_core::ControlState) { + // Dropping the old countdown removes its source unless GLib already stopped it + drop(self.dnd_expiration_source.take()); + + // Avoid re-entrant DND toggles while applying daemon state + self.dnd_guard.set(true); + self.panel + .header + .actions + .dnd_toggle + .set_active(state.dnd_enabled); + self.dnd_guard.set(false); + let expires_at = state + .dnd_enabled + .then_some(state.dnd_expires_at) + .filter(|expires_at| *expires_at > 0) + .unwrap_or(0); + super::header::dnd::update_dnd_status(&self.panel.header.actions.dnd_status, expires_at); + if expires_at > 0 { + self.dnd_expiration_source = Some(super::header::dnd::start_dnd_countdown( + &self.panel.header.actions.dnd_status, + expires_at, + )); + } + } + + pub(in crate::ui) fn refresh_counts(&mut self) { + if !self.panel_visible { + // Skip label updates while hidden to avoid unnecessary UI work + // Counts are refreshed on the next open to keep the header accurate + return; + } + // Header count always reflects total active + history entries + let total = self.list.total_count(); + if self.last_count == Some(total) { + return; + } + self.last_count = Some(total); + self.panel.header.count.set_text(&format!("{total}")); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/reload.rs b/crates/unixnotis-center/src/ui/panel/tests/apply.rs similarity index 53% rename from crates/unixnotis-center/src/ui/panel/tests/reload.rs rename to crates/unixnotis-center/src/ui/panel/tests/apply.rs index 6b0f25bd9..90b16e7e7 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/reload.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/apply.rs @@ -5,10 +5,10 @@ use unixnotis_core::{ css::hooks, PanelActionId, PanelClearButtonPlacement, PanelConfig, PanelSection, }; +use super::super::body::build_panel_sections; use super::super::header::build_panel_header; use super::super::notice::build_reload_notice; -use super::super::sections::build_panel_sections; -use super::super::types::PanelWidgets; +use super::super::widgets::PanelWidgets; use super::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; static APP_ID: AtomicUsize = AtomicUsize::new(0); @@ -40,41 +40,9 @@ fn panel_widgets(config: &PanelConfig) -> PanelWidgets { window: gtk::ApplicationWindow::new(&app), surface: gtk::Overlay::new(), root: gtk::Box::new(gtk::Orientation::Vertical, 0), - body_stack: sections.body_stack, - widget_revealer: sections.widget_revealer, - widget_stack: sections.widget_stack, - quick_controls: sections.quick_controls, - toggle_container: sections.toggle_container, - stat_container: sections.stat_container, - card_container: sections.card_container, - scroller: sections.scroller, - media_container: sections.media_container, - search_revealer: header.search.revealer, - search_entry: header.search.entry, - search_toggle: header.actions.search_toggle, - header_title: header.title, - header_subtitle: header.subtitle, - header_count: header.count, - header_top: header.top, - header_action_row: header.action_row, - header_action_group: header.actions.group, - dnd_action_group: header.actions.dnd_group, - notification_container: sections.notification_container, - notification_header_row: sections.notification_header_row, - notification_header: sections.notification_header, - toggle_section_header: sections.toggle_section_header, - stat_section_header: sections.stat_section_header, - footer_label: sections.footer, - focus_toggle: header.actions.focus_toggle, - dnd_toggle: header.actions.dnd_toggle, - dnd_status: header.actions.dnd_status, - dnd_menu: header.actions.dnd_menu, - clear_action_button: header.actions.clear_button, - clear_header_button: sections.clear_header_button, - close_button: header.actions.close_button, - reload_notice_revealer: notice.revealer, - reload_notice_shell: notice.shell, - reload_notice_label: notice.label, + header, + sections, + reload_notice: notice, } } @@ -95,10 +63,10 @@ fn apply_reloaded_panel_chrome_updates_clear_buttons_and_close_placement() { apply_reloaded_panel_chrome(&panel, &config); - assert!(!panel.clear_action_button.get_visible()); - assert!(panel.clear_header_button.get_visible()); - assert!(child_with_class(&panel.header_top, hooks::panel_action::CLOSE).is_none()); - assert!(child_with_class(&panel.header_action_group, hooks::panel_action::CLOSE).is_some()); + assert!(!panel.header.actions.clear_button.get_visible()); + assert!(panel.sections.clear_header_button.get_visible()); + assert!(child_with_class(&panel.header.top, hooks::panel_action::CLOSE).is_none()); + assert!(child_with_class(&panel.header.actions.group, hooks::panel_action::CLOSE).is_some()); } #[gtk::test] @@ -111,12 +79,13 @@ fn apply_reloaded_body_order_moves_notifications_before_widgets() { ); let first = panel + .sections .body_stack .first_child() .expect("body stack should retain both sections"); - assert_eq!(first, panel.notification_container); + assert_eq!(first, panel.sections.notification_container); let second = first .next_sibling() .expect("widget section should follow notifications"); - assert_eq!(second, panel.widget_revealer); + assert_eq!(second, panel.sections.widget_revealer); } diff --git a/crates/unixnotis-center/src/ui/panel/tests/sections.rs b/crates/unixnotis-center/src/ui/panel/tests/body.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/sections.rs rename to crates/unixnotis-center/src/ui/panel/tests/body.rs diff --git a/crates/unixnotis-center/src/ui/panel/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/tests/dnd.rs deleted file mode 100644 index a3d557bbb..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/dnd.rs +++ /dev/null @@ -1,27 +0,0 @@ -use chrono::NaiveDate; - -use super::{format_dnd_remaining, tomorrow_date}; - -#[test] -fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { - assert_eq!(format_dnd_remaining(100, 100), ""); - assert_eq!(format_dnd_remaining(99, 100), ""); - assert_eq!(format_dnd_remaining(101, 100), "· 1m"); - assert_eq!(format_dnd_remaining(100 + 47 * 60, 100), "· 47m"); -} - -#[test] -fn remaining_time_keeps_hours_compact_without_losing_partial_hour() { - assert_eq!(format_dnd_remaining(100 + 60 * 60, 100), "· 1h"); - assert_eq!( - format_dnd_remaining(100 + 2 * 60 * 60 + 5 * 60, 100), - "· 2h 5m" - ); -} - -#[test] -fn morning_choice_uses_the_next_local_eight_oclock() { - let today = NaiveDate::from_ymd_opt(2026, 7, 18).expect("valid date"); - - assert_eq!(tomorrow_date(today), NaiveDate::from_ymd_opt(2026, 7, 19)); -} diff --git a/crates/unixnotis-center/src/ui/panel/tests/timing.rs b/crates/unixnotis-center/src/ui/panel/tests/timing.rs deleted file mode 100644 index d3138404f..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/timing.rs +++ /dev/null @@ -1,10 +0,0 @@ -use super::{CONTROL_CLICK_GUARD_MS, WIDGETS_TOGGLE_COALESCE_MS}; - -#[test] -fn startup_timing_keeps_click_guard_above_event_coalescing() { - let click_guard_ms = std::hint::black_box(CONTROL_CLICK_GUARD_MS); - let coalesce_ms = std::hint::black_box(WIDGETS_TOGGLE_COALESCE_MS); - - assert!(click_guard_ms > coalesce_ms); - assert!(coalesce_ms > 0); -} diff --git a/crates/unixnotis-center/src/ui/panel/timing.rs b/crates/unixnotis-center/src/ui/panel/timing.rs deleted file mode 100644 index 6710e6410..000000000 --- a/crates/unixnotis-center/src/ui/panel/timing.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Shared startup interaction timing - -// Short guard for buttons that send daemon commands -// Prevents double-click bursts from queueing duplicate actions -pub(super) const CONTROL_CLICK_GUARD_MS: u64 = 180; - -// Tiny coalescing window for the widget collapse event -// Keeps rapid toggle edges from flooding the main event queue -pub(super) const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; - -#[cfg(test)] -#[path = "tests/timing.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/types.rs b/crates/unixnotis-center/src/ui/panel/types.rs deleted file mode 100644 index 3b6c5ab19..000000000 --- a/crates/unixnotis-center/src/ui/panel/types.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! GTK widget handles for the center panel -//! -//! Keeping the widget bundle here lets `mod.rs` stay as module wiring only - -/// GTK widgets backing the notification center panel window -pub struct PanelWidgets { - pub window: gtk::ApplicationWindow, - pub surface: gtk::Overlay, - pub root: gtk::Box, - pub body_stack: gtk::Box, - pub widget_revealer: gtk::Revealer, - pub widget_stack: gtk::Box, - pub quick_controls: gtk::Box, - pub toggle_container: gtk::Box, - pub stat_container: gtk::Box, - pub card_container: gtk::Box, - pub scroller: gtk::ScrolledWindow, - pub media_container: gtk::Box, - pub search_revealer: gtk::Revealer, - pub search_entry: gtk::SearchEntry, - pub search_toggle: gtk::ToggleButton, - pub header_title: gtk::Label, - pub header_subtitle: gtk::Label, - pub header_count: gtk::Label, - pub header_top: gtk::Box, - pub header_action_row: gtk::Box, - pub header_action_group: gtk::Box, - pub dnd_action_group: gtk::Box, - pub notification_container: gtk::Box, - pub notification_header_row: gtk::Box, - pub notification_header: gtk::Label, - pub toggle_section_header: gtk::Label, - pub stat_section_header: gtk::Label, - pub footer_label: gtk::Label, - pub focus_toggle: gtk::ToggleButton, - pub dnd_toggle: gtk::ToggleButton, - pub dnd_status: gtk::Label, - pub dnd_menu: gtk::MenuButton, - pub clear_action_button: gtk::Button, - pub clear_header_button: gtk::Button, - pub close_button: gtk::Button, - pub reload_notice_revealer: gtk::Revealer, - pub reload_notice_shell: gtk::Box, - pub reload_notice_label: gtk::Label, -} diff --git a/crates/unixnotis-center/src/ui/panel/widgets.rs b/crates/unixnotis-center/src/ui/panel/widgets.rs new file mode 100644 index 000000000..0b6a6852e --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/widgets.rs @@ -0,0 +1,17 @@ +//! Grouped GTK widget handles for the center panel +//! +//! Keeping the widget bundle here lets `mod.rs` stay as module wiring only + +use super::body::PanelSectionWidgets; +use super::header::PanelHeaderWidgets; +use super::notice::ReloadNoticeWidgets; + +/// GTK widgets backing the notification center panel window +pub struct PanelWidgets { + pub window: gtk::ApplicationWindow, + pub surface: gtk::Overlay, + pub root: gtk::Box, + pub(in crate::ui) header: PanelHeaderWidgets, + pub(in crate::ui) sections: PanelSectionWidgets, + pub(in crate::ui) reload_notice: ReloadNoticeWidgets, +} diff --git a/crates/unixnotis-center/src/ui/reload/config.rs b/crates/unixnotis-center/src/ui/reload/config.rs index 092ed5fbd..42748a9f6 100644 --- a/crates/unixnotis-center/src/ui/reload/config.rs +++ b/crates/unixnotis-center/src/ui/reload/config.rs @@ -174,24 +174,27 @@ impl UiState { fn render_reload_notice(&self) { let Some(notice) = self.reload_notices.visible() else { - self.panel.reload_notice_revealer.set_reveal_child(false); + self.panel.reload_notice.revealer.set_reveal_child(false); return; }; - self.panel.reload_notice_label.set_label(¬ice.message); + self.panel.reload_notice.label.set_label(¬ice.message); self.panel - .reload_notice_shell + .reload_notice + .shell .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_ERROR); self.panel - .reload_notice_shell + .reload_notice + .shell .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_WARNING); self.panel - .reload_notice_shell + .reload_notice + .shell .add_css_class(if notice.error { hooks::panel_shell::RELOAD_NOTICE_ERROR } else { hooks::panel_shell::RELOAD_NOTICE_WARNING }); - self.panel.reload_notice_revealer.set_reveal_child(true); + self.panel.reload_notice.revealer.set_reveal_child(true); } fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { @@ -201,7 +204,7 @@ impl UiState { fn capture_notice_dismissal(&mut self) { // The close button hides GTK immediately, then the next event records that dismissal - if !self.panel.reload_notice_revealer.reveals_child() + if !self.panel.reload_notice.revealer.reveals_child() && self.reload_notices.visible().is_some() { self.reload_notices.dismiss_visible(); @@ -211,67 +214,79 @@ impl UiState { pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { // Geometry goes first so later sections can size themselves from the final panel width panel::apply_panel_config(&self.panel, config, self.work_area); - self.panel.header_title.set_label(&config.panel.title); - self.panel.header_subtitle.set_label(&config.panel.subtitle); + self.panel.header.title.set_label(&config.panel.title); + self.panel.header.subtitle.set_label(&config.panel.subtitle); self.panel - .header_subtitle + .header + .subtitle .set_visible(!config.panel.subtitle.is_empty()); self.panel - .search_entry + .header + .search + .entry .set_placeholder_text(Some(&config.panel.search_placeholder)); + self.panel.header.search.revealer.set_reveal_child( + config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(), + ); self.panel - .search_revealer - .set_reveal_child(config.panel.search_visible || self.panel.search_toggle.is_active()); - self.panel - .header_action_row + .header + .action_row .set_visible(config.panel.action_row_visible); panel::apply_reloaded_panel_chrome(&self.panel, &config.panel); self.panel + .sections .notification_header .set_label(&config.panel.recent_notifications_label); - self.panel.notification_header.set_visible( + self.panel.sections.notification_header.set_visible( config.panel.notification_section_visible && !config.panel.recent_notifications_label.is_empty(), ); self.panel + .sections .notification_header_row .set_visible(notification_header_row_visible(&config.panel)); self.update_section_header( - &self.panel.toggle_section_header, + &self.panel.sections.toggle_section_header, &config.panel.quick_actions_label, ); self.update_section_header( - &self.panel.stat_section_header, + &self.panel.sections.stat_section_header, &config.panel.system_status_label, ); if config.panel.notification_section_visible { self.panel + .sections .notification_container .add_css_class(hooks::panel_shell::RECENT_SECTION); } else { self.panel + .sections .notification_container .remove_css_class(hooks::panel_shell::RECENT_SECTION); } self.panel + .sections .scroller .set_vexpand(config.panel.notification_list_expand); self.panel + .sections .notification_container .set_vexpand(config.panel.notification_list_expand); panel::apply_reloaded_body_order(&self.panel, &config.panel.section_order); self.apply_widget_order(&config.panel.widget_order); panel::apply_widget_density( - &self.panel.widget_stack, - &self.panel.quick_controls, - &self.panel.media_container, + &self.panel.sections.widget_stack, + &self.panel.sections.quick_controls, + &self.panel.sections.media_container, config.widgets.density, ); self.panel - .footer_label + .sections + .footer .set_label(&config.panel.footer_label); self.panel - .footer_label + .sections + .footer .set_visible(!config.panel.footer_label.is_empty()); self.log_debug(PanelDebugLevel::Info, || { "panel config applied after reload".to_string() @@ -289,13 +304,16 @@ impl UiState { for section in order { // Config enum values map to the long-lived container built at startup let child: gtk::Widget = match section { - PanelWidgetSection::Media => self.panel.media_container.clone().upcast(), - PanelWidgetSection::Toggles => self.panel.toggle_container.clone().upcast(), - PanelWidgetSection::Sliders => self.panel.quick_controls.clone().upcast(), - PanelWidgetSection::Stats => self.panel.stat_container.clone().upcast(), - PanelWidgetSection::Cards => self.panel.card_container.clone().upcast(), + PanelWidgetSection::Media => self.panel.sections.media_container.clone().upcast(), + PanelWidgetSection::Toggles => { + self.panel.sections.toggle_container.clone().upcast() + } + PanelWidgetSection::Sliders => self.panel.sections.quick_controls.clone().upcast(), + PanelWidgetSection::Stats => self.panel.sections.stat_container.clone().upcast(), + PanelWidgetSection::Cards => self.panel.sections.card_container.clone().upcast(), }; self.panel + .sections .widget_stack .reorder_child_after(&child, previous.as_ref()); // The next child is inserted after the child placed in this iteration @@ -345,13 +363,13 @@ impl UiState { fn apply_widget_config(&mut self, config: &Config) { // Old children are cleared first so the rebuild can treat each section as fresh state - clear_container(&self.panel.quick_controls); + clear_container(&self.panel.sections.quick_controls); let (volume, brightness) = build_quick_controls(&self.panel, config); self.volume = volume; self.brightness = brightness; - clear_container(&self.panel.toggle_container); - clear_container(&self.panel.stat_container); - clear_container(&self.panel.card_container); + clear_container(&self.panel.sections.toggle_container); + clear_container(&self.panel.sections.stat_container); + clear_container(&self.panel.sections.card_container); let (toggles, stats, cards) = build_extra_widgets(&self.panel, config, &self.widget_icon_resolver); // Replace all handles together after the containers hold the new children diff --git a/crates/unixnotis-center/src/ui/reload/tests/config.rs b/crates/unixnotis-center/src/ui/reload/tests/config.rs index b129f4497..9414d0627 100644 --- a/crates/unixnotis-center/src/ui/reload/tests/config.rs +++ b/crates/unixnotis-center/src/ui/reload/tests/config.rs @@ -132,10 +132,10 @@ fn reloaded_panel_applies_copy_and_widget_density() { state.apply_reloaded_panel(&config); - assert_eq!(state.panel.header_title.text(), "Operations"); - assert_eq!(state.panel.header_subtitle.text(), "Live state"); - assert!(state.panel.header_subtitle.get_visible()); - assert_eq!(state.panel.widget_stack.spacing(), 6); + assert_eq!(state.panel.header.title.text(), "Operations"); + assert_eq!(state.panel.header.subtitle.text(), "Live state"); + assert!(state.panel.header.subtitle.get_visible()); + assert_eq!(state.panel.sections.widget_stack.spacing(), 6); } #[gtk::test] @@ -176,38 +176,40 @@ fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { unixnotis_core::PanelWidgetSection::Media, unixnotis_core::PanelWidgetSection::Sliders, ]; - state.panel.search_toggle.set_active(true); + state.panel.header.actions.search_toggle.set_active(true); state.apply_reloaded_panel(&config); - assert!(!state.panel.header_subtitle.get_visible()); - assert!(state.panel.search_revealer.reveals_child()); - assert!(!state.panel.header_action_row.get_visible()); - assert!(!state.panel.notification_header.get_visible()); - assert!(!state.panel.toggle_section_header.get_visible()); - assert_eq!(state.panel.stat_section_header.text(), "Resources"); - assert!(state.panel.stat_section_header.get_visible()); + assert!(!state.panel.header.subtitle.get_visible()); + assert!(state.panel.header.search.revealer.reveals_child()); + assert!(!state.panel.header.action_row.get_visible()); + assert!(!state.panel.sections.notification_header.get_visible()); + assert!(!state.panel.sections.toggle_section_header.get_visible()); + assert_eq!(state.panel.sections.stat_section_header.text(), "Resources"); + assert!(state.panel.sections.stat_section_header.get_visible()); assert!(state .panel + .sections .notification_container .has_css_class(unixnotis_core::hooks::panel_shell::RECENT_SECTION)); - assert!(!state.panel.scroller.vexpands()); - assert!(!state.panel.notification_container.vexpands()); - assert!(!state.panel.clear_action_button.get_visible()); - assert!(state.panel.clear_header_button.get_visible()); - assert!(!state.panel.footer_label.get_visible()); + assert!(!state.panel.sections.scroller.vexpands()); + assert!(!state.panel.sections.notification_container.vexpands()); + assert!(!state.panel.header.actions.clear_button.get_visible()); + assert!(state.panel.sections.clear_header_button.get_visible()); + assert!(!state.panel.sections.footer.get_visible()); let first = state .panel + .sections .widget_stack .first_child() .expect("widget stack should keep configured sections"); - assert!(same_widget(&first, &state.panel.card_container)); + assert!(same_widget(&first, &state.panel.sections.card_container)); let mut hidden_state = new_state(); hidden_state.apply_reloaded_panel(&config); - assert!(!hidden_state.panel.search_toggle.is_active()); - assert!(!hidden_state.panel.search_revealer.reveals_child()); + assert!(!hidden_state.panel.header.actions.search_toggle.is_active()); + assert!(!hidden_state.panel.header.search.revealer.reveals_child()); } #[gtk::test] @@ -238,11 +240,16 @@ fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header_title.text(), "Reloaded from disk"); - assert_eq!(state.panel.footer_label.text(), "Ready"); - assert!(state.panel.footer_label.get_visible()); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert_eq!(state.panel.sections.footer.text(), "Ready"); + assert!(state.panel.sections.footer.get_visible()); assert!(state.toggles.is_some()); - assert!(state.panel.toggle_container.first_child().is_some()); + assert!(state + .panel + .sections + .toggle_container + .first_child() + .is_some()); assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Start); assert_eq!(state.list.empty_overlay.margin_top(), 44); assert!(state.work_area.is_none()); @@ -265,16 +272,18 @@ fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { let outcome = state.reload_config(); assert!(matches!(outcome, ConfigReloadOutcome::Rejected { .. })); assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header_title.text(), "Reloaded from disk"); - assert!(state.panel.reload_notice_revealer.reveals_child()); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert!(state.panel.reload_notice.revealer.reveals_child()); assert!(state .panel - .reload_notice_label + .reload_notice + .label .text() .contains("previous configuration is still active")); assert!(!state .panel - .reload_notice_label + .reload_notice + .label .text() .contains("title = broken")); } @@ -284,7 +293,7 @@ fn accepted_reload_clears_rejected_config_notice() { let mut state = state(); fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); + assert!(state.panel.reload_notice.revealer.reveals_child()); let valid = state.config.clone(); write_config(&state.config_path, &valid); @@ -303,7 +312,7 @@ fn accepted_reload_clears_rejected_config_notice() { let outcome = state.reload_config(); assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); - assert!(!state.panel.reload_notice_revealer.reveals_child()); + assert!(!state.panel.reload_notice.revealer.reveals_child()); } #[gtk::test] @@ -311,24 +320,25 @@ fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { let mut state = state(); fs::write(&state.config_path, "[panel\ntitle = first").expect("first broken config"); let _outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); + assert!(state.panel.reload_notice.revealer.reveals_child()); let close = state .panel - .reload_notice_shell + .reload_notice + .shell .last_child() .expect("reload notice close button") .downcast::() .expect("reload notice close widget"); close.emit_clicked(); - assert!(!state.panel.reload_notice_revealer.reveals_child()); + assert!(!state.panel.reload_notice.revealer.reveals_child()); let _same_outcome = state.reload_config(); - assert!(!state.panel.reload_notice_revealer.reveals_child()); + assert!(!state.panel.reload_notice.revealer.reveals_child()); fs::write(&state.config_path, "config_version = 999").expect("distinct broken config"); let _distinct_outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); + assert!(state.panel.reload_notice.revealer.reveals_child()); } #[gtk::test] @@ -348,13 +358,13 @@ fn successful_css_only_reload_does_not_clear_config_rejection_notice() { } fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice_label.text(); + let rejection = state.panel.reload_notice.label.text(); let report = state.reload_css(); assert_eq!(report.read_failures().count(), 0); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert_eq!(state.panel.reload_notice_label.text(), rejection); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); } #[gtk::test] @@ -362,16 +372,17 @@ fn css_failure_cannot_replace_an_active_config_rejection() { let mut state = state(); fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice_label.text(); + let rejection = state.panel.reload_notice.label.text(); let report = state.reload_css(); assert!(report.read_failures().count() > 0); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert_eq!(state.panel.reload_notice_label.text(), rejection); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); assert!(state .panel - .reload_notice_shell + .reload_notice + .shell .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_ERROR)); } @@ -381,14 +392,16 @@ fn css_reload_notice_summarizes_multiple_unreadable_layers() { let report = state.reload_css(); assert!(report.read_failures().count() > 1); - assert!(state.panel.reload_notice_revealer.reveals_child()); + assert!(state.panel.reload_notice.revealer.reveals_child()); assert!(state .panel - .reload_notice_label + .reload_notice + .label .text() .contains("other layer")); assert!(state .panel - .reload_notice_shell + .reload_notice + .shell .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); } diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 1302edf66..d75311255 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -26,8 +26,8 @@ pub struct UiState { // Widget assets are resolved relative to the active config file root pub(super) widget_icon_resolver: IconAssetResolver, pub(super) dnd_guard: Rc>, - // One countdown source updates the compact DND deadline label - pub(super) dnd_expiration_source: Option, + // One countdown owns its deadline so completed GLib sources are never removed twice + pub(super) dnd_expiration_source: Option, pub(super) search_toggle_guard: Rc>, pub(super) panel_visible: bool, pub(super) panel_visible_flag: Arc, diff --git a/crates/unixnotis-center/src/ui/widget_builders.rs b/crates/unixnotis-center/src/ui/widget_builders.rs index 8c58b852d..9ed6841f4 100644 --- a/crates/unixnotis-center/src/ui/widget_builders.rs +++ b/crates/unixnotis-center/src/ui/widget_builders.rs @@ -20,7 +20,7 @@ pub(super) fn build_quick_controls( let mut has_widgets = false; let volume = if config.widgets.volume.enabled { let widget = widgets::volume::VolumeWidget::new(config.widgets.volume.clone()); - panel.quick_controls.append(widget.root()); + panel.sections.quick_controls.append(widget.root()); has_widgets = true; Some(widget) } else { @@ -29,14 +29,14 @@ pub(super) fn build_quick_controls( let brightness = if config.widgets.brightness.enabled { let widget = widgets::brightness::BrightnessWidget::new(config.widgets.brightness.clone()); - panel.quick_controls.append(widget.root()); + panel.sections.quick_controls.append(widget.root()); has_widgets = true; Some(widget) } else { None }; - panel.quick_controls.set_visible(has_widgets); + panel.sections.quick_controls.set_visible(has_widgets); (volume, brightness) } @@ -58,10 +58,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = toggles.as_ref() { - panel.toggle_container.set_visible(true); - panel.toggle_container.append(grid.root()); + panel.sections.toggle_container.set_visible(true); + panel.sections.toggle_container.append(grid.root()); } else { - panel.toggle_container.set_visible(false); + panel.sections.toggle_container.set_visible(false); } // Stats widgets expose periodic metrics like CPU and memory usage. @@ -71,10 +71,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = stats.as_ref() { - panel.stat_container.set_visible(true); - panel.stat_container.append(grid.root()); + panel.sections.stat_container.set_visible(true); + panel.sections.stat_container.append(grid.root()); } else { - panel.stat_container.set_visible(false); + panel.sections.stat_container.set_visible(false); } // Card widgets are larger, multi-line information tiles. @@ -84,10 +84,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = cards.as_ref() { - panel.card_container.set_visible(true); - panel.card_container.append(grid.root()); + panel.sections.card_container.set_visible(true); + panel.sections.card_container.append(grid.root()); } else { - panel.card_container.set_visible(false); + panel.sections.card_container.set_visible(false); } (toggles, stats, cards) From 1a3f3bb4f83d7ea4eaf879ea0aef57ed2ee86d00 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 02:22:28 -0500 Subject: [PATCH 007/275] fix(daemon): preserve close-after-reply identity Summary: preserve close-after-reply identity. Scope: daemon. --- .../src/daemon/control/reply.rs | 4 +- .../src/daemon/control/tests/reply.rs | 31 ++++++++++++ .../src/daemon/state/notifications.rs | 16 ++++--- .../src/daemon/state/tests/notifications.rs | 2 +- crates/unixnotis-daemon/src/store/history.rs | 48 ++++++++++++++++--- .../unixnotis-daemon/src/store/lifecycle.rs | 26 ++++++++++ .../unixnotis-daemon/src/store/tests/reply.rs | 32 +++++++++++++ 7 files changed, 142 insertions(+), 17 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index d8a14b5e1..baa388256 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -56,9 +56,9 @@ impl ControlServer { post_emit().await; if !target.is_resident { - // Cleanup applies only if the exact replied generation is still active + // Cleanup applies only if the exact replied generation is still stored self.state - .dismiss_active_if_current(id, &target) + .dismiss_replied_if_current(id, &target) .await .map_err(to_fdo_error)?; } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index f86aa3cda..3430550b7 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -173,6 +173,37 @@ async fn reply_listener_replacement_survives_generation_safe_dismissal() { assert_eq!(active.summary, "Reply received"); } +#[tokio::test] +async fn reply_listener_close_removes_replied_notification_without_history() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state).await; + let id = { + let mut store = state.store.lock().await; + store + .insert(reply_notification(false, &sender), 0) + .notification + .id + }; + let closing_state = state.clone(); + + ControlServer::new(state.clone()) + .submit_inline_reply_with_post_emit(id, "yes", move || async move { + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!((signal_id, text.as_str()), (id, "yes")); + closing_state + .close_notification(id, unixnotis_core::CloseReason::ClosedByCall) + .await + .expect("sender close should succeed"); + }) + .await + .expect("reply with sender close"); + + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); +} + #[tokio::test] async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/state/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/notifications.rs index 9df26b847..b07c6c8b1 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notifications.rs @@ -51,23 +51,25 @@ impl DaemonState { Ok(()) } - pub async fn dismiss_active_if_current( + pub async fn dismiss_replied_if_current( &self, id: u32, expected: &Arc, ) -> zbus::Result { - let removed = { + let outcome = { // Object identity prevents an older action from deleting a same-ID replacement let mut store = self.store.lock().await; - store.dismiss_active_if_current(id, expected) + store.dismiss_replied_generation(id, expected) }; - if !removed { + if !outcome.removed_any() { return Ok(false); } - // Only the matching active generation owns this expiration timer - self.cancel_expiration(id); - if let Err(err) = self.emit_dismiss_fanout(id, true).await { + if outcome.removed_active { + // Only the matching active generation owns this expiration timer + self.cancel_expiration(id); + } + if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { warn!( ?err, id, "generation-safe dismiss committed but one or more D-Bus signals failed" diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs index 1c85fe450..7c2eafed7 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs @@ -128,7 +128,7 @@ async fn generation_safe_dismiss_keeps_replacement_and_its_timer() { }; let removed = state - .dismiss_active_if_current(id, &original) + .dismiss_replied_if_current(id, &original) .await .expect("stale generation dismiss should remain a no-op"); diff --git a/crates/unixnotis-daemon/src/store/history.rs b/crates/unixnotis-daemon/src/store/history.rs index 6848c8bd4..113f4eb96 100644 --- a/crates/unixnotis-daemon/src/store/history.rs +++ b/crates/unixnotis-daemon/src/store/history.rs @@ -4,12 +4,18 @@ //! and cross-cutting policy decisions use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use unixnotis_core::{Notification, NotificationView}; +struct HistoryEntry { + notification: Arc, + // Weak source identity supports race-safe cleanup without retaining live payloads + source: Weak, +} + pub(super) struct HistoryStore { - entries: HashMap>, + entries: HashMap, order: VecDeque, } @@ -30,7 +36,7 @@ impl HistoryStore { } pub(super) fn get(&self, id: &u32) -> Option<&Arc> { - self.entries.get(id) + self.entries.get(id).map(|entry| &entry.notification) } pub(super) fn clear(&mut self) { @@ -41,15 +47,15 @@ impl HistoryStore { pub(super) fn list_views(&self) -> Vec { let mut views = Vec::with_capacity(self.entries.len()); for id in self.order.iter().rev() { - if let Some(notification) = self.entries.get(id) { - views.push(notification.to_list_view()); + if let Some(entry) = self.entries.get(id) { + views.push(entry.notification.to_list_view()); } } views } pub(super) fn remove(&mut self, id: &u32) -> Option> { - let removed = self.entries.remove(id); + let removed = self.entries.remove(id).map(|entry| entry.notification); if removed.is_some() { // Removal is infrequent compared to insertion; pay the cost here to keep order clean self.order.retain(|entry| entry != id); @@ -63,10 +69,38 @@ impl HistoryStore { // Avoid duplicate IDs in order when a notification is replaced self.order.retain(|entry| *entry != id); } - self.entries.insert(id, notification); + self.entries.insert( + id, + HistoryEntry { + notification, + source: Weak::new(), + }, + ); self.order.push_back(id); } + pub(super) fn set_source(&mut self, id: u32, source: Weak) { + if let Some(entry) = self.entries.get_mut(&id) { + entry.source = source; + } + } + + pub(super) fn remove_if_source( + &mut self, + id: u32, + expected: &Arc, + ) -> Option> { + let source_matches = self + .entries + .get(&id) + .and_then(|entry| entry.source.upgrade()) + .is_some_and(|source| Arc::ptr_eq(&source, expected)); + if !source_matches { + return None; + } + self.remove(&id) + } + pub(super) fn evict_to_limit(&mut self, max_entries: usize) { if max_entries == 0 { self.clear(); diff --git a/crates/unixnotis-daemon/src/store/lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs index 85404ba98..477b5b42f 100644 --- a/crates/unixnotis-daemon/src/store/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/lifecycle.rs @@ -110,6 +110,28 @@ impl NotificationStore { true } + pub fn dismiss_replied_generation( + &mut self, + id: u32, + expected: &Arc, + ) -> DismissOutcome { + let removed_active = self.dismiss_active_if_current(id, expected); + let removed_history = if removed_active { + // Active cleanup already removed the exact generation + false + } else if self.active.contains_key(&id) { + // Any remaining active entry is a replacement with the same numeric id + false + } else { + // A close may archive the replied generation before reply cleanup resumes + self.history.remove_if_source(id, expected).is_some() + }; + DismissOutcome { + removed_active, + removed_history, + } + } + pub fn drain_active_ids(&mut self) -> Vec { // Drain in one pass so callers do not need repeated lookups let ids = self.active.keys().rev().copied().collect(); @@ -181,9 +203,13 @@ impl NotificationStore { ) { return; } + // Keep only weak source identity alongside the compact history payload + let source = Arc::downgrade(¬ification); // to_history strips non-history-only fields and keeps stored payload compact let stored = Arc::new(notification.to_history()); + let id = stored.id; self.history.insert(stored); + self.history.set_source(id, source); self.history.evict_to_limit(self.config.history.max_entries); } diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/reply.rs index 965ce8842..0acf358a2 100644 --- a/crates/unixnotis-daemon/src/store/tests/reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/reply.rs @@ -90,3 +90,35 @@ fn generation_safe_reply_dismissal_keeps_same_id_replacement() { assert!(store.dismiss_active_if_current(id, &replacement.notification)); assert!(store.active_notification_view(id).is_none()); } + +#[test] +fn replied_generation_is_removed_after_sender_archives_it() { + let mut store = make_store_with_limits(12, 20); + let original = store.insert(make_notification("original"), 0).notification; + let id = original.id; + store.close(id, CloseReason::ClosedByCall); + assert_eq!(store.list_history().len(), 1); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(!outcome.removed_active); + assert!(outcome.removed_history); + assert!(store.list_history().is_empty()); +} + +#[test] +fn replied_generation_cleanup_keeps_archived_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let original = store.insert(make_notification("original"), 0).notification; + let id = original.id; + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + store.close(id, CloseReason::ClosedByCall); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(!outcome.removed_any()); + let history = store.list_history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].summary, "replacement"); +} From 91d22c17a2917ea6a9c91abb69cf8774a9b6f037 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 02:22:41 -0500 Subject: [PATCH 008/275] fix(center): harden panel input transitions Summary: harden panel input transitions. Scope: center. --- .../src/ui/init/constructor.rs | 14 +- .../src/ui/panel/behavior/input.rs | 24 ++- .../src/ui/panel/behavior/tests/input.rs | 69 +++++++- .../src/ui/panel/header/search.rs | 163 +++++++++--------- .../ui/panel/header/tests/search_signals.rs | 114 +++++++++++- 5 files changed, 296 insertions(+), 88 deletions(-) diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index fb5687c5c..acd786b36 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -40,9 +40,17 @@ impl UiState { panel::connect_clear_button(&panel.header.actions.clear_button, init.command_tx.clone()); panel::connect_clear_button(&panel.sections.clear_header_button, init.command_tx.clone()); panel::connect_close_button(&panel, init.command_tx.clone()); - panel::connect_widget_collapse_toggle(&panel, init.event_tx.clone()); - panel::connect_filter_entry(&panel, init.event_tx.clone()); - panel::connect_search_toggle(&panel, search_toggle_guard.clone()); + panel::connect_widget_collapse_toggle( + &panel.header.actions.focus_toggle, + init.event_tx.clone(), + ); + panel::connect_filter_entry(&panel.header.search.entry, init.event_tx.clone()); + panel::connect_search_toggle( + &panel.header.actions.search_toggle, + &panel.header.search.revealer, + &panel.header.search.entry, + search_toggle_guard.clone(), + ); panel::connect_auto_close(&panel, &init, panel_visible_flag.clone()); panel::connect_keyboard_shortcuts(&panel, init.command_tx.clone()); diff --git a/crates/unixnotis-center/src/ui/panel/behavior/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/input.rs index a872b37e0..2b96e31eb 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/input.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/input.rs @@ -14,8 +14,10 @@ use crate::control::UiEvent; #[derive(Clone)] pub(in crate::ui) struct ClickCooldown { - // One bit is enough because callers only care whether a new click may start + // Block state answers whether a new click may start blocked: Rc>, + // Generation keeps retired timeout callbacks from changing a newer window + generation: Rc>, duration: Duration, } @@ -23,6 +25,7 @@ impl ClickCooldown { pub(in crate::ui) fn new(duration: Duration) -> Self { Self { blocked: Rc::new(Cell::new(false)), + generation: Rc::new(Cell::new(0)), duration, } } @@ -31,14 +34,31 @@ impl ClickCooldown { if self.blocked.replace(true) { return false; } + let ticket = self.generation.get().wrapping_add(1); + self.generation.set(ticket); // GTK-side timeout keeps the guard tied to the main-thread widget lifecycle let blocked = self.blocked.clone(); + let generation = self.generation.clone(); glib::timeout_add_local_once(self.duration, move || { - blocked.set(false); + release_cooldown_if_current(&blocked, &generation, ticket); }); true } + + pub(in crate::ui) fn release(&self) { + // Semantic actions such as Escape may end a transition immediately + // Advancing the generation also retires the earlier timeout callback + self.generation.set(self.generation.get().wrapping_add(1)); + self.blocked.set(false); + } +} + +fn release_cooldown_if_current(blocked: &Cell, generation: &Cell, ticket: u64) { + // An older timeout must not release a newer cooldown window + if generation.get() == ticket { + blocked.set(false); + } } #[derive(Clone)] diff --git a/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs index 3e8be2bdb..04482eb56 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs @@ -1,6 +1,7 @@ use std::time::Duration; -use super::ClickCooldown; +use super::{release_cooldown_if_current, ClickCooldown, LatestBoolEventGate}; +use crate::control::UiEvent; #[gtk::test] fn click_cooldown_rejects_bursts_and_reopens_after_its_timeout() { @@ -15,3 +16,69 @@ fn click_cooldown_rejects_bursts_and_reopens_after_its_timeout() { } assert!(guard.try_start()); } + +#[gtk::test] +fn click_cooldown_release_accepts_an_immediate_semantic_action() { + let guard = ClickCooldown::new(Duration::from_secs(1)); + + assert!(guard.try_start()); + guard.release(); + + assert!(guard.try_start()); +} + +#[gtk::test] +fn released_timeout_cannot_end_the_next_cooldown_early() { + let guard = ClickCooldown::new(Duration::ZERO); + assert!(guard.try_start()); + let retired_ticket = guard.generation.get(); + + guard.release(); + assert!(guard.try_start()); + let current_ticket = guard.generation.get(); + + release_cooldown_if_current(&guard.blocked, &guard.generation, retired_ticket); + assert!(!guard.try_start()); + release_cooldown_if_current(&guard.blocked, &guard.generation, current_ticket); + assert!(guard.try_start()); + + // Drain zero-duration sources so this test leaves no main-context work behind + drain_main_context(); +} + +#[gtk::test] +fn latest_bool_event_gate_sends_the_requested_state() { + let gate = LatestBoolEventGate::new(Duration::ZERO); + let (event_tx, event_rx) = async_channel::bounded(1); + + gate.request_widgets_collapsed(&event_tx, true); + drain_main_context(); + + assert!(matches!( + event_rx.try_recv(), + Ok(UiEvent::WidgetsCollapsed(true)) + )); +} + +#[gtk::test] +fn latest_bool_event_gate_coalesces_to_the_newest_state() { + let gate = LatestBoolEventGate::new(Duration::ZERO); + let (event_tx, event_rx) = async_channel::bounded(1); + + gate.request_widgets_collapsed(&event_tx, true); + gate.request_widgets_collapsed(&event_tx, false); + drain_main_context(); + + assert!(matches!( + event_rx.try_recv(), + Ok(UiEvent::WidgetsCollapsed(false)) + )); + assert!(event_rx.try_recv().is_err()); +} + +fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs index 7fb41be79..71bd6bb28 100644 --- a/crates/unixnotis-center/src/ui/panel/header/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -10,7 +10,7 @@ use unixnotis_core::{css::hooks, PanelConfig}; use crate::control::UiEvent; use crate::ui::panel::behavior::input::{ClickCooldown, LatestBoolEventGate}; -use crate::ui::panel::{PanelWidgets, WIDGET_REVEAL_TRANSITION_MS}; +use crate::ui::panel::WIDGET_REVEAL_TRANSITION_MS; pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; @@ -58,7 +58,7 @@ pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { } pub(in crate::ui) fn connect_widget_collapse_toggle( - panel: &PanelWidgets, + focus_toggle: >k::ToggleButton, event_tx: async_channel::Sender, ) { let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); @@ -68,53 +68,45 @@ pub(in crate::ui) fn connect_widget_collapse_toggle( // Restore guard prevents a rejected click rollback from re-entering this handler let collapse_restore = Rc::new(Cell::new(false)); - panel - .header - .actions - .focus_toggle - .connect_toggled(move |button| { - if collapse_restore.replace(false) { - return; - } + focus_toggle.connect_toggled(move |button| { + if collapse_restore.replace(false) { + return; + } - let collapsed = button.is_active(); - // Ignore clicks while the previous reveal animation is still changing layout - if !collapse_click_gate.try_start() { - let accepted = accepted_collapsed.get(); - if collapsed != accepted { - // Roll back only the rejected edge so the UI mirrors the running transition - collapse_restore.set(true); - button.set_active(accepted); - } - return; + let collapsed = button.is_active(); + // Ignore clicks while the previous reveal animation is still changing layout + if !collapse_click_gate.try_start() { + let accepted = accepted_collapsed.get(); + if collapsed != accepted { + // Roll back only the rejected edge so the UI mirrors the running transition + collapse_restore.set(true); + button.set_active(accepted); } + return; + } - accepted_collapsed.set(collapsed); - // Disable the control until GTK finishes the matching reveal transition - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - collapse_gate.request_widgets_collapsed(&event_tx, collapsed); - }); + accepted_collapsed.set(collapsed); + // Disable the control until GTK finishes the matching reveal transition + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once( + Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), + move || { + button_enable.set_sensitive(true); + }, + ); + collapse_gate.request_widgets_collapsed(&event_tx, collapsed); + }); } pub(in crate::ui) fn connect_filter_entry( - panel: &PanelWidgets, + search_entry: >k::SearchEntry, event_tx: async_channel::Sender, ) { // SearchChanged covers typing, clear actions, and programmatic text resets - panel - .header - .search - .entry - .connect_search_changed(move |entry| { - send_filter_event(&event_tx, entry.text().to_string()); - }); + search_entry.connect_search_changed(move |entry| { + send_filter_event(&event_tx, entry.text().to_string()); + }); } pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filter: String) { @@ -133,56 +125,65 @@ pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filte } pub(in crate::ui) fn connect_search_toggle( - panel: &PanelWidgets, + search_toggle: >k::ToggleButton, + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, search_toggle_guard: Rc>, ) { - let search_revealer = panel.header.search.revealer.clone(); - let search_entry = panel.header.search.entry.clone(); let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); - let accepted_search_reveal = Rc::new(Cell::new(false)); + let accepted_search_reveal = Rc::new(Cell::new(search_revealer.reveals_child())); // Programmatic rollback must not be mistaken for a fresh user click let search_restore = Rc::new(Cell::new(false)); + let toggled_revealer = search_revealer.clone(); + let toggled_entry = search_entry.clone(); + + // A weak reference avoids a signal cycle between the entry and toggle + let stop_toggle = search_toggle.downgrade(); + let stop_click_gate = search_click_gate.clone(); + search_entry.connect_stop_search(move |_| { + // Escape is a semantic close and should not wait for the reveal click cooldown + stop_click_gate.release(); + if let Some(toggle) = stop_toggle.upgrade() { + toggle.set_active(false); + } + }); - panel - .header - .actions - .search_toggle - .connect_toggled(move |button| { - if search_toggle_guard.get() || search_restore.replace(false) { - return; - } + search_toggle.connect_toggled(move |button| { + if search_toggle_guard.get() || search_restore.replace(false) { + return; + } - let reveal = button.is_active(); - if !search_click_gate.try_start() { - let accepted = accepted_search_reveal.get(); - if reveal != accepted { - // Keep the visual toggle synced with the accepted revealer state - search_restore.set(true); - button.set_active(accepted); - } - return; + let reveal = button.is_active(); + if !search_click_gate.try_start() { + let accepted = accepted_search_reveal.get(); + if reveal != accepted { + // Keep the visual toggle synced with the accepted revealer state + search_restore.set(true); + button.set_active(accepted); } + return; + } - accepted_search_reveal.set(reveal); - // Freeze the toggle while its revealer animates to the accepted state - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - search_revealer.set_reveal_child(reveal); - if reveal { - // Selecting existing text makes the next query replace it immediately - search_entry.grab_focus(); - search_entry.select_region(0, -1); - } else if !search_entry.text().is_empty() { - // Closing search restores the full notification list - search_entry.set_text(""); - } - }); + accepted_search_reveal.set(reveal); + // Freeze the toggle while its revealer animates to the accepted state + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once( + Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), + move || { + button_enable.set_sensitive(true); + }, + ); + toggled_revealer.set_reveal_child(reveal); + if reveal { + // Selecting existing text makes the next query replace it immediately + toggled_entry.grab_focus(); + toggled_entry.select_region(0, i32::MAX); + } else if !toggled_entry.text().is_empty() { + // Closing search restores the full notification list + toggled_entry.set_text(""); + } + }); } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs index 7af284c09..37c62c3e6 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs @@ -1,4 +1,12 @@ -use super::send_filter_event; +use std::cell::Cell; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk::prelude::*; + +use super::{ + connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, send_filter_event, +}; use crate::control::UiEvent; #[test] @@ -18,3 +26,107 @@ fn filter_event_ignores_closed_channel() { send_filter_event(&event_tx, "ignored".to_string()); } + +#[gtk::test] +fn stop_search_closes_revealer_and_clears_filter_immediately() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + revealer.set_child(Some(&entry)); + let (event_tx, event_rx) = async_channel::bounded(4); + connect_filter_entry(&entry, event_tx); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + entry.set_text("urgent"); + assert_eq!(next_filter(&event_rx), "urgent"); + + entry.emit_stop_search(); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); + assert_eq!(next_filter(&event_rx), ""); +} + +#[gtk::test] +fn guarded_search_toggle_does_not_change_revealer_state() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Rc::new(Cell::new(true)); + connect_search_toggle(&toggle, &revealer, &entry, guard); + + toggle.set_active(true); + + assert!(toggle.is_active()); + assert!(!revealer.reveals_child()); +} + +#[gtk::test] +fn rapid_search_toggle_restores_the_last_accepted_state() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + entry.set_text("urgent"); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + assert_eq!(entry.selection_bounds(), Some((0, 6))); + toggle.set_active(false); + + assert!(toggle.is_active()); + assert!(revealer.reveals_child()); + assert_eq!(entry.text(), "urgent"); +} + +#[gtk::test] +fn widget_collapse_toggle_sends_the_accepted_state_and_rejects_a_burst() { + let toggle = gtk::ToggleButton::new(); + let (event_tx, event_rx) = async_channel::bounded(2); + connect_widget_collapse_toggle(&toggle, event_tx); + + toggle.set_active(true); + assert!(!toggle.is_sensitive()); + toggle.set_active(false); + + // The rejected edge rolls back immediately to the accepted visual state + assert!(toggle.is_active()); + assert!(next_widgets_collapsed(&event_rx)); +} + +fn next_filter(event_rx: &async_channel::Receiver) -> String { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let Ok(UiEvent::FilterChanged(filter)) = event_rx.try_recv() { + return filter; + } + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!( + Instant::now() < deadline, + "search filter event should arrive before timeout" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn next_widgets_collapsed(event_rx: &async_channel::Receiver) -> bool { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let Ok(UiEvent::WidgetsCollapsed(collapsed)) = event_rx.try_recv() { + return collapsed; + } + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!( + Instant::now() < deadline, + "widget collapse event should arrive before timeout" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} From aca63905aa5fb2ae99551a826ab0927752de0b00 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 02:23:34 -0500 Subject: [PATCH 009/275] refactor(center): split notification reply flow Summary: split notification reply flow. Scope: center. --- .../ui/notifications/row/notification/mod.rs | 5 +- .../notifications/row/notification/reply.rs | 344 ------------- .../row/notification/reply/binding.rs | 78 +++ .../row/notification/reply/build.rs | 150 ++++++ .../row/notification/reply/lifecycle.rs | 106 ++++ .../row/notification/reply/mod.rs | 14 + .../row/notification/reply/presentation.rs | 70 +++ .../row/notification/reply/state.rs | 57 +++ .../notification/reply/tests/availability.rs | 125 +++++ .../notification/reply/tests/generation.rs | 161 +++++++ .../row/notification/reply/tests/keyboard.rs | 78 +++ .../row/notification/reply/tests/mod.rs | 15 + .../notification/reply/tests/presentation.rs | 95 ++++ .../row/notification/reply/tests/recovery.rs | 88 ++++ .../notification/reply/tests/submission.rs | 175 +++++++ .../row/notification/reply/tests/support.rs | 21 + .../row/notification/tests/reply.rs | 454 ------------------ 17 files changed, 1234 insertions(+), 802 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 703fb1d80..937ba468e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -1,7 +1,7 @@ //! Notification row widget module //! //! `mod.rs` only wires the notification row pieces together -//! Build, state, update, and tests stay in their own files +//! Reply logic and tests stay inside their focused module #[cfg(test)] #[path = "tests/actions.rs"] @@ -15,9 +15,6 @@ mod labels_tests; mod metadata_tests; mod reply; #[cfg(test)] -#[path = "tests/reply.rs"] -mod reply_tests; -#[cfg(test)] #[path = "tests/stack.rs"] mod stack_tests; mod state; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs deleted file mode 100644 index d28c07aba..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Reusable inline reply form for live KDE-compatible notifications - -use std::cell::Cell; -use std::rc::Rc; - -use gtk::prelude::*; -use tokio::sync::mpsc; -use unixnotis_core::{util, InlineReply}; - -use crate::control::UiCommand; -use crate::ui::try_send_command; - -const DEFAULT_PLACEHOLDER: &str = "Type a reply…"; -const DEFAULT_SUBMIT_LABEL: &str = "Send"; -// Button text stays compact even when the sender provides a long custom hint -const MAX_SUBMIT_LABEL_CHARS: usize = 20; -// GTK limits characters while the protocol boundary limits encoded bytes -const MAX_REPLY_CHARS: i32 = 4 * 1024; -const MAX_REPLY_BYTES: usize = 4 * 1024; -const MAX_REPLY_ERROR_CHARS: usize = 180; -const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; - -pub(super) struct InlineReplyWidgets { - // The form is retained with the recycled row and revealed only on explicit action - pub(super) revealer: gtk::Revealer, - pub(super) entry: gtk::Entry, - pub(super) send_button: gtk::Button, - pub(super) error_label: gtk::Label, - // Notification identity prevents a recycled row from leaking a prior draft - bound_id: Rc>, - // One shared gate covers button and Enter submissions - submitted: Rc>, -} - -pub(super) fn build_inline_reply(command_tx: mpsc::Sender) -> InlineReplyWidgets { - // Build the hidden form once so row updates only change state and metadata - let revealer = gtk::Revealer::new(); - revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - revealer.set_reveal_child(false); - - let form = gtk::Box::new(gtk::Orientation::Vertical, 4); - form.add_css_class("unixnotis-inline-reply"); - let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); - - let entry = gtk::Entry::new(); - entry.set_hexpand(true); - entry.set_max_length(MAX_REPLY_CHARS); - entry.set_placeholder_text(Some(DEFAULT_PLACEHOLDER)); - entry.add_css_class("unixnotis-inline-reply-entry"); - - let send_button = gtk::Button::with_label(DEFAULT_SUBMIT_LABEL); - send_button.set_sensitive(false); - send_button.add_css_class("unixnotis-notification-action"); - send_button.add_css_class("unixnotis-inline-reply-send"); - - let error_label = gtk::Label::new(None); - error_label.set_xalign(0.0); - error_label.set_wrap(true); - error_label.set_visible(false); - error_label.add_css_class("error"); - error_label.add_css_class("unixnotis-inline-reply-error"); - - input_row.append(&entry); - input_row.append(&send_button); - form.append(&input_row); - form.append(&error_label); - revealer.set_child(Some(&form)); - - let bound_id = Rc::new(Cell::new(0)); - let submitted = Rc::new(Cell::new(false)); - - let changed_button = send_button.clone(); - let changed_submitted = submitted.clone(); - let changed_error = error_label.clone(); - entry.connect_changed(move |entry| { - // Editing starts a fresh attempt, so an older transport error no longer applies - clear_reply_error(&changed_error); - // Sensitivity mirrors the daemon byte limit before any command is queued - let text = entry.text(); - let text = text.trim(); - let too_long = text.len() > MAX_REPLY_BYTES; - entry.set_tooltip_text(too_long.then_some("Reply text must be no larger than 4 KiB")); - let valid = !text.is_empty() && !too_long; - changed_button.set_sensitive(valid && !changed_submitted.get()); - }); - - let submit_entry = entry.clone(); - let submit_revealer = revealer.clone(); - let submit_button = send_button.clone(); - let submit_error = error_label.clone(); - let submit_id = bound_id.clone(); - let submit_gate = submitted.clone(); - let submit_tx = command_tx.clone(); - // Mouse submission shares the exact same guarded path as keyboard activation - send_button.connect_clicked(move |_| { - submit_reply( - &submit_entry, - &submit_revealer, - &submit_button, - &submit_error, - &submit_id, - &submit_gate, - &submit_tx, - ); - }); - - let activate_revealer = revealer.clone(); - let activate_button = send_button.clone(); - let activate_error = error_label.clone(); - let activate_id = bound_id.clone(); - let activate_gate = submitted.clone(); - // GtkEntry emits activate for Enter without needing a separate key handler - entry.connect_activate(move |entry| { - submit_reply( - entry, - &activate_revealer, - &activate_button, - &activate_error, - &activate_id, - &activate_gate, - &command_tx, - ); - }); - - let key_revealer = revealer.clone(); - let key_entry = entry.clone(); - let key_error = error_label.clone(); - let key_submitted = submitted.clone(); - let key_controller = gtk::EventControllerKey::new(); - // Escape owns draft cancellation while other keys continue through GTK - key_controller.connect_key_pressed(move |_, key, _, _| { - if key != gtk::gdk::Key::Escape { - return gtk::glib::Propagation::Proceed; - } - cancel_inline_reply(&key_entry, &key_revealer, &key_error, &key_submitted) - }); - entry.add_controller(key_controller); - - InlineReplyWidgets { - revealer, - entry, - send_button, - error_label, - bound_id, - submitted, - } -} - -pub(super) fn configure_inline_reply( - widgets: &InlineReplyWidgets, - id: u32, - reply: &InlineReply, - is_active: bool, -) { - // History rows keep metadata for display but never expose a live reply control - let available = is_active && reply.available; - if widgets.bound_id.get() != id { - // Recycled rows never carry typed drafts to another notification - widgets.submitted.set(false); - widgets.entry.set_sensitive(true); - widgets.entry.set_text(""); - widgets.send_button.set_sensitive(false); - clear_reply_error(&widgets.error_label); - widgets.revealer.set_reveal_child(false); - widgets.bound_id.set(id); - } - if !available { - // History and ordinary actions never expose a stale reply field - widgets.entry.set_text(""); - clear_reply_error(&widgets.error_label); - widgets.revealer.set_reveal_child(false); - widgets.entry.set_sensitive(true); - widgets.send_button.set_sensitive(false); - widgets.submitted.set(false); - return; - } - - // KDE hints customize only presentation and never change reply eligibility - let placeholder = if reply.placeholder.is_empty() { - DEFAULT_PLACEHOLDER - } else { - &reply.placeholder - }; - widgets.entry.set_placeholder_text(Some(placeholder)); - update_submit_content( - &widgets.send_button, - &reply.submit_label, - &reply.submit_icon, - ); -} - -pub(super) fn connect_inline_reply_button(button: >k::Button, widgets: &InlineReplyWidgets) { - let revealer = widgets.revealer.clone(); - let entry = widgets.entry.clone(); - let bound_id = widgets.bound_id.clone(); - let submitted = widgets.submitted.clone(); - button.connect_clicked(move |_| { - // Zero is the unbound sentinel and in-flight work cannot reopen the form - if bound_id.get() == 0 || submitted.get() { - return; - } - revealer.set_reveal_child(true); - entry.grab_focus(); - }); -} - -fn submit_reply( - entry: >k::Entry, - revealer: >k::Revealer, - button: >k::Button, - error_label: >k::Label, - bound_id: &Rc>, - submitted: &Rc>, - command_tx: &mpsc::Sender, -) { - // Trim once so UI validation and the transmitted payload use the same content - let text = entry.text().trim().to_string(); - let id = bound_id.get(); - // replace(true) closes the race between Enter and a near-simultaneous click - if id == 0 || text.is_empty() || text.len() > MAX_REPLY_BYTES || submitted.replace(true) { - return; - } - - entry.set_sensitive(false); - button.set_sensitive(false); - clear_reply_error(error_label); - // A one-shot response lets the GTK task restore the draft after transport failure - let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel(); - try_send_command( - command_tx, - UiCommand::Reply { - id, - text, - outcome: outcome_tx, - }, - ); - - let result_entry = entry.clone(); - let result_revealer = revealer.clone(); - let result_button = button.clone(); - let result_error = error_label.clone(); - let result_id = bound_id.clone(); - let result_submitted = submitted.clone(); - // The local main-context task is allowed to touch GTK widgets directly - gtk::glib::MainContext::default().spawn_local(async move { - let result = outcome_rx - .await - .unwrap_or_else(|_| Err("notification service did not return a result".to_string())); - if result_id.get() != id || !result_submitted.get() { - // A recycled row already owns different notification state - return; - } - result_submitted.set(false); - result_entry.set_sensitive(true); - match result { - Ok(()) => { - // Successful replies leave no draft behind in the reusable row - result_entry.set_text(""); - clear_reply_error(&result_error); - result_revealer.set_reveal_child(false); - result_button.set_sensitive(false); - } - Err(error) => { - // Keep the draft available for correction or retry - result_button.set_sensitive(!result_entry.text().trim().is_empty()); - show_reply_error(&result_error, &error); - result_entry.grab_focus(); - } - } - }); -} - -pub(super) fn cancel_inline_reply( - entry: >k::Entry, - revealer: >k::Revealer, - error_label: >k::Label, - submitted: &Cell, -) -> gtk::glib::Propagation { - if submitted.get() { - // An in-flight reply cannot be canceled into a second submission - return gtk::glib::Propagation::Proceed; - } - // Canceling an idle draft restores the original action row - entry.set_text(""); - clear_reply_error(error_label); - revealer.set_reveal_child(false); - gtk::glib::Propagation::Stop -} - -fn clear_reply_error(label: >k::Label) { - label.set_text(""); - label.set_visible(false); -} - -fn show_reply_error(label: >k::Label, error: &str) { - // Known liveness failures use a short stable message instead of a D-Bus error prefix - let message = if error.contains(APPLICATION_UNAVAILABLE) { - APPLICATION_UNAVAILABLE.to_string() - } else { - util::sanitize_inline_display_text(error) - }; - let message = clamp_error_message(&message); - label.set_text(&format!("Could not send: {message}")); - label.set_visible(true); -} - -fn clamp_error_message(message: &str) -> std::borrow::Cow<'_, str> { - // Remote error text is display-only and must not create an unbounded row - let Some((cut, _)) = message.char_indices().nth(MAX_REPLY_ERROR_CHARS) else { - return std::borrow::Cow::Borrowed(message); - }; - let mut bounded = String::with_capacity(cut + 3); - bounded.push_str(&message[..cut]); - bounded.push('…'); - std::borrow::Cow::Owned(bounded) -} - -fn update_submit_content(button: >k::Button, label: &str, icon_name: &str) { - // Rebuild the tiny child box because KDE may change hints on replacement - let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); - if !icon_name.is_empty() { - let icon = gtk::Image::from_icon_name(icon_name); - content.append(&icon); - } - let label = if label.is_empty() { - DEFAULT_SUBMIT_LABEL - } else { - label - }; - let label = gtk::Label::new(Some(clamp_submit_label(label).as_ref())); - content.append(&label); - button.set_child(Some(&content)); -} - -fn clamp_submit_label(label: &str) -> std::borrow::Cow<'_, str> { - // Character indexes preserve UTF-8 boundaries while enforcing visual length - let Some((cut, _)) = label.char_indices().nth(MAX_SUBMIT_LABEL_CHARS) else { - return std::borrow::Cow::Borrowed(label); - }; - let mut bounded = String::with_capacity(cut + 3); - bounded.push_str(&label[..cut]); - bounded.push('…'); - std::borrow::Cow::Owned(bounded) -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs new file mode 100644 index 000000000..d9c9077fc --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs @@ -0,0 +1,78 @@ +//! Notification binding and action-button behavior for inline replies + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::lifecycle::invalidate_reply_attempt; +use super::presentation::{clear_reply_error, update_submit_content, DEFAULT_PLACEHOLDER}; +use super::state::InlineReplyWidgets; + +pub(in super::super) fn configure_inline_reply( + widgets: &InlineReplyWidgets, + notification: &Rc, + is_active: bool, +) { + let id = notification.id; + let reply = ¬ification.inline_reply; + // History rows keep metadata for display but never expose a live reply control + let available = is_active && reply.available; + let snapshot_changed = widgets + .bound_snapshot + .borrow() + .upgrade() + .is_none_or(|bound| !Rc::ptr_eq(&bound, notification)); + if snapshot_changed || !available { + // Recycled rows, replacements, and unavailable actions begin with fresh form state + invalidate_reply_attempt(&widgets.state); + reset_reply_form(widgets); + } + if snapshot_changed { + widgets.state.bound_id.set(id); + *widgets.bound_snapshot.borrow_mut() = Rc::downgrade(notification); + } + if !available { + // History and ordinary actions never expose a stale reply field + return; + } + + // KDE hints customize only presentation and never change reply eligibility + let placeholder = if reply.placeholder.is_empty() { + DEFAULT_PLACEHOLDER + } else { + &reply.placeholder + }; + widgets.entry.set_placeholder_text(Some(placeholder)); + update_submit_content( + &widgets.send_button, + &reply.submit_label, + &reply.submit_icon, + ); +} + +fn reset_reply_form(widgets: &InlineReplyWidgets) { + // Every invalidation clears local-only state before the row can be reused + widgets.entry.set_sensitive(true); + widgets.entry.set_text(""); + widgets.send_button.set_sensitive(false); + clear_reply_error(&widgets.error_label); + widgets.revealer.set_reveal_child(false); +} + +pub(in super::super) fn connect_inline_reply_button( + button: >k::Button, + widgets: &InlineReplyWidgets, +) { + let revealer = widgets.revealer.clone(); + let entry = widgets.entry.clone(); + let state = widgets.state.clone(); + button.connect_clicked(move |_| { + // Zero is the unbound sentinel and in-flight work cannot reopen the form + if state.bound_id.get() == 0 || state.submitted.get() { + return; + } + revealer.set_reveal_child(true); + entry.grab_focus(); + }); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs new file mode 100644 index 000000000..4f0e9b4c7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs @@ -0,0 +1,150 @@ +//! Inline reply widget construction and input signal wiring + +use gtk::prelude::*; +use tokio::sync::mpsc; + +use crate::control::UiCommand; + +use super::lifecycle::{cancel_inline_reply, submit_reply, MAX_REPLY_BYTES}; +use super::presentation::{clear_reply_error, DEFAULT_PLACEHOLDER, DEFAULT_SUBMIT_LABEL}; +use super::state::{InlineReplyWidgets, ReplyState}; + +// GTK limits characters while the protocol boundary limits encoded bytes +const MAX_REPLY_CHARS: i32 = 4 * 1024; + +pub(in super::super) fn build_inline_reply( + command_tx: mpsc::Sender, +) -> InlineReplyWidgets { + // Build the hidden form once so row updates only change state and metadata + let revealer = gtk::Revealer::new(); + revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_reveal_child(false); + + let form = gtk::Box::new(gtk::Orientation::Vertical, 4); + form.add_css_class("unixnotis-inline-reply"); + let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_max_length(MAX_REPLY_CHARS); + entry.set_placeholder_text(Some(DEFAULT_PLACEHOLDER)); + entry.add_css_class("unixnotis-inline-reply-entry"); + + let send_button = gtk::Button::with_label(DEFAULT_SUBMIT_LABEL); + send_button.set_sensitive(false); + send_button.add_css_class("unixnotis-notification-action"); + send_button.add_css_class("unixnotis-inline-reply-send"); + + let error_label = gtk::Label::new(None); + error_label.set_xalign(0.0); + error_label.set_wrap(true); + error_label.set_visible(false); + error_label.add_css_class("error"); + error_label.add_css_class("unixnotis-inline-reply-error"); + + input_row.append(&entry); + input_row.append(&send_button); + form.append(&input_row); + form.append(&error_label); + revealer.set_child(Some(&form)); + + let state = ReplyState::new(); + connect_draft_changes(&entry, &send_button, &error_label, &state); + connect_submission( + &entry, + &revealer, + &send_button, + &error_label, + &state, + command_tx, + ); + connect_cancel_key(&entry, &revealer, &error_label, &state); + + InlineReplyWidgets::new(revealer, entry, send_button, error_label, state) +} + +fn connect_draft_changes( + entry: >k::Entry, + send_button: >k::Button, + error_label: >k::Label, + state: &ReplyState, +) { + let changed_button = send_button.clone(); + let changed_submitted = state.submitted.clone(); + let changed_error = error_label.clone(); + entry.connect_changed(move |entry| { + // Editing clears the prior transport error because it described an older draft + clear_reply_error(&changed_error); + // Sensitivity mirrors the daemon byte limit before any command is queued + let text = entry.text(); + let text = text.trim(); + let too_long = text.len() > MAX_REPLY_BYTES; + entry.set_tooltip_text(too_long.then_some("Reply text must be no larger than 4 KiB")); + let valid = !text.is_empty() && !too_long; + changed_button.set_sensitive(valid && !changed_submitted.get()); + }); +} + +fn connect_submission( + entry: >k::Entry, + revealer: >k::Revealer, + send_button: >k::Button, + error_label: >k::Label, + state: &ReplyState, + command_tx: mpsc::Sender, +) { + let submit_entry = entry.clone(); + let submit_revealer = revealer.clone(); + let submit_button = send_button.clone(); + let submit_error = error_label.clone(); + let submit_state = state.clone(); + let submit_tx = command_tx.clone(); + // Mouse submission shares the exact same guarded path as keyboard activation + send_button.connect_clicked(move |_| { + submit_reply( + &submit_entry, + &submit_revealer, + &submit_button, + &submit_error, + &submit_state, + &submit_tx, + ); + }); + + let activate_revealer = revealer.clone(); + let activate_button = send_button.clone(); + let activate_error = error_label.clone(); + let activate_state = state.clone(); + // GtkEntry emits activate for Enter without needing a separate key handler + entry.connect_activate(move |entry| { + submit_reply( + entry, + &activate_revealer, + &activate_button, + &activate_error, + &activate_state, + &command_tx, + ); + }); +} + +fn connect_cancel_key( + entry: >k::Entry, + revealer: >k::Revealer, + error_label: >k::Label, + state: &ReplyState, +) { + let key_revealer = revealer.clone(); + let key_entry = entry.clone(); + let key_error = error_label.clone(); + let key_submitted = state.submitted.clone(); + let key_controller = gtk::EventControllerKey::new(); + // Escape owns draft cancellation while other keys continue through GTK + key_controller.connect_key_pressed(move |_, key, _, _| { + if key != gtk::gdk::Key::Escape { + return gtk::glib::Propagation::Proceed; + } + cancel_inline_reply(&key_entry, &key_revealer, &key_error, &key_submitted) + }); + entry.add_controller(key_controller); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs new file mode 100644 index 000000000..2c765d8a3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs @@ -0,0 +1,106 @@ +//! Submission and cancellation lifecycle for inline replies + +use std::cell::Cell; + +use gtk::prelude::*; +use tokio::sync::mpsc; + +use crate::control::UiCommand; +use crate::ui::try_send_command; + +use super::presentation::{clear_reply_error, show_reply_error}; +use super::state::ReplyState; + +pub(super) const MAX_REPLY_BYTES: usize = 4 * 1024; + +pub(super) fn submit_reply( + entry: >k::Entry, + revealer: >k::Revealer, + button: >k::Button, + error_label: >k::Label, + state: &ReplyState, + command_tx: &mpsc::Sender, +) { + // Trim once so UI validation and the transmitted payload use the same content + let text = entry.text().trim().to_string(); + let id = state.bound_id.get(); + // replace(true) closes the race between Enter and a near-simultaneous click + if id == 0 || text.is_empty() || text.len() > MAX_REPLY_BYTES || state.submitted.replace(true) { + return; + } + let current_attempt = state.attempt.get().wrapping_add(1); + state.attempt.set(current_attempt); + + entry.set_sensitive(false); + button.set_sensitive(false); + clear_reply_error(error_label); + // A one-shot response lets the GTK task restore the draft after transport failure + let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel(); + try_send_command( + command_tx, + UiCommand::Reply { + id, + text, + outcome: outcome_tx, + }, + ); + + let result_entry = entry.clone(); + let result_revealer = revealer.clone(); + let result_button = button.clone(); + let result_error = error_label.clone(); + let result_state = state.clone(); + // The local main-context task is allowed to touch GTK widgets directly + gtk::glib::MainContext::default().spawn_local(async move { + let result = outcome_rx + .await + .unwrap_or_else(|_| Err("notification service did not return a result".to_string())); + if result_state.bound_id.get() != id + || result_state.attempt.get() != current_attempt + || !result_state.submitted.get() + { + // A recycled row already owns different notification state + return; + } + result_state.submitted.set(false); + result_entry.set_sensitive(true); + match result { + Ok(()) => { + // Successful replies leave no draft behind in the reusable row + result_entry.set_text(""); + clear_reply_error(&result_error); + result_revealer.set_reveal_child(false); + result_button.set_sensitive(false); + } + Err(error) => { + // Keep the draft available for correction or retry + result_button.set_sensitive(!result_entry.text().trim().is_empty()); + show_reply_error(&result_error, &error); + result_entry.grab_focus(); + } + } + }); +} + +pub(super) fn invalidate_reply_attempt(state: &ReplyState) { + // Advancing first makes every delayed result stale before the form is reset + state.attempt.set(state.attempt.get().wrapping_add(1)); + state.submitted.set(false); +} + +pub(super) fn cancel_inline_reply( + entry: >k::Entry, + revealer: >k::Revealer, + error_label: >k::Label, + submitted: &Cell, +) -> gtk::glib::Propagation { + if submitted.get() { + // An in-flight reply cannot be canceled into a second submission + return gtk::glib::Propagation::Proceed; + } + // Canceling an idle draft restores the original action row + entry.set_text(""); + clear_reply_error(error_label); + revealer.set_reveal_child(false); + gtk::glib::Propagation::Stop +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs new file mode 100644 index 000000000..338fb00b7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs @@ -0,0 +1,14 @@ +//! Inline reply form wiring + +mod binding; +mod build; +mod lifecycle; +mod presentation; +mod state; + +pub(super) use binding::{configure_inline_reply, connect_inline_reply_button}; +pub(super) use build::build_inline_reply; +pub(super) use state::InlineReplyWidgets; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs new file mode 100644 index 000000000..8d0e3c3d3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs @@ -0,0 +1,70 @@ +//! Bounded text and error presentation for inline replies + +use std::borrow::Cow; + +use gtk::prelude::*; +use unixnotis_core::util; + +pub(super) const DEFAULT_PLACEHOLDER: &str = "Type a reply…"; +pub(super) const DEFAULT_SUBMIT_LABEL: &str = "Send"; + +// Button text stays compact even when the sender provides a long custom hint +const MAX_SUBMIT_LABEL_CHARS: usize = 20; +const MAX_REPLY_ERROR_CHARS: usize = 180; +const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; + +pub(super) fn clear_reply_error(label: >k::Label) { + label.set_text(""); + label.set_visible(false); +} + +pub(super) fn show_reply_error(label: >k::Label, error: &str) { + // Known liveness failures use a short stable message instead of a D-Bus error prefix + let message = if error.contains(APPLICATION_UNAVAILABLE) { + APPLICATION_UNAVAILABLE.to_string() + } else { + util::sanitize_inline_display_text(error) + }; + let message = clamp_error_message(&message); + label.set_text(&format!("Could not send: {message}")); + label.set_visible(true); +} + +fn clamp_error_message(message: &str) -> Cow<'_, str> { + // Remote error text is display-only and must not create an unbounded row + let Some((cut, _)) = message.char_indices().nth(MAX_REPLY_ERROR_CHARS) else { + return Cow::Borrowed(message); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&message[..cut]); + bounded.push('…'); + Cow::Owned(bounded) +} + +pub(super) fn update_submit_content(button: >k::Button, label: &str, icon_name: &str) { + // Rebuild the tiny child box because KDE may change hints on replacement + let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); + if !icon_name.is_empty() { + let icon = gtk::Image::from_icon_name(icon_name); + content.append(&icon); + } + let label = if label.is_empty() { + DEFAULT_SUBMIT_LABEL + } else { + label + }; + let label = gtk::Label::new(Some(clamp_submit_label(label).as_ref())); + content.append(&label); + button.set_child(Some(&content)); +} + +fn clamp_submit_label(label: &str) -> Cow<'_, str> { + // Character indexes preserve UTF-8 boundaries while enforcing visual length + let Some((cut, _)) = label.char_indices().nth(MAX_SUBMIT_LABEL_CHARS) else { + return Cow::Borrowed(label); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&label[..cut]); + bounded.push('…'); + Cow::Owned(bounded) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs new file mode 100644 index 000000000..b9b2fa60b --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs @@ -0,0 +1,57 @@ +//! Shared state for a reusable inline reply form + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use unixnotis_core::NotificationView; + +#[derive(Clone)] +pub(super) struct ReplyState { + // Numeric identity is retained for the command sent to the daemon + pub(super) bound_id: Rc>, + // One shared gate covers button and Enter submissions + pub(super) submitted: Rc>, + // Attempt identity keeps delayed outcomes tied to one exact submission + pub(super) attempt: Rc>, +} + +impl ReplyState { + pub(super) fn new() -> Self { + Self { + bound_id: Rc::new(Cell::new(0)), + submitted: Rc::new(Cell::new(false)), + attempt: Rc::new(Cell::new(0)), + } + } +} + +pub(in super::super) struct InlineReplyWidgets { + // The form is retained with the recycled row and revealed only on explicit action + pub(in super::super) revealer: gtk::Revealer, + pub(in super::super) entry: gtk::Entry, + pub(in super::super) send_button: gtk::Button, + pub(in super::super) error_label: gtk::Label, + // Snapshot identity distinguishes replacements that deliberately keep the same id + pub(super) bound_snapshot: RefCell>, + // Shared submission state keeps every GTK callback on the same generation + pub(super) state: ReplyState, +} + +impl InlineReplyWidgets { + pub(super) const fn new( + revealer: gtk::Revealer, + entry: gtk::Entry, + send_button: gtk::Button, + error_label: gtk::Label, + state: ReplyState, + ) -> Self { + Self { + revealer, + entry, + send_button, + error_label, + bound_snapshot: RefCell::new(Weak::new()), + state, + } + } +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs new file mode 100644 index 000000000..35dd3d752 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs @@ -0,0 +1,125 @@ +//! Reply action availability and row binding tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::reply_notification; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, + connect_inline_reply_button, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_is_available_only_for_a_live_explicit_reply_action() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Duplicate reply".to_string(), + }, + ]; + notification.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + placeholder: "Write back".to_string(), + submit_label: "Send now".to_string(), + submit_icon: String::new(), + }; + + update_notification_row( + &row, + &row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + let button = row + .actions_box + .first_child() + .expect("reply action") + .downcast::() + .expect("reply child should be a button"); + assert!(button.next_sibling().is_none()); + button.emit_clicked(); + assert!(row.inline_reply.revealer.reveals_child()); + assert_eq!( + row.inline_reply.entry.placeholder_text().as_deref(), + Some("Write back") + ); + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.actions_box.first_child().is_none()); +} + +#[gtk::test] +fn inline_reply_action_does_not_open_an_unbound_or_submitted_form() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let action = gtk::Button::new(); + connect_inline_reply_button(&action, &widgets); + + action.emit_clicked(); + assert!(!widgets.revealer.reveals_child()); + + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + widgets.entry.set_text("Pending"); + widgets.entry.emit_activate(); + let _pending = command_rx.try_recv().expect("pending reply command"); + action.emit_clicked(); + + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn inactive_inline_reply_binding_clears_the_live_draft() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + widgets.entry.set_text("Live draft"); + widgets.revealer.set_reveal_child(true); + + configure_inline_reply(&widgets, ¬ification, false); + + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); + assert!(!widgets.send_button.is_sensitive()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs new file mode 100644 index 000000000..87116e377 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs @@ -0,0 +1,161 @@ +//! Delayed reply result generation tests + +use gtk::prelude::*; +use unixnotis_core::InlineReply; + +use crate::control::UiCommand; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{build_inline_reply, configure_inline_reply}; + +#[gtk::test] +fn stale_reply_result_cannot_change_a_new_inflight_reply() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + let second_notification = reply_notification(42, reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + first_outcome + .send(Err("stale failure".to_string())) + .expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.error_label.is_visible()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); +} + +#[gtk::test] +fn stale_same_id_reply_result_cannot_change_a_new_attempt() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let available_reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, available_reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + // Same-ID replacements can temporarily remove and restore reply support + let unavailable_notification = reply_notification(41, InlineReply::default()); + configure_inline_reply(&widgets, &unavailable_notification, true); + let second_notification = reply_notification(41, available_reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + + first_outcome + .send(Err("stale same-ID failure".to_string())) + .expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.error_label.is_visible()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn stale_reply_result_cannot_change_an_always_available_same_id_replacement() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + // Snapshot identity distinguishes a replacement that keeps the same id + let second_notification = reply_notification(41, reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + + first_outcome.send(Ok(())).expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs new file mode 100644 index 000000000..c882aa106 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs @@ -0,0 +1,78 @@ +//! Inline reply keyboard and editable-focus tests + +use std::cell::Cell; + +use gtk::prelude::*; + +use crate::ui::notifications::test_support::init_gtk; +use crate::ui::panel::behavior::keyboard::editable_has_focus; + +use super::{build_inline_reply, build_notification_row, cancel_inline_reply}; + +#[gtk::test] +fn inline_reply_escape_clears_an_idle_draft_and_collapses_the_form() { + init_gtk(); + let entry = gtk::Entry::new(); + let revealer = gtk::Revealer::new(); + let error_label = gtk::Label::new(Some("Could not send")); + let submitted = Cell::new(false); + entry.set_text("Unsent draft"); + revealer.set_reveal_child(true); + error_label.set_visible(true); + + assert_eq!( + cancel_inline_reply(&entry, &revealer, &error_label, &submitted), + gtk::glib::Propagation::Stop + ); + assert!(entry.text().is_empty()); + assert!(!revealer.reveals_child()); + assert!(error_label.text().is_empty()); + assert!(!error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_key_controller_cancels_only_escape() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + widgets.entry.set_text("Unsent draft"); + widgets.revealer.set_reveal_child(true); + let controllers = widgets.entry.observe_controllers(); + let controller = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index)) + .find_map(|object| object.downcast::().ok()) + .expect("inline reply key controller"); + + let proceed = controller.emit_by_name::( + "key-pressed", + &[>k::gdk::Key::a, &0_u32, >k::gdk::ModifierType::empty()], + ); + assert!(!proceed); + assert_eq!(widgets.entry.text(), "Unsent draft"); + + let stop = controller.emit_by_name::( + "key-pressed", + &[ + >k::gdk::Key::Escape, + &0_u32, + >k::gdk::ModifierType::empty(), + ], + ); + assert!(stop); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn inline_reply_entry_focus_is_recognized_as_editable_panel_input() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (root, row) = build_notification_row(command_tx); + let window = gtk::Window::new(); + window.set_child(Some(&root)); + window.set_visible(true); + + row.inline_reply.entry.grab_focus(); + + assert!(editable_has_focus(&window)); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs new file mode 100644 index 000000000..185bdd311 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs @@ -0,0 +1,15 @@ +//! Mirrored tests for inline reply behavior + +mod availability; +mod generation; +mod keyboard; +mod presentation; +mod recovery; +mod submission; +mod support; + +pub(super) use super::super::build::build_notification_row; +pub(super) use super::super::test_support::{row_data, sample_notification, RowFlags}; +pub(super) use super::super::update::update_notification_row; +pub(super) use super::lifecycle::cancel_inline_reply; +pub(super) use super::{build_inline_reply, configure_inline_reply, connect_inline_reply_button}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs new file mode 100644 index 000000000..73eb86008 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs @@ -0,0 +1,95 @@ +//! Sender-provided reply presentation tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::Action; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::{ + build_notification_row, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_submit_label_is_bounded_without_splitting_unicode() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply.submit_label = "界".repeat(22); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let content = row + .inline_reply + .send_button + .child() + .expect("submit content") + .downcast::() + .expect("submit content box"); + let label = content + .last_child() + .expect("submit label") + .downcast::() + .expect("submit label widget"); + assert_eq!(label.text(), format!("{}…", "界".repeat(20))); +} + +#[gtk::test] +fn inline_reply_submit_icon_is_rendered_before_the_label() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply.submit_icon = "mail-send-symbolic".to_string(); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let content = row + .inline_reply + .send_button + .child() + .expect("submit content") + .downcast::() + .expect("submit content box"); + assert!(content + .first_child() + .is_some_and(|child| child.is::())); + assert!(content + .last_child() + .is_some_and(|child| child.is::())); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs new file mode 100644 index 000000000..05bb1c59c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs @@ -0,0 +1,88 @@ +//! Reply failure display and row recovery tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, row_data, + sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_dead_sender_error_uses_the_stable_user_message() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("Hello?"); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err( + "org.freedesktop.DBus.Error.Failed: The application is no longer available".to_string(), + )) + .expect("reply result receiver"); + drain_main_context(); + + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: The application is no longer available" + ); + assert!(row.inline_reply.error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_rebind_clears_draft_and_prior_error() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("Old draft"); + widgets.send_button.emit_clicked(); + let _pending_reply = command_rx.try_recv().expect("pending reply command"); + assert!(!widgets.entry.is_sensitive()); + widgets.error_label.set_text("Could not send: old error"); + widgets.error_label.set_visible(true); + widgets.revealer.set_reveal_child(true); + + let second_notification = reply_notification(42, reply); + configure_inline_reply(&widgets, &second_notification, true); + + assert!(widgets.entry.text().is_empty()); + assert!(widgets.error_label.text().is_empty()); + assert!(!widgets.error_label.is_visible()); + assert!(!widgets.revealer.reveals_child()); + assert!(widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs new file mode 100644 index 000000000..1e78545f5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs @@ -0,0 +1,175 @@ +//! Reply validation, submission, and retry tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, row_data, + sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_submit_sends_text_once_and_hides_after_success() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("On my way"); + row.inline_reply.entry.emit_activate(); + row.inline_reply.send_button.emit_clicked(); + + let UiCommand::Reply { id, text, outcome } = command_rx.try_recv().expect("reply command") + else { + panic!("expected inline reply command"); + }; + assert_eq!(id, 1); + assert_eq!(text, "On my way"); + assert!(command_rx.try_recv().is_err()); + outcome.send(Ok(())).expect("reply result receiver"); + drain_main_context(); + + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.inline_reply.entry.text().is_empty()); + assert!(!row.inline_reply.error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + row.inline_reply.entry.set_text(" "); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); + row.inline_reply.entry.set_text(&"🙂".repeat(1_025)); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); + row.inline_reply.entry.set_text("Try again"); + assert!(row.inline_reply.send_button.is_sensitive()); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err("temporary failure".to_string())) + .expect("reply result receiver"); + drain_main_context(); + + assert_eq!(row.inline_reply.entry.text(), "Try again"); + assert!(row.inline_reply.entry.is_sensitive()); + assert!(row.inline_reply.send_button.is_sensitive()); + assert!(row.inline_reply.error_label.is_visible()); + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: temporary failure" + ); + + row.inline_reply.entry.set_text("Try once more"); + assert!(!row.inline_reply.error_label.is_visible()); + assert!(row.inline_reply.error_label.text().is_empty()); +} + +#[gtk::test] +fn inline_reply_accepts_exact_byte_limit_and_blocks_changes_during_submission() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + let exact_limit = "🙂".repeat(1_024); + + widgets.entry.set_text(&exact_limit); + assert!(widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + let pending = command_rx.try_recv().expect("exact-limit reply command"); + let UiCommand::Reply { text, .. } = pending else { + panic!("expected inline reply command"); + }; + assert_eq!(text, exact_limit); + + widgets.entry.set_text("Changed while pending"); + assert!(!widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn inline_reply_entry_accepts_the_limit_and_truncates_excess_characters() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let widgets = build_inline_reply(command_tx); + let exact_limit = "a".repeat(4 * 1024); + + widgets.entry.set_text(&exact_limit); + assert_eq!(widgets.entry.text().len(), exact_limit.len()); + + // GTK applies the character cap before the byte-aware submission check + let over_limit = "b".repeat((4 * 1024) + 1); + widgets.entry.set_text(&over_limit); + assert_eq!(widgets.entry.text().len(), exact_limit.len()); +} + +#[gtk::test] +fn inline_reply_does_not_submit_before_binding_a_notification() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + + widgets.entry.set_text("Not bound"); + widgets.entry.emit_activate(); + + assert!(command_rx.try_recv().is_err()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs new file mode 100644 index 000000000..437986f39 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs @@ -0,0 +1,21 @@ +//! Shared fixtures for inline reply tests + +use std::rc::Rc; + +use unixnotis_core::{InlineReply, NotificationView}; + +use super::sample_notification; + +pub(super) fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} + +pub(super) fn reply_notification(id: u32, reply: InlineReply) -> Rc { + let mut notification = sample_notification(); + notification.id = id; + notification.inline_reply = reply; + Rc::new(notification) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs deleted file mode 100644 index 795927d83..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/reply.rs +++ /dev/null @@ -1,454 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; - -use gtk::prelude::*; -use unixnotis_core::{Action, InlineReply}; - -use super::reply::{build_inline_reply, cancel_inline_reply, configure_inline_reply}; -use super::test_support::{row_data, sample_notification, RowFlags}; -use super::update::update_notification_row; -use crate::control::UiCommand; -use crate::ui::icons::IconResolver; -use crate::ui::notifications::test_support::init_gtk; -use crate::ui::panel::behavior::keyboard::editable_has_focus; - -#[gtk::test] -fn inline_reply_is_available_only_for_a_live_explicit_reply_action() { - init_gtk(); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); - let (_root, row) = super::build::build_notification_row(command_tx.clone()); - let mut notification = sample_notification(); - notification.actions = vec![ - Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }, - Action { - key: "inline-reply".to_string(), - label: "Duplicate reply".to_string(), - }, - ]; - notification.inline_reply = InlineReply { - available: true, - label: "Reply".to_string(), - placeholder: "Write back".to_string(), - submit_label: "Send now".to_string(), - submit_icon: String::new(), - }; - - update_notification_row( - &row, - &row_data( - Rc::new(notification.clone()), - RowFlags { - is_active: true, - ..Default::default() - }, - ), - &IconResolver::new(), - &command_tx, - ); - let button = row - .actions_box - .first_child() - .expect("reply action") - .downcast::() - .expect("reply child should be a button"); - assert!(button.next_sibling().is_none()); - button.emit_clicked(); - assert!(row.inline_reply.revealer.reveals_child()); - assert_eq!( - row.inline_reply.entry.placeholder_text().as_deref(), - Some("Write back") - ); - - update_notification_row( - &row, - &row_data(Rc::new(notification), RowFlags::default()), - &IconResolver::new(), - &command_tx, - ); - assert!(!row.inline_reply.revealer.reveals_child()); - assert!(row.actions_box.first_child().is_none()); -} - -#[gtk::test] -fn inline_reply_submit_sends_text_once_and_hides_after_success() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let (_root, row) = super::build::build_notification_row(command_tx.clone()); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }]; - notification.inline_reply.available = true; - - update_notification_row( - &row, - &row_data( - Rc::new(notification), - RowFlags { - is_active: true, - ..Default::default() - }, - ), - &IconResolver::new(), - &command_tx, - ); - row.inline_reply.entry.set_text("On my way"); - row.inline_reply.entry.emit_activate(); - row.inline_reply.send_button.emit_clicked(); - - let UiCommand::Reply { id, text, outcome } = command_rx.try_recv().expect("reply command") - else { - panic!("expected inline reply command"); - }; - assert_eq!(id, 1); - assert_eq!(text, "On my way"); - assert!(command_rx.try_recv().is_err()); - outcome.send(Ok(())).expect("reply result receiver"); - - let context = gtk::glib::MainContext::default(); - while context.pending() { - context.iteration(false); - } - assert!(!row.inline_reply.revealer.reveals_child()); - assert!(row.inline_reply.entry.text().is_empty()); - assert!(!row.inline_reply.error_label.is_visible()); -} - -#[gtk::test] -fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let (_root, row) = super::build::build_notification_row(command_tx.clone()); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }]; - notification.inline_reply.available = true; - - update_notification_row( - &row, - &row_data( - Rc::new(notification), - RowFlags { - is_active: true, - ..Default::default() - }, - ), - &IconResolver::new(), - &command_tx, - ); - - row.inline_reply.entry.set_text(" "); - assert!(!row.inline_reply.send_button.is_sensitive()); - row.inline_reply.entry.emit_activate(); - assert!(command_rx.try_recv().is_err()); - row.inline_reply.entry.set_text(&"🙂".repeat(1_025)); - assert!(!row.inline_reply.send_button.is_sensitive()); - row.inline_reply.entry.emit_activate(); - assert!(command_rx.try_recv().is_err()); - row.inline_reply.entry.set_text("Try again"); - assert!(row.inline_reply.send_button.is_sensitive()); - row.inline_reply.send_button.emit_clicked(); - let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { - panic!("expected inline reply command"); - }; - outcome - .send(Err("temporary failure".to_string())) - .expect("reply result receiver"); - - let context = gtk::glib::MainContext::default(); - while context.pending() { - context.iteration(false); - } - assert_eq!(row.inline_reply.entry.text(), "Try again"); - assert!(row.inline_reply.entry.is_sensitive()); - assert!(row.inline_reply.send_button.is_sensitive()); - assert!(row.inline_reply.error_label.is_visible()); - assert_eq!( - row.inline_reply.error_label.text(), - "Could not send: temporary failure" - ); - - row.inline_reply.entry.set_text("Try once more"); - assert!(!row.inline_reply.error_label.is_visible()); - assert!(row.inline_reply.error_label.text().is_empty()); -} - -#[gtk::test] -fn inline_reply_accepts_exact_byte_limit_and_blocks_changes_during_submission() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let widgets = build_inline_reply(command_tx); - let reply = InlineReply { - available: true, - ..InlineReply::default() - }; - configure_inline_reply(&widgets, 41, &reply, true); - let exact_limit = "🙂".repeat(1_024); - - widgets.entry.set_text(&exact_limit); - assert!(widgets.send_button.is_sensitive()); - widgets.entry.emit_activate(); - let pending = command_rx.try_recv().expect("exact-limit reply command"); - let UiCommand::Reply { text, .. } = pending else { - panic!("expected inline reply command"); - }; - assert_eq!(text, exact_limit); - - widgets.entry.set_text("Changed while pending"); - assert!(!widgets.send_button.is_sensitive()); - widgets.entry.emit_activate(); - assert!(command_rx.try_recv().is_err()); -} - -#[gtk::test] -fn inline_reply_does_not_submit_before_binding_a_notification() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let widgets = build_inline_reply(command_tx); - - widgets.entry.set_text("Not bound"); - widgets.entry.emit_activate(); - - assert!(command_rx.try_recv().is_err()); -} - -#[gtk::test] -fn inline_reply_escape_clears_an_idle_draft_and_collapses_the_form() { - init_gtk(); - let entry = gtk::Entry::new(); - let revealer = gtk::Revealer::new(); - let error_label = gtk::Label::new(Some("Could not send")); - let submitted = Cell::new(false); - entry.set_text("Unsent draft"); - revealer.set_reveal_child(true); - error_label.set_visible(true); - - assert_eq!( - cancel_inline_reply(&entry, &revealer, &error_label, &submitted), - gtk::glib::Propagation::Stop - ); - assert!(entry.text().is_empty()); - assert!(!revealer.reveals_child()); - assert!(error_label.text().is_empty()); - assert!(!error_label.is_visible()); -} - -#[gtk::test] -fn inline_reply_key_controller_cancels_only_escape() { - init_gtk(); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); - let widgets = build_inline_reply(command_tx); - widgets.entry.set_text("Unsent draft"); - widgets.revealer.set_reveal_child(true); - let controllers = widgets.entry.observe_controllers(); - let controller = (0..controllers.n_items()) - .filter_map(|index| controllers.item(index)) - .find_map(|object| object.downcast::().ok()) - .expect("inline reply key controller"); - - let proceed = controller.emit_by_name::( - "key-pressed", - &[>k::gdk::Key::a, &0_u32, >k::gdk::ModifierType::empty()], - ); - assert!(!proceed); - assert_eq!(widgets.entry.text(), "Unsent draft"); - - let stop = controller.emit_by_name::( - "key-pressed", - &[ - >k::gdk::Key::Escape, - &0_u32, - >k::gdk::ModifierType::empty(), - ], - ); - assert!(stop); - assert!(widgets.entry.text().is_empty()); - assert!(!widgets.revealer.reveals_child()); -} - -#[gtk::test] -fn inline_reply_entry_focus_is_recognized_as_editable_panel_input() { - init_gtk(); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); - let (root, row) = super::build::build_notification_row(command_tx); - let window = gtk::Window::new(); - window.set_child(Some(&root)); - window.set_visible(true); - - row.inline_reply.entry.grab_focus(); - - assert!(editable_has_focus(&window)); -} - -#[gtk::test] -fn inline_reply_dead_sender_error_uses_the_stable_user_message() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let (_root, row) = super::build::build_notification_row(command_tx.clone()); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }]; - notification.inline_reply.available = true; - update_notification_row( - &row, - &row_data( - Rc::new(notification), - RowFlags { - is_active: true, - ..Default::default() - }, - ), - &IconResolver::new(), - &command_tx, - ); - row.inline_reply.entry.set_text("Hello?"); - row.inline_reply.send_button.emit_clicked(); - let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { - panic!("expected inline reply command"); - }; - outcome - .send(Err( - "org.freedesktop.DBus.Error.Failed: The application is no longer available".to_string(), - )) - .expect("reply result receiver"); - let context = gtk::glib::MainContext::default(); - while context.pending() { - context.iteration(false); - } - - assert_eq!( - row.inline_reply.error_label.text(), - "Could not send: The application is no longer available" - ); - assert!(row.inline_reply.error_label.is_visible()); -} - -#[gtk::test] -fn inline_reply_rebind_clears_draft_and_prior_error() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let widgets = build_inline_reply(command_tx); - let reply = InlineReply { - available: true, - ..InlineReply::default() - }; - configure_inline_reply(&widgets, 41, &reply, true); - widgets.entry.set_text("Old draft"); - widgets.send_button.emit_clicked(); - let _pending_reply = command_rx.try_recv().expect("pending reply command"); - assert!(!widgets.entry.is_sensitive()); - widgets.error_label.set_text("Could not send: old error"); - widgets.error_label.set_visible(true); - widgets.revealer.set_reveal_child(true); - - configure_inline_reply(&widgets, 42, &reply, true); - - assert!(widgets.entry.text().is_empty()); - assert!(widgets.error_label.text().is_empty()); - assert!(!widgets.error_label.is_visible()); - assert!(!widgets.revealer.reveals_child()); - assert!(widgets.entry.is_sensitive()); - assert!(!widgets.send_button.is_sensitive()); -} - -#[gtk::test] -fn stale_reply_result_cannot_change_a_new_inflight_reply() { - init_gtk(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - let widgets = build_inline_reply(command_tx); - let reply = InlineReply { - available: true, - ..InlineReply::default() - }; - configure_inline_reply(&widgets, 41, &reply, true); - widgets.entry.set_text("First"); - widgets.entry.emit_activate(); - let UiCommand::Reply { - outcome: first_outcome, - .. - } = command_rx.try_recv().expect("first reply command") - else { - panic!("expected inline reply command"); - }; - - configure_inline_reply(&widgets, 42, &reply, true); - widgets.entry.set_text("Second"); - widgets.entry.emit_activate(); - let UiCommand::Reply { - outcome: second_outcome, - .. - } = command_rx.try_recv().expect("second reply command") - else { - panic!("expected inline reply command"); - }; - first_outcome - .send(Err("stale failure".to_string())) - .expect("first outcome receiver"); - drain_main_context(); - - assert_eq!(widgets.entry.text(), "Second"); - assert!(!widgets.entry.is_sensitive()); - assert!(!widgets.send_button.is_sensitive()); - assert!(!widgets.error_label.is_visible()); - - second_outcome - .send(Ok(())) - .expect("second outcome receiver"); - drain_main_context(); -} - -#[gtk::test] -fn inline_reply_submit_label_is_bounded_without_splitting_unicode() { - init_gtk(); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); - let (_root, row) = super::build::build_notification_row(command_tx.clone()); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }]; - notification.inline_reply.available = true; - notification.inline_reply.submit_label = "界".repeat(22); - - update_notification_row( - &row, - &row_data( - Rc::new(notification), - RowFlags { - is_active: true, - ..Default::default() - }, - ), - &IconResolver::new(), - &command_tx, - ); - - let content = row - .inline_reply - .send_button - .child() - .expect("submit content") - .downcast::() - .expect("submit content box"); - let label = content - .last_child() - .expect("submit label") - .downcast::() - .expect("submit label widget"); - assert_eq!(label.text(), format!("{}…", "界".repeat(20))); -} - -fn drain_main_context() { - let context = gtk::glib::MainContext::default(); - while context.pending() { - context.iteration(false); - } -} From e035be87048eb74d1f3e8e8460fb71f35ad88cce Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 02:23:58 -0500 Subject: [PATCH 010/275] refactor(center): split notification row updates Summary: split notification row updates. Scope: center. --- .../ui/notifications/row/notification/mod.rs | 20 +- .../notifications/row/notification/update.rs | 426 ------------------ .../row/notification/update/actions.rs | 133 ++++++ .../row/notification/update/labels.rs | 78 ++++ .../row/notification/update/metadata.rs | 98 ++++ .../row/notification/update/mod.rs | 13 + .../row/notification/update/row.rs | 57 +++ .../{ => update}/tests/actions.rs | 127 +++++- .../notification/{ => update}/tests/labels.rs | 4 +- .../{ => update}/tests/metadata.rs | 4 +- .../row/notification/update/tests/mod.rs | 15 + .../notification/{ => update}/tests/stack.rs | 4 +- .../notification/{ => update}/tests/state.rs | 18 +- .../{ => update}/tests/thumbnail.rs | 6 +- .../row/notification/update/thumbnail.rs | 7 + .../row/notification/update/visual.rs | 81 ++++ 16 files changed, 633 insertions(+), 458 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/actions.rs (54%) rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/labels.rs (92%) rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/metadata.rs (89%) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/stack.rs (90%) rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/state.rs (80%) rename crates/unixnotis-center/src/ui/notifications/row/notification/{ => update}/tests/thumbnail.rs (92%) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 937ba468e..b6d349be1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -1,32 +1,14 @@ //! Notification row widget module //! //! `mod.rs` only wires the notification row pieces together -//! Reply logic and tests stay inside their focused module +//! Build, reply, state, and update logic stay in focused modules -#[cfg(test)] -#[path = "tests/actions.rs"] -mod actions_tests; mod build; -#[cfg(test)] -#[path = "tests/labels.rs"] -mod labels_tests; -#[cfg(test)] -#[path = "tests/metadata.rs"] -mod metadata_tests; mod reply; -#[cfg(test)] -#[path = "tests/stack.rs"] -mod stack_tests; mod state; #[cfg(test)] -#[path = "tests/state.rs"] -mod state_tests; -#[cfg(test)] #[path = "tests/support.rs"] mod test_support; -#[cfg(test)] -#[path = "tests/thumbnail.rs"] -mod thumbnail_tests; mod update; // The list factory only needs the stable notification-row entry points diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs deleted file mode 100644 index 92b5f627c..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! Notification row refresh logic -//! -//! This file owns the repeated update rules for reused notification rows - -use std::borrow::Cow; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gtk::prelude::*; -use tokio::sync::mpsc; -use tracing::debug; -use unixnotis_core::{hooks, NotificationView, Urgency}; - -use crate::control::UiCommand; -use crate::ui::icons::IconResolver; -use crate::ui::panel::input::ClickCooldown; -use crate::ui::try_send_command; - -use super::super::super::item::RowData; -use super::reply::{configure_inline_reply, connect_inline_reply_button}; -use super::state::{ - IconSignature, NotificationRowWidgets, OptionalLabelState, MAX_ACTION_LABEL_CHARS, - MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, -}; - -const ACTION_BUTTON_GUARD_MS: u64 = 180; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct StackGhostVisibility { - pub(super) middle: bool, - pub(super) back: bool, -} - -pub(super) const fn stack_ghost_visibility(stack_depth: u8) -> StackGhostVisibility { - // A single rear layer uses the back slot because that slot starts without overlap - // The middle slot becomes safe only when the back layer is present beneath it - StackGhostVisibility { - middle: stack_depth >= 2, - back: stack_depth >= 1, - } -} - -pub(in crate::ui::notifications) fn update_notification_row( - row: &NotificationRowWidgets, - data: &RowData, - icon_resolver: &IconResolver, - command_tx: &mpsc::Sender, -) { - // Recycled rows can be updated with None while model changes - // Nothing should touch the GTK children until the row has real data again - let Some(notification) = data.notification.as_ref() else { - return; - }; - let notification = notification.as_ref(); - let card = &row.card; - - // State classes belong on the card, not the outer ListView row wrapper - // CSS state toggles stay explicit so stale visual state cannot linger - set_class_state( - card, - hooks::shared_state::CRITICAL, - notification.urgency == Urgency::Critical as u8, - ); - // Active rows can be styled differently from history rows - set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); - // Stacked class indicates collapsed entries in grouped mode - set_class_state(card, hooks::shared_state::STACKED, data.stacked); - // Grouped cards are separate ListView rows, so direct hooks replace dead descendant CSS - set_class_state(card, hooks::panel_card::GROUPED, true); - // Collapsed and expanded hooks let themes space grouped cards directly - set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); - set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); - // Stack ghosts occupy fixed paint slots with different overlap rules - // Depth one must skip the middle slot or its negative margin escapes the row - let ghost_visibility = stack_ghost_visibility(data.stack_depth); - set_widget_visible_if_changed(&row.stack_ghost_1, ghost_visibility.middle); - set_widget_visible_if_changed(&row.stack_ghost_2, ghost_visibility.back); - - // Extra state classes give themes better hooks without changing old selectors - set_class_state( - card, - hooks::panel_card::HAS_SUMMARY, - has_visible_text(¬ification.summary), - ); - set_class_state( - card, - hooks::panel_card::HAS_BODY, - has_visible_text(¬ification.body), - ); - let has_actions = visible_action_count(notification, data.is_active) > 0; - set_class_state(card, hooks::panel_card::HAS_ACTIONS, has_actions); - set_class_state(card, hooks::panel_card::NO_ACTIONS, !has_actions); - let has_thumbnail = - data.presentation.show_thumbnail && notification_has_thumbnail(notification); - set_class_state(card, hooks::panel_card::HAS_THUMBNAIL, has_thumbnail); - set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); - // App name always renders, even when summary or body are missing - set_label_text_if_changed(&row.app_label, ¬ification.app_name); - update_metadata_labels(row, data, notification); - // Clamp before GTK rendering to avoid giant layout passes - update_summary_label(&row.summary_label, ¬ification.summary); - update_body_label(&row.body_label, ¬ification.body); - row.notify_id.set(notification.id); - - update_actions(row, command_tx, notification, data.is_active); - - // Icon decode and apply is skipped when the icon signature is unchanged - // Text and action changes should not trigger another icon pipeline round - let next_sig = IconSignature::from(notification); - let mut sig_guard = row.icon_sig.borrow_mut(); - let signature_changed = sig_guard.as_ref() != Some(&next_sig); - if signature_changed { - let scale = card.scale_factor(); - icon_resolver.apply_icon(&row.icon, notification, 22, scale); - *sig_guard = Some(next_sig); - } - if has_thumbnail { - // The icon cache handles repeat thumbnail lookups cheaply - // Reapply while visible so config reloads cannot leave a stale preview - let scale = card.scale_factor(); - icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); - } - set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); -} - -pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { - if !has_visible_text(text) { - // Empty text rows stay hidden so card spacing stays honest - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - if max_chars == 0 { - // Zero-char clamps are an explicit request to collapse the row - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - OptionalLabelState { - visible: true, - // Notification text stays plain so layout cannot be changed by markup - text: clamp_label_text(text, max_chars), - } -} - -pub(super) fn clamp_action_label_text(text: &str) -> Cow<'_, str> { - // Action text uses the same clamp rule every time so row width stays stable - // This keeps the panel from being stretched by one bad button label - clamp_label_text(text, MAX_ACTION_LABEL_CHARS) -} - -fn update_summary_label(label: >k::Label, summary: &str) { - // Summary rows collapse fully when the sender leaves the title empty - update_optional_label(label, summary, MAX_SUMMARY_LABEL_CHARS); -} - -fn update_body_label(label: >k::Label, body: &str) { - // Body rows follow the same empty-text rule as summary rows - update_optional_label(label, body, MAX_BODY_LABEL_CHARS); -} - -fn update_metadata_labels( - row: &NotificationRowWidgets, - data: &RowData, - notification: &NotificationView, -) { - set_widget_visible_if_changed(&row.meta_top, data.presentation.show_metadata); - set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); - if !data.presentation.show_metadata { - // Disabled lanes collapse fully so default cards keep the older compact shape - set_label_visible_if_changed(&row.meta_label, false); - set_label_visible_if_changed(&row.time_badge, false); - set_label_visible_if_changed(&row.footer_left, false); - set_label_visible_if_changed(&row.footer_right, false); - return; - } - - let meta = notification_meta_label(notification); - set_label_visible_if_changed(&row.meta_label, true); - set_label_text_if_changed(&row.meta_label, &meta); - - let time_badge = relative_time_badge(data.presentation.received_at_ms); - set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); - set_label_text_if_changed(&row.time_badge, &time_badge); - - let footer_left = if notification.is_transient { - "TRANSIENT" - } else if data.is_active { - "LIVE" - } else { - "HISTORY" - }; - set_label_visible_if_changed(&row.footer_left, true); - set_label_text_if_changed(&row.footer_left, footer_left); - - let action_count = visible_action_count(notification, data.is_active); - let footer_right = if action_count == 0 { - Cow::Borrowed("") - } else { - Cow::Owned(format!("{action_count} ACTIONS")) - }; - set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); - set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); -} - -pub(super) fn notification_meta_label(notification: &NotificationView) -> String { - match notification.urgency { - value if value == Urgency::Critical as u8 => "ALERT".to_string(), - value if value == Urgency::Low as u8 => "LOW".to_string(), - _ => "NOTICE".to_string(), - } -} - -pub(super) fn relative_time_badge(received_at_ms: i64) -> String { - if received_at_ms <= 0 { - return String::new(); - } - let Some(now_ms) = now_millis() else { - return String::new(); - }; - let age_ms = now_ms.saturating_sub(received_at_ms.max(0) as u128); - let age_secs = age_ms / 1_000; - match age_secs { - 0..=59 => "now".to_string(), - 60..=3_599 => format!("{}m", age_secs / 60), - 3_600..=86_399 => format!("{}h", age_secs / 3_600), - _ => format!("{}d", age_secs / 86_400), - } -} - -fn now_millis() -> Option { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_millis()) -} - -pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> bool { - notification.image.has_image_data || !notification.image.image_path.trim().is_empty() -} - -fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { - // Build the shared row state first so summary and body stay in sync - // This keeps both rows on the same hide-or-clamp rules - let state = optional_label_state(text, max_chars); - set_label_visible_if_changed(label, state.visible); - set_label_text_if_changed(label, state.text.as_ref()); -} - -fn has_visible_text(text: &str) -> bool { - // Layout only needs to know if the row has real visible content - text.chars().any(|ch| !ch.is_whitespace()) -} - -fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { - // Reused rows are updated often - // Guard CSS churn so GTK does not reprocess classes that already match - if enabled { - if !root.has_css_class(class_name) { - root.add_css_class(class_name); - } - } else if root.has_css_class(class_name) { - root.remove_css_class(class_name); - } -} - -fn set_label_visible_if_changed(label: >k::Label, visible: bool) { - // Reused rows often receive the same visibility decision on every pass - // Skip the setter so hidden and shown states stay quiet when unchanged - if label.get_visible() != visible { - label.set_visible(visible); - } -} - -fn set_label_text_if_changed(label: >k::Label, text: &str) { - // Summary and body updates can be replayed many times while the row is stable - // Compare against the current label so GTK only sees real text changes - if label.text().as_str() != text { - label.set_text(text); - } -} - -fn set_widget_visible_if_changed>(widget: &W, visible: bool) { - // Stack ghost visibility can be replayed often while grouped counts change - if widget.get_visible() != visible { - widget.set_visible(visible); - } -} - -fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { - if max_chars == 0 { - // A zero cap means the caller wants the row blanked on purpose - return Cow::Borrowed(""); - } - // Iterate by character boundaries so UTF-8 stays valid after truncation - for (chars, (idx, _)) in text.char_indices().enumerate() { - if chars == max_chars { - // Allocate only when truncation actually happens - let mut clamped = String::with_capacity(idx + 3); - clamped.push_str(&text[..idx]); - clamped.push('…'); - return Cow::Owned(clamped); - } - } - Cow::Borrowed(text) -} - -fn update_actions( - row: &NotificationRowWidgets, - command_tx: &mpsc::Sender, - notification: &NotificationView, - is_active: bool, -) { - configure_inline_reply( - &row.inline_reply, - notification.id, - ¬ification.inline_reply, - is_active, - ); - // Fast path: skip button rebuild when the action set is unchanged - // This avoids tearing down buttons during no-op refresh passes - { - let cached = row.action_cache.borrow(); - let reply_cached = row.reply_cache.borrow(); - if row.action_cache_id.get() == notification.id - && cached.len() == notification.actions.len() - && cached - .iter() - .zip(notification.actions.iter()) - .all(|((key, label), action)| key == &action.key && label == &action.label) - && reply_cached.0 == notification.inline_reply - && reply_cached.1 == is_active - { - return; - } - } - - { - // Cache the current action signature for the next update cycle - // Reserve once so the cache grows with the current action count - let mut cached = row.action_cache.borrow_mut(); - cached.clear(); - cached.reserve(notification.actions.len()); - for action in ¬ification.actions { - cached.push((action.key.clone(), action.label.clone())); - } - row.action_cache_id.set(notification.id); - *row.reply_cache.borrow_mut() = (notification.inline_reply.clone(), is_active); - } - - // Refresh action buttons only when the action list changes - while let Some(child) = row.actions_box.first_child() { - // Remove old buttons before rebuilding the new set - row.actions_box.remove(&child); - } - if visible_action_count(notification, is_active) == 0 { - // No buttons should remain when the sender drops all actions - return; - } - - let mut reply_button_added = false; - for action in ¬ification.actions { - if action.key == "inline-reply" { - if reply_button_added || !is_active || !notification.inline_reply.available { - continue; - } - reply_button_added = true; - let label = if !notification.inline_reply.label.is_empty() { - notification.inline_reply.label.as_str() - } else if !action.label.is_empty() { - action.label.as_str() - } else { - "Reply" - }; - let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); - button.add_css_class("unixnotis-panel-action"); - button.add_css_class("unixnotis-notification-action"); - connect_inline_reply_button(&button, &row.inline_reply); - row.actions_box.append(&button); - continue; - } - // Bound action text so one long label cannot stretch the whole row - // Clamp before button creation so GTK never measures the oversized string - let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); - button.add_css_class("unixnotis-panel-action"); - button.add_css_class("unixnotis-notification-action"); - let action_key = action.key.clone(); - let tx = command_tx.clone(); - let id = notification.id; - let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); - button.connect_clicked(move |_| { - if !action_gate.try_start() { - return; - } - debug!(id, action = %action_key, "action invoked"); - // Action execution is best-effort and non-blocking - // Best-effort enqueue keeps action handling responsive - // The closure keeps its own key copy so the button can outlive the loop frame - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - row.actions_box.append(&button); - } -} - -fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { - let regular = notification - .actions - .iter() - .filter(|action| action.key != "inline-reply") - .count(); - let reply = is_active - && notification.inline_reply.available - && notification - .actions - .iter() - .any(|action| action.key == "inline-reply"); - regular + usize::from(reply) -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs new file mode 100644 index 000000000..0d46cb4e7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -0,0 +1,133 @@ +//! Notification action button rebuilding and dispatch + +use std::borrow::Cow; +use std::rc::Rc; +use std::time::Duration; + +use gtk::prelude::*; +use tokio::sync::mpsc; +use tracing::debug; +use unixnotis_core::NotificationView; + +use crate::control::UiCommand; +use crate::ui::panel::input::ClickCooldown; +use crate::ui::try_send_command; + +use super::super::reply::{configure_inline_reply, connect_inline_reply_button}; +use super::super::state::{NotificationRowWidgets, MAX_ACTION_LABEL_CHARS}; +use super::labels::clamp_label_text; + +const ACTION_BUTTON_GUARD_MS: u64 = 180; + +pub(super) fn clamp_action_label_text(text: &str) -> Cow<'_, str> { + // Action text uses the same clamp rule every time so row width stays stable + // This keeps the panel from being stretched by one bad button label + clamp_label_text(text, MAX_ACTION_LABEL_CHARS) +} + +pub(super) fn update_actions( + row: &NotificationRowWidgets, + command_tx: &mpsc::Sender, + notification: &Rc, + is_active: bool, +) { + configure_inline_reply(&row.inline_reply, notification, is_active); + // Fast path skips button rebuilding when the action set is unchanged + { + let cached = row.action_cache.borrow(); + let reply_cached = row.reply_cache.borrow(); + if row.action_cache_id.get() == notification.id + && cached.len() == notification.actions.len() + && cached + .iter() + .zip(notification.actions.iter()) + .all(|((key, label), action)| key == &action.key && label == &action.label) + && reply_cached.0 == notification.inline_reply + && reply_cached.1 == is_active + { + return; + } + } + + { + // Cache the current action signature for the next update cycle + let mut cached = row.action_cache.borrow_mut(); + cached.clear(); + cached.reserve(notification.actions.len()); + for action in ¬ification.actions { + cached.push((action.key.clone(), action.label.clone())); + } + row.action_cache_id.set(notification.id); + *row.reply_cache.borrow_mut() = (notification.inline_reply.clone(), is_active); + } + + // Old buttons leave before rebuilding the current action set + while let Some(child) = row.actions_box.first_child() { + row.actions_box.remove(&child); + } + if visible_action_count(notification, is_active) == 0 { + return; + } + + let mut reply_button_added = false; + for action in ¬ification.actions { + if action.key == "inline-reply" { + if reply_button_added || !is_active || !notification.inline_reply.available { + continue; + } + reply_button_added = true; + let label = if !notification.inline_reply.label.is_empty() { + notification.inline_reply.label.as_str() + } else if !action.label.is_empty() { + action.label.as_str() + } else { + "Reply" + }; + let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + connect_inline_reply_button(&button, &row.inline_reply); + row.actions_box.append(&button); + continue; + } + + // Bound action text before GTK measures the button + let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + let action_key = action.key.clone(); + let tx = command_tx.clone(); + let id = notification.id; + let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); + button.connect_clicked(move |_| { + if !action_gate.try_start() { + return; + } + debug!(id, action = %action_key, "action invoked"); + // The closure keeps its own key copy so the button can outlive the loop frame + try_send_command( + &tx, + UiCommand::InvokeAction { + id, + action_key: action_key.clone(), + }, + ); + }); + row.actions_box.append(&button); + } +} + +pub(super) fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { + let regular = notification + .actions + .iter() + .filter(|action| action.key != "inline-reply") + .count(); + let reply = is_active + && notification.inline_reply.available + && notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + regular + usize::from(reply) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs new file mode 100644 index 000000000..0a55e801c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs @@ -0,0 +1,78 @@ +//! Bounded notification label state and GTK updates + +use std::borrow::Cow; + +use gtk::prelude::*; + +use super::super::state::{ + NotificationRowWidgets, OptionalLabelState, MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, +}; + +pub(super) fn update_notification_text( + row: &NotificationRowWidgets, + app_name: &str, + summary: &str, + body: &str, +) { + // App name always renders while optional rows collapse on empty text + set_label_text_if_changed(&row.app_label, app_name); + update_optional_label(&row.summary_label, summary, MAX_SUMMARY_LABEL_CHARS); + update_optional_label(&row.body_label, body, MAX_BODY_LABEL_CHARS); +} + +pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { + if !has_visible_text(text) || max_chars == 0 { + // Empty and intentionally blanked labels must not reserve row space + return OptionalLabelState { + visible: false, + text: Cow::Borrowed(""), + }; + } + OptionalLabelState { + visible: true, + // Notification text stays plain so markup cannot change the layout + text: clamp_label_text(text, max_chars), + } +} + +fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { + // Summary and body use one hide-or-clamp rule + let state = optional_label_state(text, max_chars); + set_label_visible_if_changed(label, state.visible); + set_label_text_if_changed(label, state.text.as_ref()); +} + +pub(super) fn has_visible_text(text: &str) -> bool { + // Layout only needs to know whether real visible content exists + text.chars().any(|ch| !ch.is_whitespace()) +} + +pub(super) fn set_label_visible_if_changed(label: >k::Label, visible: bool) { + // Reused rows often receive the same visibility decision + if label.get_visible() != visible { + label.set_visible(visible); + } +} + +pub(super) fn set_label_text_if_changed(label: >k::Label, text: &str) { + // GTK only needs real text changes + if label.text().as_str() != text { + label.set_text(text); + } +} + +pub(super) fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { + if max_chars == 0 { + return Cow::Borrowed(""); + } + // Character boundaries keep UTF-8 valid after truncation + for (chars, (idx, _)) in text.char_indices().enumerate() { + if chars == max_chars { + let mut clamped = String::with_capacity(idx + 3); + clamped.push_str(&text[..idx]); + clamped.push('…'); + return Cow::Owned(clamped); + } + } + Cow::Borrowed(text) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs new file mode 100644 index 000000000..395e54c2b --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -0,0 +1,98 @@ +//! Notification metadata labels and relative timestamps + +use std::borrow::Cow; +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{NotificationView, Urgency}; + +use super::super::super::super::item::RowData; +use super::super::state::NotificationRowWidgets; +use super::actions::visible_action_count; +use super::labels::{set_label_text_if_changed, set_label_visible_if_changed}; +use super::visual::set_widget_visible_if_changed; + +pub(super) fn update_metadata_labels( + row: &NotificationRowWidgets, + data: &RowData, + notification: &NotificationView, +) { + // Metadata visibility controls both the compact header and footer lanes + set_widget_visible_if_changed(&row.meta_top, data.presentation.show_metadata); + set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); + if !data.presentation.show_metadata { + // Disabled lanes collapse fully so compact cards retain their shape + set_label_visible_if_changed(&row.meta_label, false); + set_label_visible_if_changed(&row.time_badge, false); + set_label_visible_if_changed(&row.footer_left, false); + set_label_visible_if_changed(&row.footer_right, false); + return; + } + + // Urgency uses short stable labels that remain useful across themes + let meta = notification_meta_label(notification); + set_label_visible_if_changed(&row.meta_label, true); + set_label_text_if_changed(&row.meta_label, &meta); + + // Missing or invalid timestamps hide the badge instead of showing stale text + let time_badge = relative_time_badge(data.presentation.received_at_ms); + set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); + set_label_text_if_changed(&row.time_badge, &time_badge); + + // The left footer distinguishes live cards from retained history at a glance + let footer_left = if notification.is_transient { + "TRANSIENT" + } else if data.is_active { + "LIVE" + } else { + "HISTORY" + }; + set_label_visible_if_changed(&row.footer_left, true); + set_label_text_if_changed(&row.footer_left, footer_left); + + // Hidden reply actions are excluded from the displayed action count + let action_count = visible_action_count(notification, data.is_active); + let footer_right = if action_count == 0 { + Cow::Borrowed("") + } else { + Cow::Owned(format!("{action_count} ACTIONS")) + }; + set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); + set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); +} + +pub(super) fn notification_meta_label(notification: &NotificationView) -> String { + // Unknown urgency values retain the normal notice presentation + match notification.urgency { + value if value == Urgency::Critical as u8 => "ALERT".to_string(), + value if value == Urgency::Low as u8 => "LOW".to_string(), + _ => "NOTICE".to_string(), + } +} + +pub(super) fn relative_time_badge(received_at_ms: i64) -> String { + if received_at_ms <= 0 { + return String::new(); + } + // A clock error should not prevent the row from rendering + let Some(now_ms) = now_millis() else { + return String::new(); + }; + // Saturation handles timestamps that are slightly ahead of the local clock + let age_ms = now_ms.saturating_sub(received_at_ms.max(0) as u128); + let age_secs = age_ms / 1_000; + // Compact units keep the metadata lane from changing card width + match age_secs { + 0..=59 => "now".to_string(), + 60..=3_599 => format!("{}m", age_secs / 60), + 3_600..=86_399 => format!("{}h", age_secs / 3_600), + _ => format!("{}d", age_secs / 86_400), + } +} + +fn now_millis() -> Option { + // Systems with an invalid pre-epoch clock omit relative time safely + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis()) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs new file mode 100644 index 000000000..cbf0c0572 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs @@ -0,0 +1,13 @@ +//! Notification row update wiring + +mod actions; +mod labels; +mod metadata; +mod row; +mod thumbnail; +mod visual; + +pub(in crate::ui::notifications) use row::update_notification_row; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs new file mode 100644 index 000000000..cbf5e96dd --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -0,0 +1,57 @@ +//! Top-level refresh flow for a reusable notification row + +use gtk::prelude::*; +use tokio::sync::mpsc; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; + +use super::super::super::super::item::RowData; +use super::super::state::{IconSignature, NotificationRowWidgets}; +use super::actions::{update_actions, visible_action_count}; +use super::labels::update_notification_text; +use super::metadata::update_metadata_labels; +use super::thumbnail::notification_has_thumbnail; +use super::visual::{apply_visual_state, set_widget_visible_if_changed}; + +pub(in crate::ui::notifications) fn update_notification_row( + row: &NotificationRowWidgets, + data: &RowData, + icon_resolver: &IconResolver, + command_tx: &mpsc::Sender, +) { + // Model changes may briefly update a recycled row without notification data + let Some(notification_snapshot) = data.notification.as_ref() else { + return; + }; + let notification = notification_snapshot.as_ref(); + let has_actions = visible_action_count(notification, data.is_active) > 0; + let has_thumbnail = + data.presentation.show_thumbnail && notification_has_thumbnail(notification); + + apply_visual_state(row, data, notification, has_actions, has_thumbnail); + update_notification_text( + row, + ¬ification.app_name, + ¬ification.summary, + ¬ification.body, + ); + update_metadata_labels(row, data, notification); + row.notify_id.set(notification.id); + update_actions(row, command_tx, notification_snapshot, data.is_active); + + // Text and action changes must not restart an unchanged icon pipeline + let next_sig = IconSignature::from(notification); + let mut sig_guard = row.icon_sig.borrow_mut(); + if sig_guard.as_ref() != Some(&next_sig) { + let scale = row.card.scale_factor(); + icon_resolver.apply_icon(&row.icon, notification, 22, scale); + *sig_guard = Some(next_sig); + } + if has_thumbnail { + // Reapply visible thumbnails so config reloads cannot leave stale previews + let scale = row.card.scale_factor(); + icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); + } + set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs similarity index 54% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index f808ac55f..5edb4ca43 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -3,13 +3,15 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::{hooks, Action}; +use unixnotis_core::{hooks, Action, InlineReply}; use crate::control::UiCommand; use crate::ui::icons::IconResolver; -use super::test_support::{child_count, notification_row, row_data, sample_notification, RowFlags}; -use super::update::update_notification_row; +use super::super::super::test_support::{ + child_count, notification_row, row_data, sample_notification, RowFlags, +}; +use super::{update_notification_row, visible_action_count}; #[gtk::test] fn update_notification_row_rebuilds_actions_only_when_signature_changes() { @@ -40,6 +42,10 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); assert_eq!(child_count(&row.actions_box), 1); + let original_button = row + .actions_box + .first_child() + .expect("original action button"); notification.actions[0].label = "Open notification details now".to_string(); let data = row_data( @@ -74,6 +80,15 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { assert_eq!(child_count(&row.actions_box), 1); assert_eq!(row.action_cache.borrow()[0].0, "reply"); + + // Repeating the unchanged update keeps the existing GTK action child + let stable_button = row.actions_box.first_child().expect("stable action button"); + assert_ne!(stable_button, original_button); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!( + row.actions_box.first_child().expect("reused action button"), + stable_button + ); } #[gtk::test] @@ -165,3 +180,109 @@ fn recycled_action_button_targets_the_new_notification_id() { Ok(UiCommand::InvokeAction { id: 2, action_key }) if action_key == "open" )); } + +#[gtk::test] +fn inactive_reply_action_stays_hidden_beside_a_regular_action() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + ]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 1); + let button = row + .actions_box + .first_child() + .expect("regular action") + .downcast::() + .expect("action child should be a button"); + assert_eq!(button.label().as_deref(), Some("Open")); +} + +#[gtk::test] +fn reply_action_label_prefers_hint_then_action_then_default() { + let labels = [ + ("Hint reply", "Action reply", "Hint reply"), + ("", "Action reply", "Action reply"), + ("", "", "Reply"), + ]; + + for (hint, action, expected) in labels { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: action.to_string(), + }]; + notification.inline_reply = InlineReply { + available: true, + label: hint.to_string(), + ..InlineReply::default() + }; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .expect("reply action") + .downcast::() + .expect("reply child should be a button"); + assert_eq!(button.label().as_deref(), Some(expected)); + } +} + +#[test] +fn visible_action_count_requires_a_live_available_explicit_reply() { + let mut notification = sample_notification(); + assert_eq!(visible_action_count(¬ification, true), 0); + + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "dismiss".to_string(), + label: "Dismiss".to_string(), + }, + ]; + assert_eq!(visible_action_count(¬ification, false), 2); + + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + assert_eq!(visible_action_count(¬ification, true), 2); + notification.inline_reply.available = true; + assert_eq!(visible_action_count(¬ification, false), 2); + assert_eq!(visible_action_count(¬ification, true), 3); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs similarity index 92% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs index 591c9419b..356605985 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs @@ -1,7 +1,7 @@ //! Text label rules for notification rows -use super::state::MAX_SUMMARY_LABEL_CHARS; -use super::update::{clamp_action_label_text, optional_label_state}; +use super::super::super::state::MAX_SUMMARY_LABEL_CHARS; +use super::{clamp_action_label_text, optional_label_state}; #[test] fn panel_summary_row_hides_when_text_is_empty() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs similarity index 89% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs index 55bd26042..1898ca7df 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs @@ -2,8 +2,8 @@ use unixnotis_core::Urgency; -use super::test_support::{current_millis, sample_notification}; -use super::update::{notification_meta_label, relative_time_badge}; +use super::super::super::test_support::{current_millis, sample_notification}; +use super::{notification_meta_label, relative_time_badge}; #[test] fn notification_metadata_falls_back_to_urgency_label() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs new file mode 100644 index 000000000..92fec462b --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -0,0 +1,15 @@ +//! Mirrored tests for notification row updates + +mod actions; +mod labels; +mod metadata; +mod stack; +mod state; +mod thumbnail; + +pub(super) use super::actions::{clamp_action_label_text, visible_action_count}; +pub(super) use super::labels::optional_label_state; +pub(super) use super::metadata::{notification_meta_label, relative_time_badge}; +pub(super) use super::row::update_notification_row; +pub(super) use super::thumbnail::notification_has_thumbnail; +pub(super) use super::visual::{stack_ghost_visibility, StackGhostVisibility}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs similarity index 90% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs index 2a903418b..6c5bfa548 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs @@ -1,7 +1,7 @@ //! Collapsed notification-stack composition tests -use super::build::{StackLayer, STACK_LAYER_ORDER}; -use super::update::{stack_ghost_visibility, StackGhostVisibility}; +use super::super::super::build::{StackLayer, STACK_LAYER_ORDER}; +use super::{stack_ghost_visibility, StackGhostVisibility}; #[test] fn notification_stack_places_readable_card_above_rear_layers() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs similarity index 80% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 88b3d7d60..e5dcd9140 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -7,8 +7,10 @@ use unixnotis_core::{hooks, Action, Urgency}; use crate::ui::icons::IconResolver; -use super::test_support::{notification_row, row_data, sample_notification, RowFlags}; -use super::update::update_notification_row; +use super::super::super::test_support::{ + notification_row, row_data, sample_notification, RowFlags, +}; +use super::update_notification_row; #[gtk::test] fn update_notification_row_applies_state_classes_and_text() { @@ -73,3 +75,15 @@ fn update_notification_row_shows_metadata_lanes_and_footer_state() { assert!(row.footer_right.get_visible()); assert_eq!(row.footer_right.text().as_str(), "1 ACTIONS"); } + +#[gtk::test] +fn update_notification_row_marks_an_empty_action_set_as_unavailable() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); + assert!(row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs similarity index 92% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index 9553df0e6..51e6fdcd5 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -7,8 +7,10 @@ use unixnotis_core::hooks; use crate::ui::icons::IconResolver; -use super::test_support::{notification_row, row_data, sample_notification, RowFlags}; -use super::update::{notification_has_thumbnail, update_notification_row}; +use super::super::super::test_support::{ + notification_row, row_data, sample_notification, RowFlags, +}; +use super::{notification_has_thumbnail, update_notification_row}; #[test] fn notification_thumbnail_only_uses_real_image_sources() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs new file mode 100644 index 000000000..6f6758443 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -0,0 +1,7 @@ +//! Thumbnail source decisions for notification rows + +use unixnotis_core::NotificationView; + +pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> bool { + notification.image.has_image_data || !notification.image.image_path.trim().is_empty() +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs new file mode 100644 index 000000000..281064f51 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -0,0 +1,81 @@ +//! Card classes, stack depth, and widget visibility + +use gtk::prelude::*; +use unixnotis_core::{hooks, NotificationView, Urgency}; + +use super::super::super::super::item::RowData; +use super::super::state::NotificationRowWidgets; +use super::labels::has_visible_text; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct StackGhostVisibility { + pub(super) middle: bool, + pub(super) back: bool, +} + +pub(super) const fn stack_ghost_visibility(stack_depth: u8) -> StackGhostVisibility { + // A single rear layer uses the back slot because it starts without overlap + StackGhostVisibility { + middle: stack_depth >= 2, + back: stack_depth >= 1, + } +} + +pub(super) fn apply_visual_state( + row: &NotificationRowWidgets, + data: &RowData, + notification: &NotificationView, + has_actions: bool, + has_thumbnail: bool, +) { + let card = &row.card; + // Explicit state updates prevent recycled rows from retaining stale classes + set_class_state( + card, + hooks::shared_state::CRITICAL, + notification.urgency == Urgency::Critical as u8, + ); + set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); + set_class_state(card, hooks::shared_state::STACKED, data.stacked); + set_class_state(card, hooks::panel_card::GROUPED, true); + set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); + set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); + + // Rear layers occupy fixed paint slots with different overlap rules + let ghost_visibility = stack_ghost_visibility(data.stack_depth); + set_widget_visible_if_changed(&row.stack_ghost_1, ghost_visibility.middle); + set_widget_visible_if_changed(&row.stack_ghost_2, ghost_visibility.back); + + set_class_state( + card, + hooks::panel_card::HAS_SUMMARY, + has_visible_text(¬ification.summary), + ); + set_class_state( + card, + hooks::panel_card::HAS_BODY, + has_visible_text(¬ification.body), + ); + set_class_state(card, hooks::panel_card::HAS_ACTIONS, has_actions); + set_class_state(card, hooks::panel_card::NO_ACTIONS, !has_actions); + set_class_state(card, hooks::panel_card::HAS_THUMBNAIL, has_thumbnail); + set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); +} + +fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { + // Guard CSS churn so GTK does not reprocess matching classes + if enabled { + if !root.has_css_class(class_name) { + root.add_css_class(class_name); + } + } else if root.has_css_class(class_name) { + root.remove_css_class(class_name); + } +} + +pub(super) fn set_widget_visible_if_changed>(widget: &W, visible: bool) { + // Stable visibility avoids unnecessary GTK property notifications + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} From 891a3a9a7128169944dad3e00ac973ec37b24164 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 12:56:36 -0500 Subject: [PATCH 011/275] fix(center): synchronize search and simplify DND controls Summary: synchronize search and simplify DND controls. Scope: center. --- .../src/ui/init/constructor.rs | 4 +- .../src/ui/panel/behavior/visibility.rs | 17 +- .../src/ui/panel/header/actions.rs | 23 ++- .../src/ui/panel/header/dnd.rs | 109 +++++++++-- .../src/ui/panel/header/mod.rs | 5 + .../src/ui/panel/header/search.rs | 62 ++++++- .../src/ui/panel/header/tests/actions.rs | 22 +++ .../src/ui/panel/header/tests/dnd.rs | 169 +++++++++++++++++- .../src/ui/panel/header/tests/header.rs | 13 ++ .../ui/panel/header/tests/search_signals.rs | 58 +++++- crates/unixnotis-center/src/ui/panel/mod.rs | 4 +- .../unixnotis-center/src/ui/reload/config.rs | 10 +- .../src/ui/reload/tests/config.rs | 36 +++- crates/unixnotis-center/src/ui/state.rs | 2 + 14 files changed, 481 insertions(+), 53 deletions(-) diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index acd786b36..d456720fc 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -36,7 +36,8 @@ impl UiState { list.set_empty_layout(has_visible_widget_section(&panel)); panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); - panel::connect_dnd_menu(&panel, init.command_tx.clone()); + let dnd_duration_menu = + panel::connect_dnd_menu(&panel.header.actions.dnd_toggle, init.command_tx.clone()); panel::connect_clear_button(&panel.header.actions.clear_button, init.command_tx.clone()); panel::connect_clear_button(&panel.sections.clear_header_button, init.command_tx.clone()); panel::connect_close_button(&panel, init.command_tx.clone()); @@ -67,6 +68,7 @@ impl UiState { config: init.config, config_path: init.config_path, css: init.css, + _dnd_duration_menu: dnd_duration_menu, panel, list, icon_resolver, diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index bd5f9d2e8..cc976755e 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -114,16 +114,13 @@ impl UiState { // Hide first so any teardown work does not trigger visible reflow self.panel.window.set_visible(false); // Reset transient search UI so each open starts from the full notification list - if self.panel.header.actions.search_toggle.is_active() { - // Programmatic close should not be treated as a user click - self.search_toggle_guard.set(true); - self.panel.header.actions.search_toggle.set_active(false); - self.search_toggle_guard.set(false); - } - if !self.panel.header.search.entry.text().is_empty() { - // Clearing text also removes any active list filter - self.panel.header.search.entry.set_text(""); - } + crate::ui::panel::set_search_open( + &self.panel.header.actions.search_toggle, + &self.panel.header.search.revealer, + &self.panel.header.search.entry, + self.search_toggle_guard.as_ref(), + false, + ); // Disable watch-based polling when hidden to reduce background load if let Some(volume) = self.volume.as_ref() { volume.set_watch_active(false); diff --git a/crates/unixnotis-center/src/ui/panel/header/actions.rs b/crates/unixnotis-center/src/ui/panel/header/actions.rs index 74a1a7f9f..de209be1e 100644 --- a/crates/unixnotis-center/src/ui/panel/header/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/header/actions.rs @@ -23,7 +23,6 @@ pub(in crate::ui) struct PanelActionWidgets { pub(in crate::ui) focus_toggle: gtk::ToggleButton, pub(in crate::ui) dnd_toggle: gtk::ToggleButton, pub(in crate::ui) dnd_status: gtk::Label, - pub(in crate::ui) dnd_menu: gtk::MenuButton, pub(in crate::ui) clear_button: gtk::Button, pub(in crate::ui) search_toggle: gtk::ToggleButton, pub(in crate::ui) close_button: gtk::Button, @@ -45,20 +44,15 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { let focus_toggle = build_toggle_action(hooks::panel_action::FOCUS, &config.focus_action); let dnd_toggle = build_toggle_action(hooks::panel_action::PRIMARY, &config.dnd_action); + set_dnd_duration_tooltip(&dnd_toggle, &config.dnd_action.tooltip); let dnd_status = gtk::Label::new(None); dnd_status.add_css_class(hooks::panel_action::LABEL); dnd_status.set_visible(false); - let dnd_menu = gtk::MenuButton::new(); - configure_action_button(&dnd_menu, hooks::panel_action::PRIMARY, true); - dnd_menu.set_icon_name("pan-down-symbolic"); - dnd_menu.set_tooltip_text(Some("Choose a Do Not Disturb duration")); - let dnd_group = gtk::Box::new(gtk::Orientation::Horizontal, 2); - // One ordered child keeps the toggle, countdown, and duration arrow together + // The duration menu opens from the DND control without adding another button dnd_group.append(&dnd_toggle); dnd_group.append(&dnd_status); - dnd_group.append(&dnd_menu); let clear_button = build_button_action(hooks::panel_action::MUTED, &resolved_clear_action(config)); @@ -91,7 +85,6 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { focus_toggle, dnd_toggle, dnd_status, - dnd_menu, clear_button, search_toggle, close_button, @@ -118,6 +111,7 @@ pub(in crate::ui::panel) fn apply_panel_action_config( hooks::panel_action::PRIMARY, &config.dnd_action, ); + set_dnd_duration_tooltip(&widgets.dnd_toggle, &config.dnd_action.tooltip); update_action_button( &widgets.clear_button, hooks::panel_action::MUTED, @@ -174,6 +168,17 @@ fn build_toggle_action(role_class: &str, config: &PanelActionConfig) -> gtk::Tog button } +fn set_dnd_duration_tooltip(button: >k::ToggleButton, base: &str) { + // Keep custom copy while making the hidden context interaction discoverable + let duration_hint = "Right-click, long-press, or press Shift+F10 for a duration"; + let tooltip = if base.is_empty() { + duration_hint.to_string() + } else { + format!("{base}\n{duration_hint}") + }; + button.set_tooltip_text(Some(&tooltip)); +} + fn build_button_action(role_class: &str, config: &PanelActionConfig) -> gtk::Button { let button = gtk::Button::new(); // Plain buttons reuse the same shell so role classes stay the only visual difference diff --git a/crates/unixnotis-center/src/ui/panel/header/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/dnd.rs index ecb2cd1db..7c2c6e960 100644 --- a/crates/unixnotis-center/src/ui/panel/header/dnd.rs +++ b/crates/unixnotis-center/src/ui/panel/header/dnd.rs @@ -8,16 +8,38 @@ use chrono::{Days, Local, NaiveDate, NaiveTime, TimeZone, Utc}; use gtk::prelude::*; use crate::control::UiCommand; -use crate::ui::panel::PanelWidgets; use crate::ui::try_send_command; const MORNING_HOUR: u32 = 8; +const DND_DURATION_CHOICES: [(&str, i64); 3] = + [("30 minutes", 1_800), ("1 hour", 3_600), ("2 hours", 7_200)]; + +// Context-menu keys use one small decision type so GTK behavior stays explicit +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DndMenuKeyAction { + Open, + Ignore, +} pub(in crate::ui) struct DndCountdown { source: Option, active: Rc>, } +pub(in crate::ui) struct DndDurationMenu { + // A manually parented popover needs an explicit owner to detach it before panel teardown + popover: gtk::Popover, +} + +impl Drop for DndDurationMenu { + fn drop(&mut self) { + // GTK does not automatically detach popovers added with set_parent + if self.popover.parent().is_some() { + self.popover.unparent(); + } + } +} + impl DndCountdown { fn remove_active_source(&mut self) { if self.active.replace(false) { @@ -37,27 +59,26 @@ impl Drop for DndCountdown { } pub(in crate::ui) fn connect_dnd_menu( - panel: &PanelWidgets, + dnd_toggle: >k::ToggleButton, command_tx: tokio::sync::mpsc::Sender, -) { - // The menu button owns this popover after setup +) -> DndDurationMenu { + // The DND toggle owns this popover without adding a separate arrow button let popover = gtk::Popover::new(); + popover.set_autohide(true); let choices = gtk::Box::new(gtk::Orientation::Vertical, 2); // Common relative choices share one absolute-deadline command path - for (label, seconds) in [ - ("30 minutes", 30 * 60), - ("1 hour", 60 * 60), - ("2 hours", 2 * 60 * 60), - ] { + for (label, seconds) in DND_DURATION_CHOICES { let button = gtk::Button::with_label(label); let tx = command_tx.clone(); - let menu = popover.clone(); + let menu = popover.downgrade(); button.connect_clicked(move |_| { // Saturation keeps an abnormal system clock from wrapping the deadline let expires_at = Utc::now().timestamp().saturating_add(seconds); try_send_command(&tx, UiCommand::SetDndUntil(expires_at)); - menu.popdown(); + if let Some(menu) = menu.upgrade() { + menu.popdown(); + } }); choices.append(&button); } @@ -65,28 +86,84 @@ pub(in crate::ui) fn connect_dnd_menu( // Morning follows the next calendar day rather than a fixed 24-hour duration let morning = gtk::Button::with_label("Until tomorrow morning"); let morning_tx = command_tx.clone(); - let morning_menu = popover.clone(); + let morning_menu = popover.downgrade(); morning.connect_clicked(move |_| { if let Some(expires_at) = next_morning_deadline() { try_send_command(&morning_tx, UiCommand::SetDndUntil(expires_at)); } else { tracing::warn!("could not resolve the next local 08:00 DND deadline"); } - morning_menu.popdown(); + if let Some(menu) = morning_menu.upgrade() { + menu.popdown(); + } }); choices.append(&morning); // Indefinite enablement deliberately replaces any existing timed deadline let indefinite = gtk::Button::with_label("Indefinitely"); - let indefinite_menu = popover.clone(); + let indefinite_menu = popover.downgrade(); indefinite.connect_clicked(move |_| { try_send_command(&command_tx, UiCommand::SetDnd(true)); - indefinite_menu.popdown(); + if let Some(menu) = indefinite_menu.upgrade() { + menu.popdown(); + } }); choices.append(&indefinite); popover.set_child(Some(&choices)); - panel.header.actions.dnd_menu.set_popover(Some(&popover)); + popover.set_parent(dnd_toggle); + connect_dnd_menu_inputs(dnd_toggle, &popover); + DndDurationMenu { popover } +} + +fn connect_dnd_menu_inputs(dnd_toggle: >k::ToggleButton, popover: >k::Popover) { + let secondary_click = gtk::GestureClick::new(); + // Secondary click keeps the primary click dedicated to immediate toggling + secondary_click.set_button(3); + let click_menu = popover.downgrade(); + secondary_click.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(menu) = click_menu.upgrade() { + menu.popup(); + } + }); + dnd_toggle.add_controller(secondary_click); + + let long_press = gtk::GestureLongPress::new(); + let press_menu = popover.downgrade(); + long_press.connect_pressed(move |gesture, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(menu) = press_menu.upgrade() { + menu.popup(); + } + }); + dnd_toggle.add_controller(long_press); + + let key_controller = gtk::EventControllerKey::new(); + let key_menu = popover.downgrade(); + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + let Some(menu) = key_menu.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; + match dnd_menu_key_action(key, modifiers) { + DndMenuKeyAction::Open => { + menu.popup(); + gtk::glib::Propagation::Stop + } + DndMenuKeyAction::Ignore => gtk::glib::Propagation::Proceed, + } + }); + dnd_toggle.add_controller(key_controller); +} + +fn dnd_menu_key_action(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> DndMenuKeyAction { + if key == gtk::gdk::Key::Menu + || (key == gtk::gdk::Key::F10 && modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK)) + { + DndMenuKeyAction::Open + } else { + DndMenuKeyAction::Ignore + } } pub(in crate::ui) fn update_dnd_status(label: >k::Label, expires_at: i64) { diff --git a/crates/unixnotis-center/src/ui/panel/header/mod.rs b/crates/unixnotis-center/src/ui/panel/header/mod.rs index bbe3ace11..b92f1ee71 100644 --- a/crates/unixnotis-center/src/ui/panel/header/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/header/mod.rs @@ -78,6 +78,11 @@ pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { header.append(&action_area.row); let search = build_panel_search(config); + // Initial configuration must keep the toggle aligned with the visible search row + action_area + .widgets + .search_toggle + .set_active(search.revealer.reveals_child()); header.append(&search.revealer); PanelHeaderWidgets { diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs index 71bd6bb28..e62be9a07 100644 --- a/crates/unixnotis-center/src/ui/panel/header/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -124,6 +124,23 @@ pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filte } } +pub(in crate::ui) fn set_search_open( + search_toggle: >k::ToggleButton, + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, + search_toggle_guard: &Cell, + open: bool, +) { + // Guarded changes still pass through the signal handler when the toggle changes + let previous_guard = search_toggle_guard.replace(true); + search_toggle.set_active(open); + // Nested callers retain the guard state owned by the outer operation + search_toggle_guard.set(previous_guard); + + // Apply directly as well because GTK emits no signal when the toggle already matches + apply_search_open_state(search_revealer, search_entry, open); +} + pub(in crate::ui) fn connect_search_toggle( search_toggle: >k::ToggleButton, search_revealer: >k::Revealer, @@ -131,7 +148,6 @@ pub(in crate::ui) fn connect_search_toggle( search_toggle_guard: Rc>, ) { let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); - let accepted_search_reveal = Rc::new(Cell::new(search_revealer.reveals_child())); // Programmatic rollback must not be mistaken for a fresh user click let search_restore = Rc::new(Cell::new(false)); let toggled_revealer = search_revealer.clone(); @@ -139,23 +155,43 @@ pub(in crate::ui) fn connect_search_toggle( // A weak reference avoids a signal cycle between the entry and toggle let stop_toggle = search_toggle.downgrade(); + let stop_revealer = search_revealer.clone(); + let stop_entry = search_entry.clone(); let stop_click_gate = search_click_gate.clone(); + let stop_guard = search_toggle_guard.clone(); search_entry.connect_stop_search(move |_| { // Escape is a semantic close and should not wait for the reveal click cooldown stop_click_gate.release(); if let Some(toggle) = stop_toggle.upgrade() { - toggle.set_active(false); + set_search_open( + &toggle, + &stop_revealer, + &stop_entry, + stop_guard.as_ref(), + false, + ); + } else { + // The entry may briefly outlive its toggle during GTK teardown + apply_search_open_state(&stop_revealer, &stop_entry, false); } }); search_toggle.connect_toggled(move |button| { - if search_toggle_guard.get() || search_restore.replace(false) { + if search_restore.replace(false) { return; } let reveal = button.is_active(); + if search_toggle_guard.get() { + // Programmatic changes must keep every search widget on the same state + search_click_gate.release(); + apply_search_open_state(&toggled_revealer, &toggled_entry, reveal); + return; + } + if !search_click_gate.try_start() { - let accepted = accepted_search_reveal.get(); + // The revealer records the last accepted transition target + let accepted = toggled_revealer.reveals_child(); if reveal != accepted { // Keep the visual toggle synced with the accepted revealer state search_restore.set(true); @@ -164,7 +200,6 @@ pub(in crate::ui) fn connect_search_toggle( return; } - accepted_search_reveal.set(reveal); // Freeze the toggle while its revealer animates to the accepted state button.set_sensitive(false); let button_enable = button.clone(); @@ -174,18 +209,27 @@ pub(in crate::ui) fn connect_search_toggle( button_enable.set_sensitive(true); }, ); - toggled_revealer.set_reveal_child(reveal); + apply_search_open_state(&toggled_revealer, &toggled_entry, reveal); if reveal { // Selecting existing text makes the next query replace it immediately toggled_entry.grab_focus(); toggled_entry.select_region(0, i32::MAX); - } else if !toggled_entry.text().is_empty() { - // Closing search restores the full notification list - toggled_entry.set_text(""); } }); } +fn apply_search_open_state( + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, + open: bool, +) { + search_revealer.set_reveal_child(open); + if !open && !search_entry.text().is_empty() { + // Closing search restores the full notification list + search_entry.set_text(""); + } +} + #[cfg(test)] #[path = "tests/search.rs"] mod construction_tests; diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs index 38b08ee29..19c0660ed 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs @@ -91,3 +91,25 @@ fn apply_panel_action_config_moves_close_between_header_and_action_group() { assert!(child_with_class(&actions.widgets.group, hooks::panel_action::CLOSE).is_none()); assert!(child_with_class(&header_top, hooks::panel_action::CLOSE).is_some()); } + +#[gtk::test] +fn dnd_duration_menu_does_not_add_a_standalone_arrow_button() { + let actions = build_panel_actions(&PanelConfig::default()); + let toggle = actions + .widgets + .dnd_group + .first_child() + .expect("DND group should contain its toggle"); + let status = toggle + .next_sibling() + .expect("DND group should contain its countdown label"); + + assert_eq!(toggle, actions.widgets.dnd_toggle); + assert_eq!(status, actions.widgets.dnd_status); + assert!(status.next_sibling().is_none()); + assert!(actions + .widgets + .dnd_toggle + .tooltip_text() + .is_some_and(|text| text.contains("Right-click"))); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs index d2a871890..cd0ef0689 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs @@ -1,9 +1,14 @@ use std::cell::Cell; use std::rc::Rc; -use chrono::NaiveDate; +use chrono::{Local, NaiveDate, TimeZone, Timelike, Utc}; +use gtk::prelude::*; -use super::{countdown_control_flow, format_dnd_remaining, tomorrow_date, DndCountdown}; +use super::{ + connect_dnd_menu, countdown_control_flow, dnd_menu_key_action, format_dnd_remaining, + tomorrow_date, update_dnd_status, DndCountdown, DndMenuKeyAction, DND_DURATION_CHOICES, +}; +use crate::control::UiCommand; #[test] fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { @@ -45,6 +50,139 @@ fn countdown_stops_at_the_deadline_and_continues_only_while_future() { ); } +#[test] +fn duration_menu_accepts_standard_keyboard_context_actions_only() { + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::Menu, gtk::gdk::ModifierType::empty()), + DndMenuKeyAction::Open + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::F10, gtk::gdk::ModifierType::SHIFT_MASK), + DndMenuKeyAction::Open + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::F10, gtk::gdk::ModifierType::empty()), + DndMenuKeyAction::Ignore + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::space, gtk::gdk::ModifierType::SHIFT_MASK), + DndMenuKeyAction::Ignore + ); +} + +#[gtk::test] +fn connected_duration_menu_installs_every_input_path_and_choice() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8); + + let _menu_owner = connect_dnd_menu(&toggle, command_tx); + + let popover = attached_popover(&toggle); + assert!(popover.is_autohide()); + assert_eq!( + menu_buttons(&popover) + .iter() + .filter_map(gtk::Button::label) + .collect::>(), + vec![ + "30 minutes", + "1 hour", + "2 hours", + "Until tomorrow morning", + "Indefinitely", + ] + ); + + let controllers = toggle.observe_controllers(); + let mut has_secondary_click = false; + let mut has_long_press = false; + let mut has_key_controller = false; + for index in 0..controllers.n_items() { + let controller = controllers + .item(index) + .expect("observed controller should remain available"); + if let Ok(click) = controller.clone().downcast::() { + has_secondary_click |= click.button() == 3; + } + has_long_press |= controller.is::(); + has_key_controller |= controller.is::(); + } + assert!(has_secondary_click); + assert!(has_long_press); + assert!(has_key_controller); +} + +#[gtk::test] +fn dropping_duration_menu_owner_detaches_the_manually_parented_popover() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let menu_owner = connect_dnd_menu(&toggle, command_tx); + let popover = menu_owner.popover.clone(); + + assert_eq!( + popover.parent().as_ref(), + Some(toggle.upcast_ref::()) + ); + drop(menu_owner); + assert!(popover.parent().is_none()); + drop(toggle); +} + +#[gtk::test] +fn duration_menu_buttons_send_their_exact_deadlines_and_indefinite_state() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8); + let _menu_owner = connect_dnd_menu(&toggle, command_tx); + let buttons = menu_buttons(&attached_popover(&toggle)); + + for ((_, seconds), button) in DND_DURATION_CHOICES.iter().zip(&buttons[..3]) { + let before = Utc::now().timestamp(); + button.emit_clicked(); + let after = Utc::now().timestamp(); + let UiCommand::SetDndUntil(expires_at) = command_rx + .try_recv() + .expect("duration command should queue") + else { + panic!("expected timed DND command"); + }; + assert!(expires_at >= before.saturating_add(*seconds)); + assert!(expires_at <= after.saturating_add(*seconds)); + } + + let before_morning = Local::now(); + buttons[3].emit_clicked(); + let UiCommand::SetDndUntil(morning_deadline) = + command_rx.try_recv().expect("morning command should queue") + else { + panic!("expected morning DND command"); + }; + let morning = Local + .timestamp_opt(morning_deadline, 0) + .single() + .expect("morning deadline should map to one local time"); + assert!(morning.date_naive() > before_morning.date_naive()); + assert_eq!(morning.hour(), 8); + assert_eq!(morning.minute(), 0); + assert_eq!(morning.second(), 0); + + buttons[4].emit_clicked(); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn dnd_status_updates_text_and_visibility_together() { + let label = gtk::Label::new(Some("stale")); + + update_dnd_status(&label, Utc::now().timestamp().saturating_add(60)); + assert!(label.is_visible()); + assert_eq!(label.text(), "· 1m"); + + update_dnd_status(&label, Utc::now().timestamp().saturating_sub(1)); + assert!(!label.is_visible()); + assert!(label.text().is_empty()); +} + #[gtk::test] fn dropping_countdown_removes_its_live_source() { let callback_runs = Rc::new(Cell::new(0)); @@ -73,3 +211,30 @@ fn drain_main_context() { context.iteration(false); } } + +fn attached_popover(toggle: >k::ToggleButton) -> gtk::Popover { + let mut child = toggle.first_child(); + while let Some(widget) = child { + if let Ok(popover) = widget.clone().downcast::() { + return popover; + } + child = widget.next_sibling(); + } + panic!("DND toggle should own its duration popover"); +} + +fn menu_buttons(popover: >k::Popover) -> Vec { + let choices = popover + .child() + .and_then(|child| child.downcast::().ok()) + .expect("DND popover should contain its choice box"); + let mut buttons = Vec::new(); + let mut child = choices.first_child(); + while let Some(widget) = child { + if let Ok(button) = widget.clone().downcast::() { + buttons.push(button); + } + child = widget.next_sibling(); + } + buttons +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/header.rs b/crates/unixnotis-center/src/ui/panel/header/tests/header.rs index bd9e5abb3..4f07c481f 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/header.rs @@ -40,3 +40,16 @@ fn build_panel_header_places_explicit_close_inside_action_group() { assert!(child_with_class(&header.top, hooks::panel_action::CLOSE).is_none()); assert!(child_with_class(&header.actions.group, hooks::panel_action::CLOSE).is_some()); } + +#[gtk::test] +fn visible_search_configuration_activates_toggle_and_revealer_together() { + let config = PanelConfig { + search_visible: true, + ..PanelConfig::default() + }; + + let header = build_panel_header(&config); + + assert!(header.actions.search_toggle.is_active()); + assert!(header.search.revealer.reveals_child()); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs index 37c62c3e6..91b213d1c 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs @@ -6,6 +6,7 @@ use gtk::prelude::*; use super::{ connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, send_filter_event, + set_search_open, }; use crate::control::UiEvent; @@ -50,7 +51,7 @@ fn stop_search_closes_revealer_and_clears_filter_immediately() { } #[gtk::test] -fn guarded_search_toggle_does_not_change_revealer_state() { +fn guarded_search_toggle_synchronizes_revealer_state() { let toggle = gtk::ToggleButton::new(); let revealer = gtk::Revealer::new(); let entry = gtk::SearchEntry::new(); @@ -60,7 +61,14 @@ fn guarded_search_toggle_does_not_change_revealer_state() { toggle.set_active(true); assert!(toggle.is_active()); + assert!(revealer.reveals_child()); + + entry.set_text("urgent"); + toggle.set_active(false); + + assert!(!toggle.is_active()); assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); } #[gtk::test] @@ -80,6 +88,54 @@ fn rapid_search_toggle_restores_the_last_accepted_state() { assert_eq!(entry.text(), "urgent"); } +#[gtk::test] +fn programmatic_panel_close_keeps_search_closed_for_the_next_open() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Rc::new(Cell::new(false)); + connect_search_toggle(&toggle, &revealer, &entry, guard.clone()); + + toggle.set_active(true); + entry.set_text("urgent"); + set_search_open(&toggle, &revealer, &entry, guard.as_ref(), false); + + // Reopening the panel does not mutate search state + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); +} + +#[gtk::test] +fn programmatic_search_sync_preserves_an_outer_guard_scope() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Cell::new(true); + + set_search_open(&toggle, &revealer, &entry, &guard, true); + + assert!(guard.get()); + assert!(toggle.is_active()); + assert!(revealer.reveals_child()); +} + +#[gtk::test] +fn stop_search_closes_a_preexisting_toggle_revealer_mismatch() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + revealer.set_reveal_child(true); + entry.set_text("urgent"); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + entry.emit_stop_search(); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); +} + #[gtk::test] fn widget_collapse_toggle_sends_the_accepted_state_and_rejects_a_burst() { let toggle = gtk::ToggleButton::new(); diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index a56b5e5a1..03af3f4c9 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -23,7 +23,7 @@ pub(in crate::ui) use behavior::{connect_auto_close, connect_keyboard_shortcuts} pub(in crate::ui) use header::actions::{ connect_clear_button, connect_close_button, connect_dnd_toggle, }; -pub(in crate::ui) use header::dnd::{connect_dnd_menu, DndCountdown}; +pub(in crate::ui) use header::dnd::{connect_dnd_menu, DndCountdown, DndDurationMenu}; pub(in crate::ui) use header::search::{ - connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, + connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, set_search_open, }; diff --git a/crates/unixnotis-center/src/ui/reload/config.rs b/crates/unixnotis-center/src/ui/reload/config.rs index 42748a9f6..5a0378a3f 100644 --- a/crates/unixnotis-center/src/ui/reload/config.rs +++ b/crates/unixnotis-center/src/ui/reload/config.rs @@ -225,8 +225,14 @@ impl UiState { .search .entry .set_placeholder_text(Some(&config.panel.search_placeholder)); - self.panel.header.search.revealer.set_reveal_child( - config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(), + let search_open = + config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); + panel::set_search_open( + &self.panel.header.actions.search_toggle, + &self.panel.header.search.revealer, + &self.panel.header.search.entry, + self.search_toggle_guard.as_ref(), + search_open, ); self.panel .header diff --git a/crates/unixnotis-center/src/ui/reload/tests/config.rs b/crates/unixnotis-center/src/ui/reload/tests/config.rs index 9414d0627..ba5688fd4 100644 --- a/crates/unixnotis-center/src/ui/reload/tests/config.rs +++ b/crates/unixnotis-center/src/ui/reload/tests/config.rs @@ -5,7 +5,8 @@ use std::{fs, path::Path}; use gtk::prelude::*; use unixnotis_core::{ - Config, ConfigError, EmptyStateAlignment, Margins, ToggleWidgetConfig, WidgetDensity, + Config, ConfigError, EmptyStateAlignment, Margins, PanelRequest, ToggleWidgetConfig, + WidgetDensity, }; use unixnotis_ui::css::CssManager; @@ -212,6 +213,39 @@ fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { assert!(!hidden_state.panel.header.search.revealer.reveals_child()); } +#[gtk::test] +fn reload_enables_configured_search_in_toggle_and_revealer() { + let mut state = state(); + let mut config = state.config.clone(); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + + config.panel.search_visible = true; + state.apply_reloaded_panel(&config); + + assert!(state.panel.header.actions.search_toggle.is_active()); + assert!(state.panel.header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn panel_close_and_reopen_keep_transient_search_closed() { + let mut state = state(); + state.apply_panel_request(PanelRequest::open()); + state.panel.header.actions.search_toggle.set_active(true); + state.panel.header.search.entry.set_text("urgent"); + assert!(state.panel.header.search.revealer.reveals_child()); + + state.apply_panel_request(PanelRequest::close()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + assert!(state.panel.header.search.entry.text().is_empty()); + + state.apply_panel_request(PanelRequest::open()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + state.apply_panel_request(PanelRequest::close()); +} + #[gtk::test] fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { let mut state = state(); diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index d75311255..8b662446b 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -19,6 +19,8 @@ pub struct UiState { pub(super) config: Config, pub(super) config_path: std::path::PathBuf, pub(super) css: CssManager, + // This owner must drop before the panel so its manually parented popover can detach + pub(super) _dnd_duration_menu: panel::DndDurationMenu, pub(super) panel: panel::PanelWidgets, pub(super) list: notifications::NotificationList, // Shared resolver keeps icon cache and inflight decode tracking centralized From 9a68ad80a8ea7b6803b128fab1be2de6724e617a Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 13:10:24 -0500 Subject: [PATCH 012/275] refactor(center): split reload and header modules Summary: split reload and header modules. Scope: center. --- .../src/ui/panel/header/build.rs | 84 ++++ .../src/ui/panel/header/mod.rs | 98 +--- .../header/tests/{header.rs => build.rs} | 16 + .../src/ui/panel/header/widgets.rs | 17 + .../unixnotis-center/src/ui/reload/config.rs | 427 ----------------- .../src/ui/reload/config/flow.rs | 88 ++++ .../src/ui/reload/config/mod.rs | 12 + .../src/ui/reload/config/notice.rs | 120 +++++ .../src/ui/reload/config/outcome.rs | 54 +++ .../src/ui/reload/config/panel.rs | 125 +++++ .../src/ui/reload/config/tests/flow.rs | 82 ++++ .../src/ui/reload/config/tests/mod.rs | 6 + .../src/ui/reload/config/tests/notice.rs | 157 +++++++ .../src/ui/reload/config/tests/outcome.rs | 60 +++ .../src/ui/reload/config/tests/panel.rs | 112 +++++ .../src/ui/reload/config/tests/support.rs | 66 +++ .../src/ui/reload/config/tests/widgets.rs | 19 + .../src/ui/reload/config/widgets.rs | 71 +++ .../src/ui/reload/tests/config.rs | 441 ------------------ 19 files changed, 1094 insertions(+), 961 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/header/build.rs rename crates/unixnotis-center/src/ui/panel/header/tests/{header.rs => build.rs} (76%) create mode 100644 crates/unixnotis-center/src/ui/panel/header/widgets.rs delete mode 100644 crates/unixnotis-center/src/ui/reload/config.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/flow.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/mod.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/notice.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/outcome.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/panel.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/flow.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/notice.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/panel.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/support.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs create mode 100644 crates/unixnotis-center/src/ui/reload/config/widgets.rs delete mode 100644 crates/unixnotis-center/src/ui/reload/tests/config.rs diff --git a/crates/unixnotis-center/src/ui/panel/header/build.rs b/crates/unixnotis-center/src/ui/panel/header/build.rs new file mode 100644 index 000000000..5a7925a30 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/build.rs @@ -0,0 +1,84 @@ +//! Panel header widget construction + +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::{css::hooks, PanelConfig}; + +use super::actions::{action_order_contains_close, build_panel_actions}; +use super::search::build_panel_search; +use super::widgets::PanelHeaderWidgets; + +pub(in crate::ui::panel) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { + let header = gtk::Box::new(gtk::Orientation::Vertical, 8); + header.add_css_class(hooks::panel_shell::HEADER); + + // Top row stays compact so header width does not jump across themes + let header_top = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header_top.add_css_class(hooks::panel_shell::HEADER_TOP); + + let title_box = gtk::Box::new(gtk::Orientation::Vertical, 2); + title_box.add_css_class(hooks::panel_shell::TITLE_STACK); + + let title = gtk::Label::new(Some(&config.title)); + title.set_xalign(0.0); + title.add_css_class(hooks::panel_shell::TITLE); + + let count = gtk::Label::new(Some("0")); + // Count stays centered so one and three digit values do not jump left + count.set_xalign(0.5); + count.set_valign(Align::Center); + count.add_css_class(hooks::panel_shell::COUNT); + + let title_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + title_row.add_css_class(hooks::panel_shell::TITLE_ROW); + // Title and count stay in one row so the header can shrink cleanly + title_row.append(&title); + title_row.append(&count); + title_box.append(&title_row); + + let subtitle = gtk::Label::new(Some(&config.subtitle)); + subtitle.set_xalign(0.0); + subtitle.add_css_class(hooks::panel_shell::SUBTITLE); + subtitle.set_visible(!config.subtitle.is_empty()); + title_box.append(&subtitle); + + let action_area = build_panel_actions(config); + action_area.row.set_visible(config.action_row_visible); + + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); + // Spacer absorbs the flexible width between the title stack and close action + spacer.set_hexpand(true); + + header_top.append(&title_box); + header_top.append(&spacer); + if !action_order_contains_close(&config.action_order) { + // Keep close away from clear so destructive actions do not blend together + header_top.append(&action_area.widgets.close_button); + } + header.append(&header_top); + // Action row sits below the title so narrow panels stay stable + header.append(&action_area.row); + + let search = build_panel_search(config); + // Initial configuration must keep the toggle aligned with the visible search row + action_area + .widgets + .search_toggle + .set_active(search.revealer.reveals_child()); + header.append(&search.revealer); + + PanelHeaderWidgets { + root: header, + top: header_top, + action_row: action_area.row, + title, + subtitle, + count, + search, + actions: action_area.widgets, + } +} + +#[cfg(test)] +#[path = "tests/build.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/header/mod.rs b/crates/unixnotis-center/src/ui/panel/header/mod.rs index b92f1ee71..1acf1b640 100644 --- a/crates/unixnotis-center/src/ui/panel/header/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/header/mod.rs @@ -1,98 +1,10 @@ -//! Panel header construction and component grouping +//! Panel header component wiring pub(in crate::ui) mod actions; +mod build; pub(in crate::ui) mod dnd; pub(in crate::ui) mod search; +mod widgets; -use gtk::prelude::*; -use gtk::Align; -use unixnotis_core::{css::hooks, PanelConfig}; - -use self::actions::{action_order_contains_close, build_panel_actions, PanelActionWidgets}; -use self::search::{build_panel_search, PanelSearchWidgets}; - -pub(in crate::ui) struct PanelHeaderWidgets { - pub(in crate::ui) root: gtk::Box, - pub(in crate::ui) top: gtk::Box, - pub(in crate::ui) action_row: gtk::Box, - pub(in crate::ui) title: gtk::Label, - pub(in crate::ui) subtitle: gtk::Label, - pub(in crate::ui) count: gtk::Label, - pub(in crate::ui) search: PanelSearchWidgets, - pub(in crate::ui) actions: PanelActionWidgets, -} - -#[cfg(test)] -#[path = "tests/header.rs"] -mod tests; - -pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { - let header = gtk::Box::new(gtk::Orientation::Vertical, 8); - header.add_css_class(hooks::panel_shell::HEADER); - - // Top row stays compact so header width does not jump across themes - let header_top = gtk::Box::new(gtk::Orientation::Horizontal, 8); - header_top.add_css_class(hooks::panel_shell::HEADER_TOP); - - let title_box = gtk::Box::new(gtk::Orientation::Vertical, 2); - title_box.add_css_class(hooks::panel_shell::TITLE_STACK); - - let title = gtk::Label::new(Some(&config.title)); - title.set_xalign(0.0); - title.add_css_class(hooks::panel_shell::TITLE); - - let count = gtk::Label::new(Some("0")); - // Count stays centered so one and three digit values do not jump left - count.set_xalign(0.5); - count.set_valign(Align::Center); - count.add_css_class(hooks::panel_shell::COUNT); - - let title_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - title_row.add_css_class(hooks::panel_shell::TITLE_ROW); - // Title and count stay in one row so the header can shrink cleanly - title_row.append(&title); - title_row.append(&count); - title_box.append(&title_row); - - let subtitle = gtk::Label::new(Some(&config.subtitle)); - subtitle.set_xalign(0.0); - subtitle.add_css_class(hooks::panel_shell::SUBTITLE); - subtitle.set_visible(!config.subtitle.is_empty()); - title_box.append(&subtitle); - - let action_area = build_panel_actions(config); - action_area.row.set_visible(config.action_row_visible); - - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // Spacer absorbs the flexible width between the title stack and close action - spacer.set_hexpand(true); - - header_top.append(&title_box); - header_top.append(&spacer); - if !action_order_contains_close(&config.action_order) { - // Keep close away from clear so destructive actions do not blend together - header_top.append(&action_area.widgets.close_button); - } - header.append(&header_top); - // Action row sits below the title so narrow panels stay stable - header.append(&action_area.row); - - let search = build_panel_search(config); - // Initial configuration must keep the toggle aligned with the visible search row - action_area - .widgets - .search_toggle - .set_active(search.revealer.reveals_child()); - header.append(&search.revealer); - - PanelHeaderWidgets { - root: header, - top: header_top, - action_row: action_area.row, - title, - subtitle, - count, - search, - actions: action_area.widgets, - } -} +pub(super) use build::build_panel_header; +pub(in crate::ui) use widgets::PanelHeaderWidgets; diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/header.rs b/crates/unixnotis-center/src/ui/panel/header/tests/build.rs similarity index 76% rename from crates/unixnotis-center/src/ui/panel/header/tests/header.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/build.rs index 4f07c481f..89f457ea1 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/build.rs @@ -53,3 +53,19 @@ fn visible_search_configuration_activates_toggle_and_revealer_together() { assert!(header.actions.search_toggle.is_active()); assert!(header.search.revealer.reveals_child()); } + +#[gtk::test] +fn subtitle_visibility_matches_whether_configured_copy_is_present() { + let visible_config = PanelConfig { + subtitle: "Live state".to_string(), + ..PanelConfig::default() + }; + let visible_header = build_panel_header(&visible_config); + assert!(visible_header.subtitle.is_visible()); + + let hidden_header = build_panel_header(&PanelConfig { + subtitle: String::new(), + ..PanelConfig::default() + }); + assert!(!hidden_header.subtitle.is_visible()); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/widgets.rs b/crates/unixnotis-center/src/ui/panel/header/widgets.rs new file mode 100644 index 000000000..ec2187f0a --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/widgets.rs @@ -0,0 +1,17 @@ +//! Widget handles owned by the panel header + +use super::actions::PanelActionWidgets; +use super::search::PanelSearchWidgets; + +pub(in crate::ui) struct PanelHeaderWidgets { + // Structural handles remain grouped so callers do not rebuild child relationships + pub(in crate::ui) root: gtk::Box, + pub(in crate::ui) top: gtk::Box, + pub(in crate::ui) action_row: gtk::Box, + pub(in crate::ui) title: gtk::Label, + pub(in crate::ui) subtitle: gtk::Label, + pub(in crate::ui) count: gtk::Label, + // Feature groups own their internal controls and signal state + pub(in crate::ui) search: PanelSearchWidgets, + pub(in crate::ui) actions: PanelActionWidgets, +} diff --git a/crates/unixnotis-center/src/ui/reload/config.rs b/crates/unixnotis-center/src/ui/reload/config.rs deleted file mode 100644 index 5a0378a3f..000000000 --- a/crates/unixnotis-center/src/ui/reload/config.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Config reload and widget rebuild logic for `UiState` -//! -//! Keeps dynamic configuration changes isolated from event handling and -//! visibility logic - -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; - -use gtk::prelude::*; -use tracing::debug; -use unixnotis_core::{ - css::hooks, Config, ConfigDiagnostic, ConfigError, PanelDebugLevel, PanelWidgetSection, - ThemePaths, -}; -use unixnotis_ui::css::CssReloadReport; - -use super::super::notifications; -use super::super::panel::notification_header_row_visible; -use super::super::widget_builders::{build_extra_widgets, build_quick_controls, clear_container}; -use super::super::{panel, UiState}; -use super::notices::{ReloadNotice, ReloadNoticeFingerprint, ReloadNoticeKind}; - -struct ReloadInputs { - config: Config, - diagnostics: Vec, - theme_paths: ThemePaths, -} - -#[derive(Debug)] -pub(in crate::ui) enum ReloadFailure { - Config(ConfigError), - ThemeBase(String), - ThemePaths(String), -} - -#[derive(Debug)] -pub(in crate::ui) enum ConfigReloadOutcome { - Applied { - diagnostics: Vec, - css: CssReloadReport, - }, - Rejected { - failure: ReloadFailure, - }, -} - -impl UiState { - pub(in crate::ui) fn reload_config(&mut self) -> ConfigReloadOutcome { - self.capture_notice_dismissal(); - let reload = match self.load_reload_inputs() { - Ok(reload) => reload, - Err(failure) => { - // Log only the stable category because parser errors can contain config text - tracing::warn!(kind = failure.kind(), "failed to reload config"); - self.show_config_reload_failure(&failure); - return ConfigReloadOutcome::Rejected { failure }; - } - }; - let widgets_changed = self.config.widgets != reload.config.widgets; - - // Store the new config early so shared helpers see one consistent state - self.config = reload.config.clone(); - debug!("config reloaded"); - - let css = self.apply_reloaded_theme(&reload); - self.apply_reloaded_panel(&reload.config); - // Media depends on panel geometry, so it needs the new width before widgets rebuild - self.apply_media_config(&reload.config); - self.apply_widget_sections_after_reload(&reload.config, widgets_changed); - self.apply_list_config_after_reload(&reload.config); - self.finish_reload_runtime(&reload.config); - // Any accepted config replaces a prior rejection before CSS reports its own result - self.clear_reload_notice(ReloadNoticeKind::Config); - self.apply_css_reload_notice(&css); - ConfigReloadOutcome::Applied { - diagnostics: reload.diagnostics, - css, - } - } - - fn load_reload_inputs(&self) -> Result { - // The accepted report keeps diagnostics tied to the same config object being applied - let report = - Config::load_from_path_with_report(&self.config_path).map_err(ReloadFailure::Config)?; - unixnotis_core::log_config_diagnostics(&report.diagnostics); - let config = report.config; - let theme_base = match Config::config_dir_for_path(&self.config_path) { - Ok(path) => path, - Err(err) => return Err(ReloadFailure::ThemeBase(err.to_string())), - }; - let theme_paths = match config.resolve_theme_paths_from(&theme_base) { - Ok(paths) => paths, - Err(err) => return Err(ReloadFailure::ThemePaths(err.to_string())), - }; - - Ok(ReloadInputs { - config, - diagnostics: report.diagnostics, - theme_paths, - }) - } - - fn apply_reloaded_theme(&mut self, reload: &ReloadInputs) -> CssReloadReport { - self.css - .update_theme(reload.theme_paths.clone(), reload.config.theme.clone()); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - // New theme assets may replace old cache misses, so clear the miss cache now - self.icon_resolver.clear_missing_cache(); - report - } - - pub(in crate::ui) fn reload_css(&mut self) -> CssReloadReport { - self.capture_notice_dismissal(); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - self.apply_css_reload_notice(&report); - report - } - - fn show_config_reload_failure(&mut self, failure: &ReloadFailure) { - let detail = match failure { - ReloadFailure::Config(error) => error.shareable_summary(), - ReloadFailure::ThemeBase(detail) | ReloadFailure::ThemePaths(detail) => detail, - }; - let detail = unixnotis_core::util::sanitize_inline_display_text(detail); - let message = - format!("Config reload rejected\nThe previous configuration is still active\n{detail}"); - let identity = failure.safe_fingerprint(); - self.set_reload_notice(ReloadNoticeKind::Config, &message, true, &identity); - } - - fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { - // Intentional empty files are valid fallback requests and do not produce a notice - let failures = report.read_failures().collect::>(); - if failures.is_empty() { - self.clear_reload_notice(ReloadNoticeKind::Css); - return; - } - let first = failures[0]; - // File names are sufficient for the panel and avoid exposing full account paths - let file = first - .path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("CSS file"); - let suffix = if failures.len() == 1 { - String::new() - } else { - format!(" and {} other layer(s)", failures.len() - 1) - }; - let message = format!( - "Theme fallback active\n{file}{suffix} could not be read; embedded styling is active" - ); - let identity = css_failure_fingerprint(&failures); - self.set_reload_notice(ReloadNoticeKind::Css, &message, false, &identity); - } - - fn set_reload_notice( - &mut self, - kind: ReloadNoticeKind, - message: &str, - error: bool, - identity: &str, - ) { - self.reload_notices.set(ReloadNotice { - fingerprint: ReloadNoticeFingerprint { - kind, - identity: identity.to_string(), - }, - message: message.to_string(), - error, - }); - self.render_reload_notice(); - } - - fn render_reload_notice(&self) { - let Some(notice) = self.reload_notices.visible() else { - self.panel.reload_notice.revealer.set_reveal_child(false); - return; - }; - self.panel.reload_notice.label.set_label(¬ice.message); - self.panel - .reload_notice - .shell - .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_ERROR); - self.panel - .reload_notice - .shell - .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_WARNING); - self.panel - .reload_notice - .shell - .add_css_class(if notice.error { - hooks::panel_shell::RELOAD_NOTICE_ERROR - } else { - hooks::panel_shell::RELOAD_NOTICE_WARNING - }); - self.panel.reload_notice.revealer.set_reveal_child(true); - } - - fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { - self.reload_notices.clear(kind); - self.render_reload_notice(); - } - - fn capture_notice_dismissal(&mut self) { - // The close button hides GTK immediately, then the next event records that dismissal - if !self.panel.reload_notice.revealer.reveals_child() - && self.reload_notices.visible().is_some() - { - self.reload_notices.dismiss_visible(); - } - } - - pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { - // Geometry goes first so later sections can size themselves from the final panel width - panel::apply_panel_config(&self.panel, config, self.work_area); - self.panel.header.title.set_label(&config.panel.title); - self.panel.header.subtitle.set_label(&config.panel.subtitle); - self.panel - .header - .subtitle - .set_visible(!config.panel.subtitle.is_empty()); - self.panel - .header - .search - .entry - .set_placeholder_text(Some(&config.panel.search_placeholder)); - let search_open = - config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); - panel::set_search_open( - &self.panel.header.actions.search_toggle, - &self.panel.header.search.revealer, - &self.panel.header.search.entry, - self.search_toggle_guard.as_ref(), - search_open, - ); - self.panel - .header - .action_row - .set_visible(config.panel.action_row_visible); - panel::apply_reloaded_panel_chrome(&self.panel, &config.panel); - self.panel - .sections - .notification_header - .set_label(&config.panel.recent_notifications_label); - self.panel.sections.notification_header.set_visible( - config.panel.notification_section_visible - && !config.panel.recent_notifications_label.is_empty(), - ); - self.panel - .sections - .notification_header_row - .set_visible(notification_header_row_visible(&config.panel)); - self.update_section_header( - &self.panel.sections.toggle_section_header, - &config.panel.quick_actions_label, - ); - self.update_section_header( - &self.panel.sections.stat_section_header, - &config.panel.system_status_label, - ); - if config.panel.notification_section_visible { - self.panel - .sections - .notification_container - .add_css_class(hooks::panel_shell::RECENT_SECTION); - } else { - self.panel - .sections - .notification_container - .remove_css_class(hooks::panel_shell::RECENT_SECTION); - } - self.panel - .sections - .scroller - .set_vexpand(config.panel.notification_list_expand); - self.panel - .sections - .notification_container - .set_vexpand(config.panel.notification_list_expand); - panel::apply_reloaded_body_order(&self.panel, &config.panel.section_order); - self.apply_widget_order(&config.panel.widget_order); - panel::apply_widget_density( - &self.panel.sections.widget_stack, - &self.panel.sections.quick_controls, - &self.panel.sections.media_container, - config.widgets.density, - ); - self.panel - .sections - .footer - .set_label(&config.panel.footer_label); - self.panel - .sections - .footer - .set_visible(!config.panel.footer_label.is_empty()); - self.log_debug(PanelDebugLevel::Info, || { - "panel config applied after reload".to_string() - }); - } - - fn update_section_header(&self, header: >k::Label, label: &str) { - // Section headers are built once and updated in place on config reload - header.set_label(label); - header.set_visible(!label.is_empty()); - } - - fn apply_widget_order(&self, order: &[PanelWidgetSection]) { - let mut previous: Option = None; - for section in order { - // Config enum values map to the long-lived container built at startup - let child: gtk::Widget = match section { - PanelWidgetSection::Media => self.panel.sections.media_container.clone().upcast(), - PanelWidgetSection::Toggles => { - self.panel.sections.toggle_container.clone().upcast() - } - PanelWidgetSection::Sliders => self.panel.sections.quick_controls.clone().upcast(), - PanelWidgetSection::Stats => self.panel.sections.stat_container.clone().upcast(), - PanelWidgetSection::Cards => self.panel.sections.card_container.clone().upcast(), - }; - self.panel - .sections - .widget_stack - .reorder_child_after(&child, previous.as_ref()); - // The next child is inserted after the child placed in this iteration - previous = Some(child); - } - } - - fn apply_widget_sections_after_reload(&mut self, config: &Config, widgets_changed: bool) { - if widgets_changed { - // Widget rebuilds are the expensive part, so skip them when structure is unchanged - self.apply_widget_config(config); - } else { - debug!("widget config unchanged; skipping rebuild"); - } - } - - pub(in crate::ui) fn apply_list_config_after_reload(&mut self, config: &Config) { - // A compact value object prevents the list from reading half-applied UI state - let list_config = notifications::NotificationListConfig { - max_active: config.history.max_active, - max_entries: config.history.max_entries, - transient_to_history: config.history.transient_to_history, - show_notification_metadata: config.panel.notification_metadata_visible, - show_notification_thumbnails: config.panel.notification_thumbnails_visible, - empty_text: config.panel.empty_text.clone(), - empty_offset_top: config.panel.empty_offset_top, - empty_alignment: config.panel.empty_alignment, - }; - self.list.apply_config(&list_config); - // Empty-state placement depends on both list settings and current widget visibility - self.set_widgets_collapsed(self.widgets_collapsed); - } - - fn finish_reload_runtime(&mut self, config: &Config) { - // Refresh timers may need new intervals even when widget structure is unchanged - self.restart_refresh_timer(); - if config.panel.respect_work_area { - // Clearing the cache prevents stale compositor margins from surviving reload - self.work_area = None; - // Work area is refreshed after reload so compositor margins can update one more time - super::super::hyprland::refresh_reserved_work_area( - config.panel.output.clone(), - self.event_tx.clone(), - ); - } - } - - fn apply_widget_config(&mut self, config: &Config) { - // Old children are cleared first so the rebuild can treat each section as fresh state - clear_container(&self.panel.sections.quick_controls); - let (volume, brightness) = build_quick_controls(&self.panel, config); - self.volume = volume; - self.brightness = brightness; - clear_container(&self.panel.sections.toggle_container); - clear_container(&self.panel.sections.stat_container); - clear_container(&self.panel.sections.card_container); - let (toggles, stats, cards) = - build_extra_widgets(&self.panel, config, &self.widget_icon_resolver); - // Replace all handles together after the containers hold the new children - self.toggles = toggles; - self.stats = stats; - self.cards = cards; - } -} - -impl ReloadFailure { - pub(super) const fn kind(&self) -> &'static str { - match self { - Self::Config(_) => "config", - Self::ThemeBase(_) => "theme-base", - Self::ThemePaths(_) => "theme-paths", - } - } - - pub(super) fn safe_fingerprint(&self) -> String { - // Hash private parser details so distinct failures remain distinguishable without display - let mut hasher = DefaultHasher::new(); - format!("{self:?}").hash(&mut hasher); - format!("{:016x}", hasher.finish()) - } -} - -pub(in crate::ui) fn log_reload_rejection(failure: &ReloadFailure) { - // Raw parser errors can contain complete config lines, commands, labels, and paths - tracing::debug!( - kind = failure.kind(), - fingerprint = %failure.safe_fingerprint(), - "config reload rejected" - ); -} - -fn css_failure_fingerprint(failures: &[&unixnotis_ui::css::CssLayerReload]) -> String { - // The UI message stays compact while the hash distinguishes changed files and read errors - let mut hasher = DefaultHasher::new(); - for failure in failures { - format!("{:?}", failure.layer).hash(&mut hasher); - failure.path.hash(&mut hasher); - failure.error.hash(&mut hasher); - } - format!("{:016x}", hasher.finish()) -} - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/reload/config/flow.rs b/crates/unixnotis-center/src/ui/reload/config/flow.rs new file mode 100644 index 000000000..6bf01f93e --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/flow.rs @@ -0,0 +1,88 @@ +//! Reload input loading and top-level application flow + +use tracing::debug; +use unixnotis_core::{Config, ConfigDiagnostic, ThemePaths}; +use unixnotis_ui::css::CssReloadReport; + +use super::outcome::{ConfigReloadOutcome, ReloadFailure}; +use crate::ui::reload::notices::ReloadNoticeKind; +use crate::ui::UiState; + +struct ReloadInputs { + config: Config, + diagnostics: Vec, + theme_paths: ThemePaths, +} + +impl UiState { + pub(in crate::ui) fn reload_config(&mut self) -> ConfigReloadOutcome { + self.capture_notice_dismissal(); + let reload = match self.load_reload_inputs() { + Ok(reload) => reload, + Err(failure) => { + // Log only the stable category because parser errors can contain config text + tracing::warn!(kind = failure.kind(), "failed to reload config"); + self.show_config_reload_failure(&failure); + return ConfigReloadOutcome::Rejected { failure }; + } + }; + let widgets_changed = self.config.widgets != reload.config.widgets; + + // Store the new config early so shared helpers see one consistent state + self.config = reload.config.clone(); + debug!("config reloaded"); + + let css = self.apply_reloaded_theme(&reload); + self.apply_reloaded_panel(&reload.config); + // Media depends on panel geometry, so it needs the new width before widgets rebuild + self.apply_media_config(&reload.config); + self.apply_widget_sections_after_reload(&reload.config, widgets_changed); + self.apply_list_config_after_reload(&reload.config); + self.finish_reload_runtime(&reload.config); + // Any accepted config replaces a prior rejection before CSS reports its own result + self.clear_reload_notice(ReloadNoticeKind::Config); + self.apply_css_reload_notice(&css); + ConfigReloadOutcome::Applied { + diagnostics: reload.diagnostics, + css, + } + } + + fn load_reload_inputs(&self) -> Result { + // The accepted report keeps diagnostics tied to the same config object being applied + let report = + Config::load_from_path_with_report(&self.config_path).map_err(ReloadFailure::Config)?; + unixnotis_core::log_config_diagnostics(&report.diagnostics); + let config = report.config; + let theme_base = match Config::config_dir_for_path(&self.config_path) { + Ok(path) => path, + Err(err) => return Err(ReloadFailure::ThemeBase(err.to_string())), + }; + let theme_paths = match config.resolve_theme_paths_from(&theme_base) { + Ok(paths) => paths, + Err(err) => return Err(ReloadFailure::ThemePaths(err.to_string())), + }; + + Ok(ReloadInputs { + config, + diagnostics: report.diagnostics, + theme_paths, + }) + } + + fn apply_reloaded_theme(&mut self, reload: &ReloadInputs) -> CssReloadReport { + self.css + .update_theme(reload.theme_paths.clone(), reload.config.theme.clone()); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + // New theme assets may replace old cache misses, so clear the miss cache now + self.icon_resolver.clear_missing_cache(); + report + } + + pub(in crate::ui) fn reload_css(&mut self) -> CssReloadReport { + self.capture_notice_dismissal(); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + self.apply_css_reload_notice(&report); + report + } +} diff --git a/crates/unixnotis-center/src/ui/reload/config/mod.rs b/crates/unixnotis-center/src/ui/reload/config/mod.rs new file mode 100644 index 000000000..86037b148 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/mod.rs @@ -0,0 +1,12 @@ +//! Configuration reload orchestration and application + +mod flow; +mod notice; +mod outcome; +mod panel; +mod widgets; + +pub(in crate::ui) use outcome::{log_reload_rejection, ConfigReloadOutcome}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/reload/config/notice.rs b/crates/unixnotis-center/src/ui/reload/config/notice.rs new file mode 100644 index 000000000..fa1191f63 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/notice.rs @@ -0,0 +1,120 @@ +//! Reload notice rendering and failure priority + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use gtk::prelude::*; +use unixnotis_core::css::hooks; +use unixnotis_ui::css::CssReloadReport; + +use super::outcome::ReloadFailure; +use crate::ui::reload::notices::{ReloadNotice, ReloadNoticeFingerprint, ReloadNoticeKind}; +use crate::ui::UiState; + +impl UiState { + pub(super) fn show_config_reload_failure(&mut self, failure: &ReloadFailure) { + let detail = match failure { + ReloadFailure::Config(error) => error.shareable_summary(), + ReloadFailure::ThemeBase(detail) | ReloadFailure::ThemePaths(detail) => detail, + }; + let detail = unixnotis_core::util::sanitize_inline_display_text(detail); + let message = + format!("Config reload rejected\nThe previous configuration is still active\n{detail}"); + let identity = failure.safe_fingerprint(); + self.set_reload_notice(ReloadNoticeKind::Config, &message, true, &identity); + } + + pub(super) fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { + // Intentional empty files are valid fallback requests and do not produce a notice + let failures = report.read_failures().collect::>(); + if failures.is_empty() { + self.clear_reload_notice(ReloadNoticeKind::Css); + return; + } + let first = failures[0]; + // File names are sufficient for the panel and avoid exposing full account paths + let file = first + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("CSS file"); + let suffix = if failures.len() == 1 { + String::new() + } else { + format!(" and {} other layer(s)", failures.len() - 1) + }; + let message = format!( + "Theme fallback active\n{file}{suffix} could not be read; embedded styling is active" + ); + let identity = css_failure_fingerprint(&failures); + self.set_reload_notice(ReloadNoticeKind::Css, &message, false, &identity); + } + + fn set_reload_notice( + &mut self, + kind: ReloadNoticeKind, + message: &str, + error: bool, + identity: &str, + ) { + self.reload_notices.set(ReloadNotice { + fingerprint: ReloadNoticeFingerprint { + kind, + identity: identity.to_string(), + }, + message: message.to_string(), + error, + }); + self.render_reload_notice(); + } + + fn render_reload_notice(&self) { + let Some(notice) = self.reload_notices.visible() else { + self.panel.reload_notice.revealer.set_reveal_child(false); + return; + }; + self.panel.reload_notice.label.set_label(¬ice.message); + self.panel + .reload_notice + .shell + .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_ERROR); + self.panel + .reload_notice + .shell + .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_WARNING); + self.panel + .reload_notice + .shell + .add_css_class(if notice.error { + hooks::panel_shell::RELOAD_NOTICE_ERROR + } else { + hooks::panel_shell::RELOAD_NOTICE_WARNING + }); + self.panel.reload_notice.revealer.set_reveal_child(true); + } + + pub(super) fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { + self.reload_notices.clear(kind); + self.render_reload_notice(); + } + + pub(super) fn capture_notice_dismissal(&mut self) { + // The close button hides GTK immediately, then the next event records that dismissal + if !self.panel.reload_notice.revealer.reveals_child() + && self.reload_notices.visible().is_some() + { + self.reload_notices.dismiss_visible(); + } + } +} + +fn css_failure_fingerprint(failures: &[&unixnotis_ui::css::CssLayerReload]) -> String { + // The UI message stays compact while the hash distinguishes changed files and read errors + let mut hasher = DefaultHasher::new(); + for failure in failures { + format!("{:?}", failure.layer).hash(&mut hasher); + failure.path.hash(&mut hasher); + failure.error.hash(&mut hasher); + } + format!("{:016x}", hasher.finish()) +} diff --git a/crates/unixnotis-center/src/ui/reload/config/outcome.rs b/crates/unixnotis-center/src/ui/reload/config/outcome.rs new file mode 100644 index 000000000..ae3b156d3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/outcome.rs @@ -0,0 +1,54 @@ +//! Reload outcomes and safe failure diagnostics + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use unixnotis_core::{ConfigDiagnostic, ConfigError}; +use unixnotis_ui::css::CssReloadReport; + +#[derive(Debug)] +pub(in crate::ui) enum ReloadFailure { + // Parser and path stages stay distinct for stable diagnostics + Config(ConfigError), + ThemeBase(String), + ThemePaths(String), +} + +#[derive(Debug)] +pub(in crate::ui) enum ConfigReloadOutcome { + // Successful reloads retain diagnostics and the matching CSS report + Applied { + diagnostics: Vec, + css: CssReloadReport, + }, + // Rejections keep the previous live state and expose only the failure category + Rejected { + failure: ReloadFailure, + }, +} + +impl ReloadFailure { + pub(super) const fn kind(&self) -> &'static str { + match self { + Self::Config(_) => "config", + Self::ThemeBase(_) => "theme-base", + Self::ThemePaths(_) => "theme-paths", + } + } + + pub(super) fn safe_fingerprint(&self) -> String { + // Hash private parser details so distinct failures remain distinguishable without display + let mut hasher = DefaultHasher::new(); + format!("{self:?}").hash(&mut hasher); + format!("{:016x}", hasher.finish()) + } +} + +pub(in crate::ui) fn log_reload_rejection(failure: &ReloadFailure) { + // Raw parser errors can contain complete config lines, commands, labels, and paths + tracing::debug!( + kind = failure.kind(), + fingerprint = %failure.safe_fingerprint(), + "config reload rejected" + ); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs new file mode 100644 index 000000000..6c84e0e32 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -0,0 +1,125 @@ +//! Panel presentation updates applied after a configuration reload + +use gtk::prelude::*; +use unixnotis_core::{css::hooks, Config, PanelDebugLevel, PanelWidgetSection}; + +use crate::ui::panel::notification_header_row_visible; +use crate::ui::{panel, UiState}; + +impl UiState { + pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { + // Geometry goes first so later sections can size themselves from the final panel width + panel::apply_panel_config(&self.panel, config, self.work_area); + self.panel.header.title.set_label(&config.panel.title); + self.panel.header.subtitle.set_label(&config.panel.subtitle); + self.panel + .header + .subtitle + .set_visible(!config.panel.subtitle.is_empty()); + self.panel + .header + .search + .entry + .set_placeholder_text(Some(&config.panel.search_placeholder)); + let search_open = + config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); + panel::set_search_open( + &self.panel.header.actions.search_toggle, + &self.panel.header.search.revealer, + &self.panel.header.search.entry, + self.search_toggle_guard.as_ref(), + search_open, + ); + self.panel + .header + .action_row + .set_visible(config.panel.action_row_visible); + panel::apply_reloaded_panel_chrome(&self.panel, &config.panel); + self.panel + .sections + .notification_header + .set_label(&config.panel.recent_notifications_label); + self.panel.sections.notification_header.set_visible( + config.panel.notification_section_visible + && !config.panel.recent_notifications_label.is_empty(), + ); + self.panel + .sections + .notification_header_row + .set_visible(notification_header_row_visible(&config.panel)); + self.update_section_header( + &self.panel.sections.toggle_section_header, + &config.panel.quick_actions_label, + ); + self.update_section_header( + &self.panel.sections.stat_section_header, + &config.panel.system_status_label, + ); + if config.panel.notification_section_visible { + self.panel + .sections + .notification_container + .add_css_class(hooks::panel_shell::RECENT_SECTION); + } else { + self.panel + .sections + .notification_container + .remove_css_class(hooks::panel_shell::RECENT_SECTION); + } + self.panel + .sections + .scroller + .set_vexpand(config.panel.notification_list_expand); + self.panel + .sections + .notification_container + .set_vexpand(config.panel.notification_list_expand); + panel::apply_reloaded_body_order(&self.panel, &config.panel.section_order); + self.apply_widget_order(&config.panel.widget_order); + panel::apply_widget_density( + &self.panel.sections.widget_stack, + &self.panel.sections.quick_controls, + &self.panel.sections.media_container, + config.widgets.density, + ); + self.panel + .sections + .footer + .set_label(&config.panel.footer_label); + self.panel + .sections + .footer + .set_visible(!config.panel.footer_label.is_empty()); + self.log_debug(PanelDebugLevel::Info, || { + "panel config applied after reload".to_string() + }); + } + + fn update_section_header(&self, header: >k::Label, label: &str) { + // Section headers are built once and updated in place on config reload + header.set_label(label); + header.set_visible(!label.is_empty()); + } + + fn apply_widget_order(&self, order: &[PanelWidgetSection]) { + let mut previous: Option = None; + for section in order { + // Config enum values map to the long-lived container built at startup + let child: gtk::Widget = match section { + PanelWidgetSection::Media => self.panel.sections.media_container.clone().upcast(), + PanelWidgetSection::Toggles => { + self.panel.sections.toggle_container.clone().upcast() + } + PanelWidgetSection::Sliders => self.panel.sections.quick_controls.clone().upcast(), + PanelWidgetSection::Stats => self.panel.sections.stat_container.clone().upcast(), + PanelWidgetSection::Cards => self.panel.sections.card_container.clone().upcast(), + }; + self.panel + .sections + .widget_stack + .reorder_child_after(&child, previous.as_ref()); + // The next child is inserted after the child placed in this iteration + previous = Some(child); + } + } +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs b/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs new file mode 100644 index 000000000..dbc4461e9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs @@ -0,0 +1,82 @@ +use std::fs; + +use gtk::prelude::*; +use unixnotis_core::{EmptyStateAlignment, Margins, ToggleWidgetConfig}; + +use super::super::outcome::ConfigReloadOutcome; +use super::support::{state, write_config}; + +#[gtk::test] +fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { + let mut state = state(); + let mut reloaded = state.config.clone(); + reloaded.panel.title = "Reloaded from disk".to_string(); + reloaded.panel.footer_label = "Ready".to_string(); + reloaded.panel.empty_alignment = EmptyStateAlignment::Auto; + reloaded.panel.empty_offset_top = 44; + reloaded.theme.base_css = "reloaded-base.css".to_string(); + reloaded.widgets.toggles = vec![ToggleWidgetConfig { + enabled: true, + kind: Some("test-toggle".to_string()), + label: "Test Toggle".to_string(), + ..ToggleWidgetConfig::default() + }]; + write_config(&state.config_path, &reloaded); + state.work_area = Some(Margins { + top: 1, + right: 2, + bottom: 3, + left: 4, + }); + + let outcome = state.reload_config(); + + assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); + assert_eq!(state.config.panel.title, "Reloaded from disk"); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert_eq!(state.panel.sections.footer.text(), "Ready"); + assert!(state.panel.sections.footer.get_visible()); + assert!(state.toggles.is_some()); + assert!(state + .panel + .sections + .toggle_container + .first_child() + .is_some()); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Start); + assert_eq!(state.list.empty_overlay.margin_top(), 44); + assert!(state.work_area.is_none()); + assert_eq!( + state.css.theme_paths().base_css, + state + .config_path + .parent() + .expect("config path should have a parent") + .join("reloaded-base.css") + ); + + state.widgets_collapsed = true; + state.apply_list_config_after_reload(&reloaded); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Center); + assert_eq!(state.list.empty_overlay.margin_top(), 0); + + fs::write(&state.config_path, "[panel\ntitle = broken") + .expect("malformed config should be written"); + let outcome = state.reload_config(); + assert!(matches!(outcome, ConfigReloadOutcome::Rejected { .. })); + assert_eq!(state.config.panel.title, "Reloaded from disk"); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state + .panel + .reload_notice + .label + .text() + .contains("previous configuration is still active")); + assert!(!state + .panel + .reload_notice + .label + .text() + .contains("title = broken")); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs b/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs new file mode 100644 index 000000000..56e9484c4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs @@ -0,0 +1,6 @@ +mod flow; +mod notice; +mod outcome; +mod panel; +mod support; +mod widgets; diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs new file mode 100644 index 000000000..7f114cec5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs @@ -0,0 +1,157 @@ +use std::fs; + +use gtk::prelude::*; + +use super::super::outcome::ConfigReloadOutcome; +use super::support::{state, write_config}; + +#[gtk::test] +fn accepted_reload_clears_rejected_config_notice() { + let mut state = state(); + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + let valid = state.config.clone(); + write_config(&state.config_path, &valid); + let theme_paths = valid + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + for path in [ + theme_paths.base_css, + theme_paths.panel_css, + theme_paths.widgets_css, + theme_paths.media_css, + ] { + fs::write(path, "/* intentionally valid */").expect("theme css"); + } + + let outcome = state.reload_config(); + + assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); + assert!(!state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { + let mut state = state(); + fs::write(&state.config_path, "[panel\ntitle = first").expect("first broken config"); + let _outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + let close = state + .panel + .reload_notice + .shell + .last_child() + .expect("reload notice close button") + .downcast::() + .expect("reload notice close widget"); + close.emit_clicked(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let _same_outcome = state.reload_config(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + fs::write(&state.config_path, "config_version = 999").expect("distinct broken config"); + let _distinct_outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn changed_css_failure_reopens_after_the_previous_failure_was_dismissed() { + let mut state = state(); + let first_report = state.reload_css(); + assert!(first_report.read_failures().count() > 1); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + let close = state + .panel + .reload_notice + .shell + .last_child() + .expect("reload notice close button") + .downcast::() + .expect("reload notice close widget"); + close.emit_clicked(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let same_report = state.reload_css(); + assert!(same_report.read_failures().count() > 1); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let theme_paths = state + .config + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + fs::write(theme_paths.base_css, "/* one layer recovered */").expect("base theme css"); + + let changed_report = state.reload_css(); + assert!(changed_report.read_failures().count() > 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn successful_css_only_reload_does_not_clear_config_rejection_notice() { + let mut state = state(); + let theme_paths = state + .config + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + for path in [ + theme_paths.base_css, + theme_paths.panel_css, + theme_paths.widgets_css, + theme_paths.media_css, + ] { + fs::write(path, "/* valid reload css */").expect("theme css"); + } + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + let rejection = state.panel.reload_notice.label.text(); + + let report = state.reload_css(); + + assert_eq!(report.read_failures().count(), 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); +} + +#[gtk::test] +fn css_failure_cannot_replace_an_active_config_rejection() { + let mut state = state(); + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + let rejection = state.panel.reload_notice.label.text(); + + let report = state.reload_css(); + + assert!(report.read_failures().count() > 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); + assert!(state + .panel + .reload_notice + .shell + .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_ERROR)); +} + +#[gtk::test] +fn css_reload_notice_summarizes_multiple_unreadable_layers() { + let mut state = state(); + let report = state.reload_css(); + + assert!(report.read_failures().count() > 1); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state + .panel + .reload_notice + .label + .text() + .contains("other layer")); + assert!(state + .panel + .reload_notice + .shell + .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs b/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs new file mode 100644 index 000000000..27f479836 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs @@ -0,0 +1,60 @@ +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; + +use unixnotis_core::ConfigError; + +use super::super::outcome::{log_reload_rejection, ReloadFailure}; + +struct CapturedWriter(Arc>>); + +impl Write for CapturedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.0 + .lock() + .map_err(|_poisoned| io::Error::other("captured log lock poisoned"))? + .write(buffer) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn reload_failure_kinds_remain_stable_for_structured_logs() { + assert_eq!( + ReloadFailure::Config(ConfigError::MissingHome).kind(), + "config" + ); + assert_eq!( + ReloadFailure::ThemeBase("missing".to_string()).kind(), + "theme-base" + ); + assert_eq!( + ReloadFailure::ThemePaths("invalid".to_string()).kind(), + "theme-paths" + ); +} + +#[test] +fn rejected_config_logs_never_include_private_parser_text() { + let output = Arc::new(Mutex::new(Vec::new())); + let writer_output = Arc::clone(&output); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer(move || CapturedWriter(Arc::clone(&writer_output))) + .finish(); + let failure = ReloadFailure::Config(ConfigError::ParseFailed( + "private-center-parser-sentinel".to_string(), + )); + + tracing::subscriber::with_default(subscriber, || log_reload_rejection(&failure)); + + let rendered = String::from_utf8(output.lock().expect("lock captured center output").clone()) + .expect("center output should be UTF-8"); + assert!(rendered.contains("kind=\"config\"")); + assert!(rendered.contains("fingerprint=")); + assert!(!rendered.contains("private-center-parser-sentinel")); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs new file mode 100644 index 000000000..a5030c5c3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs @@ -0,0 +1,112 @@ +use gtk::prelude::*; +use unixnotis_core::{PanelRequest, WidgetDensity}; + +use super::support::{same_widget, state}; + +#[gtk::test] +fn reloaded_panel_applies_copy_and_widget_density() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.title = "Operations".to_string(); + config.panel.subtitle = "Live state".to_string(); + config.widgets.density = WidgetDensity::Compact; + + state.apply_reloaded_panel(&config); + + assert_eq!(state.panel.header.title.text(), "Operations"); + assert_eq!(state.panel.header.subtitle.text(), "Live state"); + assert!(state.panel.header.subtitle.get_visible()); + assert_eq!(state.panel.sections.widget_stack.spacing(), 6); +} + +#[gtk::test] +fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { + let new_state = state; + let mut state = new_state(); + let mut config = state.config.clone(); + config.panel.subtitle.clear(); + config.panel.search_visible = false; + config.panel.action_row_visible = false; + config.panel.notification_section_visible = true; + config.panel.recent_notifications_label.clear(); + config.panel.quick_actions_label.clear(); + config.panel.system_status_label = "Resources".to_string(); + config.panel.notification_list_expand = false; + config.panel.footer_label.clear(); + config.panel.clear_button_placement = + unixnotis_core::PanelClearButtonPlacement::NotificationHeader; + config.panel.widget_order = vec![ + unixnotis_core::PanelWidgetSection::Cards, + unixnotis_core::PanelWidgetSection::Stats, + unixnotis_core::PanelWidgetSection::Toggles, + unixnotis_core::PanelWidgetSection::Media, + unixnotis_core::PanelWidgetSection::Sliders, + ]; + state.panel.header.actions.search_toggle.set_active(true); + + state.apply_reloaded_panel(&config); + + assert!(!state.panel.header.subtitle.get_visible()); + assert!(state.panel.header.search.revealer.reveals_child()); + assert!(!state.panel.header.action_row.get_visible()); + assert!(!state.panel.sections.notification_header.get_visible()); + assert!(!state.panel.sections.toggle_section_header.get_visible()); + assert_eq!(state.panel.sections.stat_section_header.text(), "Resources"); + assert!(state.panel.sections.stat_section_header.get_visible()); + assert!(state + .panel + .sections + .notification_container + .has_css_class(unixnotis_core::hooks::panel_shell::RECENT_SECTION)); + assert!(!state.panel.sections.scroller.vexpands()); + assert!(!state.panel.sections.notification_container.vexpands()); + assert!(!state.panel.header.actions.clear_button.get_visible()); + assert!(state.panel.sections.clear_header_button.get_visible()); + assert!(!state.panel.sections.footer.get_visible()); + + let first = state + .panel + .sections + .widget_stack + .first_child() + .expect("widget stack should keep configured sections"); + assert!(same_widget(&first, &state.panel.sections.card_container)); + + let mut hidden_state = new_state(); + hidden_state.apply_reloaded_panel(&config); + assert!(!hidden_state.panel.header.actions.search_toggle.is_active()); + assert!(!hidden_state.panel.header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn reload_enables_configured_search_in_toggle_and_revealer() { + let mut state = state(); + let mut config = state.config.clone(); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + + config.panel.search_visible = true; + state.apply_reloaded_panel(&config); + + assert!(state.panel.header.actions.search_toggle.is_active()); + assert!(state.panel.header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn panel_close_and_reopen_keep_transient_search_closed() { + let mut state = state(); + state.apply_panel_request(PanelRequest::open()); + state.panel.header.actions.search_toggle.set_active(true); + state.panel.header.search.entry.set_text("urgent"); + assert!(state.panel.header.search.revealer.reveals_child()); + + state.apply_panel_request(PanelRequest::close()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + assert!(state.panel.header.search.entry.text().is_empty()); + + state.apply_panel_request(PanelRequest::open()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + state.apply_panel_request(PanelRequest::close()); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs new file mode 100644 index 000000000..be706f2d6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs @@ -0,0 +1,66 @@ +use std::fs; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use gtk::prelude::*; +use unixnotis_core::Config; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::ui::{UiState, UiStateInit}; + +static APP_ID: AtomicUsize = AtomicUsize::new(0); + +pub(super) fn state() -> UiState { + let serial = APP_ID.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.config.reload.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + // External widget processes are irrelevant to configuration application tests + config.media.enabled = false; + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let config_dir = std::env::temp_dir().join(format!( + "unixnotis-config-reload-test-{}-{serial}", + std::process::id(), + )); + let config_path = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("test config directory should exist"); + let theme_paths = config + .resolve_theme_paths_from(&config_dir) + .expect("test theme paths should resolve"); + let css = CssManager::new_panel(theme_paths, config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + + UiState::new(UiStateInit { + app, + config, + config_path, + command_tx, + css, + event_tx, + media_handle: None, + runtime, + }) +} + +pub(super) fn same_widget>(left: >k::Widget, right: &W) -> bool { + left == right.as_ref() +} + +pub(super) fn write_config(path: &Path, config: &Config) { + let text = toml::to_string(config).expect("test config should serialize"); + fs::write(path, text).expect("test config should be written"); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs new file mode 100644 index 000000000..66917fef7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs @@ -0,0 +1,19 @@ +use gtk::prelude::*; +use unixnotis_core::EmptyStateAlignment; + +use super::support::state; + +#[gtk::test] +fn reloaded_list_applies_explicit_empty_alignment() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.empty_text = "Nothing pending".to_string(); + config.panel.empty_alignment = EmptyStateAlignment::End; + config.panel.empty_offset_top = 44; + + state.apply_list_config_after_reload(&config); + + assert_eq!(state.list.empty_text, "Nothing pending"); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::End); + assert_eq!(state.list.empty_overlay.margin_top(), 0); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs new file mode 100644 index 000000000..fffef4446 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -0,0 +1,71 @@ +//! Widget, list, and refresh updates applied after configuration reload + +use tracing::debug; +use unixnotis_core::Config; + +use crate::ui::notifications; +use crate::ui::widget_builders::{build_extra_widgets, build_quick_controls, clear_container}; +use crate::ui::UiState; + +impl UiState { + pub(super) fn apply_widget_sections_after_reload( + &mut self, + config: &Config, + widgets_changed: bool, + ) { + if widgets_changed { + // Widget rebuilds are the expensive part, so skip them when structure is unchanged + self.apply_widget_config(config); + } else { + debug!("widget config unchanged; skipping rebuild"); + } + } + + pub(in crate::ui) fn apply_list_config_after_reload(&mut self, config: &Config) { + // A compact value object prevents the list from reading half-applied UI state + let list_config = notifications::NotificationListConfig { + max_active: config.history.max_active, + max_entries: config.history.max_entries, + transient_to_history: config.history.transient_to_history, + show_notification_metadata: config.panel.notification_metadata_visible, + show_notification_thumbnails: config.panel.notification_thumbnails_visible, + empty_text: config.panel.empty_text.clone(), + empty_offset_top: config.panel.empty_offset_top, + empty_alignment: config.panel.empty_alignment, + }; + self.list.apply_config(&list_config); + // Empty-state placement depends on both list settings and current widget visibility + self.set_widgets_collapsed(self.widgets_collapsed); + } + + pub(super) fn finish_reload_runtime(&mut self, config: &Config) { + // Refresh timers may need new intervals even when widget structure is unchanged + self.restart_refresh_timer(); + if config.panel.respect_work_area { + // Clearing the cache prevents stale compositor margins from surviving reload + self.work_area = None; + // Work area is refreshed after reload so compositor margins can update one more time + crate::ui::hyprland::refresh_reserved_work_area( + config.panel.output.clone(), + self.event_tx.clone(), + ); + } + } + + fn apply_widget_config(&mut self, config: &Config) { + // Old children are cleared first so the rebuild can treat each section as fresh state + clear_container(&self.panel.sections.quick_controls); + let (volume, brightness) = build_quick_controls(&self.panel, config); + self.volume = volume; + self.brightness = brightness; + clear_container(&self.panel.sections.toggle_container); + clear_container(&self.panel.sections.stat_container); + clear_container(&self.panel.sections.card_container); + let (toggles, stats, cards) = + build_extra_widgets(&self.panel, config, &self.widget_icon_resolver); + // Replace all handles together after the containers hold the new children + self.toggles = toggles; + self.stats = stats; + self.cards = cards; + } +} diff --git a/crates/unixnotis-center/src/ui/reload/tests/config.rs b/crates/unixnotis-center/src/ui/reload/tests/config.rs deleted file mode 100644 index ba5688fd4..000000000 --- a/crates/unixnotis-center/src/ui/reload/tests/config.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::io::{self, Write}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::{fs, path::Path}; - -use gtk::prelude::*; -use unixnotis_core::{ - Config, ConfigError, EmptyStateAlignment, Margins, PanelRequest, ToggleWidgetConfig, - WidgetDensity, -}; -use unixnotis_ui::css::CssManager; - -use super::super::super::{UiState, UiStateInit}; -use super::{log_reload_rejection, ConfigReloadOutcome, ReloadFailure}; -use crate::control::{UiCommand, UiEvent}; - -static APP_ID: AtomicUsize = AtomicUsize::new(0); - -struct CapturedWriter(Arc>>); - -impl Write for CapturedWriter { - fn write(&mut self, buffer: &[u8]) -> io::Result { - self.0 - .lock() - .map_err(|_poisoned| io::Error::other("captured log lock poisoned"))? - .write(buffer) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -#[test] -fn reload_failure_kinds_remain_stable_for_structured_logs() { - assert_eq!( - ReloadFailure::Config(ConfigError::MissingHome).kind(), - "config" - ); - assert_eq!( - ReloadFailure::ThemeBase("missing".to_string()).kind(), - "theme-base" - ); - assert_eq!( - ReloadFailure::ThemePaths("invalid".to_string()).kind(), - "theme-paths" - ); -} - -#[test] -fn rejected_config_logs_never_include_private_parser_text() { - let output = Arc::new(Mutex::new(Vec::new())); - let writer_output = Arc::clone(&output); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_max_level(tracing::Level::DEBUG) - .with_writer(move || CapturedWriter(Arc::clone(&writer_output))) - .finish(); - let failure = ReloadFailure::Config(ConfigError::ParseFailed( - "private-center-parser-sentinel".to_string(), - )); - - tracing::subscriber::with_default(subscriber, || log_reload_rejection(&failure)); - - let rendered = String::from_utf8(output.lock().expect("lock captured center output").clone()) - .expect("center output should be UTF-8"); - assert!(rendered.contains("kind=\"config\"")); - assert!(rendered.contains("fingerprint=")); - assert!(!rendered.contains("private-center-parser-sentinel")); -} - -fn state() -> UiState { - let serial = APP_ID.fetch_add(1, Ordering::Relaxed); - let app = gtk::Application::builder() - .application_id(format!("dev.unixnotis.config.reload.test{serial}")) - .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) - .build(); - app.register(None::<>k::gio::Cancellable>) - .expect("test application should register"); - - let mut config = Config::default(); - // External widget processes are irrelevant to configuration application tests - config.media.enabled = false; - config.widgets.volume.enabled = false; - config.widgets.brightness.enabled = false; - config.widgets.toggles.clear(); - config.widgets.stats.clear(); - config.widgets.cards.clear(); - - let config_dir = std::env::temp_dir().join(format!( - "unixnotis-config-reload-test-{}-{serial}", - std::process::id(), - )); - let config_path = config_dir.join("config.toml"); - fs::create_dir_all(&config_dir).expect("test config directory should exist"); - let theme_paths = config - .resolve_theme_paths_from(&config_dir) - .expect("test theme paths should resolve"); - let css = CssManager::new_panel(theme_paths, config.theme.clone()); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); - let (event_tx, _event_rx) = async_channel::bounded::(8); - let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); - - UiState::new(UiStateInit { - app, - config, - config_path, - command_tx, - css, - event_tx, - media_handle: None, - runtime, - }) -} - -fn same_widget>(left: >k::Widget, right: &W) -> bool { - left == right.as_ref() -} - -fn write_config(path: &Path, config: &Config) { - let text = toml::to_string(config).expect("test config should serialize"); - fs::write(path, text).expect("test config should be written"); -} - -#[gtk::test] -fn reloaded_panel_applies_copy_and_widget_density() { - let mut state = state(); - let mut config = state.config.clone(); - config.panel.title = "Operations".to_string(); - config.panel.subtitle = "Live state".to_string(); - config.widgets.density = WidgetDensity::Compact; - - state.apply_reloaded_panel(&config); - - assert_eq!(state.panel.header.title.text(), "Operations"); - assert_eq!(state.panel.header.subtitle.text(), "Live state"); - assert!(state.panel.header.subtitle.get_visible()); - assert_eq!(state.panel.sections.widget_stack.spacing(), 6); -} - -#[gtk::test] -fn reloaded_list_applies_explicit_empty_alignment() { - let mut state = state(); - let mut config = state.config.clone(); - config.panel.empty_text = "Nothing pending".to_string(); - config.panel.empty_alignment = EmptyStateAlignment::End; - config.panel.empty_offset_top = 44; - - state.apply_list_config_after_reload(&config); - - assert_eq!(state.list.empty_text, "Nothing pending"); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::End); - assert_eq!(state.list.empty_overlay.margin_top(), 0); -} - -#[gtk::test] -fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { - let new_state = state; - let mut state = new_state(); - let mut config = state.config.clone(); - config.panel.subtitle.clear(); - config.panel.search_visible = false; - config.panel.action_row_visible = false; - config.panel.notification_section_visible = true; - config.panel.recent_notifications_label.clear(); - config.panel.quick_actions_label.clear(); - config.panel.system_status_label = "Resources".to_string(); - config.panel.notification_list_expand = false; - config.panel.footer_label.clear(); - config.panel.clear_button_placement = - unixnotis_core::PanelClearButtonPlacement::NotificationHeader; - config.panel.widget_order = vec![ - unixnotis_core::PanelWidgetSection::Cards, - unixnotis_core::PanelWidgetSection::Stats, - unixnotis_core::PanelWidgetSection::Toggles, - unixnotis_core::PanelWidgetSection::Media, - unixnotis_core::PanelWidgetSection::Sliders, - ]; - state.panel.header.actions.search_toggle.set_active(true); - - state.apply_reloaded_panel(&config); - - assert!(!state.panel.header.subtitle.get_visible()); - assert!(state.panel.header.search.revealer.reveals_child()); - assert!(!state.panel.header.action_row.get_visible()); - assert!(!state.panel.sections.notification_header.get_visible()); - assert!(!state.panel.sections.toggle_section_header.get_visible()); - assert_eq!(state.panel.sections.stat_section_header.text(), "Resources"); - assert!(state.panel.sections.stat_section_header.get_visible()); - assert!(state - .panel - .sections - .notification_container - .has_css_class(unixnotis_core::hooks::panel_shell::RECENT_SECTION)); - assert!(!state.panel.sections.scroller.vexpands()); - assert!(!state.panel.sections.notification_container.vexpands()); - assert!(!state.panel.header.actions.clear_button.get_visible()); - assert!(state.panel.sections.clear_header_button.get_visible()); - assert!(!state.panel.sections.footer.get_visible()); - - let first = state - .panel - .sections - .widget_stack - .first_child() - .expect("widget stack should keep configured sections"); - assert!(same_widget(&first, &state.panel.sections.card_container)); - - let mut hidden_state = new_state(); - hidden_state.apply_reloaded_panel(&config); - assert!(!hidden_state.panel.header.actions.search_toggle.is_active()); - assert!(!hidden_state.panel.header.search.revealer.reveals_child()); -} - -#[gtk::test] -fn reload_enables_configured_search_in_toggle_and_revealer() { - let mut state = state(); - let mut config = state.config.clone(); - assert!(!state.panel.header.actions.search_toggle.is_active()); - assert!(!state.panel.header.search.revealer.reveals_child()); - - config.panel.search_visible = true; - state.apply_reloaded_panel(&config); - - assert!(state.panel.header.actions.search_toggle.is_active()); - assert!(state.panel.header.search.revealer.reveals_child()); -} - -#[gtk::test] -fn panel_close_and_reopen_keep_transient_search_closed() { - let mut state = state(); - state.apply_panel_request(PanelRequest::open()); - state.panel.header.actions.search_toggle.set_active(true); - state.panel.header.search.entry.set_text("urgent"); - assert!(state.panel.header.search.revealer.reveals_child()); - - state.apply_panel_request(PanelRequest::close()); - assert!(!state.panel.header.actions.search_toggle.is_active()); - assert!(!state.panel.header.search.revealer.reveals_child()); - assert!(state.panel.header.search.entry.text().is_empty()); - - state.apply_panel_request(PanelRequest::open()); - assert!(!state.panel.header.actions.search_toggle.is_active()); - assert!(!state.panel.header.search.revealer.reveals_child()); - state.apply_panel_request(PanelRequest::close()); -} - -#[gtk::test] -fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { - let mut state = state(); - let mut reloaded = state.config.clone(); - reloaded.panel.title = "Reloaded from disk".to_string(); - reloaded.panel.footer_label = "Ready".to_string(); - reloaded.panel.empty_alignment = EmptyStateAlignment::Auto; - reloaded.panel.empty_offset_top = 44; - reloaded.theme.base_css = "reloaded-base.css".to_string(); - reloaded.widgets.toggles = vec![ToggleWidgetConfig { - enabled: true, - kind: Some("test-toggle".to_string()), - label: "Test Toggle".to_string(), - ..ToggleWidgetConfig::default() - }]; - write_config(&state.config_path, &reloaded); - state.work_area = Some(Margins { - top: 1, - right: 2, - bottom: 3, - left: 4, - }); - - let outcome = state.reload_config(); - - assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); - - assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); - assert_eq!(state.panel.sections.footer.text(), "Ready"); - assert!(state.panel.sections.footer.get_visible()); - assert!(state.toggles.is_some()); - assert!(state - .panel - .sections - .toggle_container - .first_child() - .is_some()); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Start); - assert_eq!(state.list.empty_overlay.margin_top(), 44); - assert!(state.work_area.is_none()); - assert_eq!( - state.css.theme_paths().base_css, - state - .config_path - .parent() - .expect("config path should have a parent") - .join("reloaded-base.css") - ); - - state.widgets_collapsed = true; - state.apply_list_config_after_reload(&reloaded); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Center); - assert_eq!(state.list.empty_overlay.margin_top(), 0); - - fs::write(&state.config_path, "[panel\ntitle = broken") - .expect("malformed config should be written"); - let outcome = state.reload_config(); - assert!(matches!(outcome, ConfigReloadOutcome::Rejected { .. })); - assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert!(state - .panel - .reload_notice - .label - .text() - .contains("previous configuration is still active")); - assert!(!state - .panel - .reload_notice - .label - .text() - .contains("title = broken")); -} - -#[gtk::test] -fn accepted_reload_clears_rejected_config_notice() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - assert!(state.panel.reload_notice.revealer.reveals_child()); - - let valid = state.config.clone(); - write_config(&state.config_path, &valid); - let theme_paths = valid - .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) - .expect("theme paths"); - for path in [ - theme_paths.base_css, - theme_paths.panel_css, - theme_paths.widgets_css, - theme_paths.media_css, - ] { - fs::write(path, "/* intentionally valid */").expect("theme css"); - } - - let outcome = state.reload_config(); - - assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); - assert!(!state.panel.reload_notice.revealer.reveals_child()); -} - -#[gtk::test] -fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = first").expect("first broken config"); - let _outcome = state.reload_config(); - assert!(state.panel.reload_notice.revealer.reveals_child()); - - let close = state - .panel - .reload_notice - .shell - .last_child() - .expect("reload notice close button") - .downcast::() - .expect("reload notice close widget"); - close.emit_clicked(); - assert!(!state.panel.reload_notice.revealer.reveals_child()); - - let _same_outcome = state.reload_config(); - assert!(!state.panel.reload_notice.revealer.reveals_child()); - - fs::write(&state.config_path, "config_version = 999").expect("distinct broken config"); - let _distinct_outcome = state.reload_config(); - assert!(state.panel.reload_notice.revealer.reveals_child()); -} - -#[gtk::test] -fn successful_css_only_reload_does_not_clear_config_rejection_notice() { - let mut state = state(); - let theme_paths = state - .config - .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) - .expect("theme paths"); - for path in [ - theme_paths.base_css, - theme_paths.panel_css, - theme_paths.widgets_css, - theme_paths.media_css, - ] { - fs::write(path, "/* valid reload css */").expect("theme css"); - } - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice.label.text(); - - let report = state.reload_css(); - - assert_eq!(report.read_failures().count(), 0); - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert_eq!(state.panel.reload_notice.label.text(), rejection); -} - -#[gtk::test] -fn css_failure_cannot_replace_an_active_config_rejection() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice.label.text(); - - let report = state.reload_css(); - - assert!(report.read_failures().count() > 0); - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert_eq!(state.panel.reload_notice.label.text(), rejection); - assert!(state - .panel - .reload_notice - .shell - .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_ERROR)); -} - -#[gtk::test] -fn css_reload_notice_summarizes_multiple_unreadable_layers() { - let mut state = state(); - let report = state.reload_css(); - - assert!(report.read_failures().count() > 1); - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert!(state - .panel - .reload_notice - .label - .text() - .contains("other layer")); - assert!(state - .panel - .reload_notice - .shell - .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); -} From e92dc7e3c60dd1c55d980f2c6738bbade1b0c332 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 19 Jul 2026 23:32:31 -0500 Subject: [PATCH 013/275] feat(ui): customize DND timing and notification chrome Summary: customize DND timing and notification chrome. Scope: ui. --- Cargo.toml | 2 +- .../src/css_check/geometry/stock/classes.rs | 10 + .../css_check/geometry/stock/tests/classes.rs | 4 + .../unixnotis-center/src/ui/init/builders.rs | 2 + .../src/ui/init/constructor.rs | 9 +- .../src/ui/media/widget/parts.rs | 2 +- .../src/ui/notifications/model/item.rs | 32 ++- .../src/ui/notifications/model/tests/item.rs | 5 +- .../src/ui/notifications/model/types.rs | 9 +- .../notifications/row/notification/build.rs | 7 +- .../notifications/row/notification/state.rs | 2 + .../row/notification/tests/support.rs | 4 + .../row/notification/update/metadata.rs | 68 ++++-- .../row/notification/update/tests/metadata.rs | 58 +++++- .../row/notification/update/tests/mod.rs | 4 +- .../row/notification/update/tests/state.rs | 83 +++++++- .../row/notification/update/visual.rs | 2 + .../src/ui/notifications/store/blocks.rs | 2 + .../src/ui/notifications/store/lifecycle.rs | 2 + .../src/ui/notifications/store/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 2 + .../src/ui/notifications/view/build.rs | 23 ++- .../src/ui/notifications/view/tests/build.rs | 19 ++ .../src/ui/panel/behavior/visibility.rs | 4 +- .../src/ui/panel/header/actions.rs | 40 +++- .../src/ui/panel/header/dnd.rs | 195 +++++++++++++----- .../src/ui/panel/header/search.rs | 25 +++ .../src/ui/panel/header/tests/actions.rs | 6 +- .../src/ui/panel/header/tests/dnd.rs | 116 ++++++++--- .../src/ui/panel/header/tests/search.rs | 27 ++- .../src/ui/reload/config/panel.rs | 10 + .../src/ui/reload/config/tests/panel.rs | 32 +++ .../src/ui/reload/config/widgets.rs | 4 + crates/unixnotis-center/src/ui/state.rs | 2 +- .../assets/internal-structure.css | 5 + crates/unixnotis-core/assets/panel.css | 81 ++++++++ .../src/config/appearance/corners.rs | 25 +++ .../src/config/appearance/mod.rs | 1 + .../src/config/appearance/tests/theme.rs | 33 ++- .../src/config/appearance/theme.rs | 6 + crates/unixnotis-core/src/config/mod.rs | 3 +- .../unixnotis-core/src/config/panel/config.rs | 19 +- crates/unixnotis-core/src/config/panel/dnd.rs | 79 +++++++ .../src/config/panel/metadata.rs | 45 ++++ crates/unixnotis-core/src/config/panel/mod.rs | 6 + .../src/config/panel/tests/config.rs | 1 + .../src/config/panel/tests/dnd.rs | 65 ++++++ .../src/config/panel/tests/metadata.rs | 37 ++++ .../src/config/panel/tests/mod.rs | 2 + .../src/config/runtime/sanitize/mod.rs | 2 +- .../src/config/runtime/sanitize/panel.rs | 70 ++++++- .../src/config/runtime/sanitize/pipeline.rs | 3 + .../config/runtime/sanitize/tests/pipeline.rs | 106 +++++++++- .../config/runtime/sanitize/tests/theme.rs | 2 + .../src/config/runtime/sanitize/theme.rs | 22 +- .../unixnotis-core/src/css/hooks/classes.rs | 18 ++ crates/unixnotis-core/src/css/hooks/mod.rs | 5 +- crates/unixnotis-core/src/css/tests/hooks.rs | 15 +- .../unixnotis-core/src/embedded/tests/css.rs | 30 ++- crates/unixnotis-popups/src/ui/entry/build.rs | 5 +- .../src/ui/popups/mutation.rs | 11 +- .../src/ui/popups/visibility.rs | 10 + .../src/ui/state/tests/constructor.rs | 67 +++++- crates/unixnotis-ui/src/bin/css_validate.rs | 2 +- .../unixnotis-ui/src/css/manager/provider.rs | 6 +- .../src/css/manager/tests/provider.rs | 18 ++ .../unixnotis-ui/src/cut_corner/geometry.rs | 59 ++++++ crates/unixnotis-ui/src/cut_corner/mod.rs | 10 + .../src/cut_corner/tests/geometry.rs | 100 +++++++++ .../unixnotis-ui/src/cut_corner/tests/mod.rs | 3 + crates/unixnotis-ui/src/cut_corner/widget.rs | 136 ++++++++++++ crates/unixnotis-ui/src/lib.rs | 3 + crates/unixnotis-ui/tests/cut_corner.rs | 54 +++++ 73 files changed, 1809 insertions(+), 170 deletions(-) create mode 100644 crates/unixnotis-core/src/config/appearance/corners.rs create mode 100644 crates/unixnotis-core/src/config/panel/dnd.rs create mode 100644 crates/unixnotis-core/src/config/panel/metadata.rs create mode 100644 crates/unixnotis-core/src/config/panel/tests/dnd.rs create mode 100644 crates/unixnotis-core/src/config/panel/tests/metadata.rs create mode 100644 crates/unixnotis-ui/src/css/manager/tests/provider.rs create mode 100644 crates/unixnotis-ui/src/cut_corner/geometry.rs create mode 100644 crates/unixnotis-ui/src/cut_corner/mod.rs create mode 100644 crates/unixnotis-ui/src/cut_corner/tests/geometry.rs create mode 100644 crates/unixnotis-ui/src/cut_corner/tests/mod.rs create mode 100644 crates/unixnotis-ui/src/cut_corner/widget.rs create mode 100644 crates/unixnotis-ui/tests/cut_corner.rs diff --git a/Cargo.toml b/Cargo.toml index 73178ef11..60b7c2626 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ gio = "0.21" gdk-pixbuf = "0.21" gdk4-wayland = { version = "0.10.3", features = ["v4_18"] } glib = "0.21" -gtk = { package = "gtk4", version = "0.10" } +gtk = { package = "gtk4", version = "0.10", features = ["v4_18"] } gtk4-layer-shell = "0.7.1" indexmap = "2" libc = "0.2" diff --git a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs index 2a1a0eda7..f1a8a3899 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs @@ -68,6 +68,13 @@ fn insert_hook_class(classes: &mut HashSet, class_name: &str) { const fn hook_unixnotis_classes() -> &'static [&'static str] { // Hook-only classes can be real live selectors before the stock theme gives them rules &[ + hooks::cut_corner::ROOT, + hooks::dnd_menu::ROOT, + hooks::dnd_menu::CONTENT, + hooks::dnd_menu::TITLE, + hooks::dnd_menu::CHOICE, + hooks::dnd_menu::INDEFINITE, + hooks::dnd_menu::SEPARATOR, hooks::panel_action::ROW, hooks::panel_action::GROUP, hooks::panel_action::ROOT, @@ -83,6 +90,9 @@ const fn hook_unixnotis_classes() -> &'static [&'static str] { hooks::panel_action::ICON_ONLY, hooks::panel_action::LABEL_HIDDEN, hooks::panel_shell::SUBTITLE, + hooks::panel_shell::SEARCH_MAGNIFIER, + hooks::panel_shell::SEARCH_CLEAR, + hooks::panel_shell::SEARCH_OWNED_ICONS, hooks::panel_shell::SEARCH_SHELL, hooks::panel_shell::SEARCH_ACCENT, hooks::panel_shell::SEARCH_STAR, diff --git a/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs index 6ab0a54db..4a39ad6c6 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs @@ -35,7 +35,11 @@ fn decorative_theme_hooks_are_treated_as_known_public_classes() { assert!(classes.contains(".unixnotis-panel-edge-top")); assert!(classes.contains(".unixnotis-panel-rail-left")); + assert!(classes.contains(".unixnotis-cut-corner")); assert!(classes.contains(".unixnotis-panel-search-shell")); + assert!(classes.contains(".unixnotis-panel-search-magnifier")); + assert!(classes.contains(".unixnotis-panel-search-clear")); + assert!(classes.contains(".unixnotis-panel-search-owned-icons")); assert!(classes.contains(".unixnotis-quick-slider-segments")); assert!(classes.contains(".unixnotis-info-media")); assert!(classes.contains(".unixnotis-info-card-banner")); diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index cc2fb0866..ad2d08b14 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -16,6 +16,8 @@ pub(super) fn build_notification_list( max_entries: init.config.history.max_entries, transient_to_history: init.config.history.transient_to_history, show_notification_metadata: init.config.panel.notification_metadata_visible, + notification_metadata: init.config.panel.notification_metadata.clone(), + notification_corners: init.config.theme.notification_corners, show_notification_thumbnails: init.config.panel.notification_thumbnails_visible, empty_text: init.config.panel.empty_text.clone(), empty_offset_top: init.config.panel.empty_offset_top, diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index d456720fc..b1bbddd21 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -36,8 +36,11 @@ impl UiState { list.set_empty_layout(has_visible_widget_section(&panel)); panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); - let dnd_duration_menu = - panel::connect_dnd_menu(&panel.header.actions.dnd_toggle, init.command_tx.clone()); + let dnd_duration_menu = panel::connect_dnd_menu( + &panel.header.actions.dnd_toggle, + &init.config.panel, + init.command_tx.clone(), + ); panel::connect_clear_button(&panel.header.actions.clear_button, init.command_tx.clone()); panel::connect_clear_button(&panel.sections.clear_header_button, init.command_tx.clone()); panel::connect_close_button(&panel, init.command_tx.clone()); @@ -68,7 +71,7 @@ impl UiState { config: init.config, config_path: init.config_path, css: init.css, - _dnd_duration_menu: dnd_duration_menu, + dnd_duration_menu, panel, list, icon_resolver, diff --git a/crates/unixnotis-center/src/ui/media/widget/parts.rs b/crates/unixnotis-center/src/ui/media/widget/parts.rs index 5fe54f00d..a79fc8570 100644 --- a/crates/unixnotis-center/src/ui/media/widget/parts.rs +++ b/crates/unixnotis-center/src/ui/media/widget/parts.rs @@ -135,7 +135,7 @@ fn build_art_picture(art_size_px: i32) -> gtk::Picture { art.add_css_class(hooks::media_shell::ART); art.set_can_shrink(true); art.set_size_request(art_size_px, art_size_px); - art.set_keep_aspect_ratio(true); + art.set_content_fit(gtk::ContentFit::Contain); art.set_hexpand(false); art.set_vexpand(false); art.set_halign(Align::Center); diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 8de32ec0c..2e757c710 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -7,7 +7,7 @@ use std::sync::OnceLock; use glib::subclass::prelude::*; use gtk::glib; use gtk::glib::object::ObjectExt; -use unixnotis_core::NotificationView; +use unixnotis_core::{CutCorners, NotificationMetadataConfig, NotificationView}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowKind { @@ -15,15 +15,43 @@ pub enum RowKind { Notification, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct RowPresentation { // Local receipt timestamp supports relative badges without changing D-Bus payloads pub received_at_ms: i64, // Optional lanes are disabled by default to preserve the compact stock card pub show_metadata: bool, pub show_thumbnail: bool, + // Shared config avoids cloning every metadata string into every row snapshot + pub metadata: Rc, + // Card clipping follows theme reloads through the same row refresh path + pub card_corners: CutCorners, } +impl Default for RowPresentation { + fn default() -> Self { + Self { + received_at_ms: 0, + show_metadata: false, + show_thumbnail: false, + metadata: Rc::new(NotificationMetadataConfig::default()), + card_corners: CutCorners::default(), + } + } +} + +impl PartialEq for RowPresentation { + fn eq(&self, other: &Self) -> bool { + self.received_at_ms == other.received_at_ms + && self.show_metadata == other.show_metadata + && self.show_thumbnail == other.show_thumbnail + && Rc::ptr_eq(&self.metadata, &other.metadata) + && self.card_corners == other.card_corners + } +} + +impl Eq for RowPresentation {} + #[derive(Debug, Clone)] pub struct RowData { pub kind: RowKind, diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index f6fafd16c..b4badf526 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -41,6 +41,7 @@ fn row_data_notification_sets_expected_fields() { received_at_ms: 123, show_metadata: true, show_thumbnail: true, + ..RowPresentation::default() }; let data = RowData::notification( @@ -50,7 +51,7 @@ fn row_data_notification_sets_expected_fields() { 2, false, true, - presentation, + presentation.clone(), ); assert_eq!(data.kind, RowKind::Notification); @@ -111,6 +112,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { received_at_ms: 12, show_metadata: true, show_thumbnail: false, + ..RowPresentation::default() }, ); @@ -165,6 +167,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { received_at_ms: 12, show_metadata: true, show_thumbnail: false, + ..RowPresentation::default() }, ) .is_equivalent(&changed)); diff --git a/crates/unixnotis-center/src/ui/notifications/model/types.rs b/crates/unixnotis-center/src/ui/notifications/model/types.rs index 8e997f83d..9f4cdcc17 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/types.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/types.rs @@ -6,8 +6,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use gtk::glib; -use unixnotis_core::EmptyStateAlignment; -use unixnotis_core::NotificationView; +use unixnotis_core::{ + CutCorners, EmptyStateAlignment, NotificationMetadataConfig, NotificationView, +}; use super::item::RowItem; @@ -47,6 +48,8 @@ pub struct NotificationList { pub(in crate::ui::notifications) transient_to_history: bool, // Optional metadata lanes stay config-owned so the stock row remains compact pub(in crate::ui::notifications) show_notification_metadata: bool, + pub(in crate::ui::notifications) notification_metadata: Rc, + pub(in crate::ui::notifications) notification_corners: CutCorners, pub(in crate::ui::notifications) show_notification_thumbnails: bool, pub(in crate::ui::notifications) max_active: usize, pub(in crate::ui::notifications) max_entries: usize, @@ -58,6 +61,8 @@ pub struct NotificationListConfig { pub max_entries: usize, pub transient_to_history: bool, pub show_notification_metadata: bool, + pub notification_metadata: NotificationMetadataConfig, + pub notification_corners: CutCorners, pub show_notification_thumbnails: bool, pub empty_text: String, pub empty_offset_top: i32, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 23c848a2a..531e61b49 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -10,6 +10,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; use unixnotis_core::css::hooks; +use unixnotis_ui::CutCorner; use crate::control::UiCommand; use crate::ui::try_send_command; @@ -165,6 +166,9 @@ pub(in crate::ui::notifications) fn build_notification_row( card.append(&actions_box); card.append(&inline_reply.revealer); + // The wrapper clips the complete styled card while the inner box keeps all CSS hooks + let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); + let stack_ghost_1 = build_stack_ghost(1); let stack_ghost_2 = build_stack_ghost(2); @@ -173,7 +177,7 @@ pub(in crate::ui::notifications) fn build_notification_row( match layer { StackLayer::Back => root.append(&stack_ghost_2), StackLayer::Middle => root.append(&stack_ghost_1), - StackLayer::Foreground => root.append(&card), + StackLayer::Foreground => root.append(&card_plate), } } @@ -198,6 +202,7 @@ pub(in crate::ui::notifications) fn build_notification_row( root, NotificationRowWidgets { card, + card_plate, stack_ghost_1, stack_ghost_2, icon, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 43362a084..340aef1c6 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -13,6 +13,8 @@ use super::reply::InlineReplyWidgets; pub(in crate::ui::notifications) struct NotificationRowWidgets { // Styled notification card inside the ListView row wrapper pub(super) card: gtk::Box, + // Polygon wrapper clips both visual output and pointer hit testing + pub(super) card_plate: unixnotis_ui::CutCorner, // Internal stack depth cards keep collapsed stacks in the same row update pub(super) stack_ghost_1: gtk::Box, pub(super) stack_ghost_2: gtk::Box, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 84616b86f..0bb09b113 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -39,6 +39,8 @@ pub(super) struct RowFlags { pub(super) stack_depth: u8, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, + pub(super) metadata: Option, + pub(super) card_corners: unixnotis_core::CutCorners, } pub(super) fn row_data(notification: Rc, flags: RowFlags) -> RowData { @@ -53,6 +55,8 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R received_at_ms: current_millis(), show_metadata: flags.show_metadata, show_thumbnail: flags.show_thumbnail, + metadata: Rc::new(flags.metadata.unwrap_or_default()), + card_corners: flags.card_corners, }, ) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index 395e54c2b..409be3848 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -1,9 +1,8 @@ //! Notification metadata labels and relative timestamps -use std::borrow::Cow; use std::time::{SystemTime, UNIX_EPOCH}; -use unixnotis_core::{NotificationView, Urgency}; +use unixnotis_core::{NotificationMetadataConfig, NotificationView, Urgency}; use super::super::super::super::item::RowData; use super::super::state::NotificationRowWidgets; @@ -28,48 +27,57 @@ pub(super) fn update_metadata_labels( return; } - // Urgency uses short stable labels that remain useful across themes - let meta = notification_meta_label(notification); - set_label_visible_if_changed(&row.meta_label, true); - set_label_text_if_changed(&row.meta_label, &meta); + // Urgency copy comes from one config block so themes can rename every lane together + let metadata = data.presentation.metadata.as_ref(); + let meta = notification_meta_label(notification, metadata); + set_label_visible_if_changed(&row.meta_label, !meta.is_empty()); + set_label_text_if_changed(&row.meta_label, meta); // Missing or invalid timestamps hide the badge instead of showing stale text - let time_badge = relative_time_badge(data.presentation.received_at_ms); + let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); set_label_text_if_changed(&row.time_badge, &time_badge); // The left footer distinguishes live cards from retained history at a glance let footer_left = if notification.is_transient { - "TRANSIENT" + metadata.transient_label.as_str() } else if data.is_active { - "LIVE" + metadata.live_label.as_str() } else { - "HISTORY" + metadata.history_label.as_str() }; - set_label_visible_if_changed(&row.footer_left, true); + set_label_visible_if_changed(&row.footer_left, !footer_left.is_empty()); set_label_text_if_changed(&row.footer_left, footer_left); // Hidden reply actions are excluded from the displayed action count let action_count = visible_action_count(notification, data.is_active); let footer_right = if action_count == 0 { - Cow::Borrowed("") + String::new() + } else if action_count == 1 { + render_template(&metadata.action_count_one, "{count}", action_count) } else { - Cow::Owned(format!("{action_count} ACTIONS")) + render_template(&metadata.action_count_many, "{count}", action_count) }; set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); } -pub(super) fn notification_meta_label(notification: &NotificationView) -> String { +pub(super) fn notification_meta_label<'a>( + notification: &NotificationView, + metadata: &'a NotificationMetadataConfig, +) -> &'a str { // Unknown urgency values retain the normal notice presentation match notification.urgency { - value if value == Urgency::Critical as u8 => "ALERT".to_string(), - value if value == Urgency::Low as u8 => "LOW".to_string(), - _ => "NOTICE".to_string(), + value if value == Urgency::Critical as u8 => metadata.critical_label.as_str(), + value if value == Urgency::Low as u8 => metadata.low_label.as_str(), + _ => metadata.normal_label.as_str(), } } -pub(super) fn relative_time_badge(received_at_ms: i64) -> String { +pub(super) fn relative_time_badge( + received_at_ms: i64, + metadata: &NotificationMetadataConfig, +) -> String { if received_at_ms <= 0 { return String::new(); } @@ -77,18 +85,34 @@ pub(super) fn relative_time_badge(received_at_ms: i64) -> String { let Some(now_ms) = now_millis() else { return String::new(); }; + relative_time_badge_at(received_at_ms, now_ms, metadata) +} + +pub(super) fn relative_time_badge_at( + received_at_ms: i64, + now_ms: u128, + metadata: &NotificationMetadataConfig, +) -> String { + if received_at_ms <= 0 { + return String::new(); + } // Saturation handles timestamps that are slightly ahead of the local clock let age_ms = now_ms.saturating_sub(received_at_ms.max(0) as u128); let age_secs = age_ms / 1_000; // Compact units keep the metadata lane from changing card width match age_secs { - 0..=59 => "now".to_string(), - 60..=3_599 => format!("{}m", age_secs / 60), - 3_600..=86_399 => format!("{}h", age_secs / 3_600), - _ => format!("{}d", age_secs / 86_400), + 0..=59 => metadata.relative_now.clone(), + 60..=3_599 => render_template(&metadata.relative_minutes, "{value}", age_secs / 60), + 3_600..=86_399 => render_template(&metadata.relative_hours, "{value}", age_secs / 3_600), + _ => render_template(&metadata.relative_days, "{value}", age_secs / 86_400), } } +fn render_template(template: &str, token: &str, value: impl std::fmt::Display) -> String { + // Missing tokens are allowed so a theme can use fixed copy for a whole bucket + template.replace(token, &value.to_string()) +} + fn now_millis() -> Option { // Systems with an invalid pre-epoch clock omit relative time safely SystemTime::now() diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs index 1898ca7df..c58b715c9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs @@ -1,39 +1,75 @@ //! Metadata and relative-time rules for notification rows -use unixnotis_core::Urgency; +use unixnotis_core::{NotificationMetadataConfig, Urgency}; use super::super::super::test_support::{current_millis, sample_notification}; -use super::{notification_meta_label, relative_time_badge}; +use super::{notification_meta_label, relative_time_badge, relative_time_badge_at}; #[test] fn notification_metadata_falls_back_to_urgency_label() { let mut notification = sample_notification(); notification.urgency = Urgency::Critical as u8; + let metadata = NotificationMetadataConfig::default(); - assert_eq!(notification_meta_label(¬ification), "ALERT"); + assert_eq!(notification_meta_label(¬ification, &metadata), "ALERT"); } #[test] fn notification_metadata_labels_cover_low_and_normal_urgency() { let mut notification = sample_notification(); + let metadata = NotificationMetadataConfig::default(); notification.urgency = Urgency::Low as u8; - assert_eq!(notification_meta_label(¬ification), "LOW"); + assert_eq!(notification_meta_label(¬ification, &metadata), "LOW"); notification.urgency = Urgency::Normal as u8; - assert_eq!(notification_meta_label(¬ification), "NOTICE"); + assert_eq!(notification_meta_label(¬ification, &metadata), "NOTICE"); } #[test] fn empty_timestamp_hides_relative_time_badge() { - assert!(relative_time_badge(0).is_empty()); + assert!(relative_time_badge(0, &NotificationMetadataConfig::default()).is_empty()); } #[test] fn relative_time_badge_formats_minutes_hours_and_days() { - let now = current_millis(); + let now = u128::try_from(current_millis()).expect("current time should be positive"); + let metadata = NotificationMetadataConfig::default(); - assert_eq!(relative_time_badge(now - 30_000), "now"); - assert_eq!(relative_time_badge(now - 5 * 60_000), "5m"); - assert_eq!(relative_time_badge(now - 2 * 3_600_000), "2h"); - assert_eq!(relative_time_badge(now - 3 * 86_400_000), "3d"); + assert_eq!( + relative_time_badge_at((now - 30_000) as i64, now, &metadata), + "now" + ); + assert_eq!( + relative_time_badge_at((now - 5 * 60_000) as i64, now, &metadata), + "5m" + ); + assert_eq!( + relative_time_badge_at((now - 2 * 3_600_000) as i64, now, &metadata), + "2h" + ); + assert_eq!( + relative_time_badge_at((now - 3 * 86_400_000) as i64, now, &metadata), + "3d" + ); +} + +#[test] +fn custom_metadata_text_and_templates_replace_runtime_strings() { + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + let metadata = NotificationMetadataConfig { + critical_label: "PRIORITY".to_string(), + relative_hours: "{value} HOURS AGO".to_string(), + ..NotificationMetadataConfig::default() + }; + + assert_eq!( + notification_meta_label(¬ification, &metadata), + "PRIORITY" + ); + assert_eq!(relative_time_badge_at(0, 0, &metadata), ""); + assert_eq!( + relative_time_badge_at(1, 7_200_001, &metadata), + "2 HOURS AGO" + ); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs index 92fec462b..b73bcac91 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -9,7 +9,9 @@ mod thumbnail; pub(super) use super::actions::{clamp_action_label_text, visible_action_count}; pub(super) use super::labels::optional_label_state; -pub(super) use super::metadata::{notification_meta_label, relative_time_badge}; +pub(super) use super::metadata::{ + notification_meta_label, relative_time_badge, relative_time_badge_at, +}; pub(super) use super::row::update_notification_row; pub(super) use super::thumbnail::notification_has_thumbnail; pub(super) use super::visual::{stack_ghost_visibility, StackGhostVisibility}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index e5dcd9140..75e78f8aa 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::{hooks, Action, Urgency}; +use unixnotis_core::{hooks, Action, CutCorners, NotificationMetadataConfig, Urgency}; use crate::ui::icons::IconResolver; @@ -73,7 +73,51 @@ fn update_notification_row_shows_metadata_lanes_and_footer_state() { assert!(row.time_badge.get_visible()); assert_eq!(row.footer_left.text().as_str(), "TRANSIENT"); assert!(row.footer_right.get_visible()); - assert_eq!(row.footer_right.text().as_str(), "1 ACTIONS"); + assert_eq!(row.footer_right.text().as_str(), "1 ACTION"); +} + +#[gtk::test] +fn update_notification_row_applies_custom_metadata_and_corner_geometry() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "save".to_string(), + label: "Save".to_string(), + }, + ]; + let corners = CutCorners { + top_left: 18, + bottom_right: 12, + ..CutCorners::default() + }; + let metadata = NotificationMetadataConfig { + normal_label: "INFO".to_string(), + history_label: "ARCHIVE".to_string(), + action_count_many: "{count} OPTIONS".to_string(), + ..NotificationMetadataConfig::default() + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_metadata: true, + metadata: Some(metadata), + card_corners: corners, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.meta_label.text().as_str(), "INFO"); + assert_eq!(row.footer_left.text().as_str(), "ARCHIVE"); + assert_eq!(row.footer_right.text().as_str(), "2 OPTIONS"); + assert_eq!(row.card_plate.corners(), corners); } #[gtk::test] @@ -87,3 +131,38 @@ fn update_notification_row_marks_an_empty_action_set_as_unavailable() { assert!(!row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); assert!(row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); } + +#[gtk::test] +fn update_notification_row_hides_metadata_labels_with_empty_custom_copy() { + let (_root, row) = notification_row(); + let metadata = NotificationMetadataConfig { + critical_label: String::new(), + low_label: String::new(), + normal_label: String::new(), + relative_now: String::new(), + relative_minutes: String::new(), + relative_hours: String::new(), + relative_days: String::new(), + transient_label: String::new(), + live_label: String::new(), + history_label: String::new(), + action_count_one: String::new(), + action_count_many: String::new(), + }; + let data = row_data( + Rc::new(sample_notification()), + RowFlags { + show_metadata: true, + metadata: Some(metadata), + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.meta_label.get_visible()); + assert!(!row.time_badge.get_visible()); + assert!(!row.footer_left.get_visible()); + assert!(!row.footer_right.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 281064f51..1afc617eb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -29,6 +29,8 @@ pub(super) fn apply_visual_state( has_thumbnail: bool, ) { let card = &row.card; + // Theme changes update recycled rows without rebuilding the GTK child tree + row.card_plate.set_corners(data.presentation.card_corners); // Explicit state updates prevent recycled rows from retaining stale classes set_class_state( card, diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index d92fdf630..60c978fb0 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -56,6 +56,8 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; entry.item.update(RowData::notification( entry.app_key.clone(), diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 4236a164e..4a58bbb15 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -94,6 +94,8 @@ impl NotificationList { received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; let item = RowItem::new(RowData::notification( app_key.clone(), diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 03271ebd6..f06e9413c 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -88,6 +88,8 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; // Update the row object in-place when the visible span stays identical entry.item.update(super::item::RowData::notification( diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 00bc79dc3..2f37886eb 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -23,6 +23,8 @@ pub(super) fn list_config() -> NotificationListConfig { max_entries: 10, transient_to_history: true, show_notification_metadata: false, + notification_metadata: unixnotis_core::NotificationMetadataConfig::default(), + notification_corners: unixnotis_core::CutCorners::default(), show_notification_thumbnails: false, empty_text: "No notifications".to_string(), empty_offset_top: 24, diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index 61a7581a6..0489a06ad 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -53,7 +53,10 @@ impl NotificationList { let command_tx_clone = command_tx.clone(); let event_tx_clone = event_tx.clone(); - factory.connect_setup(move |_, gtk_item| { + factory.connect_setup(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; let widgets = RowWidgets::new( RowKind::Notification, command_tx_clone.clone(), @@ -65,7 +68,10 @@ impl NotificationList { let command_tx_clone = command_tx; let event_tx_clone = event_tx; let icon_resolver_clone = icon_resolver; - factory.connect_bind(move |_, gtk_item| { + factory.connect_bind(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; let Some(row_item) = gtk_item.item().and_downcast::() else { return; }; @@ -80,7 +86,10 @@ impl NotificationList { bind_row(widgets, &row_item, &data, icon_resolver_clone.clone()); }); - factory.connect_unbind(move |_, gtk_item| { + factory.connect_unbind(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; if let Some(widgets) = get_row_widgets(gtk_item) { widgets.unbind(); } @@ -116,6 +125,8 @@ impl NotificationList { filter_query: None, transient_to_history: config.transient_to_history, show_notification_metadata: config.show_notification_metadata, + notification_metadata: Rc::new(config.notification_metadata), + notification_corners: config.notification_corners, show_notification_thumbnails: config.show_notification_thumbnails, max_active: config.max_active, max_entries: config.max_entries, @@ -127,8 +138,14 @@ impl NotificationList { self.transient_to_history = config.transient_to_history; let presentation_changed = self.show_notification_metadata != config.show_notification_metadata + || self.notification_metadata.as_ref() != &config.notification_metadata + || self.notification_corners != config.notification_corners || self.show_notification_thumbnails != config.show_notification_thumbnails; self.show_notification_metadata = config.show_notification_metadata; + if self.notification_metadata.as_ref() != &config.notification_metadata { + self.notification_metadata = Rc::new(config.notification_metadata.clone()); + } + self.notification_corners = config.notification_corners; self.show_notification_thumbnails = config.show_notification_thumbnails; if self.empty_text != config.empty_text { update_empty_row(&self.empty_overlay, &config.empty_text); diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index 75dae9934..f7a91ac0f 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -61,6 +61,25 @@ fn apply_config_requests_rebuild_when_metadata_or_thumbnail_flags_change() { assert!(list.needs_rebuild()); } +#[gtk::test] +fn apply_config_requests_rebuild_when_metadata_text_or_corner_geometry_changes() { + let mut list = support::make_list(); + let mut config = support::list_config(); + config.notification_metadata.live_label = "CURRENT".to_string(); + + list.apply_config(&config); + + assert_eq!(list.notification_metadata.live_label, "CURRENT"); + assert!(list.needs_rebuild()); + + list.needs_rebuild = false; + config.notification_corners.top_right = 16; + list.apply_config(&config); + + assert_eq!(list.notification_corners.top_right, 16); + assert!(list.needs_rebuild()); +} + #[gtk::test] fn set_empty_layout_switches_between_widget_offset_and_centered_empty_state() { let list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index cc976755e..65a5edabd 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -104,8 +104,8 @@ impl UiState { // This avoids leaving plugin-backed stats on the n/a placeholder until a later tick self.refresh_widgets(true); self.start_refresh_timer(); - let width = self.panel.window.allocated_width(); - let height = self.panel.window.allocated_height(); + let width = self.panel.window.width(); + let height = self.panel.window.height(); let message = format!("panel allocated size: {width}x{height}"); self.log_debug(PanelDebugLevel::Verbose, move || message); } else { diff --git a/crates/unixnotis-center/src/ui/panel/header/actions.rs b/crates/unixnotis-center/src/ui/panel/header/actions.rs index de209be1e..dfd03f354 100644 --- a/crates/unixnotis-center/src/ui/panel/header/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/header/actions.rs @@ -7,7 +7,8 @@ use std::time::Duration; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{ - css::hooks, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelConfig, + css::hooks, DndMenuTrigger, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, + PanelConfig, }; use crate::control::UiCommand; @@ -44,7 +45,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { let focus_toggle = build_toggle_action(hooks::panel_action::FOCUS, &config.focus_action); let dnd_toggle = build_toggle_action(hooks::panel_action::PRIMARY, &config.dnd_action); - set_dnd_duration_tooltip(&dnd_toggle, &config.dnd_action.tooltip); + set_dnd_duration_tooltip(&dnd_toggle, config); let dnd_status = gtk::Label::new(None); dnd_status.add_css_class(hooks::panel_action::LABEL); dnd_status.set_visible(false); @@ -111,7 +112,7 @@ pub(in crate::ui::panel) fn apply_panel_action_config( hooks::panel_action::PRIMARY, &config.dnd_action, ); - set_dnd_duration_tooltip(&widgets.dnd_toggle, &config.dnd_action.tooltip); + set_dnd_duration_tooltip(&widgets.dnd_toggle, config); update_action_button( &widgets.clear_button, hooks::panel_action::MUTED, @@ -168,11 +169,36 @@ fn build_toggle_action(role_class: &str, config: &PanelActionConfig) -> gtk::Tog button } -fn set_dnd_duration_tooltip(button: >k::ToggleButton, base: &str) { - // Keep custom copy while making the hidden context interaction discoverable - let duration_hint = "Right-click, long-press, or press Shift+F10 for a duration"; +fn set_dnd_duration_tooltip(button: >k::ToggleButton, config: &PanelConfig) { + // Keep custom copy while documenting only the input paths that are actually active + let mut hints = Vec::new(); + if !config.dnd_menu_choices.is_empty() { + if config + .dnd_menu_triggers + .contains(&DndMenuTrigger::RightClick) + { + hints.push("right-click"); + } + if config + .dnd_menu_triggers + .contains(&DndMenuTrigger::LongPress) + { + hints.push("long-press"); + } + if config.dnd_menu_triggers.contains(&DndMenuTrigger::Keyboard) { + hints.push("Shift+F10"); + } + } + let duration_hint = if hints.is_empty() { + String::new() + } else { + format!("{} for a duration", hints.join(", ")) + }; + let base = &config.dnd_action.tooltip; let tooltip = if base.is_empty() { - duration_hint.to_string() + duration_hint + } else if duration_hint.is_empty() { + base.clone() } else { format!("{base}\n{duration_hint}") }; diff --git a/crates/unixnotis-center/src/ui/panel/header/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/dnd.rs index 7c2c6e960..a0a27ac8e 100644 --- a/crates/unixnotis-center/src/ui/panel/header/dnd.rs +++ b/crates/unixnotis-center/src/ui/panel/header/dnd.rs @@ -6,14 +6,11 @@ use std::time::Duration; use chrono::{Days, Local, NaiveDate, NaiveTime, TimeZone, Utc}; use gtk::prelude::*; +use unixnotis_core::{css::hooks, DndMenuChoice, DndMenuTrigger, PanelConfig}; use crate::control::UiCommand; use crate::ui::try_send_command; -const MORNING_HOUR: u32 = 8; -const DND_DURATION_CHOICES: [(&str, i64); 3] = - [("30 minutes", 1_800), ("1 hour", 3_600), ("2 hours", 7_200)]; - // Context-menu keys use one small decision type so GTK behavior stays explicit #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DndMenuKeyAction { @@ -29,6 +26,10 @@ pub(in crate::ui) struct DndCountdown { pub(in crate::ui) struct DndDurationMenu { // A manually parented popover needs an explicit owner to detach it before panel teardown popover: gtk::Popover, + secondary_click: gtk::GestureClick, + long_press: gtk::GestureLongPress, + key_controller: gtk::EventControllerKey, + command_tx: tokio::sync::mpsc::Sender, } impl Drop for DndDurationMenu { @@ -60,63 +61,152 @@ impl Drop for DndCountdown { pub(in crate::ui) fn connect_dnd_menu( dnd_toggle: >k::ToggleButton, + config: &PanelConfig, command_tx: tokio::sync::mpsc::Sender, ) -> DndDurationMenu { // The DND toggle owns this popover without adding a separate arrow button let popover = gtk::Popover::new(); + popover.add_css_class(hooks::dnd_menu::ROOT); + // A flat edge aligns with the action row without GTK's detached-looking arrow notch + popover.set_has_arrow(false); popover.set_autohide(true); - let choices = gtk::Box::new(gtk::Orientation::Vertical, 2); - - // Common relative choices share one absolute-deadline command path - for (label, seconds) in DND_DURATION_CHOICES { - let button = gtk::Button::with_label(label); - let tx = command_tx.clone(); - let menu = popover.downgrade(); - button.connect_clicked(move |_| { - // Saturation keeps an abnormal system clock from wrapping the deadline - let expires_at = Utc::now().timestamp().saturating_add(seconds); - try_send_command(&tx, UiCommand::SetDndUntil(expires_at)); - if let Some(menu) = menu.upgrade() { - menu.popdown(); - } - }); - choices.append(&button); + popover.set_parent(dnd_toggle); + let (secondary_click, long_press, key_controller) = + connect_dnd_menu_inputs(dnd_toggle, &popover); + let menu = DndDurationMenu { + popover, + secondary_click, + long_press, + key_controller, + command_tx, + }; + menu.apply_config(config); + menu +} + +impl DndDurationMenu { + pub(in crate::ui) fn apply_config(&self, config: &PanelConfig) { + self.popover.popdown(); + self.popover.set_child(Some(&build_choice_box( + &config.dnd_menu_choices, + &self.command_tx, + &self.popover, + ))); + + // Installed controllers can be disabled safely without replacing GTK ownership + let has_choices = !config.dnd_menu_choices.is_empty(); + set_controller_enabled( + &self.secondary_click, + has_choices + && config + .dnd_menu_triggers + .contains(&DndMenuTrigger::RightClick), + ); + set_controller_enabled( + &self.long_press, + has_choices + && config + .dnd_menu_triggers + .contains(&DndMenuTrigger::LongPress), + ); + set_controller_enabled( + &self.key_controller, + has_choices && config.dnd_menu_triggers.contains(&DndMenuTrigger::Keyboard), + ); } +} + +fn build_choice_box( + choices: &[DndMenuChoice], + command_tx: &tokio::sync::mpsc::Sender, + popover: >k::Popover, +) -> gtk::Box { + let container = gtk::Box::new(gtk::Orientation::Vertical, 0); + container.add_css_class(hooks::dnd_menu::CONTENT); - // Morning follows the next calendar day rather than a fixed 24-hour duration - let morning = gtk::Button::with_label("Until tomorrow morning"); - let morning_tx = command_tx.clone(); - let morning_menu = popover.downgrade(); - morning.connect_clicked(move |_| { - if let Some(expires_at) = next_morning_deadline() { - try_send_command(&morning_tx, UiCommand::SetDndUntil(expires_at)); - } else { - tracing::warn!("could not resolve the next local 08:00 DND deadline"); + // A small heading explains the time choices without repeating DND state + let title = gtk::Label::new(Some("Pause notifications")); + title.set_xalign(0.0); + title.add_css_class(hooks::dnd_menu::TITLE); + container.append(&title); + + for choice in choices { + if matches!(choice, DndMenuChoice::Indefinite { .. }) { + // A real separator stays crisp without borrowing a button border + let separator = gtk::Separator::new(gtk::Orientation::Horizontal); + separator.add_css_class(hooks::dnd_menu::SEPARATOR); + container.append(&separator); } - if let Some(menu) = morning_menu.upgrade() { - menu.popdown(); + // Left-aligned rows scan faster than a stack of centered default buttons + let button = gtk::Button::with_label(choice.label()); + if let Some(label) = button.child().and_downcast::() { + label.set_xalign(0.0); + label.set_hexpand(true); } - }); - choices.append(&morning); - - // Indefinite enablement deliberately replaces any existing timed deadline - let indefinite = gtk::Button::with_label("Indefinitely"); - let indefinite_menu = popover.downgrade(); - indefinite.connect_clicked(move |_| { - try_send_command(&command_tx, UiCommand::SetDnd(true)); - if let Some(menu) = indefinite_menu.upgrade() { + button.add_css_class(hooks::dnd_menu::CHOICE); + if matches!(choice, DndMenuChoice::Indefinite { .. }) { + // Indefinite mode is separated because it has no automatic resume time + button.add_css_class(hooks::dnd_menu::INDEFINITE); + } + connect_choice_button(&button, choice, command_tx, popover); + container.append(&button); + } + container +} + +fn connect_choice_button( + button: >k::Button, + choice: &DndMenuChoice, + command_tx: &tokio::sync::mpsc::Sender, + popover: >k::Popover, +) { + let choice = choice.clone(); + let command_tx = command_tx.clone(); + let menu = popover.downgrade(); + button.connect_clicked(move |_| { + match choice { + DndMenuChoice::Duration { minutes, .. } => { + // Sanitized minute values still use saturation at the clock boundary + let seconds = i64::from(minutes).saturating_mul(60); + let expires_at = Utc::now().timestamp().saturating_add(seconds); + try_send_command(&command_tx, UiCommand::SetDndUntil(expires_at)); + } + DndMenuChoice::Tomorrow { hour, minute, .. } => { + if let Some(expires_at) = next_day_deadline(u32::from(hour), u32::from(minute)) { + try_send_command(&command_tx, UiCommand::SetDndUntil(expires_at)); + } else { + // Config values stay out of logs because the stable failure category is enough + tracing::warn!("could not resolve configured next-day DND deadline"); + } + } + DndMenuChoice::Indefinite { .. } => { + // Indefinite enablement deliberately replaces any timed deadline + try_send_command(&command_tx, UiCommand::SetDnd(true)); + } + } + if let Some(menu) = menu.upgrade() { menu.popdown(); } }); - choices.append(&indefinite); +} - popover.set_child(Some(&choices)); - popover.set_parent(dnd_toggle); - connect_dnd_menu_inputs(dnd_toggle, &popover); - DndDurationMenu { popover } +fn set_controller_enabled(controller: &impl IsA, enabled: bool) { + let phase = if enabled { + gtk::PropagationPhase::Bubble + } else { + gtk::PropagationPhase::None + }; + controller.set_propagation_phase(phase); } -fn connect_dnd_menu_inputs(dnd_toggle: >k::ToggleButton, popover: >k::Popover) { +fn connect_dnd_menu_inputs( + dnd_toggle: >k::ToggleButton, + popover: >k::Popover, +) -> ( + gtk::GestureClick, + gtk::GestureLongPress, + gtk::EventControllerKey, +) { let secondary_click = gtk::GestureClick::new(); // Secondary click keeps the primary click dedicated to immediate toggling secondary_click.set_button(3); @@ -127,7 +217,7 @@ fn connect_dnd_menu_inputs(dnd_toggle: >k::ToggleButton, popover: >k::Popove menu.popup(); } }); - dnd_toggle.add_controller(secondary_click); + dnd_toggle.add_controller(secondary_click.clone()); let long_press = gtk::GestureLongPress::new(); let press_menu = popover.downgrade(); @@ -137,7 +227,7 @@ fn connect_dnd_menu_inputs(dnd_toggle: >k::ToggleButton, popover: >k::Popove menu.popup(); } }); - dnd_toggle.add_controller(long_press); + dnd_toggle.add_controller(long_press.clone()); let key_controller = gtk::EventControllerKey::new(); let key_menu = popover.downgrade(); @@ -153,7 +243,8 @@ fn connect_dnd_menu_inputs(dnd_toggle: >k::ToggleButton, popover: >k::Popove DndMenuKeyAction::Ignore => gtk::glib::Propagation::Proceed, } }); - dnd_toggle.add_controller(key_controller); + dnd_toggle.add_controller(key_controller.clone()); + (secondary_click, long_press, key_controller) } fn dnd_menu_key_action(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> DndMenuKeyAction { @@ -220,12 +311,12 @@ fn format_dnd_remaining(expires_at: i64, now: i64) -> String { } } -fn next_morning_deadline() -> Option { +fn next_day_deadline(hour: u32, minute: u32) -> Option { let now = Local::now(); // Construct the local clock value separately from the next calendar date - let morning = NaiveTime::from_hms_opt(MORNING_HOUR, 0, 0)?; + let local_time = NaiveTime::from_hms_opt(hour, minute, 0)?; let date = tomorrow_date(now.date_naive())?; - match Local.from_local_datetime(&date.and_time(morning)) { + match Local.from_local_datetime(&date.and_time(local_time)) { chrono::LocalResult::Single(value) => Some(value.timestamp()), // The earliest occurrence is sufficient because the whole date is in the future chrono::LocalResult::Ambiguous(first, _) => Some(first.timestamp()), diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs index e62be9a07..d7410e016 100644 --- a/crates/unixnotis-center/src/ui/panel/header/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -18,6 +18,8 @@ const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; pub(in crate::ui) struct PanelSearchWidgets { pub(in crate::ui) revealer: gtk::Revealer, pub(in crate::ui) entry: gtk::SearchEntry, + pub(in crate::ui) magnifier: gtk::Image, + pub(in crate::ui) clear_button: gtk::Button, } pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { @@ -32,14 +34,35 @@ pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { let star_accent = gtk::Label::new(Some("*")); star_accent.add_css_class(hooks::panel_shell::SEARCH_STAR); + let magnifier = gtk::Image::from_icon_name(&config.search_magnifier_icon); + magnifier.add_css_class(hooks::panel_shell::SEARCH_MAGNIFIER); + magnifier.set_accessible_role(gtk::AccessibleRole::Presentation); + let search_entry = gtk::SearchEntry::new(); search_entry.add_css_class(hooks::panel_shell::SEARCH); + // Native icons have no public child or dedicated CSS node, so owned siblings replace them + search_entry.add_css_class(hooks::panel_shell::SEARCH_OWNED_ICONS); // Placeholder text keeps the intent obvious before the first query search_entry.set_placeholder_text(Some(&config.search_placeholder)); search_entry.set_hexpand(true); search_entry.set_tooltip_text(Some("Type to filter notifications")); + + let clear_button = gtk::Button::from_icon_name("edit-clear-symbolic"); + clear_button.add_css_class(hooks::panel_shell::SEARCH_CLEAR); + clear_button.set_tooltip_text(Some("Clear search")); + clear_button.set_visible(false); + let clear_entry = search_entry.clone(); + clear_button.connect_clicked(move |_| clear_entry.set_text("")); + let visible_clear = clear_button.clone(); + search_entry.connect_changed(move |entry| { + // The clear action exists only while a query can be removed + visible_clear.set_visible(!entry.text().is_empty()); + }); + search_shell.append(&leading_accent); + search_shell.append(&magnifier); search_shell.append(&search_entry); + search_shell.append(&clear_button); search_shell.append(&star_accent); let search_revealer = gtk::Revealer::new(); @@ -54,6 +77,8 @@ pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { PanelSearchWidgets { revealer: search_revealer, entry: search_entry, + magnifier, + clear_button, } } diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs index 19c0660ed..083268b8d 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs @@ -111,5 +111,9 @@ fn dnd_duration_menu_does_not_add_a_standalone_arrow_button() { .widgets .dnd_toggle .tooltip_text() - .is_some_and(|text| text.contains("Right-click"))); + .is_some_and(|text| { + text.contains("right-click") + && !text.contains("long-press") + && !text.contains("Shift+F10") + })); } diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs index cd0ef0689..39bf3c4ee 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs @@ -6,9 +6,10 @@ use gtk::prelude::*; use super::{ connect_dnd_menu, countdown_control_flow, dnd_menu_key_action, format_dnd_remaining, - tomorrow_date, update_dnd_status, DndCountdown, DndMenuKeyAction, DND_DURATION_CHOICES, + next_day_deadline, tomorrow_date, update_dnd_status, DndCountdown, DndMenuKeyAction, }; use crate::control::UiCommand; +use unixnotis_core::{DndMenuChoice, DndMenuTrigger, PanelConfig}; #[test] fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { @@ -28,10 +29,12 @@ fn remaining_time_keeps_hours_compact_without_losing_partial_hour() { } #[test] -fn morning_choice_uses_the_next_local_eight_oclock() { +fn next_day_choice_uses_the_next_local_calendar_date() { let today = NaiveDate::from_ymd_opt(2026, 7, 18).expect("valid date"); assert_eq!(tomorrow_date(today), NaiveDate::from_ymd_opt(2026, 7, 19)); + assert!(next_day_deadline(24, 0).is_none()); + assert!(next_day_deadline(8, 60).is_none()); } #[test] @@ -71,14 +74,20 @@ fn duration_menu_accepts_standard_keyboard_context_actions_only() { } #[gtk::test] -fn connected_duration_menu_installs_every_input_path_and_choice() { +fn default_duration_menu_enables_only_right_click_and_keeps_stock_choices() { let toggle = gtk::ToggleButton::new(); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8); + let config = PanelConfig::default(); - let _menu_owner = connect_dnd_menu(&toggle, command_tx); + let menu_owner = connect_dnd_menu(&toggle, &config, command_tx); let popover = attached_popover(&toggle); assert!(popover.is_autohide()); + assert!(!popover.has_arrow()); + assert!(popover.has_css_class("unixnotis-dnd-menu")); + assert!(popover + .child() + .is_some_and(|child| child.has_css_class("unixnotis-dnd-menu-content"))); assert_eq!( menu_buttons(&popover) .iter() @@ -92,31 +101,70 @@ fn connected_duration_menu_installs_every_input_path_and_choice() { "Indefinitely", ] ); + assert!(menu_buttons(&popover) + .iter() + .all(|button| button.has_css_class("unixnotis-dnd-menu-choice"))); + assert!(!menu_buttons(&popover)[3].has_css_class("unixnotis-dnd-menu-choice-indefinite")); + assert!(menu_buttons(&popover)[4].has_css_class("unixnotis-dnd-menu-choice-indefinite")); + assert!(menu_separator(&popover).has_css_class("unixnotis-dnd-menu-separator")); - let controllers = toggle.observe_controllers(); - let mut has_secondary_click = false; - let mut has_long_press = false; - let mut has_key_controller = false; - for index in 0..controllers.n_items() { - let controller = controllers - .item(index) - .expect("observed controller should remain available"); - if let Ok(click) = controller.clone().downcast::() { - has_secondary_click |= click.button() == 3; - } - has_long_press |= controller.is::(); - has_key_controller |= controller.is::(); - } - assert!(has_secondary_click); - assert!(has_long_press); - assert!(has_key_controller); + assert_eq!(menu_owner.secondary_click.button(), 3); + assert_eq!( + menu_owner.secondary_click.propagation_phase(), + gtk::PropagationPhase::Bubble + ); + assert_eq!( + menu_owner.long_press.propagation_phase(), + gtk::PropagationPhase::None + ); + assert_eq!( + menu_owner.key_controller.propagation_phase(), + gtk::PropagationPhase::None + ); +} + +#[gtk::test] +fn duration_menu_applies_custom_inputs_and_choices_without_reconnecting() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8); + let menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); + let config = PanelConfig { + dnd_menu_triggers: vec![DndMenuTrigger::LongPress, DndMenuTrigger::Keyboard], + dnd_menu_choices: vec![DndMenuChoice::Duration { + label: "Focus block".to_string(), + minutes: 45, + }], + ..PanelConfig::default() + }; + + menu_owner.apply_config(&config); + + assert_eq!( + menu_buttons(&menu_owner.popover) + .iter() + .filter_map(gtk::Button::label) + .collect::>(), + vec!["Focus block"] + ); + assert_eq!( + menu_owner.secondary_click.propagation_phase(), + gtk::PropagationPhase::None + ); + assert_eq!( + menu_owner.long_press.propagation_phase(), + gtk::PropagationPhase::Bubble + ); + assert_eq!( + menu_owner.key_controller.propagation_phase(), + gtk::PropagationPhase::Bubble + ); } #[gtk::test] fn dropping_duration_menu_owner_detaches_the_manually_parented_popover() { let toggle = gtk::ToggleButton::new(); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); - let menu_owner = connect_dnd_menu(&toggle, command_tx); + let menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); let popover = menu_owner.popover.clone(); assert_eq!( @@ -132,10 +180,11 @@ fn dropping_duration_menu_owner_detaches_the_manually_parented_popover() { fn duration_menu_buttons_send_their_exact_deadlines_and_indefinite_state() { let toggle = gtk::ToggleButton::new(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8); - let _menu_owner = connect_dnd_menu(&toggle, command_tx); + let _menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); let buttons = menu_buttons(&attached_popover(&toggle)); - for ((_, seconds), button) in DND_DURATION_CHOICES.iter().zip(&buttons[..3]) { + for (minutes, button) in [30_i64, 60, 120].iter().zip(&buttons[..3]) { + let seconds = minutes.saturating_mul(60); let before = Utc::now().timestamp(); button.emit_clicked(); let after = Utc::now().timestamp(); @@ -145,8 +194,8 @@ fn duration_menu_buttons_send_their_exact_deadlines_and_indefinite_state() { else { panic!("expected timed DND command"); }; - assert!(expires_at >= before.saturating_add(*seconds)); - assert!(expires_at <= after.saturating_add(*seconds)); + assert!(expires_at >= before.saturating_add(seconds)); + assert!(expires_at <= after.saturating_add(seconds)); } let before_morning = Local::now(); @@ -238,3 +287,18 @@ fn menu_buttons(popover: >k::Popover) -> Vec { } buttons } + +fn menu_separator(popover: >k::Popover) -> gtk::Separator { + let choices = popover + .child() + .and_then(|child| child.downcast::().ok()) + .expect("DND popover should contain its choice box"); + let mut child = choices.first_child(); + while let Some(widget) = child { + if let Ok(separator) = widget.clone().downcast::() { + return separator; + } + child = widget.next_sibling(); + } + panic!("DND popover should separate the indefinite choice"); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search.rs index 632bbad85..f48b475e0 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search.rs @@ -1,4 +1,5 @@ -use unixnotis_core::PanelConfig; +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelConfig}; use super::{build_panel_search, SEARCH_REVEAL_TRANSITION_MS}; @@ -7,6 +8,7 @@ fn search_widget_applies_visibility_copy_and_transition_policy() { let config = PanelConfig { search_visible: true, search_placeholder: "Find alerts".to_string(), + search_magnifier_icon: "edit-find-symbolic".to_string(), ..PanelConfig::default() }; @@ -21,4 +23,27 @@ fn search_widget_applies_visibility_copy_and_transition_policy() { search.entry.placeholder_text().as_deref(), Some("Find alerts") ); + assert!(search + .magnifier + .has_css_class(hooks::panel_shell::SEARCH_MAGNIFIER)); + assert_eq!( + search.magnifier.icon_name().as_deref(), + Some("edit-find-symbolic") + ); + assert!(search + .entry + .has_css_class(hooks::panel_shell::SEARCH_OWNED_ICONS)); + assert!(!search.clear_button.get_visible()); +} + +#[gtk::test] +fn search_clear_action_tracks_and_removes_the_current_query() { + let search = build_panel_search(&PanelConfig::default()); + + search.entry.set_text("urgent"); + assert!(search.clear_button.get_visible()); + + search.clear_button.emit_clicked(); + assert!(search.entry.text().is_empty()); + assert!(!search.clear_button.get_visible()); } diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs index 6c84e0e32..6cccfae1a 100644 --- a/crates/unixnotis-center/src/ui/reload/config/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -21,6 +21,16 @@ impl UiState { .search .entry .set_placeholder_text(Some(&config.panel.search_placeholder)); + self.panel + .header + .search + .magnifier + .set_icon_name(Some(&config.panel.search_magnifier_icon)); + self.panel + .header + .search + .clear_button + .set_visible(!self.panel.header.search.entry.text().is_empty()); let search_open = config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); panel::set_search_open( diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs index a5030c5c3..4a973d655 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs @@ -92,6 +92,38 @@ fn reload_enables_configured_search_in_toggle_and_revealer() { assert!(state.panel.header.search.revealer.reveals_child()); } +#[gtk::test] +fn panel_reload_updates_search_icons_and_clear_visibility_from_live_text() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.search_visible = true; + config.panel.search_placeholder = "Filter alerts".to_string(); + config.panel.search_magnifier_icon = "system-search-symbolic".to_string(); + state.panel.header.search.entry.set_text("disk"); + + state.apply_reloaded_panel(&config); + + assert_eq!( + state + .panel + .header + .search + .entry + .placeholder_text() + .as_deref(), + Some("Filter alerts") + ); + assert_eq!( + state.panel.header.search.magnifier.icon_name().as_deref(), + Some("system-search-symbolic") + ); + assert!(state.panel.header.search.clear_button.get_visible()); + + state.panel.header.search.entry.set_text(""); + state.apply_reloaded_panel(&config); + assert!(!state.panel.header.search.clear_button.get_visible()); +} + #[gtk::test] fn panel_close_and_reopen_keep_transient_search_closed() { let mut state = state(); diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs index fffef4446..cb3f98f14 100644 --- a/crates/unixnotis-center/src/ui/reload/config/widgets.rs +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -22,12 +22,16 @@ impl UiState { } pub(in crate::ui) fn apply_list_config_after_reload(&mut self, config: &Config) { + // Menu inputs and typed deadlines are live configuration like the surrounding actions + self.dnd_duration_menu.apply_config(&config.panel); // A compact value object prevents the list from reading half-applied UI state let list_config = notifications::NotificationListConfig { max_active: config.history.max_active, max_entries: config.history.max_entries, transient_to_history: config.history.transient_to_history, show_notification_metadata: config.panel.notification_metadata_visible, + notification_metadata: config.panel.notification_metadata.clone(), + notification_corners: config.theme.notification_corners, show_notification_thumbnails: config.panel.notification_thumbnails_visible, empty_text: config.panel.empty_text.clone(), empty_offset_top: config.panel.empty_offset_top, diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 8b662446b..e7d0644c2 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -20,7 +20,7 @@ pub struct UiState { pub(super) config_path: std::path::PathBuf, pub(super) css: CssManager, // This owner must drop before the panel so its manually parented popover can detach - pub(super) _dnd_duration_menu: panel::DndDurationMenu, + pub(super) dnd_duration_menu: panel::DndDurationMenu, pub(super) panel: panel::PanelWidgets, pub(super) list: notifications::NotificationList, // Shared resolver keeps icon cache and inflight decode tracking centralized diff --git a/crates/unixnotis-core/assets/internal-structure.css b/crates/unixnotis-core/assets/internal-structure.css index 71539b756..509919d61 100644 --- a/crates/unixnotis-core/assets/internal-structure.css +++ b/crates/unixnotis-core/assets/internal-structure.css @@ -16,3 +16,8 @@ min-height: 28px; padding: 0; } + +/* GtkSearchEntry has no public icon child, so the native glyphs yield to owned controls */ +.unixnotis-panel-search-owned-icons { + -gtk-icon-source: none; +} diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 405b53b44..66290d2c5 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -201,6 +201,75 @@ border-color: alpha(@unixnotis-accent, 0.58); } +/* The DND menu is a compact action list rather than a stack of stock buttons */ +.unixnotis-dnd-menu > contents { + padding: 8px; + border-radius: 12px; + border: 1px solid alpha(#ffffff, 0.08); + background-color: @unixnotis-surface-base; + background-image: none; + box-shadow: + 0 18px 38px -20px @unixnotis-shadow-strong, + inset 0 1px alpha(#ffffff, 0.025); +} + +.unixnotis-dnd-menu-content { + min-width: 216px; + border-spacing: 2px; +} + +.unixnotis-dnd-menu-title { + margin: 5px 9px 7px; + color: @unixnotis-muted; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice { + min-height: 32px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 9px; + color: @unixnotis-text; + background-color: transparent; + background-image: none; + box-shadow: none; + text-shadow: none; + transition: background-color 0.1s ease-out, border-color 0.1s ease-out; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice label { + font-size: 12px; + font-weight: 500; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:hover, +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:focus-visible { + color: @unixnotis-text; + border-color: alpha(#ffffff, 0.07); + background-color: @unixnotis-card-base; + background-image: none; + box-shadow: none; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:active { + border-color: alpha(#ffffff, 0.10); + background-color: @unixnotis-surface-strong-base; + background-image: none; + box-shadow: none; +} + +.unixnotis-dnd-menu-choice-indefinite { + color: @unixnotis-text; +} + +.unixnotis-dnd-menu-separator { + margin: 4px 9px 3px; + min-height: 1px; + background-color: alpha(#ffffff, 0.08); +} + .unixnotis-panel-search-revealer { margin-top: 2px; } @@ -220,6 +289,18 @@ transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } +.unixnotis-panel-search-magnifier { + min-width: 16px; + min-height: 16px; + color: @unixnotis-muted; +} + +.unixnotis-panel-search-clear { + min-width: 24px; + min-height: 24px; + padding: 0; +} + .unixnotis-panel-search:focus-within { border-color: alpha(@unixnotis-accent, 0.60); box-shadow: diff --git a/crates/unixnotis-core/src/config/appearance/corners.rs b/crates/unixnotis-core/src/config/appearance/corners.rs new file mode 100644 index 000000000..754850b64 --- /dev/null +++ b/crates/unixnotis-core/src/config/appearance/corners.rs @@ -0,0 +1,25 @@ +//! Angled corner geometry shared by notification surfaces + +use serde::{Deserialize, Serialize}; + +/// Pixel cuts applied to the four corners of a rendered plate +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct CutCorners { + /// Diagonal cut measured from the top-left corner + pub top_left: u16, + /// Diagonal cut measured from the top-right corner + pub top_right: u16, + /// Diagonal cut measured from the bottom-right corner + pub bottom_right: u16, + /// Diagonal cut measured from the bottom-left corner + pub bottom_left: u16, +} + +impl CutCorners { + /// Return true when at least one corner needs path clipping + #[must_use] + pub const fn is_active(self) -> bool { + self.top_left != 0 || self.top_right != 0 || self.bottom_right != 0 || self.bottom_left != 0 + } +} diff --git a/crates/unixnotis-core/src/config/appearance/mod.rs b/crates/unixnotis-core/src/config/appearance/mod.rs index 2fd44deb7..cc55b7674 100644 --- a/crates/unixnotis-core/src/config/appearance/mod.rs +++ b/crates/unixnotis-core/src/config/appearance/mod.rs @@ -1,4 +1,5 @@ //! Theme values and safely resolved icon assets +pub(in crate::config) mod corners; pub(in crate::config) mod icon_assets; pub(in crate::config) mod theme; diff --git a/crates/unixnotis-core/src/config/appearance/tests/theme.rs b/crates/unixnotis-core/src/config/appearance/tests/theme.rs index da0991ae6..066e5f6b5 100644 --- a/crates/unixnotis-core/src/config/appearance/tests/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/tests/theme.rs @@ -1,4 +1,4 @@ -use super::ThemeConfig; +use super::{CutCorners, ThemeConfig}; #[test] fn default_theme_opacity_values_stay_within_css_alpha_bounds() { @@ -14,3 +14,34 @@ fn default_theme_opacity_values_stay_within_css_alpha_bounds() { assert!((0.0..=1.0).contains(&alpha)); } } + +#[test] +fn default_theme_keeps_notification_corner_clipping_disabled() { + let theme = ThemeConfig::default(); + + assert!(!theme.notification_corners.is_active()); +} + +#[test] +fn every_individual_cut_corner_enables_clipping() { + for corners in [ + CutCorners { + top_left: 1, + ..CutCorners::default() + }, + CutCorners { + top_right: 1, + ..CutCorners::default() + }, + CutCorners { + bottom_right: 1, + ..CutCorners::default() + }, + CutCorners { + bottom_left: 1, + ..CutCorners::default() + }, + ] { + assert!(corners.is_active()); + } +} diff --git a/crates/unixnotis-core/src/config/appearance/theme.rs b/crates/unixnotis-core/src/config/appearance/theme.rs index 93283dfd7..08bd8c47a 100644 --- a/crates/unixnotis-core/src/config/appearance/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/theme.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use super::corners::CutCorners; + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ThemeConfig { @@ -16,6 +18,8 @@ pub struct ThemeConfig { pub border_width: u8, /// Corner radius for notification cards (pixels). pub card_radius: u8, + /// True diagonal cuts applied to panel and popup notification cards + pub notification_corners: CutCorners, /// Base alpha for panel surfaces (0.0 - 1.0). pub surface_alpha: f32, /// Stronger alpha for panel surfaces (0.0 - 1.0). @@ -43,6 +47,8 @@ impl Default for ThemeConfig { border_width: 1, // Matches the default card radius used by the bundled theme. card_radius: 22, + // Square clipping preserves the existing rounded CSS presentation + notification_corners: CutCorners::default(), surface_alpha: 0.88, surface_strong_alpha: 0.96, card_alpha: 0.94, diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 38609277a..d36b2aa76 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -13,8 +13,9 @@ mod types; mod validation; mod widgets; -pub(in crate::config) use appearance::{icon_assets, theme}; +pub(in crate::config) use appearance::{corners, icon_assets, theme}; pub use command::{parse_command, CommandParseError, ExecutionMode, ParsedCommand}; +pub use corners::CutCorners; pub use diagnostics::{ log_config_diagnostics, ConfigDiagnostic, ConfigDiagnosticKind, ConfigLoadReport, }; diff --git a/crates/unixnotis-core/src/config/panel/config.rs b/crates/unixnotis-core/src/config/panel/config.rs index befe0874c..be70d43fe 100644 --- a/crates/unixnotis-core/src/config/panel/config.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -4,9 +4,10 @@ use serde::{Deserialize, Serialize}; use super::super::{Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT}; use super::{ - default_panel_action_order, default_panel_section_order, default_panel_widget_order, - EmptyStateAlignment, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelSection, - PanelWidgetSection, + default_dnd_menu_choices, default_dnd_menu_triggers, default_panel_action_order, + default_panel_section_order, default_panel_widget_order, DndMenuChoice, DndMenuTrigger, + EmptyStateAlignment, NotificationMetadataConfig, PanelActionConfig, PanelActionId, + PanelClearButtonPlacement, PanelSection, PanelWidgetSection, }; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -32,6 +33,8 @@ pub struct PanelConfig { pub subtitle: String, /// Placeholder text shown in the panel search entry pub search_placeholder: String, + /// GTK icon-theme name used by the UnixNotis-owned search magnifier + pub search_magnifier_icon: String, /// Show the search entry without requiring the search toggle first pub search_visible: bool, /// Show the compact utility action row below the header @@ -42,6 +45,8 @@ pub struct PanelConfig { pub notification_list_expand: bool, /// Show optional notification metadata lanes pub notification_metadata_visible: bool, + /// Text and compact templates rendered inside notification metadata lanes + pub notification_metadata: NotificationMetadataConfig, /// Show optional notification image thumbnails in panel rows pub notification_thumbnails_visible: bool, /// Where the "clear all" action is rendered @@ -66,6 +71,10 @@ pub struct PanelConfig { pub focus_action: PanelActionConfig, /// Do-not-disturb action customization pub dnd_action: PanelActionConfig, + /// Input gestures that open the timed DND menu + pub dnd_menu_triggers: Vec, + /// Typed deadlines shown in the timed DND menu + pub dnd_menu_choices: Vec, /// Clear-notifications action customization pub clear_action: PanelActionConfig, /// Search action customization @@ -105,11 +114,13 @@ impl Default for PanelConfig { title: "Notifications".to_string(), subtitle: String::new(), search_placeholder: "Search app, title, or message".to_string(), + search_magnifier_icon: "system-search-symbolic".to_string(), search_visible: false, action_row_visible: true, notification_section_visible: false, notification_list_expand: true, notification_metadata_visible: false, + notification_metadata: NotificationMetadataConfig::default(), notification_thumbnails_visible: false, clear_button_placement: PanelClearButtonPlacement::ActionRow, quick_actions_label: "Quick settings".to_string(), @@ -122,6 +133,8 @@ impl Default for PanelConfig { action_order: default_panel_action_order(), focus_action: PanelActionConfig::widgets(), dnd_action: PanelActionConfig::dnd(), + dnd_menu_triggers: default_dnd_menu_triggers(), + dnd_menu_choices: default_dnd_menu_choices(), clear_action: PanelActionConfig::clear(), search_action: PanelActionConfig::search(), close_action: PanelActionConfig::close(), diff --git a/crates/unixnotis-core/src/config/panel/dnd.rs b/crates/unixnotis-core/src/config/panel/dnd.rs new file mode 100644 index 000000000..62f98bdab --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/dnd.rs @@ -0,0 +1,79 @@ +//! Timed Do Not Disturb menu configuration + +use serde::{Deserialize, Serialize}; + +/// Input gestures that can open the timed DND menu +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum DndMenuTrigger { + RightClick, + LongPress, + Keyboard, +} + +/// One typed deadline shown in the timed DND menu +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum DndMenuChoice { + /// Enable DND for a relative number of minutes + Duration { label: String, minutes: u32 }, + /// Enable DND until a clock time on the next local calendar day + Tomorrow { label: String, hour: u8, minute: u8 }, + /// Enable DND without an expiration deadline + Indefinite { label: String }, +} + +impl DndMenuChoice { + /// Return the user-facing menu label + #[must_use] + pub fn label(&self) -> &str { + match self { + Self::Duration { label, .. } + | Self::Tomorrow { label, .. } + | Self::Indefinite { label } => label, + } + } + + /// Return mutable access to the user-facing menu label + pub(in crate::config) fn label_mut(&mut self) -> &mut String { + match self { + Self::Duration { label, .. } + | Self::Tomorrow { label, .. } + | Self::Indefinite { label } => label, + } + } +} + +/// Return the stock DND menu input policy +#[must_use] +pub fn default_dnd_menu_triggers() -> Vec { + // Secondary click is the only default path so ordinary pointer use stays quiet + vec![DndMenuTrigger::RightClick] +} + +/// Return the stock DND deadline menu +#[must_use] +pub fn default_dnd_menu_choices() -> Vec { + vec![ + DndMenuChoice::Duration { + label: "30 minutes".to_string(), + minutes: 30, + }, + DndMenuChoice::Duration { + label: "1 hour".to_string(), + minutes: 60, + }, + DndMenuChoice::Duration { + label: "2 hours".to_string(), + minutes: 120, + }, + DndMenuChoice::Tomorrow { + label: "Until tomorrow morning".to_string(), + hour: 8, + minute: 0, + }, + DndMenuChoice::Indefinite { + label: "Indefinitely".to_string(), + }, + ] +} diff --git a/crates/unixnotis-core/src/config/panel/metadata.rs b/crates/unixnotis-core/src/config/panel/metadata.rs new file mode 100644 index 000000000..79a5e9eb9 --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/metadata.rs @@ -0,0 +1,45 @@ +//! Configurable notification metadata text + +use serde::{Deserialize, Serialize}; + +/// Text and compact templates used by optional notification metadata lanes +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct NotificationMetadataConfig { + pub critical_label: String, + pub low_label: String, + pub normal_label: String, + pub relative_now: String, + /// Minute template where `{value}` is replaced with the elapsed count + pub relative_minutes: String, + /// Hour template where `{value}` is replaced with the elapsed count + pub relative_hours: String, + /// Day template where `{value}` is replaced with the elapsed count + pub relative_days: String, + pub transient_label: String, + pub live_label: String, + pub history_label: String, + /// Singular template where `{count}` is replaced with one + pub action_count_one: String, + /// Plural template where `{count}` is replaced with the visible action count + pub action_count_many: String, +} + +impl Default for NotificationMetadataConfig { + fn default() -> Self { + Self { + critical_label: "ALERT".to_string(), + low_label: "LOW".to_string(), + normal_label: "NOTICE".to_string(), + relative_now: "now".to_string(), + relative_minutes: "{value}m".to_string(), + relative_hours: "{value}h".to_string(), + relative_days: "{value}d".to_string(), + transient_label: "TRANSIENT".to_string(), + live_label: "LIVE".to_string(), + history_label: "HISTORY".to_string(), + action_count_one: "{count} ACTION".to_string(), + action_count_many: "{count} ACTIONS".to_string(), + } + } +} diff --git a/crates/unixnotis-core/src/config/panel/mod.rs b/crates/unixnotis-core/src/config/panel/mod.rs index a0ceafb5f..06e979976 100644 --- a/crates/unixnotis-core/src/config/panel/mod.rs +++ b/crates/unixnotis-core/src/config/panel/mod.rs @@ -2,14 +2,20 @@ mod actions; mod config; +mod dnd; mod empty; +mod metadata; mod sections; pub use self::actions::{ default_panel_action_order, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, }; pub use self::config::PanelConfig; +pub use self::dnd::{ + default_dnd_menu_choices, default_dnd_menu_triggers, DndMenuChoice, DndMenuTrigger, +}; pub use self::empty::EmptyStateAlignment; +pub use self::metadata::NotificationMetadataConfig; pub use self::sections::{ default_panel_section_order, default_panel_widget_order, PanelSection, PanelWidgetSection, }; diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs index 020384d8e..b1da8b273 100644 --- a/crates/unixnotis-core/src/config/panel/tests/config.rs +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -31,6 +31,7 @@ fn default_panel_config_keeps_expected_layout_and_text_contract() { assert_eq!(panel.quick_actions_label, "Quick settings"); assert_eq!(panel.system_status_label, "System health"); assert_eq!(panel.search_placeholder, "Search app, title, or message"); + assert_eq!(panel.search_magnifier_icon, "system-search-symbolic"); assert!(panel.action_row_visible); assert!(panel.notification_list_expand); assert!(panel.close_on_click_outside); diff --git a/crates/unixnotis-core/src/config/panel/tests/dnd.rs b/crates/unixnotis-core/src/config/panel/tests/dnd.rs new file mode 100644 index 000000000..61f02430d --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/dnd.rs @@ -0,0 +1,65 @@ +use super::super::*; + +#[test] +fn default_dnd_menu_uses_only_right_click_and_keeps_stock_deadlines() { + assert_eq!( + default_dnd_menu_triggers(), + vec![DndMenuTrigger::RightClick] + ); + assert_eq!(default_dnd_menu_choices().len(), 5); + assert!(matches!( + &default_dnd_menu_choices()[0], + DndMenuChoice::Duration { minutes: 30, .. } + )); + assert!(matches!( + &default_dnd_menu_choices()[3], + DndMenuChoice::Tomorrow { + hour: 8, + minute: 0, + .. + } + )); + assert!(matches!( + &default_dnd_menu_choices()[4], + DndMenuChoice::Indefinite { .. } + )); +} + +#[test] +fn dnd_menu_parses_custom_triggers_and_typed_choices() { + let panel: PanelConfig = toml::from_str( + r#" + dnd_menu_triggers = ["right-click", "keyboard"] + + [[dnd_menu_choices]] + mode = "duration" + label = "Focus block" + minutes = 45 + + [[dnd_menu_choices]] + mode = "tomorrow" + label = "Tomorrow at lunch" + hour = 12 + minute = 30 + + [[dnd_menu_choices]] + mode = "indefinite" + label = "Until disabled" + "#, + ) + .expect("custom DND menu should parse"); + + assert_eq!( + panel.dnd_menu_triggers, + vec![DndMenuTrigger::RightClick, DndMenuTrigger::Keyboard] + ); + assert_eq!(panel.dnd_menu_choices[0].label(), "Focus block"); + assert!(matches!( + panel.dnd_menu_choices[1], + DndMenuChoice::Tomorrow { + hour: 12, + minute: 30, + .. + } + )); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/metadata.rs b/crates/unixnotis-core/src/config/panel/tests/metadata.rs new file mode 100644 index 000000000..8f1f58273 --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/metadata.rs @@ -0,0 +1,37 @@ +use super::super::NotificationMetadataConfig; + +#[test] +fn metadata_defaults_keep_existing_runtime_copy() { + let metadata = NotificationMetadataConfig::default(); + + assert_eq!(metadata.critical_label, "ALERT"); + assert_eq!(metadata.relative_minutes, "{value}m"); + assert_eq!(metadata.live_label, "LIVE"); + assert_eq!(metadata.action_count_one, "{count} ACTION"); + assert_eq!(metadata.action_count_many, "{count} ACTIONS"); +} + +#[test] +fn metadata_text_parses_as_one_nested_panel_block() { + #[derive(serde::Deserialize)] + struct Fixture { + metadata: NotificationMetadataConfig, + } + + let fixture: Fixture = toml::from_str( + r#" + [metadata] + critical_label = "PRIORITY" + relative_hours = "{value} hours ago" + history_label = "ARCHIVE" + action_count_many = "{count} OPTIONS" + "#, + ) + .expect("metadata block should parse"); + + assert_eq!(fixture.metadata.critical_label, "PRIORITY"); + assert_eq!(fixture.metadata.relative_hours, "{value} hours ago"); + assert_eq!(fixture.metadata.history_label, "ARCHIVE"); + assert_eq!(fixture.metadata.action_count_many, "{count} OPTIONS"); + assert_eq!(fixture.metadata.low_label, "LOW"); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/mod.rs b/crates/unixnotis-core/src/config/panel/tests/mod.rs index c46bf825a..75a5229f8 100644 --- a/crates/unixnotis-core/src/config/panel/tests/mod.rs +++ b/crates/unixnotis-core/src/config/panel/tests/mod.rs @@ -2,4 +2,6 @@ use super::*; mod actions; mod config; +mod dnd; +mod metadata; mod sections; diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs b/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs index 66f000e28..43061602b 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs @@ -10,7 +10,7 @@ mod theme; pub(in super::super) use pipeline::sanitize_config; pub(super) use pipeline::{ - MAX_BORDER_WIDTH, MAX_CARD_HEIGHT, MAX_CARD_RADIUS, MAX_MEDIA_ART_SIZE, + MAX_BORDER_WIDTH, MAX_CARD_HEIGHT, MAX_CARD_RADIUS, MAX_CORNER_CUT, MAX_MEDIA_ART_SIZE, MAX_MEDIA_TEXT_WIDTH_FLOOR, MAX_MEDIA_TITLE_CHAR_LIMIT, MAX_SPACING, MAX_WIDGET_COLUMNS, MIN_MEDIA_TEXT_WIDTH_FLOOR, MIN_MEDIA_TITLE_CHAR_LIMIT, MIN_WIDGET_COLUMNS, }; diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs b/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs index bbf3c608f..516f3c389 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs @@ -3,9 +3,16 @@ use std::collections::HashSet; use super::{MAX_WIDGET_COLUMNS, MIN_WIDGET_COLUMNS}; use crate::{ default_panel_action_order, default_panel_section_order, default_panel_widget_order, Config, - PanelActionConfig, PanelActionId, PanelConfig, PanelSection, PanelWidgetSection, + DndMenuChoice, NotificationMetadataConfig, PanelActionConfig, PanelActionId, PanelConfig, + PanelSection, PanelWidgetSection, }; +const MAX_DND_MENU_CHOICES: usize = 16; +const MAX_DND_DURATION_MINUTES: u32 = 525_600; +const MAX_DND_LABEL_CHARS: usize = 96; +const MAX_METADATA_TEXT_CHARS: usize = 128; +const MAX_ICON_NAME_CHARS: usize = 128; + pub(super) fn sanitize_panel_text(panel: &mut PanelConfig) { // Empty core labels make the panel harder to operate, so restore only required text if panel.title.trim().is_empty() { @@ -14,6 +21,10 @@ pub(super) fn sanitize_panel_text(panel: &mut PanelConfig) { if panel.clear_label.trim().is_empty() { panel.clear_label = PanelConfig::default().clear_label; } + if panel.search_magnifier_icon.trim().is_empty() { + panel.search_magnifier_icon = PanelConfig::default().search_magnifier_icon; + } + truncate_chars(&mut panel.search_magnifier_icon, MAX_ICON_NAME_CHARS); sanitize_action_config(&mut panel.focus_action, PanelActionConfig::widgets()); sanitize_action_config(&mut panel.dnd_action, PanelActionConfig::dnd()); sanitize_action_config(&mut panel.clear_action, PanelActionConfig::clear()); @@ -33,6 +44,55 @@ pub(super) fn sanitize_panel_action_order(order: &mut Vec) { sanitize_order(order, default_panel_action_order); } +pub(super) fn sanitize_dnd_menu(panel: &mut PanelConfig) { + let mut seen = HashSet::new(); + // An empty trigger list deliberately disables the context menu + panel + .dnd_menu_triggers + .retain(|trigger| seen.insert(*trigger)); + panel.dnd_menu_choices.truncate(MAX_DND_MENU_CHOICES); + panel.dnd_menu_choices.retain_mut(|choice| { + let label = choice.label_mut(); + truncate_chars(label, MAX_DND_LABEL_CHARS); + if label.trim().is_empty() { + // Empty buttons are unusable and should not occupy menu space + return false; + } + + match choice { + DndMenuChoice::Duration { minutes, .. } => { + *minutes = (*minutes).clamp(1, MAX_DND_DURATION_MINUTES); + } + DndMenuChoice::Tomorrow { hour, minute, .. } => { + *hour = (*hour).min(23); + *minute = (*minute).min(59); + } + DndMenuChoice::Indefinite { .. } => {} + } + true + }); +} + +pub(super) fn sanitize_notification_metadata(config: &mut NotificationMetadataConfig) { + for text in [ + &mut config.critical_label, + &mut config.low_label, + &mut config.normal_label, + &mut config.relative_now, + &mut config.relative_minutes, + &mut config.relative_hours, + &mut config.relative_days, + &mut config.transient_label, + &mut config.live_label, + &mut config.history_label, + &mut config.action_count_one, + &mut config.action_count_many, + ] { + // Empty metadata text is valid because it hides that optional badge + truncate_chars(text, MAX_METADATA_TEXT_CHARS); + } +} + fn sanitize_order(order: &mut Vec, defaults: fn() -> Vec) where T: Copy + Eq + std::hash::Hash, @@ -93,3 +153,11 @@ fn sanitize_column_count(value: usize, default_value: usize) -> usize { } value.clamp(MIN_WIDGET_COLUMNS, MAX_WIDGET_COLUMNS) } + +fn truncate_chars(value: &mut String, max_chars: usize) { + // UTF-8 boundaries are found through char indices before truncation + let Some((index, _)) = value.char_indices().nth(max_chars) else { + return; + }; + value.truncate(index); +} diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs index c82851ede..2fd4e7fc4 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs @@ -20,6 +20,7 @@ pub(in super::super) const MAX_HISTORY_ENTRIES: usize = 5_000; pub(in super::super) const MAX_HISTORY_ACTIVE: usize = 12; pub(in super::super) const MAX_BORDER_WIDTH: u8 = 16; pub(in super::super) const MAX_CARD_RADIUS: u8 = 64; +pub(in super::super) const MAX_CORNER_CUT: u16 = 512; pub(in super::super) const MIN_WIDGET_COLUMNS: usize = 1; pub(in super::super) const MAX_WIDGET_COLUMNS: usize = 8; @@ -84,6 +85,8 @@ fn sanitize_panel_geometry(config: &mut Config) { panel::sanitize_panel_section_order(&mut config.panel.section_order); panel::sanitize_panel_widget_order(&mut config.panel.widget_order); panel::sanitize_panel_action_order(&mut config.panel.action_order); + panel::sanitize_dnd_menu(&mut config.panel); + panel::sanitize_notification_metadata(&mut config.panel.notification_metadata); panel::sanitize_widget_columns(config); config.panel.margin.top = config.panel.margin.top.clamp(0, MAX_MARGIN); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 950148c1d..19ac5d400 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -1,9 +1,10 @@ use super::super::super::super::widgets::{CardWidgetConfig, StatWidgetConfig}; use super::*; use crate::{ - Config, PanelActionConfig, PanelActionId, PanelConfig, PanelSection, PanelWidgetSection, - PopupConfig, ToggleLayout, WidgetPluginConfig, CURRENT_CONFIG_VERSION, MAX_CARD_WIDGETS, - MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, MAX_TOTAL_WIDGETS, + Config, DndMenuChoice, DndMenuTrigger, PanelActionConfig, PanelActionId, PanelConfig, + PanelSection, PanelWidgetSection, PopupConfig, ToggleLayout, WidgetPluginConfig, + CURRENT_CONFIG_VERSION, MAX_CARD_WIDGETS, MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, + MAX_TOTAL_WIDGETS, }; use proptest::prelude::*; use proptest::test_runner::RngSeed; @@ -202,11 +203,49 @@ fn sanitize_clamps_panel_and_popup_sizes() { assert_eq!(config.popups.spacing, MAX_SPACING); } +#[test] +fn sanitize_bounds_every_custom_notification_metadata_string() { + let mut config = Config::default(); + let oversized = "界".repeat(160); + config.panel.notification_metadata.critical_label = oversized.clone(); + config.panel.notification_metadata.low_label = oversized.clone(); + config.panel.notification_metadata.normal_label = oversized.clone(); + config.panel.notification_metadata.relative_now = oversized.clone(); + config.panel.notification_metadata.relative_minutes = oversized.clone(); + config.panel.notification_metadata.relative_hours = oversized.clone(); + config.panel.notification_metadata.relative_days = oversized.clone(); + config.panel.notification_metadata.transient_label = oversized.clone(); + config.panel.notification_metadata.live_label = oversized.clone(); + config.panel.notification_metadata.history_label = oversized.clone(); + config.panel.notification_metadata.action_count_one = oversized.clone(); + config.panel.notification_metadata.action_count_many = oversized; + + sanitize_config(&mut config); + + for text in [ + &config.panel.notification_metadata.critical_label, + &config.panel.notification_metadata.low_label, + &config.panel.notification_metadata.normal_label, + &config.panel.notification_metadata.relative_now, + &config.panel.notification_metadata.relative_minutes, + &config.panel.notification_metadata.relative_hours, + &config.panel.notification_metadata.relative_days, + &config.panel.notification_metadata.transient_label, + &config.panel.notification_metadata.live_label, + &config.panel.notification_metadata.history_label, + &config.panel.notification_metadata.action_count_one, + &config.panel.notification_metadata.action_count_many, + ] { + assert_eq!(text.chars().count(), 128); + } +} + #[test] fn sanitize_preserves_optional_panel_labels_and_repairs_widget_order() { let mut config = Config::default(); config.panel.title = " ".to_string(); config.panel.search_placeholder.clear(); + config.panel.search_magnifier_icon = "x".repeat(256); config.panel.quick_actions_label.clear(); config.panel.system_status_label.clear(); config.panel.recent_notifications_label.clear(); @@ -223,6 +262,7 @@ fn sanitize_preserves_optional_panel_labels_and_repairs_widget_order() { assert_eq!(config.panel.title, PanelConfig::default().title); assert!(config.panel.search_placeholder.is_empty()); + assert_eq!(config.panel.search_magnifier_icon.chars().count(), 128); assert!(config.panel.quick_actions_label.is_empty()); assert!(config.panel.system_status_label.is_empty()); assert!(config.panel.recent_notifications_label.is_empty()); @@ -291,6 +331,66 @@ fn sanitize_preserves_explicit_close_action_order() { ); } +#[test] +fn sanitize_dnd_menu_deduplicates_triggers_and_bounds_choices() { + let mut config = Config::default(); + config.panel.dnd_menu_triggers = vec![ + DndMenuTrigger::RightClick, + DndMenuTrigger::Keyboard, + DndMenuTrigger::RightClick, + ]; + config.panel.dnd_menu_choices = vec![ + DndMenuChoice::Duration { + label: "".to_string(), + minutes: 0, + }, + DndMenuChoice::Duration { + label: "Year".to_string(), + minutes: u32::MAX, + }, + DndMenuChoice::Tomorrow { + label: "Next day".to_string(), + hour: u8::MAX, + minute: u8::MAX, + }, + ]; + + sanitize_config(&mut config); + + assert_eq!( + config.panel.dnd_menu_triggers, + vec![DndMenuTrigger::RightClick, DndMenuTrigger::Keyboard] + ); + assert_eq!(config.panel.dnd_menu_choices.len(), 2); + assert!(matches!( + config.panel.dnd_menu_choices[0], + DndMenuChoice::Duration { + minutes: 525_600, + .. + } + )); + assert!(matches!( + config.panel.dnd_menu_choices[1], + DndMenuChoice::Tomorrow { + hour: 23, + minute: 59, + .. + } + )); +} + +#[test] +fn sanitize_preserves_an_explicitly_disabled_dnd_menu() { + let mut config = Config::default(); + config.panel.dnd_menu_triggers.clear(); + config.panel.dnd_menu_choices.clear(); + + sanitize_config(&mut config); + + assert!(config.panel.dnd_menu_triggers.is_empty()); + assert!(config.panel.dnd_menu_choices.is_empty()); +} + #[test] fn default_panel_section_labels_name_the_visible_widget_groups() { let config = PanelConfig::default(); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs index f62dccdf5..d8d69dc6f 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs @@ -21,6 +21,7 @@ fn sanitize_clamps_alpha_and_theme_limits() { config.theme.shadow_strong_alpha = -0.5; config.theme.border_width = MAX_BORDER_WIDTH + 2; config.theme.card_radius = MAX_CARD_RADIUS + 3; + config.theme.notification_corners.top_left = u16::MAX; sanitize_config(&mut config); assert_eq!(config.theme.surface_alpha, 0.0); @@ -36,6 +37,7 @@ fn sanitize_clamps_alpha_and_theme_limits() { assert_eq!(config.theme.shadow_strong_alpha, 0.0); assert_eq!(config.theme.border_width, MAX_BORDER_WIDTH); assert_eq!(config.theme.card_radius, MAX_CARD_RADIUS); + assert_eq!(config.theme.notification_corners.top_left, MAX_CORNER_CUT); } #[test] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs index 6c8c1d0a1..e7b017545 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs @@ -1,4 +1,4 @@ -use super::{MAX_BORDER_WIDTH, MAX_CARD_RADIUS}; +use super::{MAX_BORDER_WIDTH, MAX_CARD_RADIUS, MAX_CORNER_CUT}; use crate::{Config, ThemeConfig}; pub(super) fn sanitize_theme_config(config: &mut Config) { @@ -38,6 +38,26 @@ pub(super) fn sanitize_theme_config(config: &mut Config) { // CSS generation reads these directly, so keep values inside simple visual bounds config.theme.border_width = config.theme.border_width.min(MAX_BORDER_WIDTH); config.theme.card_radius = config.theme.card_radius.min(MAX_CARD_RADIUS); + config.theme.notification_corners.top_left = config + .theme + .notification_corners + .top_left + .min(MAX_CORNER_CUT); + config.theme.notification_corners.top_right = config + .theme + .notification_corners + .top_right + .min(MAX_CORNER_CUT); + config.theme.notification_corners.bottom_right = config + .theme + .notification_corners + .bottom_right + .min(MAX_CORNER_CUT); + config.theme.notification_corners.bottom_left = config + .theme + .notification_corners + .bottom_left + .min(MAX_CORNER_CUT); } const fn clamp_alpha(value: &mut f32, fallback: f32) { diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 91461f4d0..2699cfa77 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -9,6 +9,21 @@ pub mod shared_state { pub const STACKED: &str = "stacked"; } +pub mod cut_corner { + // The wrapper hook lets themes adjust the primitive without using its custom CSS node name + pub const ROOT: &str = "unixnotis-cut-corner"; +} + +pub mod dnd_menu { + // Timed DND hooks expose the popover layers without relying on GTK node names + pub const ROOT: &str = "unixnotis-dnd-menu"; + pub const CONTENT: &str = "unixnotis-dnd-menu-content"; + pub const TITLE: &str = "unixnotis-dnd-menu-title"; + pub const CHOICE: &str = "unixnotis-dnd-menu-choice"; + pub const INDEFINITE: &str = "unixnotis-dnd-menu-choice-indefinite"; + pub const SEPARATOR: &str = "unixnotis-dnd-menu-separator"; +} + pub mod panel_action { // Panel action hooks expose both shared structure and per-button role pub const ROW: &str = "unixnotis-panel-actions"; @@ -39,6 +54,9 @@ pub mod panel_shell { pub const SUBTITLE: &str = "unixnotis-panel-subtitle"; pub const COUNT: &str = "unixnotis-panel-count"; pub const SEARCH: &str = "unixnotis-panel-search"; + pub const SEARCH_MAGNIFIER: &str = "unixnotis-panel-search-magnifier"; + pub const SEARCH_CLEAR: &str = "unixnotis-panel-search-clear"; + pub const SEARCH_OWNED_ICONS: &str = "unixnotis-panel-search-owned-icons"; pub const SEARCH_SHELL: &str = "unixnotis-panel-search-shell"; pub const SEARCH_ACCENT: &str = "unixnotis-panel-search-accent"; pub const SEARCH_STAR: &str = "unixnotis-panel-search-star"; diff --git a/crates/unixnotis-core/src/css/hooks/mod.rs b/crates/unixnotis-core/src/css/hooks/mod.rs index a1c86ebd8..84960fc60 100644 --- a/crates/unixnotis-core/src/css/hooks/mod.rs +++ b/crates/unixnotis-core/src/css/hooks/mod.rs @@ -3,8 +3,9 @@ mod classes; pub use self::classes::{ - empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, - panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, + cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, + panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, + toggle_card, }; #[cfg(test)] diff --git a/crates/unixnotis-core/src/css/tests/hooks.rs b/crates/unixnotis-core/src/css/tests/hooks.rs index 3f833e42a..78354ce50 100644 --- a/crates/unixnotis-core/src/css/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/tests/hooks.rs @@ -1,8 +1,9 @@ use std::collections::HashSet; use super::{ - empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, - panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, + cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, + panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, + toggle_card, }; #[test] @@ -13,6 +14,13 @@ use super::{ fn hook_names_stay_unique() { // One flat set makes accidental selector reuse obvious during refactors let names = [ + cut_corner::ROOT, + dnd_menu::ROOT, + dnd_menu::CONTENT, + dnd_menu::TITLE, + dnd_menu::CHOICE, + dnd_menu::INDEFINITE, + dnd_menu::SEPARATOR, shared_state::ACTIVE, shared_state::CRITICAL, shared_state::EMPTY, @@ -42,6 +50,9 @@ fn hook_names_stay_unique() { panel_shell::SUBTITLE, panel_shell::COUNT, panel_shell::SEARCH, + panel_shell::SEARCH_MAGNIFIER, + panel_shell::SEARCH_CLEAR, + panel_shell::SEARCH_OWNED_ICONS, panel_shell::SEARCH_SHELL, panel_shell::SEARCH_ACCENT, panel_shell::SEARCH_STAR, diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index b98afb7ff..5274be8e1 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -18,7 +18,35 @@ fn every_embedded_css_layer_contains_real_stylesheet_content() { } #[test] -fn internal_structure_css_only_targets_reload_notice_structure() { +fn internal_structure_css_contains_only_required_fallback_structure() { assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice")); + assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-panel-search-owned-icons")); assert!(!INTERNAL_STRUCTURE_CSS.contains("@define-color")); } + +#[test] +fn panel_css_keeps_the_dnd_menu_visual_hooks() { + for selector in [ + ".unixnotis-dnd-menu > contents", + ".unixnotis-dnd-menu-title", + ".unixnotis-dnd-menu-choice", + ".unixnotis-dnd-menu-choice-indefinite", + ".unixnotis-dnd-menu-separator", + ] { + assert!( + DEFAULT_PANEL_CSS.contains(selector), + "panel CSS should retain {selector}" + ); + } +} + +#[test] +fn dnd_menu_hover_and_keyboard_focus_share_one_visual_rule() { + let shared_selector = ".unixnotis-dnd-menu .unixnotis-dnd-menu-choice:hover,\n\ +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:focus-visible"; + + // PrintScreen can switch GTK into keyboard modality while the pointer remains over a row + // One selector keeps that modality change from altering the captured menu appearance + assert!(DEFAULT_PANEL_CSS.contains(shared_selector)); + assert!(!DEFAULT_PANEL_CSS.contains("box-shadow: inset 2px 0")); +} diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index d41734c7b..d5117bac4 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -4,6 +4,7 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; use unixnotis_core::{hooks, Action, NotificationView, Urgency}; +use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; use super::super::UiState; @@ -230,7 +231,9 @@ impl UiState { revealer.add_css_class("unixnotis-popup-revealer"); revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); revealer.set_transition_duration(200); - revealer.set_child(Some(root)); + // The shared primitive clips the full styled popup instead of approximating the corners + let plate = CutCorner::new(root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); // Visibility is driven centrally so only rows inside max_visible animate in revealer.set_reveal_child(false); diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 9026e091c..28d45f9e6 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -3,6 +3,7 @@ use gtk::prelude::*; use tracing::debug; use unixnotis_core::NotificationView; +use unixnotis_ui::CutCorner; use super::super::entry::PopupEntry; use super::super::window::refresh_popup_input_region; @@ -148,7 +149,15 @@ impl UiState { if old_root.has_css_class("unixnotis-popup-visible") { new_root.add_css_class("unixnotis-popup-visible"); } - revealer.set_child(Some(&new_root)); + if let Some(plate) = revealer.child().and_downcast::() { + // Preserve the reveal animation while swapping only the clipped card contents + plate.set_child(Some(&new_root)); + plate.set_corners(self.config.theme.notification_corners); + } else { + // Older in-memory rows cannot normally reach this branch, but rebuilding stays safe + let plate = CutCorner::new(&new_root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } if let Some(entry) = self.popups.get_mut(&id) { entry.root = Some(new_root); diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index 2ac32765b..90d3d07d9 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -5,6 +5,7 @@ use super::super::UiState; use super::mutation::VisiblePopupUpdate; use gtk::prelude::*; use tracing::{debug, warn}; +use unixnotis_ui::CutCorner; impl UiState { pub(in super::super) fn update_popup_visibility(&mut self, force_region_refresh: bool) { @@ -71,6 +72,15 @@ impl UiState { continue; }; root.set_size_request(popup_width, -1); + if let Some(plate) = entry + .revealer + .as_ref() + .and_then(gtk::Revealer::child) + .and_downcast::() + { + // Theme corner changes apply to existing visible popups immediately + plate.set_corners(self.config.theme.notification_corners); + } } // Re-run visibility so max_visible changes take effect right away self.update_popup_visibility(true); diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 561fc2131..e9adf0dfc 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -1,13 +1,13 @@ -use std::path::PathBuf; +use std::path::Path; use gtk::prelude::*; -use unixnotis_core::{Config, ThemePaths}; -use unixnotis_ui::css::CssManager; +use unixnotis_core::{Config, CutCorners, NotificationImage, NotificationView, ThemePaths}; +use unixnotis_ui::{css::CssManager, CutCorner}; use super::super::UiState; -fn theme_paths(root: &str) -> ThemePaths { - let root = PathBuf::from(root); +fn theme_paths(root: &Path) -> ThemePaths { + let root = root.to_path_buf(); ThemePaths { base_dir: root.clone(), base_css: root.join("base.css"), @@ -18,6 +18,55 @@ fn theme_paths(root: &str) -> ThemePaths { } } +#[gtk::test] +fn popup_entry_uses_the_configured_cut_corner_primitive() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupCornerTest") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup corner test application"); + let mut config = Config::default(); + config.theme.notification_corners = CutCorners { + top_left: 20, + bottom_right: 14, + ..CutCorners::default() + }; + let corners = config.theme.notification_corners; + let config_root = std::env::temp_dir().join("unixnotis-popup-corners"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 1, + app_name: "Demo".to_string(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + urgency: 1, + is_transient: false, + image: NotificationImage::default(), + }; + + let entry = state.build_popup_entry(¬ification); + let plate = entry + .revealer + .and_then(|revealer| revealer.child()) + .and_downcast::() + .expect("popup revealer should contain the cut-corner primitive"); + let root = entry.root.expect("popup entry should keep its styled root"); + + assert_eq!(plate.corners(), corners); + assert_eq!(plate.child().as_ref(), Some(root.upcast_ref())); +} + #[gtk::test] fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { let app = gtk::Application::builder() @@ -27,12 +76,10 @@ fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { app.register(None::<>k::gio::Cancellable>) .expect("register popup test application"); let config = Config::default(); - let config_path = PathBuf::from("/tmp/unixnotis-popup-state/config.toml"); + let config_root = std::env::temp_dir().join("unixnotis-popup-state"); + let config_path = config_root.join("config.toml"); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); - let css = CssManager::new_popup( - theme_paths("/tmp/unixnotis-popup-state"), - config.theme.clone(), - ); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); let state = UiState::new(&app, config, config_path.clone(), command_tx, css); diff --git a/crates/unixnotis-ui/src/bin/css_validate.rs b/crates/unixnotis-ui/src/bin/css_validate.rs index 464a0b3d9..e2a396ad7 100644 --- a/crates/unixnotis-ui/src/bin/css_validate.rs +++ b/crates/unixnotis-ui/src/bin/css_validate.rs @@ -104,7 +104,7 @@ fn run_stdin_protocol() -> ExitCode { error ); }); - provider.load_from_data(&css); + provider.load_from_string(&css); // Success remains silent for easy use from build scripts if parse_errors.get() == 0 { diff --git a/crates/unixnotis-ui/src/css/manager/provider.rs b/crates/unixnotis-ui/src/css/manager/provider.rs index 8e8cf0978..185b48eb9 100644 --- a/crates/unixnotis-ui/src/css/manager/provider.rs +++ b/crates/unixnotis-ui/src/css/manager/provider.rs @@ -10,10 +10,14 @@ pub(super) trait CssProviderBackend: Clone { impl CssProviderBackend for CssProvider { fn load_css_data(&self, data: &str) { - self.load_from_data(data); + self.load_from_string(data); } fn add_to_display(&self, display: &gdk::Display, priority: u32) { gtk::style_context_add_provider_for_display(display, self, priority); } } + +#[cfg(test)] +#[path = "tests/provider.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/css/manager/tests/provider.rs b/crates/unixnotis-ui/src/css/manager/tests/provider.rs new file mode 100644 index 000000000..22299a8a8 --- /dev/null +++ b/crates/unixnotis-ui/src/css/manager/tests/provider.rs @@ -0,0 +1,18 @@ +use std::cell::Cell; +use std::rc::Rc; + +use super::{CssProvider, CssProviderBackend}; + +#[gtk::test] +fn gtk_provider_backend_loads_css_and_reports_invalid_input() { + let provider = CssProvider::new(); + let parse_errors = Rc::new(Cell::new(0)); + let observed_errors = parse_errors.clone(); + provider.connect_parsing_error(move |_, _, _| { + observed_errors.set(observed_errors.get() + 1); + }); + + provider.load_css_data(".broken { color: ;"); + + assert!(parse_errors.get() > 0); +} diff --git a/crates/unixnotis-ui/src/cut_corner/geometry.rs b/crates/unixnotis-ui/src/cut_corner/geometry.rs new file mode 100644 index 000000000..cc3052b89 --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/geometry.rs @@ -0,0 +1,59 @@ +//! Bounded polygon construction and hit testing + +use gtk::gsk; +use unixnotis_core::CutCorners; + +#[derive(Clone, Copy)] +struct NormalizedCorners { + top_left: f32, + top_right: f32, + bottom_right: f32, + bottom_left: f32, +} + +impl NormalizedCorners { + fn new(width: f32, height: f32, corners: CutCorners) -> Self { + // Half-edge limits stop neighboring diagonal cuts from crossing + let limit = (width.max(0.0) / 2.0).min(height.max(0.0) / 2.0); + Self { + top_left: f32::from(corners.top_left).min(limit), + top_right: f32::from(corners.top_right).min(limit), + bottom_right: f32::from(corners.bottom_right).min(limit), + bottom_left: f32::from(corners.bottom_left).min(limit), + } + } +} + +pub(super) fn build_path(width: f32, height: f32, corners: CutCorners) -> gsk::Path { + let width = width.max(0.0); + let height = height.max(0.0); + let corners = NormalizedCorners::new(width, height, corners); + let path = gsk::PathBuilder::new(); + + // Clockwise points form one convex plate with a diagonal at every active corner + path.move_to(corners.top_left, 0.0); + path.line_to(width - corners.top_right, 0.0); + path.line_to(width, corners.top_right); + path.line_to(width, height - corners.bottom_right); + path.line_to(width - corners.bottom_right, height); + path.line_to(corners.bottom_left, height); + path.line_to(0.0, height - corners.bottom_left); + path.line_to(0.0, corners.top_left); + path.close(); + path.to_path() +} + +pub(super) fn contains_point(width: f32, height: f32, corners: CutCorners, x: f64, y: f64) -> bool { + let x = x as f32; + let y = y as f32; + if x < 0.0 || y < 0.0 || x >= width || y >= height { + // GTK hit testing excludes the far allocation edge + return false; + } + + let corners = NormalizedCorners::new(width, height, corners); + x + y >= corners.top_left + && (width - x) + y >= corners.top_right + && (width - x) + (height - y) >= corners.bottom_right + && x + (height - y) >= corners.bottom_left +} diff --git a/crates/unixnotis-ui/src/cut_corner/mod.rs b/crates/unixnotis-ui/src/cut_corner/mod.rs new file mode 100644 index 000000000..76dc4b45a --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/mod.rs @@ -0,0 +1,10 @@ +//! Reusable child clipping for true diagonal card corners + +mod geometry; +mod widget; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; + +pub use widget::CutCorner; diff --git a/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs new file mode 100644 index 000000000..349535ccf --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs @@ -0,0 +1,100 @@ +use unixnotis_core::CutCorners; + +use gtk::{graphene, gsk}; + +use super::super::geometry::{build_path, contains_point}; + +#[test] +fn hit_testing_rejects_clipped_pixels_and_accepts_the_plate() { + let corners = CutCorners { + top_left: 20, + top_right: 20, + bottom_right: 20, + bottom_left: 20, + }; + + assert!(!contains_point(100.0, 80.0, corners, 1.0, 1.0)); + assert!(!contains_point(100.0, 80.0, corners, 99.0, 1.0)); + assert!(contains_point(100.0, 80.0, corners, 50.0, 40.0)); + assert!(contains_point(100.0, 80.0, corners, 20.0, 0.0)); +} + +#[test] +fn oversized_corner_values_are_bounded_to_non_crossing_edges() { + let corners = CutCorners { + top_left: u16::MAX, + top_right: u16::MAX, + bottom_right: u16::MAX, + bottom_left: u16::MAX, + }; + + assert!(!contains_point(100.0, 40.0, corners, 1.0, 1.0)); + assert!(contains_point(100.0, 40.0, corners, 50.0, 20.0)); +} + +#[test] +fn hit_testing_rejects_every_point_outside_the_allocation() { + let corners = CutCorners::default(); + + assert!(!contains_point(100.0, 40.0, corners, -1.0, 20.0)); + assert!(!contains_point(100.0, 40.0, corners, 50.0, -1.0)); + assert!(!contains_point(100.0, 40.0, corners, 100.0, 20.0)); + assert!(!contains_point(100.0, 40.0, corners, 50.0, 40.0)); +} + +#[test] +fn hit_testing_includes_near_edges_and_cuts_each_corner_independently() { + let corners = CutCorners { + top_left: 8, + top_right: 12, + bottom_right: 16, + bottom_left: 20, + }; + + // Each pair straddles one diagonal so all four corner equations stay covered + for (outside, inside) in [ + ((2.0, 2.0), (4.0, 4.0)), + ((96.0, 2.0), (94.0, 6.0)), + ((94.0, 46.0), (90.0, 40.0)), + ((4.0, 46.0), (12.0, 38.0)), + ] { + assert!(!contains_point(100.0, 48.0, corners, outside.0, outside.1)); + assert!(contains_point(100.0, 48.0, corners, inside.0, inside.1)); + } + + assert!(contains_point(100.0, 48.0, CutCorners::default(), 0.0, 0.0)); + assert!(contains_point( + 100.0, + 48.0, + CutCorners::default(), + 99.999, + 47.999 + )); +} + +#[test] +fn rendered_path_and_pointer_shape_match_across_the_plate() { + let width = 37.0; + let height = 29.0; + let corners = CutCorners { + top_left: 5, + top_right: 9, + bottom_right: 12, + bottom_left: 7, + }; + let path = build_path(width, height, corners); + + // A dense grid catches drift between the visible polygon and pointer hit testing + for y in 0..29 { + for x in 0..37 { + // Unequal fractions avoid sampling directly on a diagonal boundary + let x = x as f32 + 0.33; + let y = y as f32 + 0.21; + assert_eq!( + path.in_fill(&graphene::Point::new(x, y), gsk::FillRule::Winding), + contains_point(width, height, corners, f64::from(x), f64::from(y)), + "path and hit test differ at ({x}, {y})" + ); + } + } +} diff --git a/crates/unixnotis-ui/src/cut_corner/tests/mod.rs b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs new file mode 100644 index 000000000..8509047b5 --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs @@ -0,0 +1,3 @@ +//! Cut-corner geometry regression coverage + +mod geometry; diff --git a/crates/unixnotis-ui/src/cut_corner/widget.rs b/crates/unixnotis-ui/src/cut_corner/widget.rs new file mode 100644 index 000000000..2053ba85d --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/widget.rs @@ -0,0 +1,136 @@ +//! GTK widget that clips one child to an angled polygon + +use std::cell::{Cell, RefCell}; + +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use unixnotis_core::{css::hooks, CutCorners}; + +use super::geometry::{build_path, contains_point}; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct CutCorner { + pub(super) child: RefCell>, + pub(super) corners: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CutCorner { + const NAME: &'static str = "UnixNotisCutCorner"; + type Type = super::CutCorner; + type ParentType = gtk::Widget; + + fn class_init(class: &mut Self::Class) { + // BinLayout delegates measurement and allocation to the single child + class.set_layout_manager_type::(); + class.set_css_name("unixnotis-cut-corner"); + } + } + + impl ObjectImpl for CutCorner { + fn dispose(&self) { + if let Some(child) = self.child.borrow_mut().take() { + // Custom child parenting must be undone before the wrapper is finalized + child.unparent(); + } + } + } + + impl WidgetImpl for CutCorner { + fn contains(&self, x: f64, y: f64) -> bool { + let widget = self.obj(); + contains_point( + widget.width() as f32, + widget.height() as f32, + self.corners.get(), + x, + y, + ) + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + if !child.is_visible() { + return; + } + + let corners = self.corners.get(); + if !corners.is_active() { + // The default path avoids creating a render node when no cut is requested + self.obj().snapshot_child(&child, snapshot); + return; + } + + let path = build_path( + self.obj().width() as f32, + self.obj().height() as f32, + corners, + ); + // GTK records the child until pop and discards pixels outside this polygon + snapshot.push_fill(&path, gtk::gsk::FillRule::Winding); + self.obj().snapshot_child(&child, snapshot); + snapshot.pop(); + } + } +} + +glib::wrapper! { + /// Single-child container that clips rendering and pointer hits to diagonal corners + pub struct CutCorner(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl CutCorner { + /// Build an angled wrapper around one existing widget + #[must_use] + pub fn new(child: &impl IsA, corners: CutCorners) -> Self { + let wrapper: Self = glib::Object::new(); + wrapper.add_css_class(hooks::cut_corner::ROOT); + wrapper.set_corners(corners); + wrapper.set_child(Some(child)); + wrapper + } + + /// Replace the wrapped widget without rebuilding the clipping primitive + pub fn set_child(&self, child: Option<&impl IsA>) { + let imp = self.imp(); + let next = child.map(|child| child.clone().upcast::()); + if imp.child.borrow().as_ref() == next.as_ref() { + return; + } + if let Some(current) = imp.child.borrow_mut().take() { + current.unparent(); + } + if let Some(next) = next { + next.set_parent(self); + imp.child.replace(Some(next)); + } + self.queue_resize(); + } + + /// Return the current wrapped widget + #[must_use] + pub fn child(&self) -> Option { + self.imp().child.borrow().clone() + } + + /// Apply new corner geometry and invalidate the rendered plate + pub fn set_corners(&self, corners: CutCorners) { + if self.imp().corners.replace(corners) != corners { + self.queue_draw(); + } + } + + /// Return the active corner geometry + #[must_use] + pub fn corners(&self) -> CutCorners { + self.imp().corners.get() + } +} diff --git a/crates/unixnotis-ui/src/lib.rs b/crates/unixnotis-ui/src/lib.rs index 5a5d72587..5e62c5e94 100644 --- a/crates/unixnotis-ui/src/lib.rs +++ b/crates/unixnotis-ui/src/lib.rs @@ -9,4 +9,7 @@ //! ``` pub mod css; +mod cut_corner; pub mod icons; + +pub use cut_corner::CutCorner; diff --git a/crates/unixnotis-ui/tests/cut_corner.rs b/crates/unixnotis-ui/tests/cut_corner.rs new file mode 100644 index 000000000..6f3782990 --- /dev/null +++ b/crates/unixnotis-ui/tests/cut_corner.rs @@ -0,0 +1,54 @@ +use gtk::prelude::*; +use unixnotis_core::CutCorners; +use unixnotis_ui::CutCorner; + +#[gtk::test] +fn cut_corner_wraps_one_child_and_retains_configured_geometry() { + let child = gtk::Label::new(Some("plate")); + let corners = CutCorners { + top_left: 12, + top_right: 8, + bottom_right: 4, + bottom_left: 2, + }; + + let wrapper = CutCorner::new(&child, corners); + + assert_eq!(wrapper.child().as_ref(), Some(child.upcast_ref())); + assert_eq!(wrapper.corners(), corners); + assert!(wrapper.has_css_class("unixnotis-cut-corner")); +} + +#[gtk::test] +fn cut_corner_class_sets_layout_hit_testing_and_cleanup_contracts() { + let child = gtk::Label::new(Some("plate")); + let wrapper = CutCorner::new( + &child, + CutCorners { + top_left: 20, + ..CutCorners::default() + }, + ); + let window = gtk::Window::new(); + window.set_default_size(100, 60); + window.set_child(Some(&wrapper)); + window.present(); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + assert_eq!(wrapper.css_name(), "unixnotis-cut-corner"); + assert!(wrapper.layout_manager().is_some()); + assert!(!wrapper.contains(1.0, 1.0)); + assert!(wrapper.contains( + f64::from(wrapper.width()) / 2.0, + f64::from(wrapper.height()) / 2.0 + )); + + window.set_child(gtk::Widget::NONE); + window.close(); + drop(window); + drop(wrapper); + assert!(child.parent().is_none()); +} From c7c4109dd1e8441e137added23efc9fc8eedf92a Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 01:19:50 -0500 Subject: [PATCH 014/275] fix: harden config loading and panel feedback Summary: harden config loading and panel feedback. Scope: repository. --- README.md | 10 +- crates/unixnotis-center/src/ui/events.rs | 2 + .../unixnotis-center/src/ui/init/builders.rs | 1 + .../src/ui/notifications/mod.rs | 1 + .../src/ui/notifications/model/grouping.rs | 31 +- .../ui/notifications/model/tests/grouping.rs | 28 ++ .../src/ui/notifications/model/types.rs | 10 + .../row/notification/update/metadata.rs | 2 +- .../ui/notifications/store/tests/update.rs | 41 ++ .../src/ui/notifications/store/update.rs | 11 +- .../src/ui/notifications/tests/support.rs | 1 + .../src/ui/notifications/view/build.rs | 9 +- .../src/ui/notifications/view/tests/build.rs | 3 + crates/unixnotis-center/src/ui/panel/state.rs | 20 +- .../src/ui/panel/tests/state.rs | 31 ++ .../src/ui/reload/config/tests/widgets.rs | 2 + .../src/ui/reload/config/widgets.rs | 1 + crates/unixnotis-center/src/ui/state.rs | 4 +- .../unixnotis-core/src/config/loading/io.rs | 366 ------------------ .../src/config/loading/io/error.rs | 28 ++ .../src/config/loading/io/load.rs | 149 +++++++ .../src/config/loading/io/mod.rs | 15 + .../src/config/loading/io/paths.rs | 128 ++++++ .../src/config/loading/io/scripts.rs | 58 +++ .../loading/{tests/io => io/tests}/load.rs | 85 +++- .../loading/{tests/io => io/tests}/mod.rs | 2 +- .../loading/{tests/io => io/tests}/paths.rs | 2 + .../loading/{tests/io => io/tests}/scripts.rs | 2 + .../loading/{tests/io => io/tests}/support.rs | 2 + .../{tests/io => io/tests}/theme_files.rs | 2 + .../loading/{tests/io => io/tests}/write.rs | 6 +- .../src/config/loading/io/theme_files.rs | 69 ++++ .../src/config/loading/io/write.rs | 13 + crates/unixnotis-core/src/config/mod.rs | 2 +- .../unixnotis-core/src/config/panel/config.rs | 3 + crates/unixnotis-core/src/config/panel/dnd.rs | 2 +- .../src/config/panel/tests/config.rs | 1 + .../config/runtime/sanitize/tests/pipeline.rs | 2 +- .../unixnotis-popups/src/ui/config_reload.rs | 1 + .../src/ui/tests/config_reload.rs | 10 + .../unixnotis-ui/src/cut_corner/geometry.rs | 19 +- .../src/cut_corner/tests/geometry.rs | 16 +- crates/unixnotis-ui/src/cut_corner/widget.rs | 20 +- 43 files changed, 798 insertions(+), 413 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/tests/state.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/error.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/load.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/mod.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/paths.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/scripts.rs rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/load.rs (56%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/mod.rs (65%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/paths.rs (98%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/scripts.rs (99%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/support.rs (95%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/theme_files.rs (98%) rename crates/unixnotis-core/src/config/loading/{tests/io => io/tests}/write.rs (79%) create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_files.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/write.rs diff --git a/README.md b/README.md index 697a13c13..1885c76f3 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ git clone https://github.com/locainin/UnixNotis.wiki.git ## Features - Freedesktop.org notification daemon with history, rules, sound, and DND. -- Persistent and timed DND state across daemon restarts. -- KDE-compatible inline replies in the control-center panel for live notifications that advertise reply support. +- Persistent DND state across daemon restarts. - Control-center panel with widgets, notification list, and media controls. - Toast popup UI with configurable timeouts and styling. - D-Bus inhibit API for programmatic popup suppression. @@ -93,13 +92,6 @@ noticenterctl doctor --config "$HOME/path/to/config.toml" noticenterctl css-check --config "$HOME/path/to/config.toml" ``` -Timed DND can use a relative duration or the next occurrence of a local clock time: - -```sh -noticenterctl dnd on --for 30m -noticenterctl dnd on --until 08:00 -``` - Verbose systemd reports include a sanitized, bounded window of up to 30 user-journal lines. Review verbose output before posting it because application metadata can still be present. Dinit, runit, s6-rc, manual, and unknown launches report service status without pretending that diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index fb6d2e562..7aae3f2f3 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -167,6 +167,8 @@ impl UiState { self.log_debug(PanelDebugLevel::Verbose, || { format!("notification filter updated: '{query}'") }); + // Counts derive from list data and stay accurate before the GTK rebuild lands + self.refresh_counts(); } } UiEvent::WidgetsCollapsed(collapsed) => { diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index ad2d08b14..97455fac0 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -20,6 +20,7 @@ pub(super) fn build_notification_list( notification_corners: init.config.theme.notification_corners, show_notification_thumbnails: init.config.panel.notification_thumbnails_visible, empty_text: init.config.panel.empty_text.clone(), + no_matching_text: init.config.panel.no_matching_text.clone(), empty_offset_top: init.config.panel.empty_offset_top, empty_alignment: init.config.panel.empty_alignment, }; diff --git a/crates/unixnotis-center/src/ui/notifications/mod.rs b/crates/unixnotis-center/src/ui/notifications/mod.rs index b06eedb3c..5c03ccbb5 100644 --- a/crates/unixnotis-center/src/ui/notifications/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/mod.rs @@ -10,6 +10,7 @@ mod store; pub(super) mod test_support; mod view; +pub(in crate::ui) use model::types::NotificationCounts; pub use model::types::{NotificationList, NotificationListConfig}; pub(in crate::ui::notifications) use model::item; diff --git a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs index ad7ebc0f0..3a2ae149a 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use std::rc::Rc; -use super::types::{FilterQuery, NotificationList}; +use super::types::{FilterQuery, NotificationCounts, NotificationList}; impl NotificationList { pub(in crate::ui::notifications) fn intern_key(&mut self, key: &str) -> Rc { @@ -120,7 +120,34 @@ impl NotificationList { }) } - fn entry_matches_filter(&self, view: &unixnotis_core::NotificationView) -> bool { + pub(in crate::ui) fn notification_counts(&self) -> NotificationCounts { + let total = self.total_count(); + let Some(_) = self.filter_query.as_ref() else { + return NotificationCounts { + matching: total, + total, + filter_active: false, + }; + }; + // Count notifications rather than GTK rows because each group adds a header row + let matching = self + .active_order + .iter() + .chain(&self.history_order) + .filter_map(|id| self.entries.get(id)) + .filter(|entry| self.entry_matches_filter(&entry.view)) + .count(); + NotificationCounts { + matching, + total, + filter_active: true, + } + } + + pub(in crate::ui::notifications) fn entry_matches_filter( + &self, + view: &unixnotis_core::NotificationView, + ) -> bool { let Some(query) = self.filter_query.as_ref() else { return true; }; diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs index f3aeef353..dc999d65d 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs @@ -105,6 +105,34 @@ fn group_visibility_and_entry_filter_cover_app_summary_and_body() { assert!(!list.group_has_visible_entries(&terminal_ids)); } +#[gtk::test] +fn notification_counts_report_matches_and_total_for_active_search() { + let mut list = support::make_list(); + let mut terminal = support::notification(1, "Terminal"); + terminal.body = "Build complete".to_string(); + list.seed( + vec![terminal, support::notification(2, "Browser")], + vec![support::notification(3, "Terminal history")], + ); + + let counts = list.notification_counts(); + assert_eq!(counts.matching, 3); + assert_eq!(counts.total, 3); + assert!(!counts.filter_active); + + assert!(list.set_filter_query("terminal")); + let counts = list.notification_counts(); + assert_eq!(counts.matching, 2); + assert_eq!(counts.total, 3); + assert!(counts.filter_active); + + assert!(list.set_filter_query("missing")); + let counts = list.notification_counts(); + assert_eq!(counts.matching, 0); + assert_eq!(counts.total, 3); + assert!(counts.filter_active); +} + #[test] fn ignorable_group_chars_cover_controls_and_zero_width_marks() { assert!(is_ignorable_group_char('\n')); diff --git a/crates/unixnotis-center/src/ui/notifications/model/types.rs b/crates/unixnotis-center/src/ui/notifications/model/types.rs index 9f4cdcc17..7cad39bb3 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/types.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/types.rs @@ -19,6 +19,7 @@ pub struct NotificationList { pub(in crate::ui::notifications) empty_offset_top: i32, pub(in crate::ui::notifications) empty_alignment: EmptyStateAlignment, pub(in crate::ui) empty_text: String, + pub(in crate::ui) no_matching_text: String, pub(in crate::ui::notifications) entries: HashMap, // Active notifications render first to match the in-flight stack pub(in crate::ui::notifications) active_order: VecDeque, @@ -65,10 +66,19 @@ pub struct NotificationListConfig { pub notification_corners: CutCorners, pub show_notification_thumbnails: bool, pub empty_text: String, + pub no_matching_text: String, pub empty_offset_top: i32, pub empty_alignment: EmptyStateAlignment, } +/// Counts used by the panel header for normal and filtered list states +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::ui) struct NotificationCounts { + pub(in crate::ui) matching: usize, + pub(in crate::ui) total: usize, + pub(in crate::ui) filter_active: bool, +} + pub(in crate::ui::notifications) struct NotificationEntry { pub(in crate::ui::notifications) view: Rc, pub(in crate::ui::notifications) is_active: bool, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index 409be3848..22d77ea1f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -62,7 +62,7 @@ pub(super) fn update_metadata_labels( set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); } -pub(super) fn notification_meta_label<'a>( +pub(super) const fn notification_meta_label<'a>( notification: &NotificationView, metadata: &'a NotificationMetadataConfig, ) -> &'a str { diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs index b46577f27..4e52ee75c 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs @@ -1,4 +1,5 @@ use gio::prelude::ListModelExt; +use gtk::prelude::Cast; use gtk::prelude::WidgetExt; use std::rc::Rc; @@ -11,6 +12,16 @@ use super::{ should_keep_group, should_rebuild_from_scratch, }; +fn empty_overlay_text(list: &crate::ui::notifications::NotificationList) -> String { + list.empty_overlay + .first_child() + .expect("empty overlay should contain a label") + .downcast::() + .expect("empty overlay child should be a label") + .text() + .to_string() +} + #[gtk::test] fn request_rebuild_marks_list_dirty() { let mut list = support::make_list(); @@ -161,6 +172,36 @@ fn flush_rebuild_filters_existing_list_with_minimal_middle_splice() { assert_eq!(list.group_ranges[&browser].len, 2); } +#[gtk::test] +fn empty_overlay_distinguishes_no_matches_from_an_empty_notification_store() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + + assert!(list.set_filter_query("missing")); + list.flush_rebuild(); + + assert!(list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No matching notifications"); + + assert!(list.set_filter_query("")); + list.flush_rebuild(); + + assert!(!list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No notifications"); +} + +#[gtk::test] +fn empty_overlay_keeps_normal_copy_when_searching_an_empty_store() { + let mut list = support::make_list(); + + assert!(list.set_filter_query("missing")); + list.flush_rebuild(); + + assert!(list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No notifications"); +} + #[gtk::test] fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { let mut list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/update.rs b/crates/unixnotis-center/src/ui/notifications/store/update.rs index e29c9020f..e0a458a64 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/update.rs @@ -15,6 +15,7 @@ use tracing::debug; use super::blocks; use super::types::{GroupRange, NotificationList, RowKey}; use super::RowItem; +use crate::ui::notifications::row::empty::update_empty_row; impl NotificationList { pub fn flush_rebuild(&mut self) { @@ -279,8 +280,16 @@ impl NotificationList { self.visible_ids_for_group(ids).is_empty().not() } - fn update_empty_overlay(&self) { + pub(in crate::ui::notifications) fn update_empty_overlay(&self) { let is_empty = self.store.n_items() == 0; + let counts = self.notification_counts(); + let text = if counts.filter_active && counts.total > 0 && counts.matching == 0 { + &self.no_matching_text + } else { + &self.empty_text + }; + // Search with existing notifications needs different feedback from a truly empty list + update_empty_row(&self.empty_overlay, text); // Compare against the widget's own visible flag // Effective visibility can flip with parent state and leave the overlay logically stale if self.empty_overlay.get_visible() != is_empty { diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 2f37886eb..9d676de5d 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -27,6 +27,7 @@ pub(super) fn list_config() -> NotificationListConfig { notification_corners: unixnotis_core::CutCorners::default(), show_notification_thumbnails: false, empty_text: "No notifications".to_string(), + no_matching_text: "No matching notifications".to_string(), empty_offset_top: 24, empty_alignment: unixnotis_core::EmptyStateAlignment::Auto, } diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index 0489a06ad..663869881 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -13,7 +13,7 @@ use tokio::sync::mpsc; use crate::control::{UiCommand, UiEvent}; use super::item::RowKind; -use super::row::empty::{build_empty_row, update_empty_row}; +use super::row::empty::build_empty_row; use super::types::{NotificationList, NotificationListConfig}; use super::widgets::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; use crate::ui::icons::IconResolver; @@ -104,6 +104,7 @@ impl NotificationList { empty_offset_top: config.empty_offset_top, empty_alignment: config.empty_alignment, empty_text: config.empty_text, + no_matching_text: config.no_matching_text, entries: std::collections::HashMap::new(), active_order: std::collections::VecDeque::new(), history_order: std::collections::VecDeque::new(), @@ -148,9 +149,13 @@ impl NotificationList { self.notification_corners = config.notification_corners; self.show_notification_thumbnails = config.show_notification_thumbnails; if self.empty_text != config.empty_text { - update_empty_row(&self.empty_overlay, &config.empty_text); self.empty_text = config.empty_text.clone(); } + if self.no_matching_text != config.no_matching_text { + self.no_matching_text = config.no_matching_text.clone(); + } + // The visible copy depends on both configuration and current search state + self.update_empty_overlay(); if self.empty_offset_top != config.empty_offset_top { self.empty_offset_top = config.empty_offset_top; } diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index f7a91ac0f..705b78b66 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -20,6 +20,7 @@ fn new_list_attaches_overlay_to_scroller() { assert!(scroller.child().is_some()); assert_eq!(list.empty_text, "No notifications"); + assert_eq!(list.no_matching_text, "No matching notifications"); assert_eq!(list.empty_offset_top, 24); assert!(list.empty_overlay.get_visible()); } @@ -29,12 +30,14 @@ fn apply_config_updates_empty_copy_and_offset() { let mut list = support::make_list(); let mut config = support::list_config(); config.empty_text = "All clear".to_string(); + config.no_matching_text = "Nothing found".to_string(); config.empty_offset_top = 48; list.apply_config(&config); list.set_empty_layout(true); assert_eq!(list.empty_text, "All clear"); + assert_eq!(list.no_matching_text, "Nothing found"); assert_eq!(list.empty_offset_top, 48); assert_eq!(list.empty_overlay.margin_top(), 48); } diff --git a/crates/unixnotis-center/src/ui/panel/state.rs b/crates/unixnotis-center/src/ui/panel/state.rs index de1621b60..afa720967 100644 --- a/crates/unixnotis-center/src/ui/panel/state.rs +++ b/crates/unixnotis-center/src/ui/panel/state.rs @@ -66,12 +66,22 @@ impl UiState { // Counts are refreshed on the next open to keep the header accurate return; } - // Header count always reflects total active + history entries - let total = self.list.total_count(); - if self.last_count == Some(total) { + let counts = self.list.notification_counts(); + if self.last_count == Some(counts) { return; } - self.last_count = Some(total); - self.panel.header.count.set_text(&format!("{total}")); + self.last_count = Some(counts); + self.panel.header.count.set_text(&format_counts(counts)); } } + +fn format_counts(counts: crate::ui::notifications::NotificationCounts) -> String { + if counts.filter_active { + return format!("{} / {}", counts.matching, counts.total); + } + counts.total.to_string() +} + +#[cfg(test)] +#[path = "tests/state.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/state.rs b/crates/unixnotis-center/src/ui/panel/tests/state.rs new file mode 100644 index 000000000..0353a052e --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/state.rs @@ -0,0 +1,31 @@ +use crate::ui::notifications::NotificationCounts; + +use super::format_counts; + +#[test] +fn count_text_shows_total_when_search_is_inactive() { + let counts = NotificationCounts { + matching: 42, + total: 42, + filter_active: false, + }; + + assert_eq!(format_counts(counts), "42"); +} + +#[test] +fn count_text_shows_matches_over_total_during_search() { + let matches = NotificationCounts { + matching: 3, + total: 42, + filter_active: true, + }; + let no_matches = NotificationCounts { + matching: 0, + total: 42, + filter_active: true, + }; + + assert_eq!(format_counts(matches), "3 / 42"); + assert_eq!(format_counts(no_matches), "0 / 42"); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs index 66917fef7..c24588992 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs @@ -8,12 +8,14 @@ fn reloaded_list_applies_explicit_empty_alignment() { let mut state = state(); let mut config = state.config.clone(); config.panel.empty_text = "Nothing pending".to_string(); + config.panel.no_matching_text = "Nothing found".to_string(); config.panel.empty_alignment = EmptyStateAlignment::End; config.panel.empty_offset_top = 44; state.apply_list_config_after_reload(&config); assert_eq!(state.list.empty_text, "Nothing pending"); + assert_eq!(state.list.no_matching_text, "Nothing found"); assert_eq!(state.list.empty_overlay.valign(), gtk::Align::End); assert_eq!(state.list.empty_overlay.margin_top(), 0); } diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs index cb3f98f14..105f78f06 100644 --- a/crates/unixnotis-center/src/ui/reload/config/widgets.rs +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -34,6 +34,7 @@ impl UiState { notification_corners: config.theme.notification_corners, show_notification_thumbnails: config.panel.notification_thumbnails_visible, empty_text: config.panel.empty_text.clone(), + no_matching_text: config.panel.no_matching_text.clone(), empty_offset_top: config.panel.empty_offset_top, empty_alignment: config.panel.empty_alignment, }; diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index e7d0644c2..5f8f2fb83 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -34,8 +34,8 @@ pub struct UiState { pub(super) panel_visible: bool, pub(super) panel_visible_flag: Arc, pub(super) work_area: Option, - // Tracks the last rendered count to avoid redundant label updates - pub(super) last_count: Option, + // Tracks the last rendered counts to avoid redundant label updates + pub(super) last_count: Option, pub(super) media: Option, pub(super) media_handle: Option, // Holds the most recent media snapshot while the panel is hidden diff --git a/crates/unixnotis-core/src/config/loading/io.rs b/crates/unixnotis-core/src/config/loading/io.rs deleted file mode 100644 index a54cb0a38..000000000 --- a/crates/unixnotis-core/src/config/loading/io.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Configuration loading, path resolution, and on-disk defaults -//! -//! Focuses on I/O and filesystem-related helpers for config management - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; - -use thiserror::Error; -use tracing::warn; - -use crate::filesystem::{make_file_executable, write_file_atomic, write_file_if_missing}; -use crate::util::expand_tilde; -use crate::{ - DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_SCRIPTS, - DEFAULT_WIDGETS_CSS, -}; - -use super::super::runtime::{apply_brightness_backend, apply_volume_backend, sanitize_config}; -use super::super::schema::deserialize_config_with_migrations; -use super::super::{log_config_diagnostics, Config, ConfigLoadReport}; -use super::diagnostics::{ - adjustment_diagnostics, migrated_field_diagnostic, migration_diagnostic, unknown_key_diagnostic, -}; - -static LEGACY_RENAME_WARNED: AtomicBool = AtomicBool::new(false); -static INVALID_XDG_WARNED: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone)] -pub struct ThemePaths { - // Base directory used to resolve relative theme paths - pub base_dir: PathBuf, - pub base_css: PathBuf, - pub popup_css: PathBuf, - pub panel_css: PathBuf, - pub widgets_css: PathBuf, - pub media_css: PathBuf, -} - -#[derive(Debug, Error)] -pub enum ConfigError { - #[error("failed to read config file: {0}")] - ReadFailed(String), - #[error("failed to parse config: {0}")] - ParseFailed(String), - #[error("missing $HOME, unable to resolve config directory")] - MissingHome, -} - -impl ConfigError { - /// Return a stable summary that never includes configuration contents - #[must_use] - pub const fn shareable_summary(&self) -> &'static str { - match self { - Self::ReadFailed(_) => "Configuration file could not be read", - Self::ParseFailed(_) => "Configuration TOML or schema is invalid", - Self::MissingHome => "HOME is missing, so the configuration path cannot resolve", - } - } -} - -impl Config { - /// Load configuration from a specific path - /// - /// # Errors - /// - /// Returns an error when the file cannot be read or its TOML cannot be parsed - pub fn load_from_path(path: &Path) -> Result { - let report = Self::load_from_path_with_report(path)?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Load configuration from a specific path with structured diagnostics - /// - /// # Errors - /// - /// Returns an error when the file cannot be read or its TOML cannot be parsed - pub fn load_from_path_with_report(path: &Path) -> Result { - let contents = - fs::read_to_string(path).map_err(|err| ConfigError::ReadFailed(err.to_string()))?; - Self::parse_with_report(&contents) - } - - /// Parse and migrate configuration text without reading the filesystem - /// - /// # Errors - /// - /// Returns an error for invalid TOML or unsupported schema versions - pub fn parse(contents: &str) -> Result { - let report = Self::parse_with_report(contents)?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Parse and migrate configuration text with structured diagnostics - /// - /// # Errors - /// - /// Returns an error for invalid TOML or unsupported schema versions - pub fn parse_with_report(contents: &str) -> Result { - let (mut config, ignored_keys, migrated_paths) = - deserialize_config_with_migrations(contents).map_err(ConfigError::ParseFailed)?; - let mut diagnostics = migration_diagnostic(contents) - .into_iter() - .collect::>(); - diagnostics.extend(migrated_paths.into_iter().map(migrated_field_diagnostic)); - diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); - let before_runtime = config.clone(); - config.apply_runtime_defaults(); - diagnostics.extend(adjustment_diagnostics(&before_runtime, &config)); - Ok(ConfigLoadReport { - config, - diagnostics, - }) - } - - /// Load configuration from the default XDG config location, if present - /// - /// # Errors - /// - /// Returns an error when the default location cannot be resolved or an existing config file - /// cannot be read and parsed - pub fn load_default() -> Result { - let report = Self::load_default_with_report()?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Load default configuration with structured diagnostics - /// - /// # Errors - /// - /// Returns an error when the default location cannot be resolved or read - pub fn load_default_with_report() -> Result { - let path = Self::default_config_path()?; - if !path.exists() { - let mut config = Self::default(); - let before_runtime = config.clone(); - config.apply_runtime_defaults(); - return Ok(ConfigLoadReport { - diagnostics: adjustment_diagnostics(&before_runtime, &config), - config, - }); - } - Self::load_from_path_with_report(&path) - } - - /// Resolve configured CSS paths relative to the config directory - /// - /// # Errors - /// - /// Returns an error when the default config directory cannot be resolved - pub fn resolve_theme_paths(&self) -> Result { - let base = Self::default_config_dir()?; - self.resolve_theme_paths_from(&base) - } - - /// Resolve the config directory that should anchor relative theme paths - /// - /// # Errors - /// - /// Returns an error when a parentless relative path requires the current directory and that - /// directory cannot be read - pub fn config_dir_for_path(path: &Path) -> Result { - if let Some(parent) = path.parent() { - // Plain file names report an empty parent, so skip that case - if !parent.as_os_str().is_empty() { - return Ok(parent.to_path_buf()); - } - } - env::current_dir().map_err(|err| ConfigError::ReadFailed(err.to_string())) - } - - /// Resolve configured CSS paths relative to an explicit config directory - /// - /// # Errors - /// - /// This operation currently has no failure path; the result type is retained for API - /// compatibility with other theme-resolution helpers - pub fn resolve_theme_paths_from(&self, base: &Path) -> Result { - // Resolve relative paths against the supplied config directory - Ok(ThemePaths { - base_dir: base.to_path_buf(), - base_css: Self::resolve_path(base, &self.theme.base_css), - popup_css: Self::resolve_path(base, &self.theme.popup_css), - panel_css: Self::resolve_path(base, &self.theme.panel_css), - widgets_css: Self::resolve_path(base, &self.theme.widgets_css), - media_css: Self::resolve_path(base, &self.theme.media_css), - }) - } - - /// Ensure all theme files exist in the config directory - /// - /// # Errors - /// - /// Returns an error when a missing theme file cannot be created safely - pub fn ensure_theme_files(&self, theme_paths: &ThemePaths) -> Result<(), ConfigError> { - // Use the same base directory used for resolving theme paths - let config_dir = &theme_paths.base_dir; - - let legacy = config_dir.join("style.css"); - let base_exists = theme_paths.base_css.exists(); - let legacy_contents = if base_exists { - None - } else { - fs::read_to_string(&legacy) - .ok() - .filter(|contents| !contents.trim().is_empty()) - }; - - write_if_missing( - &theme_paths.base_css, - legacy_contents.as_deref().unwrap_or(DEFAULT_BASE_CSS), - )?; - write_if_missing(&theme_paths.panel_css, DEFAULT_PANEL_CSS)?; - write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; - write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; - write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; - - if legacy_contents.is_some() && legacy.exists() { - let backup = legacy.with_extension("css.bak"); - if !backup.exists() { - if let Err(err) = fs::rename(&legacy, &backup) { - // Non-fatal: leave legacy style.css in place if backup fails (permissions, - // existing paths, or filesystem limitations) - if LEGACY_RENAME_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - ?err, - legacy = %legacy.display(), - backup = %backup.display(), - "failed to rename legacy style.css" - ); - } - } - } - } - - Ok(()) - } - - /// Ensure helper scripts used by the shipped default config exist - /// - /// # Errors - /// - /// Returns an error when a missing script cannot be written or made executable - pub fn ensure_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { - for script in DEFAULT_SCRIPTS { - let path = config_dir.join(script.relative_path); - // Existing files are preserved so user-edited helpers are not overwritten - if !path.exists() { - write_default_script(&path, script.contents)?; - } - // Relative commands run the helper directly, so execute bits must be present - set_executable(&path)?; - } - Ok(()) - } - - /// Overwrite helper scripts with the built-in defaults - /// - /// # Errors - /// - /// Returns an error when any script cannot be replaced safely - pub fn write_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { - for script in DEFAULT_SCRIPTS { - write_default_script(&config_dir.join(script.relative_path), script.contents)?; - } - Ok(()) - } - - fn apply_runtime_defaults(&mut self) { - apply_volume_backend(&mut self.widgets.volume); - apply_brightness_backend(&mut self.widgets.brightness); - sanitize_config(self); - } - - /// Return the default config directory based on XDG or $HOME - /// - /// # Errors - /// - /// Returns an error when neither a valid absolute `XDG_CONFIG_HOME` nor `HOME` is available - pub fn default_config_dir() -> Result { - if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { - let trimmed = xdg.trim(); - if !trimmed.is_empty() { - let path = PathBuf::from(trimmed); - if path.is_absolute() { - // Prefer the XDG base directory when it is explicitly configured - return Ok(path.join("unixnotis")); - } - } - if INVALID_XDG_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!("invalid XDG_CONFIG_HOME; falling back to $HOME/.config"); - } - } - let home = env::var("HOME").map_err(|_error| ConfigError::MissingHome)?; - // Fall back to the standard $HOME/.config path for predictable location - Ok(PathBuf::from(home).join(".config").join("unixnotis")) - } - - /// Return the default config file path - /// - /// # Errors - /// - /// Returns an error when the default config directory cannot be resolved - pub fn default_config_path() -> Result { - Ok(Self::default_config_dir()?.join("config.toml")) - } - - /// Resolve the environment-selected config file or the normal default file - /// - /// # Errors - /// - /// Returns an error when no explicit path is set and the default directory cannot resolve - pub fn active_config_path() -> Result { - let configured = - env::var_os(crate::util::CONFIG_PATH_ENV).filter(|value| !value.is_empty()); - configured.map_or_else(Self::default_config_path, |path| Ok(PathBuf::from(path))) - } - - fn resolve_path(base: &Path, value: &str) -> PathBuf { - let path = expand_tilde(value); - let path = PathBuf::from(path.as_ref()); - if path.is_absolute() { - path - } else { - base.join(path) - } - } -} - -fn write_if_missing(path: &Path, contents: &str) -> Result<(), ConfigError> { - write_file_if_missing(path, contents.as_bytes(), 0o644) - .map(|_created| ()) - .map_err(|err| ConfigError::ReadFailed(err.to_string())) -} - -fn write_default_script(path: &Path, contents: &str) -> Result<(), ConfigError> { - // Script reset uses the same atomic path as startup provisioning - // This keeps installer resets from leaving half-written helpers behind - write_file_atomic(path, contents.as_bytes(), 0o755) - .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; - set_executable(path) -} - -#[cfg(unix)] -fn set_executable(path: &Path) -> Result<(), ConfigError> { - make_file_executable(path).map_err(|err| ConfigError::ReadFailed(err.to_string())) -} - -#[cfg(not(unix))] -fn set_executable(_path: &Path) -> Result<(), ConfigError> { - Ok(()) -} - -#[cfg(test)] -#[path = "tests/io/mod.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/error.rs b/crates/unixnotis-core/src/config/loading/io/error.rs new file mode 100644 index 000000000..ada1e24e2 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/error.rs @@ -0,0 +1,28 @@ +//! Errors returned while loading and preparing configuration files + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file: {0}")] + ReadFailed(String), + #[error("failed to parse config: {0}")] + ParseFailed(String), + #[error("configuration file is too large ({size} bytes; maximum {max} bytes)")] + TooLarge { size: u64, max: u64 }, + #[error("missing $HOME, unable to resolve config directory")] + MissingHome, +} + +impl ConfigError { + /// Return a stable summary that never includes configuration contents + #[must_use] + pub const fn shareable_summary(&self) -> &'static str { + match self { + Self::ReadFailed(_) => "Configuration file could not be read", + Self::ParseFailed(_) => "Configuration TOML or schema is invalid", + Self::TooLarge { .. } => "Configuration file exceeds the maximum supported size", + Self::MissingHome => "HOME is missing, so the configuration path cannot resolve", + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/load.rs b/crates/unixnotis-core/src/config/loading/io/load.rs new file mode 100644 index 000000000..ee7cdcde1 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/load.rs @@ -0,0 +1,149 @@ +//! Bounded configuration loading and parsing +//! +//! Focuses on I/O and filesystem-related helpers for config management + +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use crate::config::runtime::{apply_brightness_backend, apply_volume_backend, sanitize_config}; +use crate::config::schema::deserialize_config_with_migrations; +use crate::{log_config_diagnostics, Config, ConfigLoadReport}; + +use super::super::diagnostics::{ + adjustment_diagnostics, migrated_field_diagnostic, migration_diagnostic, unknown_key_diagnostic, +}; +use super::ConfigError; + +/// Maximum accepted `config.toml` size before parsing +pub const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +impl Config { + /// Load configuration from a specific path + /// + /// # Errors + /// + /// Returns an error when the file cannot be read or its TOML cannot be parsed + pub fn load_from_path(path: &Path) -> Result { + let report = Self::load_from_path_with_report(path)?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Load configuration from a specific path with structured diagnostics + /// + /// # Errors + /// + /// Returns an error when the file cannot be read or its TOML cannot be parsed + pub fn load_from_path_with_report(path: &Path) -> Result { + let contents = read_config_bounded(path)?; + Self::parse_with_report(&contents) + } + + /// Parse and migrate configuration text without reading the filesystem + /// + /// # Errors + /// + /// Returns an error for invalid TOML or unsupported schema versions + pub fn parse(contents: &str) -> Result { + let report = Self::parse_with_report(contents)?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Parse and migrate configuration text with structured diagnostics + /// + /// # Errors + /// + /// Returns an error for invalid TOML or unsupported schema versions + pub fn parse_with_report(contents: &str) -> Result { + let (mut config, ignored_keys, migrated_paths) = + deserialize_config_with_migrations(contents).map_err(ConfigError::ParseFailed)?; + let mut diagnostics = migration_diagnostic(contents) + .into_iter() + .collect::>(); + diagnostics.extend(migrated_paths.into_iter().map(migrated_field_diagnostic)); + diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); + let before_runtime = config.clone(); + config.apply_runtime_defaults(); + diagnostics.extend(adjustment_diagnostics(&before_runtime, &config)); + Ok(ConfigLoadReport { + config, + diagnostics, + }) + } + + /// Load configuration from the default XDG config location, if present + /// + /// # Errors + /// + /// Returns an error when the default location cannot be resolved or an existing config file + /// cannot be read and parsed + pub fn load_default() -> Result { + let report = Self::load_default_with_report()?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Load default configuration with structured diagnostics + /// + /// # Errors + /// + /// Returns an error when the default location cannot be resolved or read + pub fn load_default_with_report() -> Result { + let path = Self::default_config_path()?; + if !path.exists() { + let mut config = Self::default(); + let before_runtime = config.clone(); + config.apply_runtime_defaults(); + return Ok(ConfigLoadReport { + diagnostics: adjustment_diagnostics(&before_runtime, &config), + config, + }); + } + Self::load_from_path_with_report(&path) + } + + fn apply_runtime_defaults(&mut self) { + apply_volume_backend(&mut self.widgets.volume); + apply_brightness_backend(&mut self.widgets.brightness); + sanitize_config(self); + } +} + +fn read_config_bounded(path: &Path) -> Result { + // Opening first keeps metadata and reads tied to the same filesystem object + let file = File::open(path).map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + let initial_size = file + .metadata() + .map_err(|err| ConfigError::ReadFailed(err.to_string()))? + .len(); + read_config_contents(file, initial_size) +} + +pub(super) fn read_config_contents( + reader: R, + initial_size: u64, +) -> Result { + if initial_size > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + size: initial_size, + max: MAX_CONFIG_BYTES, + }); + } + + // The extra byte detects files that grow after metadata is checked + let mut contents = String::with_capacity(initial_size as usize); + reader + .take(MAX_CONFIG_BYTES + 1) + .read_to_string(&mut contents) + .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + let observed_size = contents.len() as u64; + if observed_size > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + size: observed_size, + max: MAX_CONFIG_BYTES, + }); + } + Ok(contents) +} diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs new file mode 100644 index 000000000..1d79d60b0 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -0,0 +1,15 @@ +//! Configuration filesystem operations + +mod error; +mod load; +mod paths; +mod scripts; +mod theme_files; +mod write; + +pub use error::ConfigError; +pub use load::MAX_CONFIG_BYTES; +pub use paths::ThemePaths; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/paths.rs b/crates/unixnotis-core/src/config/loading/io/paths.rs new file mode 100644 index 000000000..b1ff903a8 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/paths.rs @@ -0,0 +1,128 @@ +//! Configuration and theme path discovery + +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::warn; + +use crate::util::expand_tilde; +use crate::Config; + +use super::ConfigError; + +static INVALID_XDG_WARNED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone)] +pub struct ThemePaths { + // Base directory used to resolve relative theme paths + pub base_dir: PathBuf, + pub base_css: PathBuf, + pub popup_css: PathBuf, + pub panel_css: PathBuf, + pub widgets_css: PathBuf, + pub media_css: PathBuf, +} + +impl Config { + /// Resolve configured CSS paths relative to the config directory + /// + /// # Errors + /// + /// Returns an error when the default config directory cannot be resolved + pub fn resolve_theme_paths(&self) -> Result { + let base = Self::default_config_dir()?; + self.resolve_theme_paths_from(&base) + } + + /// Resolve the config directory that should anchor relative theme paths + /// + /// # Errors + /// + /// Returns an error when a parentless relative path requires the current directory and that + /// directory cannot be read + pub fn config_dir_for_path(path: &Path) -> Result { + if let Some(parent) = path.parent() { + // Plain file names report an empty parent, so skip that case + if !parent.as_os_str().is_empty() { + return Ok(parent.to_path_buf()); + } + } + env::current_dir().map_err(|err| ConfigError::ReadFailed(err.to_string())) + } + + /// Resolve configured CSS paths relative to an explicit config directory + /// + /// # Errors + /// + /// This operation currently has no failure path; the result type is retained for API + /// compatibility with other theme-resolution helpers + pub fn resolve_theme_paths_from(&self, base: &Path) -> Result { + // Resolve relative paths against the supplied config directory + Ok(ThemePaths { + base_dir: base.to_path_buf(), + base_css: Self::resolve_path(base, &self.theme.base_css), + popup_css: Self::resolve_path(base, &self.theme.popup_css), + panel_css: Self::resolve_path(base, &self.theme.panel_css), + widgets_css: Self::resolve_path(base, &self.theme.widgets_css), + media_css: Self::resolve_path(base, &self.theme.media_css), + }) + } + + /// Return the default config directory based on XDG or $HOME + /// + /// # Errors + /// + /// Returns an error when neither a valid absolute `XDG_CONFIG_HOME` nor `HOME` is available + pub fn default_config_dir() -> Result { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + let trimmed = xdg.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + // Prefer the XDG base directory when it is explicitly configured + return Ok(path.join("unixnotis")); + } + } + if INVALID_XDG_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!("invalid XDG_CONFIG_HOME; falling back to $HOME/.config"); + } + } + let home = env::var("HOME").map_err(|_error| ConfigError::MissingHome)?; + // Fall back to the standard $HOME/.config path for predictable location + Ok(PathBuf::from(home).join(".config").join("unixnotis")) + } + + /// Return the default config file path + /// + /// # Errors + /// + /// Returns an error when the default config directory cannot be resolved + pub fn default_config_path() -> Result { + Ok(Self::default_config_dir()?.join("config.toml")) + } + + /// Resolve the environment-selected config file or the normal default file + /// + /// # Errors + /// + /// Returns an error when no explicit path is set and the default directory cannot resolve + pub fn active_config_path() -> Result { + let configured = + env::var_os(crate::util::CONFIG_PATH_ENV).filter(|value| !value.is_empty()); + configured.map_or_else(Self::default_config_path, |path| Ok(PathBuf::from(path))) + } + + fn resolve_path(base: &Path, value: &str) -> PathBuf { + let path = expand_tilde(value); + let path = PathBuf::from(path.as_ref()); + if path.is_absolute() { + path + } else { + base.join(path) + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/scripts.rs b/crates/unixnotis-core/src/config/loading/io/scripts.rs new file mode 100644 index 000000000..7b34f4c98 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/scripts.rs @@ -0,0 +1,58 @@ +//! Provisioning for built-in helper scripts + +use std::path::Path; + +use crate::filesystem::{make_file_executable, write_file_atomic}; +use crate::{Config, DEFAULT_SCRIPTS}; + +use super::ConfigError; + +impl Config { + /// Ensure helper scripts used by the shipped default config exist + /// + /// # Errors + /// + /// Returns an error when a missing script cannot be written or made executable + pub fn ensure_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { + for script in DEFAULT_SCRIPTS { + let path = config_dir.join(script.relative_path); + // Existing files are preserved so user-edited helpers are not overwritten + if !path.exists() { + write_default_script(&path, script.contents)?; + } + // Relative commands run the helper directly, so execute bits must be present + set_executable(&path)?; + } + Ok(()) + } + + /// Overwrite helper scripts with the built-in defaults + /// + /// # Errors + /// + /// Returns an error when any script cannot be replaced safely + pub fn write_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { + for script in DEFAULT_SCRIPTS { + write_default_script(&config_dir.join(script.relative_path), script.contents)?; + } + Ok(()) + } +} + +fn write_default_script(path: &Path, contents: &str) -> Result<(), ConfigError> { + // Script reset uses the same atomic path as startup provisioning + // This keeps installer resets from leaving half-written helpers behind + write_file_atomic(path, contents.as_bytes(), 0o755) + .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + set_executable(path) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<(), ConfigError> { + make_file_executable(path).map_err(|err| ConfigError::ReadFailed(err.to_string())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<(), ConfigError> { + Ok(()) +} diff --git a/crates/unixnotis-core/src/config/loading/tests/io/load.rs b/crates/unixnotis-core/src/config/loading/io/tests/load.rs similarity index 56% rename from crates/unixnotis-core/src/config/loading/tests/io/load.rs rename to crates/unixnotis-core/src/config/loading/io/tests/load.rs index af393f6b4..f41019d31 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/load.rs @@ -1,9 +1,15 @@ +//! Tests for bounded configuration loading and parsing + use std::fs; +use std::io::Cursor; -use crate::{Config, ConfigError}; +use crate::{Config, ConfigError, MAX_CONFIG_BYTES}; +use super::super::load::read_config_contents; use super::support::{env_lock, test_root, EnvGuard}; +const EXPECTED_MAX_CONFIG_BYTES: usize = 1_048_576; + #[test] fn load_from_path_reads_toml_and_applies_runtime_defaults() { let root = test_root("load-from-path"); @@ -50,6 +56,83 @@ fn load_from_path_returns_parse_error_for_invalid_toml() { let _ = fs::remove_dir_all(&root); } +#[test] +fn parse_returns_the_config_produced_by_the_report_pipeline() { + let config = Config::parse( + r#" + [panel] + title = "Parsed Title" + "#, + ) + .expect("valid config text should parse"); + + assert_eq!(config.panel.title, "Parsed Title"); +} + +#[test] +fn load_from_path_rejects_oversized_config_before_parsing() { + assert_eq!(MAX_CONFIG_BYTES, EXPECTED_MAX_CONFIG_BYTES as u64); + let root = test_root("load-oversized"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("config dir"); + let path = root.join("config.toml"); + let file = fs::File::create(&path).expect("oversized config file"); + // A sparse file exercises the metadata guard without allocating the payload in the test + file.set_len(EXPECTED_MAX_CONFIG_BYTES as u64 + 1) + .expect("oversized config length"); + + let error = Config::load_from_path(&path).expect_err("oversized config should fail"); + + assert!(matches!( + error, + ConfigError::TooLarge { + size, + max, + } if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); + assert_eq!( + error.shareable_summary(), + "Configuration file exceeds the maximum supported size" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_reader_accepts_exact_limit_and_rejects_a_growing_stream() { + assert_eq!(MAX_CONFIG_BYTES, EXPECTED_MAX_CONFIG_BYTES as u64); + let declared_oversized = read_config_contents( + Cursor::new(Vec::::new()), + EXPECTED_MAX_CONFIG_BYTES as u64 + 1, + ) + .expect_err("declared oversized input should fail before reading"); + + assert!(matches!( + declared_oversized, + ConfigError::TooLarge { size, max } + if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); + + let exact = vec![b' '; EXPECTED_MAX_CONFIG_BYTES]; + + let contents = read_config_contents(Cursor::new(exact), EXPECTED_MAX_CONFIG_BYTES as u64) + .expect("a config at the exact size limit should be accepted"); + + assert_eq!(contents.len(), EXPECTED_MAX_CONFIG_BYTES); + + let grew_after_metadata = vec![b' '; EXPECTED_MAX_CONFIG_BYTES + 1]; + let error = read_config_contents(Cursor::new(grew_after_metadata), 0) + .expect_err("a stream that grows beyond the limit should be rejected"); + + assert!(matches!( + error, + ConfigError::TooLarge { size, max } + if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); +} + #[test] fn shareable_error_summaries_never_echo_private_error_details() { let error = ConfigError::ParseFailed("secret_command = 'private-parser-sentinel'".to_string()); diff --git a/crates/unixnotis-core/src/config/loading/tests/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs similarity index 65% rename from crates/unixnotis-core/src/config/loading/tests/io/mod.rs rename to crates/unixnotis-core/src/config/loading/io/tests/mod.rs index 94c531119..fca862943 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -1,4 +1,4 @@ -//! Config I/O test declarations +//! Configuration I/O test declarations mod load; mod paths; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/paths.rs b/crates/unixnotis-core/src/config/loading/io/tests/paths.rs similarity index 98% rename from crates/unixnotis-core/src/config/loading/tests/io/paths.rs rename to crates/unixnotis-core/src/config/loading/io/tests/paths.rs index e30de80e7..be827c058 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/paths.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/paths.rs @@ -1,3 +1,5 @@ +//! Tests for configuration path discovery and resolution + use std::env; use std::path::PathBuf; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/scripts.rs b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs similarity index 99% rename from crates/unixnotis-core/src/config/loading/tests/io/scripts.rs rename to crates/unixnotis-core/src/config/loading/io/tests/scripts.rs index a2b28af20..5b9b4aefe 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/scripts.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs @@ -1,3 +1,5 @@ +//! Tests for provisioning built-in helper scripts + use std::fs; use crate::Config; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/support.rs b/crates/unixnotis-core/src/config/loading/io/tests/support.rs similarity index 95% rename from crates/unixnotis-core/src/config/loading/tests/io/support.rs rename to crates/unixnotis-core/src/config/loading/io/tests/support.rs index 2ab50664e..5099913e4 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/support.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/support.rs @@ -1,3 +1,5 @@ +//! Shared filesystem and environment support for configuration I/O tests + use std::env; use std::ffi::{OsStr, OsString}; use std::path::PathBuf; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs similarity index 98% rename from crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs rename to crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs index 4721a556b..d04544ea0 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs @@ -1,3 +1,5 @@ +//! Tests for provisioning configured theme files + use std::fs; use crate::Config; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/write.rs b/crates/unixnotis-core/src/config/loading/io/tests/write.rs similarity index 79% rename from crates/unixnotis-core/src/config/loading/tests/io/write.rs rename to crates/unixnotis-core/src/config/loading/io/tests/write.rs index a01887575..8ee0c0e47 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/write.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/write.rs @@ -1,3 +1,5 @@ +//! Tests for safe configuration file writes + use std::fs; use super::support::test_root; @@ -11,7 +13,7 @@ fn write_if_missing_preserves_existing_contents() { let path = root.join("file.txt"); fs::write(&path, "keep").expect("existing file"); - super::super::write_if_missing(&path, "replace").expect("write should succeed"); + super::super::write::write_if_missing(&path, "replace").expect("write should succeed"); assert_eq!(fs::read_to_string(&path).expect("file contents"), "keep"); @@ -26,7 +28,7 @@ fn write_if_missing_creates_new_file() { fs::create_dir_all(&root).expect("root"); let path = root.join("file.txt"); - super::super::write_if_missing(&path, "created").expect("write should succeed"); + super::super::write::write_if_missing(&path, "created").expect("write should succeed"); assert_eq!(fs::read_to_string(&path).expect("file contents"), "created"); diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs new file mode 100644 index 000000000..65f491a70 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_files.rs @@ -0,0 +1,69 @@ +//! Provisioning and migration for configured theme files + +use std::fs; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::warn; + +use crate::{ + Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, + DEFAULT_WIDGETS_CSS, +}; + +use super::write::write_if_missing; +use super::{ConfigError, ThemePaths}; + +static LEGACY_RENAME_WARNED: AtomicBool = AtomicBool::new(false); + +impl Config { + /// Ensure all theme files exist in the config directory + /// + /// # Errors + /// + /// Returns an error when a missing theme file cannot be created safely + pub fn ensure_theme_files(&self, theme_paths: &ThemePaths) -> Result<(), ConfigError> { + // Use the same base directory used for resolving theme paths + let config_dir = &theme_paths.base_dir; + + let legacy = config_dir.join("style.css"); + let base_exists = theme_paths.base_css.exists(); + let legacy_contents = if base_exists { + None + } else { + fs::read_to_string(&legacy) + .ok() + .filter(|contents| !contents.trim().is_empty()) + }; + + write_if_missing( + &theme_paths.base_css, + legacy_contents.as_deref().unwrap_or(DEFAULT_BASE_CSS), + )?; + write_if_missing(&theme_paths.panel_css, DEFAULT_PANEL_CSS)?; + write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; + write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; + write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; + + if legacy_contents.is_some() && legacy.exists() { + let backup = legacy.with_extension("css.bak"); + if !backup.exists() { + if let Err(err) = fs::rename(&legacy, &backup) { + // Non-fatal: leave legacy style.css in place if backup fails + if LEGACY_RENAME_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!( + ?err, + legacy = %legacy.display(), + backup = %backup.display(), + "failed to rename legacy style.css" + ); + } + } + } + } + + Ok(()) + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/write.rs b/crates/unixnotis-core/src/config/loading/io/write.rs new file mode 100644 index 000000000..40392c333 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/write.rs @@ -0,0 +1,13 @@ +//! Safe filesystem writes shared by configuration provisioning paths + +use std::path::Path; + +use crate::filesystem::write_file_if_missing; + +use super::ConfigError; + +pub(super) fn write_if_missing(path: &Path, contents: &str) -> Result<(), ConfigError> { + write_file_if_missing(path, contents.as_bytes(), 0o644) + .map(|_created| ()) + .map_err(|err| ConfigError::ReadFailed(err.to_string())) +} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index d36b2aa76..33dfb4e09 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -26,7 +26,7 @@ pub use icon_assets::{ ResolvedIconAsset, DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_BYTES, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; -pub use io::{ConfigError, ThemePaths}; +pub use io::{ConfigError, ThemePaths, MAX_CONFIG_BYTES}; pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; pub use media::*; diff --git a/crates/unixnotis-core/src/config/panel/config.rs b/crates/unixnotis-core/src/config/panel/config.rs index be70d43fe..9ef175e1a 100644 --- a/crates/unixnotis-core/src/config/panel/config.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -27,6 +27,8 @@ pub struct PanelConfig { pub output: Option, /// Text shown when the notification list is empty pub empty_text: String, + /// Text shown when an active search has no matching notifications + pub no_matching_text: String, /// Main heading shown in the panel header pub title: String, /// Secondary text shown below the main heading @@ -111,6 +113,7 @@ impl Default for PanelConfig { keyboard_interactivity: PanelKeyboardInteractivity::OnDemand, output: None, empty_text: "NO NOTIFICATIONS".to_string(), + no_matching_text: "NO MATCHING NOTIFICATIONS".to_string(), title: "Notifications".to_string(), subtitle: String::new(), search_placeholder: "Search app, title, or message".to_string(), diff --git a/crates/unixnotis-core/src/config/panel/dnd.rs b/crates/unixnotis-core/src/config/panel/dnd.rs index 62f98bdab..598e7855b 100644 --- a/crates/unixnotis-core/src/config/panel/dnd.rs +++ b/crates/unixnotis-core/src/config/panel/dnd.rs @@ -35,7 +35,7 @@ impl DndMenuChoice { } /// Return mutable access to the user-facing menu label - pub(in crate::config) fn label_mut(&mut self) -> &mut String { + pub(in crate::config) const fn label_mut(&mut self) -> &mut String { match self { Self::Duration { label, .. } | Self::Tomorrow { label, .. } diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs index b1da8b273..81cf00163 100644 --- a/crates/unixnotis-core/src/config/panel/tests/config.rs +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -26,6 +26,7 @@ fn default_panel_config_keeps_expected_layout_and_text_contract() { )); assert_eq!(panel.title, "Notifications"); assert_eq!(panel.empty_text, "NO NOTIFICATIONS"); + assert_eq!(panel.no_matching_text, "NO MATCHING NOTIFICATIONS"); assert_eq!(panel.empty_offset_top, 24); assert_eq!(panel.empty_alignment, EmptyStateAlignment::Auto); assert_eq!(panel.quick_actions_label, "Quick settings"); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 19ac5d400..2b47994af 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -341,7 +341,7 @@ fn sanitize_dnd_menu_deduplicates_triggers_and_bounds_choices() { ]; config.panel.dnd_menu_choices = vec![ DndMenuChoice::Duration { - label: "".to_string(), + label: String::new(), minutes: 0, }, DndMenuChoice::Duration { diff --git a/crates/unixnotis-popups/src/ui/config_reload.rs b/crates/unixnotis-popups/src/ui/config_reload.rs index 4350e8098..2b4559632 100644 --- a/crates/unixnotis-popups/src/ui/config_reload.rs +++ b/crates/unixnotis-popups/src/ui/config_reload.rs @@ -20,6 +20,7 @@ const fn config_error_kind(error: &ConfigError) -> &'static str { match error { ConfigError::ReadFailed(_) => "read", ConfigError::ParseFailed(_) => "parse", + ConfigError::TooLarge { .. } => "too-large", ConfigError::MissingHome => "missing-home", } } diff --git a/crates/unixnotis-popups/src/ui/tests/config_reload.rs b/crates/unixnotis-popups/src/ui/tests/config_reload.rs index 23fbfe758..ef64be532 100644 --- a/crates/unixnotis-popups/src/ui/tests/config_reload.rs +++ b/crates/unixnotis-popups/src/ui/tests/config_reload.rs @@ -38,6 +38,16 @@ fn rejected_config_logs_never_include_private_parser_text() { assert!(!rendered.contains("private-popup-parser-sentinel")); } +#[test] +fn oversized_config_uses_a_stable_rejection_kind() { + let error = ConfigError::TooLarge { + size: 2_000_000, + max: 1_048_576, + }; + + assert_eq!(config_error_kind(&error), "too-large"); +} + #[test] fn theme_resolution_failure_logs_only_the_stable_stage() { let output = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/unixnotis-ui/src/cut_corner/geometry.rs b/crates/unixnotis-ui/src/cut_corner/geometry.rs index cc3052b89..20cea46eb 100644 --- a/crates/unixnotis-ui/src/cut_corner/geometry.rs +++ b/crates/unixnotis-ui/src/cut_corner/geometry.rs @@ -43,17 +43,20 @@ pub(super) fn build_path(width: f32, height: f32, corners: CutCorners) -> gsk::P path.to_path() } -pub(super) fn contains_point(width: f32, height: f32, corners: CutCorners, x: f64, y: f64) -> bool { - let x = x as f32; - let y = y as f32; +pub(super) fn contains_point(width: f64, height: f64, corners: CutCorners, x: f64, y: f64) -> bool { if x < 0.0 || y < 0.0 || x >= width || y >= height { // GTK hit testing excludes the far allocation edge return false; } - let corners = NormalizedCorners::new(width, height, corners); - x + y >= corners.top_left - && (width - x) + y >= corners.top_right - && (width - x) + (height - y) >= corners.bottom_right - && x + (height - y) >= corners.bottom_left + // Pointer coordinates stay in GTK's native f64 space to avoid lossy input conversion + let limit = (width.max(0.0) / 2.0).min(height.max(0.0) / 2.0); + let top_left = f64::from(corners.top_left).min(limit); + let top_right = f64::from(corners.top_right).min(limit); + let bottom_right = f64::from(corners.bottom_right).min(limit); + let bottom_left = f64::from(corners.bottom_left).min(limit); + x + y >= top_left + && (width - x) + y >= top_right + && (width - x) + (height - y) >= bottom_right + && x + (height - y) >= bottom_left } diff --git a/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs index 349535ccf..2afb35487 100644 --- a/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs +++ b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs @@ -85,14 +85,20 @@ fn rendered_path_and_pointer_shape_match_across_the_plate() { let path = build_path(width, height, corners); // A dense grid catches drift between the visible polygon and pointer hit testing - for y in 0..29 { - for x in 0..37 { + for y in 0_u16..29 { + for x in 0_u16..37 { // Unequal fractions avoid sampling directly on a diagonal boundary - let x = x as f32 + 0.33; - let y = y as f32 + 0.21; + let x = f32::from(x) + 0.33; + let y = f32::from(y) + 0.21; assert_eq!( path.in_fill(&graphene::Point::new(x, y), gsk::FillRule::Winding), - contains_point(width, height, corners, f64::from(x), f64::from(y)), + contains_point( + f64::from(width), + f64::from(height), + corners, + f64::from(x), + f64::from(y) + ), "path and hit test differ at ({x}, {y})" ); } diff --git a/crates/unixnotis-ui/src/cut_corner/widget.rs b/crates/unixnotis-ui/src/cut_corner/widget.rs index 2053ba85d..c4b09e06f 100644 --- a/crates/unixnotis-ui/src/cut_corner/widget.rs +++ b/crates/unixnotis-ui/src/cut_corner/widget.rs @@ -10,7 +10,9 @@ use unixnotis_core::{css::hooks, CutCorners}; use super::geometry::{build_path, contains_point}; mod imp { - use super::*; + use super::{build_path, contains_point, glib, render_dimension, Cell, CutCorners, RefCell}; + use gtk::prelude::*; + use gtk::subclass::prelude::*; #[derive(Default)] pub struct CutCorner { @@ -44,8 +46,8 @@ mod imp { fn contains(&self, x: f64, y: f64) -> bool { let widget = self.obj(); contains_point( - widget.width() as f32, - widget.height() as f32, + f64::from(widget.width()), + f64::from(widget.height()), self.corners.get(), x, y, @@ -68,8 +70,8 @@ mod imp { } let path = build_path( - self.obj().width() as f32, - self.obj().height() as f32, + render_dimension(self.obj().width()), + render_dimension(self.obj().height()), corners, ); // GTK records the child until pop and discards pixels outside this polygon @@ -80,6 +82,14 @@ mod imp { } } +#[expect( + clippy::cast_precision_loss, + reason = "GTK logical dimensions are bounded far below f32's exact integer range" +)] +const fn render_dimension(value: i32) -> f32 { + value as f32 +} + glib::wrapper! { /// Single-child container that clips rendering and pointer hits to diagonal corners pub struct CutCorner(ObjectSubclass) From dbad1140e968088232b1ba47010a319731a42577 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 01:45:59 -0500 Subject: [PATCH 015/275] docs: keep detailed usage in the wiki Summary: keep detailed usage in the wiki. Scope: repository. --- README.md | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1885c76f3..ea3759a0f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ git clone https://github.com/locainin/UnixNotis.wiki.git ## Features - Freedesktop.org notification daemon with history, rules, sound, and DND. -- Persistent DND state across daemon restarts. +- Persistent and timed DND state across daemon restarts. +- KDE-compatible inline replies in the control-center panel for live notifications that advertise reply support. - Control-center panel with widgets, notification list, and media controls. - Toast popup UI with configurable timeouts and styling. - D-Bus inhibit API for programmatic popup suppression. @@ -82,21 +83,6 @@ When launched from a downloaded release, the installer verifies the bundled bina them into `$HOME/.local/bin` instead of building from source. The TUI shows the installed version and reports when a newer GitHub release is available. -For a shareable configuration, theme, D-Bus, and service report, run: - -```sh -noticenterctl doctor -noticenterctl doctor --verbose -noticenterctl doctor --json -noticenterctl doctor --config "$HOME/path/to/config.toml" -noticenterctl css-check --config "$HOME/path/to/config.toml" -``` - -Verbose systemd reports include a sanitized, bounded window of up to 30 user-journal lines. -Review verbose output before posting it because application metadata can still be present. -Dinit, runit, s6-rc, manual, and unknown launches report service status without pretending that -the installed artifacts provide persistent logs. - Maintainers can build a local release archive manually: ```sh From dfde632aeb94190c4d603702571ee7d1d1f360d9 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:31:36 -0500 Subject: [PATCH 016/275] refactor(core): add typed command specifications Summary: add typed command specifications. Scope: core. --- crates/unixnotis-core/src/lib.rs | 2 + crates/unixnotis-core/src/process/legacy.rs | 161 ++++++++++++ crates/unixnotis-core/src/process/mod.rs | 10 + crates/unixnotis-core/src/process/spec.rs | 247 ++++++++++++++++++ .../src/process/tests/legacy.rs | 185 +++++++++++++ .../unixnotis-core/src/process/tests/mod.rs | 2 + .../unixnotis-core/src/process/tests/spec.rs | 93 +++++++ 7 files changed, 700 insertions(+) create mode 100644 crates/unixnotis-core/src/process/legacy.rs create mode 100644 crates/unixnotis-core/src/process/mod.rs create mode 100644 crates/unixnotis-core/src/process/spec.rs create mode 100644 crates/unixnotis-core/src/process/tests/legacy.rs create mode 100644 crates/unixnotis-core/src/process/tests/mod.rs create mode 100644 crates/unixnotis-core/src/process/tests/spec.rs diff --git a/crates/unixnotis-core/src/lib.rs b/crates/unixnotis-core/src/lib.rs index aed752224..df58be14e 100644 --- a/crates/unixnotis-core/src/lib.rs +++ b/crates/unixnotis-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod css; pub mod embedded; pub mod filesystem; pub mod model; +pub mod process; pub mod reconnect; pub mod service_manager; #[cfg(test)] @@ -34,6 +35,7 @@ pub use control::*; pub use css::*; pub use embedded::*; pub use model::*; +pub use process::*; pub use util::program_in_path; /// Compatibility path for script resources published before the embedded module was introduced diff --git a/crates/unixnotis-core/src/process/legacy.rs b/crates/unixnotis-core/src/process/legacy.rs new file mode 100644 index 000000000..d2d889cff --- /dev/null +++ b/crates/unixnotis-core/src/process/legacy.rs @@ -0,0 +1,161 @@ +//! One-way migration from legacy shell-shaped command strings + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use thiserror::Error; + +use super::CommandSpec; + +const VALUE_PLACEHOLDER: &str = "{value}"; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum LegacyCommandError { + #[error("command is empty")] + Empty, + #[error("command contains malformed shell quoting: {0}")] + Malformed(String), + #[error("command contains environment assignments but no program")] + MissingProgram, +} + +/// Convert one legacy command string into an explicit direct or shell specification +/// +/// # Errors +/// +/// Returns an error when the legacy command is empty, malformed, or has no program +pub fn parse_legacy_command(command: &str) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err(LegacyCommandError::Empty); + } + + let parts = shell_words::split(trimmed) + .map_err(|error| LegacyCommandError::Malformed(error.to_string()))?; + // Shell operators are detected before quote removal so literal punctuation stays direct + if contains_shell_syntax(trimmed) { + return Ok(CommandSpec::shell(trimmed)); + } + let (env, remaining) = split_leading_env_assignments(parts); + let mut remaining = remaining.into_iter(); + let program = remaining.next().ok_or(LegacyCommandError::MissingProgram)?; + + let spec = CommandSpec::Direct { + program: PathBuf::from(program), + args: remaining.map(OsString::from).collect(), + env, + }; + if let Some(script) = exact_shell_c_script(&spec) { + return Ok(CommandSpec::shell(script)); + } + Ok(spec) +} + +fn exact_shell_c_script(spec: &CommandSpec) -> Option<&str> { + let CommandSpec::Direct { args, env, .. } = spec else { + return None; + }; + // Environment prefixes and extra operands change shell wrapper semantics + if !env.is_empty() || !spec.invokes_shell() { + return None; + } + let [flag, script] = args.as_slice() else { + return None; + }; + if flag != "-c" { + return None; + } + script.to_str() +} + +fn split_leading_env_assignments( + mut parts: Vec, +) -> (BTreeMap, Vec) { + let assignment_count = parts + .iter() + .take_while(|token| split_env_assignment(token).is_some()) + .count(); + let remaining = parts.split_off(assignment_count); + let env = parts + .iter() + .filter_map(|token| split_env_assignment(token)) + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect(); + (env, remaining) +} + +fn split_env_assignment(token: &str) -> Option<(&str, &str)> { + let (name, value) = token.split_once('=')?; + let mut chars = name.chars(); + let first = chars.next()?; + if !(first == '_' || first.is_ascii_alphabetic()) { + return None; + } + if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) { + return None; + } + Some((name, value)) +} + +fn contains_shell_syntax(command: &str) -> bool { + let mut quote = None; + let mut escaped = false; + let mut token_start = true; + let mut chars = command.char_indices(); + + while let Some((index, character)) = chars.next() { + if escaped { + escaped = false; + token_start = false; + continue; + } + + match quote { + Some('\'') => { + if character == '\'' { + quote = None; + } + continue; + } + Some('"') => { + match character { + '"' => quote = None, + '\\' => escaped = true, + '$' | '`' => return true, + _ => {} + } + continue; + } + Some(_) => unreachable!("legacy scanner stores only shell quote characters"), + None => {} + } + + match character { + '\'' | '"' => { + quote = Some(character); + token_start = false; + } + '\\' => { + escaped = true; + token_start = false; + } + ' ' | '\t' => token_start = true, + '#' | '!' if token_start => return true, + '{' if command[index..].starts_with(VALUE_PLACEHOLDER) => { + // Skip the rest of the known runtime placeholder as literal direct data + for _ in 1..VALUE_PLACEHOLDER.len() { + let _ = chars.next(); + } + token_start = false; + } + '\n' | '\r' | '|' | '&' | ';' | '<' | '>' | '$' | '`' | '(' | ')' | '[' | ']' | '*' + | '?' | '~' | '{' | '}' => return true, + _ => token_start = false, + } + } + + // shell_words validates these states before this classifier runs + debug_assert!(quote.is_none() && !escaped); + false +} diff --git a/crates/unixnotis-core/src/process/mod.rs b/crates/unixnotis-core/src/process/mod.rs new file mode 100644 index 000000000..fc7ad9047 --- /dev/null +++ b/crates/unixnotis-core/src/process/mod.rs @@ -0,0 +1,10 @@ +//! Typed child-process descriptions shared across `UnixNotis` binaries + +mod legacy; +mod spec; + +pub use legacy::{parse_legacy_command, LegacyCommandError}; +pub use spec::CommandSpec; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/process/spec.rs b/crates/unixnotis-core/src/process/spec.rs new file mode 100644 index 000000000..19b75d9e0 --- /dev/null +++ b/crates/unixnotis-core/src/process/spec.rs @@ -0,0 +1,247 @@ +//! Explicit direct and shell command representations + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// One command whose shell boundary is selected by configuration, not inferred at runtime +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum CommandSpec { + /// Executes one program with literal arguments and child-local environment overrides + Direct { + program: PathBuf, + #[serde(default, with = "os_string_vec")] + args: Vec, + #[serde(default, with = "os_string_map")] + env: BTreeMap, + }, + /// Executes one script through the system's POSIX shell + Shell { script: String }, +} + +impl fmt::Display for CommandSpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.display_lossy()) + } +} + +impl CommandSpec { + /// Build a direct command without any shell parsing or expansion + pub fn direct(program: impl Into, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Direct { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + env: BTreeMap::new(), + } + } + + /// Build an explicit POSIX shell command + pub fn shell(script: impl Into) -> Self { + Self::Shell { + script: script.into(), + } + } + + /// Add one child-local environment value to a direct command + #[must_use] + pub fn with_env(mut self, name: impl Into, value: impl Into) -> Self { + if let Self::Direct { env, .. } = &mut self { + env.insert(name.into(), value.into()); + } + self + } + + #[must_use] + pub const fn is_shell(&self) -> bool { + matches!(self, Self::Shell { .. }) + } + + #[must_use] + pub fn invokes_shell(&self) -> bool { + match self { + Self::Shell { .. } => true, + Self::Direct { program, args, .. } => { + let shell = program + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + matches!(name, "sh" | "ash" | "bash" | "dash" | "ksh" | "zsh") + }); + shell + && args.iter().any(|argument| { + argument.to_str().is_some_and(|argument| { + argument + .strip_prefix('-') + .is_some_and(|flags| flags.contains('c')) + }) + }) + } + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + match self { + Self::Direct { program, .. } => program.as_os_str().is_empty(), + Self::Shell { script } => script.trim().is_empty(), + } + } + + #[must_use] + pub fn program(&self) -> Option<&Path> { + match self { + Self::Direct { program, .. } => Some(program), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub fn args(&self) -> Option<&[OsString]> { + match self { + Self::Direct { args, .. } => Some(args), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub const fn env(&self) -> Option<&BTreeMap> { + match self { + Self::Direct { env, .. } => Some(env), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub fn script(&self) -> Option<&str> { + match self { + Self::Direct { .. } => None, + Self::Shell { script } => Some(script), + } + } + + /// Replace a runtime placeholder without reparsing direct arguments + #[must_use] + pub fn replace(&self, placeholder: &str, value: &str) -> Self { + match self { + Self::Direct { program, args, env } => Self::Direct { + program: replace_os(program.as_os_str(), placeholder, value).into(), + args: args + .iter() + .map(|arg| replace_os(arg, placeholder, value)) + .collect(), + env: env + .iter() + .map(|(name, current)| { + ( + name.clone(), + replace_os(current.as_os_str(), placeholder, value), + ) + }) + .collect(), + }, + Self::Shell { script } => Self::shell(script.replace(placeholder, value)), + } + } + + /// Produce bounded-log input without changing execution semantics + #[must_use] + pub fn display_lossy(&self) -> String { + match self { + Self::Direct { program, args, .. } => { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(program.as_os_str().to_string_lossy().into_owned()); + parts.extend(args.iter().map(|arg| arg.to_string_lossy().into_owned())); + parts.join(" ") + } + Self::Shell { script } => script.clone(), + } + } +} + +fn replace_os(value: &OsStr, placeholder: &str, replacement: &str) -> OsString { + // TOML-originated values are UTF-8; non-UTF-8 programmatic values remain byte-for-byte stable + value.to_str().map_or_else( + || value.to_os_string(), + |value| OsString::from(value.replace(placeholder, replacement)), + ) +} + +mod os_string_vec { + use std::ffi::OsString; + + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) fn serialize(values: &[OsString], serializer: S) -> Result + where + S: Serializer, + { + values + .iter() + .map(|value| { + value + .to_str() + .ok_or_else(|| serde::ser::Error::custom("command argument is not UTF-8")) + }) + .collect::, _>>()? + .serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(deserializer) + .map(|values| values.into_iter().map(OsString::from).collect()) + } +} + +mod os_string_map { + use std::collections::BTreeMap; + use std::ffi::OsString; + + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) fn serialize( + values: &BTreeMap, + serializer: S, + ) -> Result + where + S: Serializer, + { + let values = values + .iter() + .map(|(name, value)| { + let name = name + .to_str() + .ok_or_else(|| serde::ser::Error::custom("environment name is not UTF-8"))?; + let value = value + .to_str() + .ok_or_else(|| serde::ser::Error::custom("environment value is not UTF-8"))?; + Ok((name, value)) + }) + .collect::, S::Error>>()?; + values.serialize(serializer) + } + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + BTreeMap::::deserialize(deserializer).map(|values| { + values + .into_iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() + }) + } +} diff --git a/crates/unixnotis-core/src/process/tests/legacy.rs b/crates/unixnotis-core/src/process/tests/legacy.rs new file mode 100644 index 000000000..14e93a07f --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/legacy.rs @@ -0,0 +1,185 @@ +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use super::super::{parse_legacy_command, CommandSpec, LegacyCommandError}; + +#[test] +fn quoted_shell_punctuation_migrates_to_literal_direct_arguments() { + let parsed = parse_legacy_command("printf '%s\\n' 'battery|charging'") + .expect("parse quoted literal command"); + + assert_eq!(parsed.program(), Some(Path::new("printf"))); + assert_eq!( + parsed.args(), + Some([OsString::from("%s\\n"), OsString::from("battery|charging")].as_slice()) + ); + assert!(!parsed.is_shell()); +} + +#[test] +fn leading_environment_assignments_migrate_to_direct_environment() { + let parsed = parse_legacy_command("LANG=C MODE='two words' /bin/printf ok") + .expect("parse environment command"); + + assert_eq!(parsed.program(), Some(Path::new("/bin/printf"))); + let env = parsed.env().expect("direct environment"); + assert_eq!(env.get(OsStr::new("LANG")), Some(&"C".into())); + assert_eq!(env.get(OsStr::new("MODE")), Some(&"two words".into())); +} + +#[test] +fn real_shell_operators_remain_explicit_shell_scripts() { + for command in [ + "producer | parser", + "first && second", + "echo $HOME", + "printf '%s' \"$HOME\"", + "echo *.png", + ] { + assert_eq!( + parse_legacy_command(command).expect("parse shell command"), + CommandSpec::shell(command), + "{command}" + ); + } +} + +#[test] +fn legacy_shell_c_wrapper_migrates_to_the_inner_explicit_script() { + assert_eq!( + parse_legacy_command("sh -c 'producer | parser'").expect("parse shell wrapper"), + CommandSpec::shell("producer | parser") + ); +} + +#[test] +fn shell_wrappers_with_environment_or_extra_arguments_remain_direct() { + for command in ["MODE=safe sh -c 'exit 0'", "sh -c 'exit 0' extra"] { + let parsed = parse_legacy_command(command).expect("parse shell wrapper"); + + assert!(!parsed.is_shell(), "{command}"); + assert!(parsed.invokes_shell(), "{command}"); + } +} + +#[test] +fn ordinary_two_argument_commands_never_become_shell_scripts() { + let parsed = parse_legacy_command("printf -c literal").expect("parse direct command"); + + assert!(!parsed.is_shell()); + assert_eq!(parsed.program(), Some(Path::new("printf"))); +} + +#[test] +fn escaped_metacharacters_and_runtime_placeholders_stay_direct() { + for command in [ + r"printf battery\|charging", + "wpctl set-volume sink {value}%", + r"printf \$HOME", + ] { + assert!( + !parse_legacy_command(command) + .expect("parse direct command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn escaped_quotes_do_not_expose_literal_shell_punctuation() { + for command in [ + r#"printf "literal\"|value""#, + r"printf 'literal|value'", + r"printf literal\|value", + ] { + assert!( + !parse_legacy_command(command) + .expect("parse quoted direct command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn shell_expansion_inside_double_quotes_remains_shell_mode() { + for command in [r#"printf "$HOME""#, r#"printf "`pwd`""#] { + assert!( + parse_legacy_command(command) + .expect("parse expanded command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn comments_and_history_expansion_only_trigger_at_token_boundaries() { + for command in ["printf value#suffix", "printf value!suffix"] { + assert!( + !parse_legacy_command(command) + .expect("parse literal token") + .is_shell(), + "{command}" + ); + } + for command in ["printf value # comment", "printf value ! history"] { + assert!( + parse_legacy_command(command) + .expect("parse shell token") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn unknown_or_unbalanced_braces_require_explicit_shell_mode() { + for command in ["printf {other}", "printf value}", "printf {value}{other}"] { + assert!( + parse_legacy_command(command) + .expect("parse brace command") + .is_shell(), + "{command}" + ); + } + + assert!(!parse_legacy_command("printf {value}") + .expect("parse runtime placeholder") + .is_shell()); +} + +#[test] +fn environment_assignment_names_follow_portable_identifier_rules() { + let parsed = parse_legacy_command("_A=1 A2=two /bin/true").expect("parse valid assignments"); + let env = parsed.env().expect("direct environment"); + assert_eq!(env.get(OsStr::new("_A")), Some(&OsString::from("1"))); + assert_eq!(env.get(OsStr::new("A2")), Some(&OsString::from("two"))); + + for command in [ + "1A=value /bin/true", + "A-B=value /bin/true", + "=value /bin/true", + ] { + let parsed = parse_legacy_command(command).expect("parse non-assignment token"); + assert_eq!( + parsed.program(), + Some(Path::new(command.split_whitespace().next().unwrap())) + ); + assert!(parsed.env().expect("direct environment").is_empty()); + } +} + +#[test] +fn invalid_legacy_commands_fail_closed() { + assert_eq!(parse_legacy_command(" "), Err(LegacyCommandError::Empty)); + assert!(matches!( + parse_legacy_command("echo 'unterminated"), + Err(LegacyCommandError::Malformed(_)) + )); + assert_eq!( + parse_legacy_command("NAME=value"), + Err(LegacyCommandError::MissingProgram) + ); +} diff --git a/crates/unixnotis-core/src/process/tests/mod.rs b/crates/unixnotis-core/src/process/tests/mod.rs new file mode 100644 index 000000000..efc97803f --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/mod.rs @@ -0,0 +1,2 @@ +mod legacy; +mod spec; diff --git a/crates/unixnotis-core/src/process/tests/spec.rs b/crates/unixnotis-core/src/process/tests/spec.rs new file mode 100644 index 000000000..50fcb30d0 --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/spec.rs @@ -0,0 +1,93 @@ +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use super::super::CommandSpec; + +#[test] +fn direct_spec_round_trips_through_toml_as_strings() { + let spec = + CommandSpec::direct("wpctl", ["get-volume", "@DEFAULT_AUDIO_SINK@"]).with_env("LANG", "C"); + let encoded = toml::to_string(&spec).expect("serialize direct command"); + let decoded: CommandSpec = toml::from_str(&encoded).expect("deserialize direct command"); + + assert_eq!(decoded, spec); + assert!(encoded.contains("mode = \"direct\"")); + assert!(encoded.contains("args = [\"get-volume\", \"@DEFAULT_AUDIO_SINK@\"]")); +} + +#[test] +fn placeholder_replacement_preserves_direct_command_boundaries() { + let spec = CommandSpec::direct("tool", ["--value={value}", "literal space"]) + .with_env("CURRENT", "{value}"); + let replaced = spec.replace("{value}", "42"); + + assert_eq!(replaced.program(), Some(Path::new("tool"))); + assert_eq!( + replaced.args(), + Some( + [ + OsString::from("--value=42"), + OsString::from("literal space") + ] + .as_slice() + ) + ); + assert_eq!( + replaced + .env() + .expect("direct environment") + .get(OsStr::new("CURRENT")), + Some(&"42".into()) + ); +} + +#[test] +fn placeholder_replacement_updates_explicit_shell_script_without_reclassification() { + let replaced = CommandSpec::shell("producer {value} | parser").replace("{value}", "7"); + + assert_eq!(replaced, CommandSpec::shell("producer 7 | parser")); +} + +#[test] +fn shell_detection_includes_direct_interpreter_invocations() { + assert!(CommandSpec::shell("printf ready").invokes_shell()); + assert!(CommandSpec::direct("/bin/sh", ["-c", "printf ready"]).invokes_shell()); + assert!(CommandSpec::direct("bash", ["-lc", "printf ready"]).invokes_shell()); + assert!(!CommandSpec::direct("sh", ["-x", "script"]).invokes_shell()); + assert!(!CommandSpec::direct("printf", ["sh -c"]).invokes_shell()); +} + +#[test] +fn command_accessors_distinguish_direct_and_shell_data() { + let direct = CommandSpec::direct("printf", ["literal value"]); + let shell = CommandSpec::shell("producer | parser"); + + assert_eq!(direct.program(), Some(Path::new("printf"))); + assert_eq!(direct.script(), None); + assert_eq!(shell.program(), None); + assert_eq!(shell.args(), None); + assert_eq!(shell.env(), None); + assert_eq!(shell.script(), Some("producer | parser")); +} + +#[test] +fn empty_commands_are_detected_in_both_explicit_modes() { + assert!(CommandSpec::direct("", [] as [&str; 0]).is_empty()); + assert!(!CommandSpec::direct("printf", [] as [&str; 0]).is_empty()); + assert!(CommandSpec::shell(" \t\n").is_empty()); + assert!(!CommandSpec::shell("true").is_empty()); +} + +#[test] +fn command_display_keeps_program_arguments_and_shell_script_readable() { + let direct = CommandSpec::direct("printf", ["literal value", "battery|charging"]); + let shell = CommandSpec::shell("producer | parser"); + + assert_eq!( + direct.display_lossy(), + "printf literal value battery|charging" + ); + assert_eq!(direct.to_string(), "printf literal value battery|charging"); + assert_eq!(shell.display_lossy(), "producer | parser"); + assert_eq!(shell.to_string(), "producer | parser"); +} From 3d9e8d709bfee72854c3ef7b4e6a08d4d279d300 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:31:46 -0500 Subject: [PATCH 017/275] refactor(config): require explicit command execution modes Summary: require explicit command execution modes. Scope: config. --- .../src/config/command/defaults.rs | 69 ++++++++--- .../unixnotis-core/src/config/command/mod.rs | 3 - .../src/config/command/parse.rs | 107 ----------------- .../src/config/command/tests/defaults.rs | 14 ++- .../src/config/command/tests/mod.rs | 1 - .../src/config/command/tests/parse.rs | 58 ---------- crates/unixnotis-core/src/config/mod.rs | 1 - .../src/config/runtime/sanitize/plugins.rs | 8 +- .../src/config/runtime/sanitize/shell.rs | 17 +-- .../config/runtime/sanitize/tests/pipeline.rs | 12 +- .../config/runtime/sanitize/tests/plugins.rs | 27 ++++- .../config/runtime/sanitize/tests/shell.rs | 98 ++++++++++------ .../src/config/runtime/tests/widgets.rs | 41 +++---- .../src/config/runtime/widgets.rs | 33 +++--- crates/unixnotis-core/src/config/types.rs | 2 +- .../src/config/validation/schema.rs | 108 +++++++++++++++++- .../src/config/validation/tests/schema.rs | 64 ++++++++++- .../src/config/widgets/cards.rs | 3 +- .../unixnotis-core/src/config/widgets/mod.rs | 2 +- .../src/config/widgets/plugin.rs | 6 +- .../src/config/widgets/sliders.rs | 59 ++++++---- .../src/config/widgets/stats.rs | 18 ++- .../src/config/widgets/tests/cards.rs | 13 ++- .../src/config/widgets/tests/sliders.rs | 38 +++--- .../src/config/widgets/tests/stats.rs | 18 ++- .../src/config/widgets/tests/toggles.rs | 69 +++++++---- .../src/config/widgets/toggles.rs | 65 +++++++---- crates/unixnotis-core/src/util/commands.rs | 36 ------ crates/unixnotis-core/src/util/mod.rs | 2 - .../unixnotis-core/src/util/tests/commands.rs | 34 ------ 30 files changed, 553 insertions(+), 473 deletions(-) delete mode 100644 crates/unixnotis-core/src/config/command/parse.rs delete mode 100644 crates/unixnotis-core/src/config/command/tests/parse.rs delete mode 100644 crates/unixnotis-core/src/util/commands.rs delete mode 100644 crates/unixnotis-core/src/util/tests/commands.rs diff --git a/crates/unixnotis-core/src/config/command/defaults.rs b/crates/unixnotis-core/src/config/command/defaults.rs index a571058fb..2f0e17f3f 100644 --- a/crates/unixnotis-core/src/config/command/defaults.rs +++ b/crates/unixnotis-core/src/config/command/defaults.rs @@ -1,21 +1,54 @@ -//! Shared command templates for widget defaults and runtime migrations - -pub const WIFI_STATE_NMCLI: &str = "nmcli radio wifi"; -pub const WIFI_ON_NMCLI: &str = "nmcli radio wifi on"; -pub const WIFI_OFF_NMCLI: &str = "nmcli radio wifi off"; -pub const WIFI_WATCH_NMCLI: &str = "nmcli -t monitor"; - -pub const BLUETOOTH_STATE_BLUETOOTHCTL: &str = "bluetoothctl show"; -pub const BLUETOOTH_ON_BLUETOOTHCTL: &str = "bluetoothctl power on"; -pub const BLUETOOTH_OFF_BLUETOOTHCTL: &str = "bluetoothctl power off"; -// D-Bus monitoring keeps updates flowing without a controlling terminal -pub const BLUETOOTH_WATCH_DBUS: &str = "dbus-monitor --system type=signal,sender=org.bluez"; - -pub const AIRPLANE_STATE_CMD: &str = - "rfkill list all | awk '/Soft blocked:/ { seen=1; if ($3 != \"yes\") bad=1 } END { exit (seen && !bad) ? 0 : 1 }'"; -pub const AIRPLANE_ON_CMD: &str = "rfkill block all"; -pub const AIRPLANE_OFF_CMD: &str = "rfkill unblock all"; -pub const AIRPLANE_WATCH_CMD: &str = "udevadm monitor --udev --subsystem-match=rfkill"; +//! Shared typed command templates for widget defaults and runtime migrations + +use crate::CommandSpec; + +pub fn wifi_state() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi"]) +} + +pub fn wifi_on() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi", "on"]) +} + +pub fn wifi_off() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi", "off"]) +} + +pub fn wifi_watch() -> CommandSpec { + CommandSpec::direct("nmcli", ["-t", "monitor"]) +} + +pub fn bluetooth_state() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["show"]) +} + +pub fn bluetooth_on() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["power", "on"]) +} + +pub fn bluetooth_off() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["power", "off"]) +} + +pub fn bluetooth_watch() -> CommandSpec { + CommandSpec::direct("dbus-monitor", ["--system", "type=signal,sender=org.bluez"]) +} + +pub fn airplane_state() -> CommandSpec { + CommandSpec::direct("rfkill", ["--json"]) +} + +pub fn airplane_on() -> CommandSpec { + CommandSpec::direct("rfkill", ["block", "all"]) +} + +pub fn airplane_off() -> CommandSpec { + CommandSpec::direct("rfkill", ["unblock", "all"]) +} + +pub fn airplane_watch() -> CommandSpec { + CommandSpec::direct("udevadm", ["monitor", "--udev", "--subsystem-match=rfkill"]) +} pub const TOGGLE_KIND_WIFI: &str = "wifi"; pub const TOGGLE_KIND_BLUETOOTH: &str = "bluetooth"; diff --git a/crates/unixnotis-core/src/config/command/mod.rs b/crates/unixnotis-core/src/config/command/mod.rs index 10d5d2fee..a870d58be 100644 --- a/crates/unixnotis-core/src/config/command/mod.rs +++ b/crates/unixnotis-core/src/config/command/mod.rs @@ -1,9 +1,6 @@ //! Command parsing and built-in widget command templates pub(super) mod defaults; -mod parse; - -pub use parse::{parse_command, CommandParseError, ExecutionMode, ParsedCommand}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/config/command/parse.rs b/crates/unixnotis-core/src/config/command/parse.rs deleted file mode 100644 index ec82304e3..000000000 --- a/crates/unixnotis-core/src/config/command/parse.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Shared widget command parsing policy - -use thiserror::Error; - -use crate::util::SHELL_META_CHARS; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ExecutionMode { - // Direct commands are spawned without a shell - Direct, - // Shell commands retain syntax that must be interpreted by `sh -c` - Shell, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ParsedCommand { - // Leading assignments apply only to the spawned command - pub env: Vec<(String, String)>, - // Program and arguments are unquoted exactly once by the shared parser - pub program: String, - pub args: Vec, - pub execution_mode: ExecutionMode, -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum CommandParseError { - #[error("command is empty")] - Empty, - #[error("command contains malformed shell quoting: {0}")] - Malformed(String), - #[error("command contains environment assignments but no program")] - MissingProgram, -} - -/// Parse one simple command into environment assignments, program, and arguments -/// -/// # Errors -/// -/// Returns an error when the command is empty, malformed, or contains no program -pub fn parse_command(command: &str) -> Result { - let trimmed = command.trim(); - if trimmed.is_empty() { - return Err(CommandParseError::Empty); - } - - // One parser owns quote removal for runtime execution and preset review - let parts = shell_words::split(trimmed) - .map_err(|error| CommandParseError::Malformed(error.to_string()))?; - let (env, remaining) = split_leading_env_assignments(parts); - let mut remaining = remaining.into_iter(); - let program = remaining.next().ok_or(CommandParseError::MissingProgram)?; - let args = remaining.collect(); - - // Shell syntax remains explicit even when tokenization succeeds - let execution_mode = if requires_shell(trimmed) { - ExecutionMode::Shell - } else { - ExecutionMode::Direct - }; - - Ok(ParsedCommand { - env, - program, - args, - execution_mode, - }) -} - -fn split_leading_env_assignments(mut parts: Vec) -> (Vec<(String, String)>, Vec) { - let mut env = Vec::new(); - let mut index = 0; - - // Assignment scanning ends at the first token that is not a valid shell name - while let Some(token) = parts.get(index) { - let Some((name, value)) = split_env_assignment(token) else { - break; - }; - env.push((name.to_string(), value.to_string())); - index += 1; - } - - // Split ownership at the first program token so arguments are not cloned - let remaining = parts.split_off(index); - (env, remaining) -} - -fn split_env_assignment(token: &str) -> Option<(&str, &str)> { - let (name, value) = token.split_once('=')?; - let mut chars = name.chars(); - let first = chars.next()?; - if !(first == '_' || first.is_ascii_alphabetic()) { - return None; - } - if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) { - return None; - } - Some((name, value)) -} - -fn requires_shell(command: &str) -> bool { - command.chars().any(|character| { - SHELL_META_CHARS.contains(&character) - || character == '~' - || character == '\n' - || character == '\r' - }) -} diff --git a/crates/unixnotis-core/src/config/command/tests/defaults.rs b/crates/unixnotis-core/src/config/command/tests/defaults.rs index 141aafe19..a72558eb0 100644 --- a/crates/unixnotis-core/src/config/command/tests/defaults.rs +++ b/crates/unixnotis-core/src/config/command/tests/defaults.rs @@ -1,6 +1,6 @@ use super::super::defaults::{ - BLUETOOTH_WATCH_DBUS, TOGGLE_KIND_AIRPLANE, TOGGLE_KIND_BLUETOOTH, TOGGLE_KIND_NIGHT, - TOGGLE_KIND_WIFI, WIFI_STATE_NMCLI, + bluetooth_watch, wifi_state, TOGGLE_KIND_AIRPLANE, TOGGLE_KIND_BLUETOOTH, TOGGLE_KIND_NIGHT, + TOGGLE_KIND_WIFI, }; #[test] @@ -13,6 +13,12 @@ fn built_in_toggle_kinds_and_watch_commands_remain_nonempty() { ] { assert!(!kind.is_empty()); } - assert!(WIFI_STATE_NMCLI.starts_with("nmcli ")); - assert!(BLUETOOTH_WATCH_DBUS.starts_with("dbus-monitor ")); + assert_eq!( + wifi_state().program().and_then(|path| path.to_str()), + Some("nmcli") + ); + assert_eq!( + bluetooth_watch().program().and_then(|path| path.to_str()), + Some("dbus-monitor") + ); } diff --git a/crates/unixnotis-core/src/config/command/tests/mod.rs b/crates/unixnotis-core/src/config/command/tests/mod.rs index 49eb869aa..59595b2c1 100644 --- a/crates/unixnotis-core/src/config/command/tests/mod.rs +++ b/crates/unixnotis-core/src/config/command/tests/mod.rs @@ -1,2 +1 @@ mod defaults; -mod parse; diff --git a/crates/unixnotis-core/src/config/command/tests/parse.rs b/crates/unixnotis-core/src/config/command/tests/parse.rs deleted file mode 100644 index f61ff25ee..000000000 --- a/crates/unixnotis-core/src/config/command/tests/parse.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::{parse_command, CommandParseError, ExecutionMode}; - -#[test] -fn quoted_assignments_and_arguments_are_unquoted_once() { - let parsed = parse_command("LD_PRELOAD=\"/tmp/library.so\" VAR='two words' /bin/true done") - .expect("parse quoted command"); - - assert_eq!( - parsed.env, - vec![ - ("LD_PRELOAD".to_string(), "/tmp/library.so".to_string()), - ("VAR".to_string(), "two words".to_string()), - ] - ); - assert_eq!(parsed.program, "/bin/true"); - assert_eq!(parsed.args, vec!["done"]); - assert_eq!(parsed.execution_mode, ExecutionMode::Direct); -} - -#[test] -fn shell_syntax_is_classified_for_shell_execution() { - for command in ["~/bin/probe", "echo ok | wc -l", "echo one\recho two"] { - assert_eq!( - parse_command(command) - .expect("parse shell command") - .execution_mode, - ExecutionMode::Shell - ); - } -} - -#[test] -fn malformed_quoting_and_assignment_only_commands_are_rejected() { - assert!(matches!( - parse_command("echo \"unterminated"), - Err(CommandParseError::Malformed(_)) - )); - assert_eq!( - parse_command("HOME=/tmp"), - Err(CommandParseError::MissingProgram) - ); -} - -#[test] -fn invalid_environment_names_remain_program_tokens() { - let parsed = parse_command("1INVALID=value /bin/true").expect("parse invalid assignment name"); - - assert!(parsed.env.is_empty()); - assert_eq!(parsed.program, "1INVALID=value"); -} - -#[test] -fn escaped_spaces_remain_inside_the_program_token() { - let parsed = parse_command("scripts/escaped\\ path/tool --check").expect("parse escaped path"); - - assert_eq!(parsed.program, "scripts/escaped path/tool"); - assert_eq!(parsed.args, vec!["--check"]); -} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 33dfb4e09..fbb5be607 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -14,7 +14,6 @@ mod validation; mod widgets; pub(in crate::config) use appearance::{corners, icon_assets, theme}; -pub use command::{parse_command, CommandParseError, ExecutionMode, ParsedCommand}; pub use corners::CutCorners; pub use diagnostics::{ log_config_diagnostics, ConfigDiagnostic, ConfigDiagnosticKind, ConfigLoadReport, diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs index d88189199..6e05060ee 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs @@ -4,7 +4,6 @@ use super::{ super::super::{Config, SliderWidgetConfig, WidgetPluginConfig}, MAX_CARD_HEIGHT, }; -use crate::util; pub(super) const MIN_PLUGIN_TIMEOUT_MS: u64 = 100; pub(super) const MAX_PLUGIN_TIMEOUT_MS: u64 = 30_000; @@ -114,8 +113,7 @@ fn sanitize_widget_plugin( return; } - let command = plugin_cfg.command.trim(); - if command.is_empty() { + if plugin_cfg.command.is_empty() { // Empty commands only look configured but can never run warn!( widget_type, @@ -124,7 +122,7 @@ fn sanitize_widget_plugin( *plugin = None; return; } - if !util::is_simple_command(command) { + if plugin_cfg.command.invokes_shell() { // Shell syntax is not allowed in the plugin command field warn!( widget_type, @@ -133,8 +131,6 @@ fn sanitize_widget_plugin( *plugin = None; return; } - plugin_cfg.command = command.to_string(); - if plugin_cfg.timeout_ms == 0 { // Zero timeout falls back to the canonical plugin default plugin_cfg.timeout_ms = WidgetPluginConfig::default().timeout_ms; diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs index 5d2874f33..4b3a189f9 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs @@ -1,7 +1,7 @@ use tracing::warn; use super::super::super::Config; -use crate::{program_in_path, util}; +use crate::{program_in_path, CommandSpec}; pub(super) fn warn_missing_shell(config: &Config) -> bool { // Only warn when the config actually depends on shell syntax @@ -66,19 +66,12 @@ fn config_requires_shell(config: &Config) -> bool { }) } -fn command_requires_shell_opt(value: &Option) -> bool { - value.as_deref().is_some_and(command_requires_shell) +fn command_requires_shell_opt(value: &Option) -> bool { + value.as_ref().is_some_and(command_requires_shell) } -fn command_requires_shell(cmd: &str) -> bool { - let cmd = cmd.trim(); - if cmd.is_empty() { - return false; - } - - // Strip known runtime placeholders so braces do not trigger false positives - let cmd = cmd.replace("{value}", "0"); - !util::is_simple_command(&cmd) +fn command_requires_shell(command: &CommandSpec) -> bool { + command.invokes_shell() } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 2b47994af..9f6ddc517 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -1,5 +1,6 @@ use super::super::super::super::widgets::{CardWidgetConfig, StatWidgetConfig}; use super::*; +use crate::CommandSpec; use crate::{ Config, DndMenuChoice, DndMenuTrigger, PanelActionConfig, PanelActionId, PanelConfig, PanelSection, PanelWidgetSection, PopupConfig, ToggleLayout, WidgetPluginConfig, @@ -138,7 +139,7 @@ proptest! { let mut config = Config::default(); config.widgets.stats[0].plugin = Some(WidgetPluginConfig { api_version, - command, + command: CommandSpec::direct(command, [] as [&str; 0]), ..WidgetPluginConfig::default() }); @@ -523,7 +524,7 @@ fn widget_toggle_tooltips_parse_cleanly() { enabled = true label = "Custom Action" icon = "applications-system-symbolic" - toggle_cmd = "scripts/custom-action" + toggle_cmd = { mode = "direct", program = "scripts/custom-action" } "#, ) .expect("config should parse"); @@ -535,7 +536,10 @@ fn widget_toggle_tooltips_parse_cleanly() { assert_eq!(config.widgets.stat_columns, 4); assert_eq!(config.widgets.card_columns, 1); assert_eq!( - config.widgets.toggles[0].toggle_cmd.as_deref(), - Some("scripts/custom-action") + config.widgets.toggles[0].toggle_cmd, + Some(CommandSpec::direct( + "scripts/custom-action", + [] as [&str; 0] + )) ); } diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs index a94b8007f..07da2bb03 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs @@ -5,14 +5,13 @@ use super::super::super::super::widgets::WidgetPluginConfig; use super::super::*; -use crate::Config; +use crate::{CommandSpec, Config}; #[test] -fn sanitize_widget_plugin_clamps_bounds_and_trim_command() { - // Plugin commands should be trimmed and bounded before any worker runs them +fn sanitize_widget_plugin_clamps_bounds_and_preserves_literal_arguments() { let mut config = Config::default(); config.widgets.stats[0].plugin = Some(WidgetPluginConfig { - command: " script arg ".to_string(), + command: CommandSpec::direct("script", [" literal arg "]), timeout_ms: super::super::plugins::MAX_PLUGIN_TIMEOUT_MS + 1, max_output_bytes: super::super::plugins::MAX_PLUGIN_OUTPUT_BYTES + 10, ..WidgetPluginConfig::default() @@ -23,7 +22,10 @@ fn sanitize_widget_plugin_clamps_bounds_and_trim_command() { .plugin .as_ref() .expect("plugin should remain enabled"); - assert_eq!(plugin.command, "script arg"); + assert_eq!( + plugin.command, + CommandSpec::direct("script", [" literal arg "]) + ); assert_eq!( plugin.timeout_ms, super::super::plugins::MAX_PLUGIN_TIMEOUT_MS @@ -39,13 +41,26 @@ fn sanitize_widget_plugin_rejects_shell_meta_commands() { // Shell syntax is not allowed in the simple plugin command field let mut config = Config::default(); config.widgets.cards[0].plugin = Some(WidgetPluginConfig { - command: "sh -c 'echo pwned | cat'".to_string(), + command: CommandSpec::shell("echo pwned | cat"), ..WidgetPluginConfig::default() }); sanitize_config(&mut config); assert!(config.widgets.cards[0].plugin.is_none()); } +#[test] +fn sanitize_widget_plugin_rejects_direct_shell_interpreters() { + let mut config = Config::default(); + config.widgets.cards[0].plugin = Some(WidgetPluginConfig { + command: CommandSpec::direct("sh", ["-c", "printf unsafe"]), + ..WidgetPluginConfig::default() + }); + + sanitize_config(&mut config); + + assert!(config.widgets.cards[0].plugin.is_none()); +} + #[test] fn sanitize_widget_options_caps_decorative_layout_counts() { let mut config = Config::default(); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs index e52d6bcc8..708f5a0e3 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs @@ -52,12 +52,12 @@ fn write_fake_program(dir: &std::path::Path, name: &str) { fn config_without_shell_commands() -> Config { let mut config = Config::default(); - config.widgets.volume.get_cmd = "volume-get".to_string(); - config.widgets.volume.set_cmd = "volume-set {value}".to_string(); + config.widgets.volume.get_cmd = CommandSpec::direct("volume-get", [] as [&str; 0]); + config.widgets.volume.set_cmd = CommandSpec::direct("volume-set", ["{value}"]); config.widgets.volume.toggle_cmd = None; config.widgets.volume.watch_cmd = None; - config.widgets.brightness.get_cmd = "brightness-get".to_string(); - config.widgets.brightness.set_cmd = "brightness-set {value}".to_string(); + config.widgets.brightness.get_cmd = CommandSpec::direct("brightness-get", [] as [&str; 0]); + config.widgets.brightness.set_cmd = CommandSpec::direct("brightness-set", ["{value}"]); config.widgets.brightness.toggle_cmd = None; config.widgets.brightness.watch_cmd = None; config.widgets.toggles.clear(); @@ -68,15 +68,23 @@ fn config_without_shell_commands() -> Config { #[test] fn command_requires_shell_accepts_plain_commands_and_placeholders() { - // The slider placeholder is replaced before shell-character checks run - assert!(!command_requires_shell("notify-send hello")); - assert!(!command_requires_shell("wpctl set-volume sink {value}%")); - assert!(!command_requires_shell(" ")); + assert!(!command_requires_shell(&CommandSpec::direct( + "notify-send", + ["hello"] + ))); + assert!(!command_requires_shell(&CommandSpec::direct( + "wpctl", + ["set-volume", "sink", "{value}%"] + ))); + assert!(!command_requires_shell(&CommandSpec::direct( + "", + [] as [&str; 0] + ))); } #[test] fn command_requires_shell_rejects_shell_syntax() { - for command in [ + for script in [ "echo hi | wc -l", "echo hi && echo bye", "echo > file", @@ -84,8 +92,8 @@ fn command_requires_shell_rejects_shell_syntax() { "echo ~/file", ] { assert!( - command_requires_shell(command), - "command should need shell: {command}" + command_requires_shell(&CommandSpec::shell(script)), + "command should need shell: {script}" ); } } @@ -93,12 +101,13 @@ fn command_requires_shell_rejects_shell_syntax() { #[test] fn optional_command_requires_shell_only_when_present_and_complex() { assert!(!command_requires_shell_opt(&None)); - assert!(!command_requires_shell_opt(&Some( - "notify-send hi".to_string() - ))); - assert!(command_requires_shell_opt(&Some( - "notify-send hi | cat".to_string() - ))); + assert!(!command_requires_shell_opt(&Some(CommandSpec::direct( + "notify-send", + ["hi"] + )))); + assert!(command_requires_shell_opt(&Some(CommandSpec::shell( + "notify-send hi | cat" + )))); } #[test] @@ -107,7 +116,7 @@ fn config_requires_shell_checks_volume_and_brightness_commands() { assert!(config_requires_shell(&Config { widgets: crate::WidgetsConfig { volume: SliderWidgetConfig { - get_cmd: "echo volume | cat".to_string(), + get_cmd: CommandSpec::shell("echo volume | cat"), ..config.widgets.volume.clone() }, ..config.widgets.clone() @@ -115,28 +124,33 @@ fn config_requires_shell_checks_volume_and_brightness_commands() { ..config.clone() })); - config.widgets.brightness.set_cmd = "brightnessctl s {value}% && notify-send done".to_string(); + config.widgets.brightness.set_cmd = + CommandSpec::shell("brightnessctl s {value}% && notify-send done"); assert!(config_requires_shell(&config)); } #[test] fn config_requires_shell_checks_each_slider_command_branch() { let slider_cases: [fn(&mut Config); 8] = [ - |config: &mut Config| config.widgets.volume.get_cmd = "echo get | cat".to_string(), - |config: &mut Config| config.widgets.volume.set_cmd = "echo set | cat".to_string(), + |config: &mut Config| config.widgets.volume.get_cmd = CommandSpec::shell("echo get | cat"), + |config: &mut Config| config.widgets.volume.set_cmd = CommandSpec::shell("echo set | cat"), + |config: &mut Config| { + config.widgets.volume.toggle_cmd = Some(CommandSpec::shell("echo toggle | cat")); + }, |config: &mut Config| { - config.widgets.volume.toggle_cmd = Some("echo toggle | cat".to_string()); + config.widgets.volume.watch_cmd = Some(CommandSpec::shell("echo watch | cat")); }, |config: &mut Config| { - config.widgets.volume.watch_cmd = Some("echo watch | cat".to_string()); + config.widgets.brightness.get_cmd = CommandSpec::shell("echo bget | cat"); }, - |config: &mut Config| config.widgets.brightness.get_cmd = "echo bget | cat".to_string(), - |config: &mut Config| config.widgets.brightness.set_cmd = "echo bset | cat".to_string(), |config: &mut Config| { - config.widgets.brightness.toggle_cmd = Some("echo btoggle | cat".to_string()); + config.widgets.brightness.set_cmd = CommandSpec::shell("echo bset | cat"); }, |config: &mut Config| { - config.widgets.brightness.watch_cmd = Some("echo bwatch | cat".to_string()); + config.widgets.brightness.toggle_cmd = Some(CommandSpec::shell("echo btoggle | cat")); + }, + |config: &mut Config| { + config.widgets.brightness.watch_cmd = Some(CommandSpec::shell("echo bwatch | cat")); }, ]; @@ -155,7 +169,7 @@ fn config_requires_shell_checks_each_slider_command_branch() { fn config_requires_shell_checks_toggle_commands() { let mut config = Config::default(); config.widgets.toggles = vec![ToggleWidgetConfig { - toggle_cmd: Some("echo toggle | cat".to_string()), + toggle_cmd: Some(CommandSpec::shell("echo toggle | cat")), ..ToggleWidgetConfig::default() }]; @@ -165,11 +179,19 @@ fn config_requires_shell_checks_toggle_commands() { #[test] fn config_requires_shell_checks_each_toggle_command_branch() { let toggle_cases: [fn(&mut ToggleWidgetConfig); 5] = [ - |toggle: &mut ToggleWidgetConfig| toggle.state_cmd = Some("echo state | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.toggle_cmd = Some("echo toggle | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.on_cmd = Some("echo on | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.off_cmd = Some("echo off | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.watch_cmd = Some("echo watch | cat".to_string()), + |toggle: &mut ToggleWidgetConfig| { + toggle.state_cmd = Some(CommandSpec::shell("echo state | cat")); + }, + |toggle: &mut ToggleWidgetConfig| { + toggle.toggle_cmd = Some(CommandSpec::shell("echo toggle | cat")); + }, + |toggle: &mut ToggleWidgetConfig| toggle.on_cmd = Some(CommandSpec::shell("echo on | cat")), + |toggle: &mut ToggleWidgetConfig| { + toggle.off_cmd = Some(CommandSpec::shell("echo off | cat")); + }, + |toggle: &mut ToggleWidgetConfig| { + toggle.watch_cmd = Some(CommandSpec::shell("echo watch | cat")); + }, ]; for make_shell_command in toggle_cases { @@ -188,7 +210,7 @@ fn config_requires_shell_checks_each_toggle_command_branch() { fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut stat_config = config_without_shell_commands(); stat_config.widgets.stats = vec![StatWidgetConfig { - cmd: Some("echo stat | cat".to_string()), + cmd: Some(CommandSpec::shell("echo stat | cat")), ..StatWidgetConfig::default() }]; assert!(config_requires_shell(&stat_config)); @@ -196,7 +218,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut stat_plugin = config_without_shell_commands(); stat_plugin.widgets.stats = vec![StatWidgetConfig { plugin: Some(WidgetPluginConfig { - command: "echo stat-plugin | cat".to_string(), + command: CommandSpec::shell("echo stat-plugin | cat"), ..WidgetPluginConfig::default() }), ..StatWidgetConfig::default() @@ -205,7 +227,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut card_config = config_without_shell_commands(); card_config.widgets.cards = vec![CardWidgetConfig { - cmd: Some("echo card | cat".to_string()), + cmd: Some(CommandSpec::shell("echo card | cat")), ..CardWidgetConfig::default() }]; assert!(config_requires_shell(&card_config)); @@ -213,7 +235,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut card_plugin = config_without_shell_commands(); card_plugin.widgets.cards = vec![CardWidgetConfig { plugin: Some(WidgetPluginConfig { - command: "echo card-plugin | cat".to_string(), + command: CommandSpec::shell("echo card-plugin | cat"), ..WidgetPluginConfig::default() }), ..CardWidgetConfig::default() @@ -231,7 +253,7 @@ fn warn_missing_shell_reports_only_when_shell_is_missing_and_needed() { let previous = set_path(&root); let mut config = Config::default(); config.widgets.toggles = vec![ToggleWidgetConfig { - toggle_cmd: Some("echo toggle | cat".to_string()), + toggle_cmd: Some(CommandSpec::shell("echo toggle | cat")), ..ToggleWidgetConfig::default() }]; config.widgets.stats.clear(); diff --git a/crates/unixnotis-core/src/config/runtime/tests/widgets.rs b/crates/unixnotis-core/src/config/runtime/tests/widgets.rs index d234284d1..7b31a0945 100644 --- a/crates/unixnotis-core/src/config/runtime/tests/widgets.rs +++ b/crates/unixnotis-core/src/config/runtime/tests/widgets.rs @@ -54,8 +54,8 @@ fn custom_volume_without_watch_stays_config_owned() { label: "Volume".to_string(), icon: "audio-volume-high-symbolic".to_string(), icon_muted: None, - get_cmd: "custom-volume-get".to_string(), - set_cmd: "custom-volume-set {value}".to_string(), + get_cmd: CommandSpec::direct("custom-volume-get", [] as [&str; 0]), + set_cmd: CommandSpec::direct("custom-volume-set", ["{value}"]), toggle_cmd: None, watch_cmd: None, min: 0.0, @@ -84,15 +84,15 @@ fn partial_stock_volume_commands_do_not_migrate_to_pactl() { let cases = [ SliderWidgetConfig { - get_cmd: "custom get".to_string(), + get_cmd: CommandSpec::direct("custom", ["get"]), ..SliderWidgetConfig::default() }, SliderWidgetConfig { - set_cmd: "custom set {value}".to_string(), + set_cmd: CommandSpec::direct("custom", ["set", "{value}"]), ..SliderWidgetConfig::default() }, SliderWidgetConfig { - toggle_cmd: Some("custom toggle".to_string()), + toggle_cmd: Some(CommandSpec::direct("custom", ["toggle"])), ..SliderWidgetConfig::default() }, ]; @@ -126,16 +126,10 @@ fn stock_volume_uses_pactl_when_wpctl_is_missing() { apply_volume_backend(&mut volume); assert!(volume.enabled); - assert_eq!(volume.get_cmd, SliderWidgetConfig::PACTL_GET); - assert_eq!(volume.set_cmd, SliderWidgetConfig::PACTL_SET); - assert_eq!( - volume.toggle_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_TOGGLE) - ); - assert_eq!( - volume.watch_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_WATCH) - ); + assert_eq!(volume.get_cmd, SliderWidgetConfig::pactl_get()); + assert_eq!(volume.set_cmd, SliderWidgetConfig::pactl_set()); + assert_eq!(volume.toggle_cmd, Some(SliderWidgetConfig::pactl_toggle())); + assert_eq!(volume.watch_cmd, Some(SliderWidgetConfig::pactl_watch())); restore_path(previous); let _ = fs::remove_dir_all(root); @@ -154,12 +148,9 @@ fn stock_volume_keeps_wpctl_when_available() { apply_volume_backend(&mut volume); assert!(volume.enabled); - assert_eq!(volume.get_cmd, SliderWidgetConfig::WPCTL_GET); - assert_eq!(volume.set_cmd, SliderWidgetConfig::WPCTL_SET); - assert_eq!( - volume.watch_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_WATCH) - ); + assert_eq!(volume.get_cmd, SliderWidgetConfig::wpctl_get()); + assert_eq!(volume.set_cmd, SliderWidgetConfig::wpctl_set()); + assert_eq!(volume.watch_cmd, Some(SliderWidgetConfig::pactl_watch())); restore_path(previous); let _ = fs::remove_dir_all(root); @@ -190,7 +181,7 @@ fn legacy_wpctl_watch_is_removed_when_pactl_is_missing() { let previous = set_path(&root); let mut volume = SliderWidgetConfig { - watch_cmd: Some("wpctl subscribe".to_string()), + watch_cmd: Some(CommandSpec::direct("wpctl", ["subscribe"])), ..SliderWidgetConfig::default() }; apply_volume_backend(&mut volume); @@ -208,10 +199,10 @@ fn legacy_brightness_watch_is_removed() { label: "Brightness".to_string(), icon: "display-brightness-symbolic".to_string(), icon_muted: None, - get_cmd: "brightnessctl -m".to_string(), - set_cmd: "brightnessctl s {value}%".to_string(), + get_cmd: CommandSpec::direct("brightnessctl", ["-m"]), + set_cmd: CommandSpec::direct("brightnessctl", ["s", "{value}%"]), toggle_cmd: None, - watch_cmd: Some("brightnessctl -w".to_string()), + watch_cmd: Some(CommandSpec::direct("brightnessctl", ["-w"])), min: 1.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-core/src/config/runtime/widgets.rs b/crates/unixnotis-core/src/config/runtime/widgets.rs index 3e6231cd6..81386885c 100644 --- a/crates/unixnotis-core/src/config/runtime/widgets.rs +++ b/crates/unixnotis-core/src/config/runtime/widgets.rs @@ -1,22 +1,23 @@ //! Runtime adjustments for slider widget backends use super::super::{NumericParseMode, SliderWidgetConfig}; -use crate::program_in_path; +use crate::{program_in_path, CommandSpec}; use tracing::warn; -const LEGACY_WPCTL_WATCH: &str = "wpctl subscribe"; - pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { if !volume.enabled { return; } - let is_wpctl_default = volume.get_cmd == SliderWidgetConfig::WPCTL_GET - && volume.set_cmd == SliderWidgetConfig::WPCTL_SET + let is_wpctl_default = volume.get_cmd == SliderWidgetConfig::wpctl_get() + && volume.set_cmd == SliderWidgetConfig::wpctl_set() && volume .toggle_cmd - .as_deref() - .is_some_and(|cmd| cmd == SliderWidgetConfig::WPCTL_TOGGLE); - let watch_is_legacy = volume.watch_cmd.as_deref() == Some(LEGACY_WPCTL_WATCH); + .as_ref() + .is_some_and(|cmd| *cmd == SliderWidgetConfig::wpctl_toggle()); + let watch_is_legacy = volume + .watch_cmd + .as_ref() + .is_some_and(|command| *command == CommandSpec::direct("wpctl", ["subscribe"])); let pactl_available = program_in_path("pactl"); let wpctl_available = program_in_path("wpctl"); @@ -25,7 +26,7 @@ pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { if watch_needs_stock_backfill || watch_is_legacy { if pactl_available { // Prefer the documented long-running `pactl subscribe` watcher when available - volume.watch_cmd = Some(SliderWidgetConfig::PACTL_WATCH.to_string()); + volume.watch_cmd = Some(SliderWidgetConfig::pactl_watch()); } else if watch_is_legacy { // Avoid spawning the legacy wpctl watcher that is not part of `wpctl` CLI volume.watch_cmd = None; @@ -40,13 +41,13 @@ pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { } if pactl_available { // pactl is the compatible fallback when wpctl is not installed - volume.get_cmd = SliderWidgetConfig::PACTL_GET.to_string(); - volume.set_cmd = SliderWidgetConfig::PACTL_SET.to_string(); - volume.toggle_cmd = Some(SliderWidgetConfig::PACTL_TOGGLE.to_string()); + volume.get_cmd = SliderWidgetConfig::pactl_get(); + volume.set_cmd = SliderWidgetConfig::pactl_set(); + volume.toggle_cmd = Some(SliderWidgetConfig::pactl_toggle()); // Fall back to auto parsing because pactl output differs from wpctl ratios volume.parse_mode = NumericParseMode::Auto; if volume.watch_cmd.is_none() { - volume.watch_cmd = Some(SliderWidgetConfig::PACTL_WATCH.to_string()); + volume.watch_cmd = Some(SliderWidgetConfig::pactl_watch()); } } else { // Disable the widget explicitly when no supported backend is present @@ -59,7 +60,11 @@ pub(in super::super) fn apply_brightness_backend(brightness: &mut SliderWidgetCo if !brightness.enabled { return; } - if brightness.watch_cmd.as_deref() == Some("brightnessctl -w") { + if brightness + .watch_cmd + .as_ref() + .is_some_and(|command| *command == CommandSpec::direct("brightnessctl", ["-w"])) + { // Remove the legacy watch flag because brightnessctl has no watch mode brightness.watch_cmd = None; } diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index dca9ec600..a28394f92 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -12,7 +12,7 @@ use super::rules::RuleConfig; use super::theme::ThemeConfig; use super::widgets::WidgetsConfig; -pub const CURRENT_CONFIG_VERSION: u32 = 2; +pub const CURRENT_CONFIG_VERSION: u32 = 3; /// Top-level configuration loaded from config.toml #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index 33f9dc415..ef5bec0e2 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -3,6 +3,7 @@ use serde::de::IntoDeserializer; use super::super::{Config, CURRENT_CONFIG_VERSION}; +use crate::{parse_legacy_command, CommandSpec}; pub(in crate::config) fn deserialize_config_with_migrations( contents: &str, @@ -110,7 +111,15 @@ fn migrate_document(document: &mut toml::Value) -> Result migrate_legacy_layout(root), + 0 | 1 => { + let result = migrate_legacy_layout(root); + migrate_legacy_commands(root)?; + result + } + 2 => { + migrate_legacy_commands(root)?; + MigrationResult::default() + } CURRENT_CONFIG_VERSION => MigrationResult::default(), _ => return Err(format!("unsupported config version {version}")), }; @@ -121,6 +130,103 @@ fn migrate_document(document: &mut toml::Value) -> Result Result<(), String> { + let Some(widgets) = root.get_mut("widgets").and_then(toml::Value::as_table_mut) else { + return Ok(()); + }; + + for slider_name in ["volume", "brightness"] { + let Some(slider) = widgets + .get_mut(slider_name) + .and_then(toml::Value::as_table_mut) + else { + continue; + }; + for field in ["get_cmd", "set_cmd", "toggle_cmd", "watch_cmd"] { + migrate_command_field(slider, field, &format!("widgets.{slider_name}.{field}"))?; + } + } + + migrate_command_array( + widgets, + "toggles", + &["state_cmd", "toggle_cmd", "on_cmd", "off_cmd", "watch_cmd"], + )?; + for collection in ["stats", "cards"] { + migrate_command_array(widgets, collection, &["cmd"])?; + migrate_plugin_commands(widgets, collection)?; + } + Ok(()) +} + +fn migrate_command_array( + widgets: &mut toml::Table, + collection: &str, + fields: &[&str], +) -> Result<(), String> { + let Some(entries) = widgets + .get_mut(collection) + .and_then(toml::Value::as_array_mut) + else { + return Ok(()); + }; + for (index, entry) in entries.iter_mut().enumerate() { + let Some(table) = entry.as_table_mut() else { + continue; + }; + for field in fields { + migrate_command_field( + table, + field, + &format!("widgets.{collection}[{index}].{field}"), + )?; + } + } + Ok(()) +} + +fn migrate_plugin_commands(widgets: &mut toml::Table, collection: &str) -> Result<(), String> { + let Some(entries) = widgets + .get_mut(collection) + .and_then(toml::Value::as_array_mut) + else { + return Ok(()); + }; + for (index, entry) in entries.iter_mut().enumerate() { + let Some(plugin) = entry + .as_table_mut() + .and_then(|table| table.get_mut("plugin")) + .and_then(toml::Value::as_table_mut) + else { + continue; + }; + migrate_command_field( + plugin, + "command", + &format!("widgets.{collection}[{index}].plugin.command"), + )?; + } + Ok(()) +} + +fn migrate_command_field(table: &mut toml::Table, field: &str, path: &str) -> Result<(), String> { + let Some(value) = table.get_mut(field) else { + return Ok(()); + }; + let Some(command) = value.as_str() else { + return Ok(()); + }; + let spec = if command.trim().is_empty() { + CommandSpec::direct("", std::iter::empty::<&str>()) + } else { + parse_legacy_command(command) + .map_err(|error| format!("failed to migrate {path}: {error}"))? + }; + *value = toml::Value::try_from(spec) + .map_err(|error| format!("failed to migrate {path}: {error}"))?; + Ok(()) +} + fn migrate_legacy_layout(root: &mut toml::Table) -> MigrationResult { // Missing legacy tables still represent omitted old fields, not a request for new defaults if let Some(panel) = child_table_or_insert(root, "panel") { diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index 59557e9f5..5115300d5 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -1,8 +1,8 @@ use super::*; -use crate::{PanelSection, PanelWidgetSection, WidgetDensity}; +use crate::{CommandSpec, PanelSection, PanelWidgetSection, WidgetDensity}; const LEGACY_FIXTURE: &str = include_str!("fixtures/config-v0.toml"); -const CURRENT_PARTIAL_FIXTURE: &str = include_str!("fixtures/config-v2-partial.toml"); +const V2_PARTIAL_FIXTURE: &str = include_str!("fixtures/config-v2-partial.toml"); fn deserialize_config(contents: &str) -> Result<(Config, Vec), String> { let (config, ignored_keys, _migrated_paths) = deserialize_config_with_migrations(contents)?; @@ -39,9 +39,9 @@ fn unversioned_fixture_migrates_to_the_legacy_layout() { } #[test] -fn current_partial_fixture_uses_current_defaults() { +fn version_two_partial_fixture_migrates_and_uses_current_defaults() { let (config, ignored) = - deserialize_config(CURRENT_PARTIAL_FIXTURE).expect("parse current config"); + deserialize_config(V2_PARTIAL_FIXTURE).expect("parse version two config"); assert!(ignored.is_empty()); assert_eq!(config.panel.quick_actions_label, "Quick settings"); @@ -51,6 +51,62 @@ fn current_partial_fixture_uses_current_defaults() { assert_eq!(config.media.art_size_px, 48); } +#[test] +fn version_two_commands_migrate_quoted_punctuation_to_direct_and_operators_to_shell() { + let input = r#" + config_version = 2 + + [widgets.volume] + get_cmd = "printf '%s\\n' 'battery|charging'" + set_cmd = "producer | parser" + "#; + + let (config, ignored) = deserialize_config(input).expect("migrate version two commands"); + + assert!(ignored.is_empty()); + assert_eq!( + config.widgets.volume.get_cmd, + CommandSpec::direct("printf", ["%s\\n", "battery|charging"]) + ); + assert_eq!( + config.widgets.volume.set_cmd, + CommandSpec::shell("producer | parser") + ); +} + +#[test] +fn version_three_requires_explicit_command_mode() { + let legacy = r#" + config_version = 3 + + [widgets.volume] + get_cmd = "printf ready" + "#; + let error = deserialize_config(legacy).expect_err("reject a string command in version three"); + + assert!(error.contains("expected internally tagged enum CommandSpec")); +} + +#[test] +fn version_three_accepts_structured_direct_commands_without_inference() { + let input = r#" + config_version = 3 + + [widgets.volume.get_cmd] + mode = "direct" + program = "printf" + args = ["battery|charging"] + "#; + + let (config, ignored) = deserialize_config(input).expect("parse version three command"); + + assert!(ignored.is_empty()); + assert_eq!( + config.widgets.volume.get_cmd, + CommandSpec::direct("printf", ["battery|charging"]) + ); +} + #[test] fn future_schema_is_rejected_instead_of_guessed() { let error = deserialize_config("config_version = 999\n").expect_err("reject future config"); diff --git a/crates/unixnotis-core/src/config/widgets/cards.rs b/crates/unixnotis-core/src/config/widgets/cards.rs index 5c848c43d..0c6c949e5 100644 --- a/crates/unixnotis-core/src/config/widgets/cards.rs +++ b/crates/unixnotis-core/src/config/widgets/cards.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::WidgetPluginConfig; +use crate::CommandSpec; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] @@ -12,7 +13,7 @@ pub struct CardWidgetConfig { pub subtitle: Option, pub icon: Option, pub icon_asset: Option, - pub cmd: Option, + pub cmd: Option, /// External plugin source for this card (preferred over cmd when set) pub plugin: Option, pub min_height: i32, diff --git a/crates/unixnotis-core/src/config/widgets/mod.rs b/crates/unixnotis-core/src/config/widgets/mod.rs index 13f358f0a..7b74a1cf3 100644 --- a/crates/unixnotis-core/src/config/widgets/mod.rs +++ b/crates/unixnotis-core/src/config/widgets/mod.rs @@ -12,4 +12,4 @@ pub use self::plugin::WidgetPluginConfig; pub use self::settings::{WidgetDensity, WidgetsConfig}; pub use self::sliders::{NumericParseMode, SliderWidgetConfig}; pub use self::stats::StatWidgetConfig; -pub use self::toggles::{ToggleLayout, ToggleWidgetConfig}; +pub use self::toggles::{ToggleBackend, ToggleLayout, ToggleWidgetConfig}; diff --git a/crates/unixnotis-core/src/config/widgets/plugin.rs b/crates/unixnotis-core/src/config/widgets/plugin.rs index ecd1e1e29..60160818c 100644 --- a/crates/unixnotis-core/src/config/widgets/plugin.rs +++ b/crates/unixnotis-core/src/config/widgets/plugin.rs @@ -1,12 +1,14 @@ use serde::{Deserialize, Serialize}; +use crate::CommandSpec; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] pub struct WidgetPluginConfig { /// Versioned widget plugin contract pub api_version: u32, /// Plugin command executed by the widget worker - pub command: String, + pub command: CommandSpec, /// Maximum allowed command runtime before timeout (milliseconds) pub timeout_ms: u64, /// Maximum accepted stdout payload size before parse rejection @@ -23,7 +25,7 @@ impl Default for WidgetPluginConfig { fn default() -> Self { Self { api_version: Self::API_VERSION_V1, - command: String::new(), + command: CommandSpec::direct("", std::iter::empty::<&str>()), timeout_ms: Self::DEFAULT_TIMEOUT_MS, max_output_bytes: Self::DEFAULT_MAX_OUTPUT_BYTES, } diff --git a/crates/unixnotis-core/src/config/widgets/sliders.rs b/crates/unixnotis-core/src/config/widgets/sliders.rs index 66b4d830a..df43f27cd 100644 --- a/crates/unixnotis-core/src/config/widgets/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/sliders.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::CommandSpec; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(default)] pub struct SliderWidgetConfig { @@ -9,10 +11,10 @@ pub struct SliderWidgetConfig { pub label: String, pub icon: String, pub icon_muted: Option, - pub get_cmd: String, - pub set_cmd: String, - pub toggle_cmd: Option, - pub watch_cmd: Option, + pub get_cmd: CommandSpec, + pub set_cmd: CommandSpec, + pub toggle_cmd: Option, + pub watch_cmd: Option, pub min: f64, pub max: f64, pub step: f64, @@ -32,22 +34,37 @@ pub struct SliderWidgetConfig { impl SliderWidgetConfig { // wpctl is the stock PipeWire path and stays shell-free for the common case - pub(in crate::config) const WPCTL_GET: &'static str = "wpctl get-volume @DEFAULT_AUDIO_SINK@"; - pub(in crate::config) const WPCTL_SET: &'static str = - "wpctl set-volume @DEFAULT_AUDIO_SINK@ {value}%"; - pub(in crate::config) const WPCTL_TOGGLE: &'static str = - "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; + pub(in crate::config) fn wpctl_get() -> CommandSpec { + CommandSpec::direct("wpctl", ["get-volume", "@DEFAULT_AUDIO_SINK@"]) + } + + pub(in crate::config) fn wpctl_set() -> CommandSpec { + CommandSpec::direct("wpctl", ["set-volume", "@DEFAULT_AUDIO_SINK@", "{value}%"]) + } + + pub(in crate::config) fn wpctl_toggle() -> CommandSpec { + CommandSpec::direct("wpctl", ["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) + } // pactl supports both PulseAudio and pipewire-pulse setups - pub(in crate::config) const PACTL_GET: &'static str = - "pactl get-sink-volume @DEFAULT_SINK@; pactl get-sink-mute @DEFAULT_SINK@"; - pub(in crate::config) const PACTL_SET: &'static str = - "pactl set-sink-volume @DEFAULT_SINK@ {value}%"; - pub(in crate::config) const PACTL_TOGGLE: &'static str = - "pactl set-sink-mute @DEFAULT_SINK@ toggle"; + pub(in crate::config) fn pactl_get() -> CommandSpec { + CommandSpec::shell( + "pactl get-sink-volume @DEFAULT_SINK@; pactl get-sink-mute @DEFAULT_SINK@", + ) + } + + pub(in crate::config) fn pactl_set() -> CommandSpec { + CommandSpec::direct("pactl", ["set-sink-volume", "@DEFAULT_SINK@", "{value}%"]) + } + + pub(in crate::config) fn pactl_toggle() -> CommandSpec { + CommandSpec::direct("pactl", ["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) + } // Long-running watcher used only when runtime detection confirms pactl exists - pub(in crate::config) const PACTL_WATCH: &'static str = "pactl subscribe"; + pub(in crate::config) fn pactl_watch() -> CommandSpec { + CommandSpec::direct("pactl", ["subscribe"]) + } pub(super) fn default_volume() -> Self { Self { @@ -56,9 +73,9 @@ impl SliderWidgetConfig { icon: "audio-volume-high-symbolic".to_string(), icon_muted: Some("audio-volume-muted-symbolic".to_string()), // Runtime migration may switch these to pactl only for untouched stock config - get_cmd: Self::WPCTL_GET.to_string(), - set_cmd: Self::WPCTL_SET.to_string(), - toggle_cmd: Some(Self::WPCTL_TOGGLE.to_string()), + get_cmd: Self::wpctl_get(), + set_cmd: Self::wpctl_set(), + toggle_cmd: Some(Self::wpctl_toggle()), // None avoids writing a watcher that may not exist on the target host watch_cmd: None, min: 0.0, @@ -81,8 +98,8 @@ impl SliderWidgetConfig { icon: "display-brightness-symbolic".to_string(), icon_muted: None, // -m keeps brightnessctl output stable enough for the shared parser - get_cmd: "brightnessctl -m".to_string(), - set_cmd: "brightnessctl s {value}%".to_string(), + get_cmd: CommandSpec::direct("brightnessctl", ["-m"]), + set_cmd: CommandSpec::direct("brightnessctl", ["s", "{value}%"]), toggle_cmd: None, // brightnessctl has no reliable stock watch mode, so polling remains explicit watch_cmd: None, diff --git a/crates/unixnotis-core/src/config/widgets/stats.rs b/crates/unixnotis-core/src/config/widgets/stats.rs index 7e76ecfed..a76431e2e 100644 --- a/crates/unixnotis-core/src/config/widgets/stats.rs +++ b/crates/unixnotis-core/src/config/widgets/stats.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::WidgetPluginConfig; +use crate::CommandSpec; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] @@ -10,7 +11,7 @@ pub struct StatWidgetConfig { pub icon: Option, pub icon_asset: Option, pub kind: Option, - pub cmd: Option, + pub cmd: Option, /// External plugin source for this stat (preferred over cmd when set) pub plugin: Option, pub min_height: i32, @@ -25,7 +26,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("cpu".to_string()), // Builtins avoid shelling out for common fast-refresh stats - cmd: Some("builtin:cpu".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:cpu", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } @@ -39,7 +43,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("ram".to_string()), // Memory comes from the same builtin path so defaults stay cheap to poll - cmd: Some("builtin:memory".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:memory", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } @@ -53,7 +60,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("battery".to_string()), // Battery remains optional at runtime; systems without a battery render fallback text - cmd: Some("builtin:battery".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:battery", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } diff --git a/crates/unixnotis-core/src/config/widgets/tests/cards.rs b/crates/unixnotis-core/src/config/widgets/tests/cards.rs index 51e35cf95..c477db622 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/cards.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/cards.rs @@ -1,4 +1,4 @@ -use crate::{CardLayout, CardWidgetConfig, WidgetPluginConfig, WidgetsConfig}; +use crate::{CardLayout, CardWidgetConfig, CommandSpec, WidgetPluginConfig, WidgetsConfig}; #[test] fn default_card_widgets_keep_builtin_identity_and_layout() { @@ -54,7 +54,7 @@ fn custom_card_layout_and_carousel_options_parse() { subtitle = "Live" icon = "image-x-generic-symbolic" icon_asset = "assets/card.webp" - cmd = "scripts/card" + cmd = { mode = "direct", program = "scripts/card" } min_height = 220 monospace = true carousel_dots = 5 @@ -62,7 +62,7 @@ fn custom_card_layout_and_carousel_options_parse() { [plugin] api_version = 1 - command = "scripts/card-plugin" + command = { mode = "direct", program = "scripts/card-plugin" } timeout_ms = 3000 max_output_bytes = 4096 "#, @@ -76,7 +76,10 @@ fn custom_card_layout_and_carousel_options_parse() { assert_eq!(card.subtitle.as_deref(), Some("Live")); assert_eq!(card.icon.as_deref(), Some("image-x-generic-symbolic")); assert_eq!(card.icon_asset.as_deref(), Some("assets/card.webp")); - assert_eq!(card.cmd.as_deref(), Some("scripts/card")); + assert_eq!( + card.cmd, + Some(CommandSpec::direct("scripts/card", [] as [&str; 0])) + ); assert_eq!(card.min_height, 220); assert!(card.monospace); assert_eq!(card.carousel_dots, 5); @@ -85,7 +88,7 @@ fn custom_card_layout_and_carousel_options_parse() { card.plugin, Some(WidgetPluginConfig { api_version: 1, - command: "scripts/card-plugin".to_string(), + command: CommandSpec::direct("scripts/card-plugin", [] as [&str; 0]), timeout_ms: 3000, max_output_bytes: 4096, }) diff --git a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs index f0882e9e3..bf6915911 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs @@ -3,7 +3,7 @@ reason = "TOML parsing preserves these exactly representable slider values" )] -use crate::{NumericParseMode, SliderWidgetConfig, WidgetsConfig}; +use crate::{CommandSpec, NumericParseMode, SliderWidgetConfig, WidgetsConfig}; #[test] fn default_slider_widgets_keep_stock_commands() { @@ -11,11 +11,11 @@ fn default_slider_widgets_keep_stock_commands() { assert!(widgets.volume.enabled); assert_eq!(widgets.volume.label, "Volume"); - assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::WPCTL_GET); - assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::WPCTL_SET); + assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::wpctl_get()); + assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::wpctl_set()); assert_eq!( - widgets.volume.toggle_cmd.as_deref(), - Some(SliderWidgetConfig::WPCTL_TOGGLE) + widgets.volume.toggle_cmd, + Some(SliderWidgetConfig::wpctl_toggle()) ); assert_eq!(widgets.volume.watch_cmd, None); assert_eq!(widgets.volume.segments, 10); @@ -25,8 +25,14 @@ fn default_slider_widgets_keep_stock_commands() { assert!(widgets.brightness.enabled); assert_eq!(widgets.brightness.label, "Brightness"); - assert_eq!(widgets.brightness.get_cmd, "brightnessctl -m"); - assert_eq!(widgets.brightness.set_cmd, "brightnessctl s {value}%"); + assert_eq!( + widgets.brightness.get_cmd, + CommandSpec::direct("brightnessctl", ["-m"]) + ); + assert_eq!( + widgets.brightness.set_cmd, + CommandSpec::direct("brightnessctl", ["s", "{value}%"]) + ); assert_eq!(widgets.brightness.watch_cmd, None); assert_eq!(widgets.brightness.segments, 10); assert!(widgets.brightness.show_sublabels); @@ -58,10 +64,10 @@ fn custom_slider_config_parses_numeric_bounds_and_labels() { label = "Mic" icon = "audio-input-microphone-symbolic" icon_muted = "microphone-disabled-symbolic" - get_cmd = "scripts/mic get" - set_cmd = "scripts/mic set {value}" - toggle_cmd = "scripts/mic toggle" - watch_cmd = "scripts/mic watch" + get_cmd = { mode = "direct", program = "scripts/mic", args = ["get"] } + set_cmd = { mode = "direct", program = "scripts/mic", args = ["set", "{value}"] } + toggle_cmd = { mode = "direct", program = "scripts/mic", args = ["toggle"] } + watch_cmd = { mode = "direct", program = "scripts/mic", args = ["watch"] } min = -12.5 max = 12.5 step = 0.5 @@ -81,8 +87,14 @@ fn custom_slider_config_parses_numeric_bounds_and_labels() { slider.icon_muted.as_deref(), Some("microphone-disabled-symbolic") ); - assert_eq!(slider.toggle_cmd.as_deref(), Some("scripts/mic toggle")); - assert_eq!(slider.watch_cmd.as_deref(), Some("scripts/mic watch")); + assert_eq!( + slider.toggle_cmd, + Some(CommandSpec::direct("scripts/mic", ["toggle"])) + ); + assert_eq!( + slider.watch_cmd, + Some(CommandSpec::direct("scripts/mic", ["watch"])) + ); assert_eq!(slider.min, -12.5); assert_eq!(slider.max, 12.5); assert_eq!(slider.step, 0.5); diff --git a/crates/unixnotis-core/src/config/widgets/tests/stats.rs b/crates/unixnotis-core/src/config/widgets/tests/stats.rs index f313ef783..a8bc5175d 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/stats.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/stats.rs @@ -1,4 +1,4 @@ -use crate::{StatWidgetConfig, WidgetPluginConfig, WidgetsConfig}; +use crate::{CommandSpec, StatWidgetConfig, WidgetPluginConfig, WidgetsConfig}; #[test] fn default_stat_widgets_keep_builtin_commands() { @@ -25,7 +25,10 @@ fn default_stat_widgets_keep_builtin_commands() { assert_eq!(stat.icon.as_deref(), Some(icon)); assert_eq!(stat.icon_asset, None); assert_eq!(stat.kind.as_deref(), Some(kind)); - assert_eq!(stat.cmd.as_deref(), Some(command)); + assert_eq!( + stat.cmd, + Some(CommandSpec::direct(command, [] as [&str; 0])) + ); assert_eq!(stat.min_height, 72); } } @@ -53,12 +56,12 @@ fn custom_stat_plugin_config_parses_with_command_fallback() { icon = "video-display-symbolic" icon_asset = "assets/gpu.svg" kind = "gpu" - cmd = "scripts/gpu-fallback" + cmd = { mode = "direct", program = "scripts/gpu-fallback" } min_height = 96 [plugin] api_version = 1 - command = "scripts/gpu-plugin" + command = { mode = "direct", program = "scripts/gpu-plugin" } timeout_ms = 1500 max_output_bytes = 2048 "#, @@ -70,13 +73,16 @@ fn custom_stat_plugin_config_parses_with_command_fallback() { assert_eq!(stat.icon.as_deref(), Some("video-display-symbolic")); assert_eq!(stat.icon_asset.as_deref(), Some("assets/gpu.svg")); assert_eq!(stat.kind.as_deref(), Some("gpu")); - assert_eq!(stat.cmd.as_deref(), Some("scripts/gpu-fallback")); + assert_eq!( + stat.cmd, + Some(CommandSpec::direct("scripts/gpu-fallback", [] as [&str; 0])) + ); assert_eq!(stat.min_height, 96); assert_eq!( stat.plugin, Some(WidgetPluginConfig { api_version: 1, - command: "scripts/gpu-plugin".to_string(), + command: CommandSpec::direct("scripts/gpu-plugin", [] as [&str; 0]), timeout_ms: 1500, max_output_bytes: 2048, }) diff --git a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs index b89822e90..43ddc7ab6 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::{ToggleLayout, ToggleWidgetConfig, WidgetsConfig}; +use crate::{CommandSpec, ToggleLayout, ToggleWidgetConfig, WidgetsConfig}; #[test] fn default_toggles_have_unique_stable_kinds() { @@ -26,16 +26,25 @@ fn default_night_toggle_uses_shipped_relative_scripts() { // The commands stay config-owned while core startup guarantees the files exist assert_eq!( - night.state_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-state") + night.state_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-state", + [] as [&str; 0] + )) ); assert_eq!( - night.on_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-on") + night.on_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-on", + [] as [&str; 0] + )) ); assert_eq!( - night.off_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-off") + night.off_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-off", + [] as [&str; 0] + )) ); assert_eq!(night.toggle_cmd, None); assert_eq!(night.watch_cmd, None); @@ -47,18 +56,18 @@ fn default_toggles_keep_commands_config_owned() { for toggle in widgets.toggles { for command in [ - toggle.state_cmd.as_deref(), - toggle.toggle_cmd.as_deref(), - toggle.on_cmd.as_deref(), - toggle.off_cmd.as_deref(), - toggle.watch_cmd.as_deref(), + toggle.state_cmd.as_ref(), + toggle.toggle_cmd.as_ref(), + toggle.on_cmd.as_ref(), + toggle.off_cmd.as_ref(), + toggle.watch_cmd.as_ref(), ] .into_iter() .flatten() { // Stock commands should stay relative or PATH based so config files remain portable assert!( - !command.starts_with('/'), + command.program().is_none_or(|program| !program.is_absolute()), "absolute command leaked: {command}" ); } @@ -75,11 +84,11 @@ fn custom_toggles_round_trip_arbitrary_user_commands() { label = "Build" icon = "applications-development-symbolic" icon_asset = "assets/build.svg" - state_cmd = "scripts/build-state" - toggle_cmd = "sh -c 'make test && notify-send done'" - on_cmd = "scripts/build-on" - off_cmd = "scripts/build-off" - watch_cmd = "scripts/build-watch" + state_cmd = { mode = "direct", program = "scripts/build-state" } + toggle_cmd = { mode = "shell", script = "make test && notify-send done" } + on_cmd = { mode = "direct", program = "scripts/build-on" } + off_cmd = { mode = "direct", program = "scripts/build-off" } + watch_cmd = { mode = "direct", program = "scripts/build-watch" } "#, ) .expect("widgets config should parse"); @@ -88,14 +97,26 @@ fn custom_toggles_round_trip_arbitrary_user_commands() { assert_eq!(toggle.kind.as_deref(), Some("build")); assert_eq!(toggle.label, "Build"); assert_eq!(toggle.icon_asset.as_deref(), Some("assets/build.svg")); - assert_eq!(toggle.state_cmd.as_deref(), Some("scripts/build-state")); assert_eq!( - toggle.toggle_cmd.as_deref(), - Some("sh -c 'make test && notify-send done'") + toggle.state_cmd, + Some(CommandSpec::direct("scripts/build-state", [] as [&str; 0])) + ); + assert_eq!( + toggle.toggle_cmd, + Some(CommandSpec::shell("make test && notify-send done")) + ); + assert_eq!( + toggle.on_cmd, + Some(CommandSpec::direct("scripts/build-on", [] as [&str; 0])) + ); + assert_eq!( + toggle.off_cmd, + Some(CommandSpec::direct("scripts/build-off", [] as [&str; 0])) + ); + assert_eq!( + toggle.watch_cmd, + Some(CommandSpec::direct("scripts/build-watch", [] as [&str; 0])) ); - assert_eq!(toggle.on_cmd.as_deref(), Some("scripts/build-on")); - assert_eq!(toggle.off_cmd.as_deref(), Some("scripts/build-off")); - assert_eq!(toggle.watch_cmd.as_deref(), Some("scripts/build-watch")); } #[test] diff --git a/crates/unixnotis-core/src/config/widgets/toggles.rs b/crates/unixnotis-core/src/config/widgets/toggles.rs index d5af0b093..c0ff1bf18 100644 --- a/crates/unixnotis-core/src/config/widgets/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/toggles.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::config::command::defaults as commands; +use crate::CommandSpec; /// Icon and label orientation for toggle cards #[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq, Default)] @@ -11,6 +12,13 @@ pub enum ToggleLayout { Vertical, } +/// Built-in state parser used after a direct toggle command completes +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ToggleBackend { + Rfkill, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] pub struct ToggleWidgetConfig { @@ -22,14 +30,15 @@ pub struct ToggleWidgetConfig { pub label: String, pub icon: String, pub icon_asset: Option, - pub state_cmd: Option, + pub backend: Option, + pub state_cmd: Option, /// Optional command run for every user click before state is refreshed /// /// Useful for custom buttons that do not map cleanly to separate on/off commands - pub toggle_cmd: Option, - pub on_cmd: Option, - pub off_cmd: Option, - pub watch_cmd: Option, + pub toggle_cmd: Option, + pub on_cmd: Option, + pub off_cmd: Option, + pub watch_cmd: Option, } impl ToggleWidgetConfig { @@ -40,11 +49,12 @@ impl ToggleWidgetConfig { label: "Wi-Fi".to_string(), icon: "network-wireless-signal-excellent-symbolic".to_string(), icon_asset: None, - state_cmd: Some(commands::WIFI_STATE_NMCLI.to_string()), + backend: None, + state_cmd: Some(commands::wifi_state()), toggle_cmd: None, - on_cmd: Some(commands::WIFI_ON_NMCLI.to_string()), - off_cmd: Some(commands::WIFI_OFF_NMCLI.to_string()), - watch_cmd: Some(commands::WIFI_WATCH_NMCLI.to_string()), + on_cmd: Some(commands::wifi_on()), + off_cmd: Some(commands::wifi_off()), + watch_cmd: Some(commands::wifi_watch()), } } @@ -55,12 +65,13 @@ impl ToggleWidgetConfig { label: "Bluetooth".to_string(), icon: "bluetooth-active-symbolic".to_string(), icon_asset: None, - state_cmd: Some(commands::BLUETOOTH_STATE_BLUETOOTHCTL.to_string()), + backend: None, + state_cmd: Some(commands::bluetooth_state()), toggle_cmd: None, - on_cmd: Some(commands::BLUETOOTH_ON_BLUETOOTHCTL.to_string()), - off_cmd: Some(commands::BLUETOOTH_OFF_BLUETOOTHCTL.to_string()), + on_cmd: Some(commands::bluetooth_on()), + off_cmd: Some(commands::bluetooth_off()), // D-Bus monitoring avoids TTY requirements and follows BlueZ state changes - watch_cmd: Some(commands::BLUETOOTH_WATCH_DBUS.to_string()), + watch_cmd: Some(commands::bluetooth_watch()), } } @@ -71,12 +82,13 @@ impl ToggleWidgetConfig { label: "Airplane".to_string(), icon: "airplane-mode-symbolic".to_string(), icon_asset: None, - // Airplane reads active only when every rfkill device is soft-blocked - state_cmd: Some(commands::AIRPLANE_STATE_CMD.to_string()), + backend: Some(ToggleBackend::Rfkill), + // Airplane state is parsed from rfkill's machine-readable JSON output + state_cmd: Some(commands::airplane_state()), toggle_cmd: None, - on_cmd: Some(commands::AIRPLANE_ON_CMD.to_string()), - off_cmd: Some(commands::AIRPLANE_OFF_CMD.to_string()), - watch_cmd: Some(commands::AIRPLANE_WATCH_CMD.to_string()), + on_cmd: Some(commands::airplane_on()), + off_cmd: Some(commands::airplane_off()), + watch_cmd: Some(commands::airplane_watch()), } } @@ -87,11 +99,21 @@ impl ToggleWidgetConfig { label: "Night".to_string(), icon: "weather-clear-night-symbolic".to_string(), icon_asset: None, + backend: None, // Shipped scripts keep backend fallback logic in editable files - state_cmd: Some("scripts/unixnotis-blue-light-state".to_string()), + state_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-state", + std::iter::empty::<&str>(), + )), toggle_cmd: None, - on_cmd: Some("scripts/unixnotis-blue-light-on".to_string()), - off_cmd: Some("scripts/unixnotis-blue-light-off".to_string()), + on_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-on", + std::iter::empty::<&str>(), + )), + off_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-off", + std::iter::empty::<&str>(), + )), watch_cmd: None, } } @@ -105,6 +127,7 @@ impl Default for ToggleWidgetConfig { label: "Toggle".to_string(), icon: "applications-system-symbolic".to_string(), icon_asset: None, + backend: None, state_cmd: None, toggle_cmd: None, on_cmd: None, diff --git a/crates/unixnotis-core/src/util/commands.rs b/crates/unixnotis-core/src/util/commands.rs deleted file mode 100644 index 463593f8d..000000000 --- a/crates/unixnotis-core/src/util/commands.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Command-shape checks shared by configuration consumers - -pub const SHELL_META_CHARS: [char; 15] = [ - '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '[', ']', '*', '?', -]; - -/// Returns true when the command can run without a shell wrapper -/// -/// # Example -/// ``` -/// use unixnotis_core::util::is_simple_command; -/// -/// assert!(is_simple_command("echo hello")); -/// assert!(!is_simple_command("echo hello | wc -l")); -/// ``` -#[must_use] -pub fn is_simple_command(cmd: &str) -> bool { - if cmd - .chars() - .any(|ch| SHELL_META_CHARS.contains(&ch) || ch == '~' || ch == '\n' || ch == '\r') - { - return false; - } - - // Leading assignments need shell parsing unless the first token is an explicit path - let first = cmd.split_whitespace().next().unwrap_or_default(); - if first.contains('=') && !first.starts_with('/') && !first.starts_with("./") { - return false; - } - - true -} - -#[cfg(test)] -#[path = "tests/commands.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/util/mod.rs b/crates/unixnotis-core/src/util/mod.rs index 27524bab4..8510b4bf8 100644 --- a/crates/unixnotis-core/src/util/mod.rs +++ b/crates/unixnotis-core/src/util/mod.rs @@ -1,12 +1,10 @@ //! Shared helper utilities used across `UnixNotis` components -mod commands; mod diagnostics; mod display; mod paths; mod programs; -pub use commands::{is_simple_command, SHELL_META_CHARS}; pub use diagnostics::{ default_log_limit, diagnostic_log_limit, diagnostic_mode, log_limit, log_snippet, }; diff --git a/crates/unixnotis-core/src/util/tests/commands.rs b/crates/unixnotis-core/src/util/tests/commands.rs deleted file mode 100644 index e67947acd..000000000 --- a/crates/unixnotis-core/src/util/tests/commands.rs +++ /dev/null @@ -1,34 +0,0 @@ -use super::*; - -#[test] -fn simple_command_accepts_plain_program_and_arguments() { - assert!(is_simple_command("notify-send hello world")); - assert!(is_simple_command("/usr/bin/notify-send hello")); - assert!(is_simple_command("./local-helper --flag value")); -} - -#[test] -fn simple_command_rejects_shell_meta_characters_and_newlines() { - for command in [ - "echo hi | wc -l", - "echo hi && echo bye", - "echo hi; rm -rf x", - "echo $(date)", - "echo `date`", - "echo ~/file", - "echo one\necho two", - "echo one\recho two", - ] { - assert!( - !is_simple_command(command), - "command should need a shell: {command}" - ); - } -} - -#[test] -fn simple_command_rejects_leading_env_assignment_without_explicit_path() { - assert!(!is_simple_command("FOO=bar notify-send hi")); - assert!(is_simple_command("/tmp/FOO=bar notify-send hi")); - assert!(is_simple_command("./FOO=bar notify-send hi")); -} From 04d293b8a87dcd10d68d473c5f50dbf142e78568 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:31:57 -0500 Subject: [PATCH 018/275] feat(center): execute structured widget commands Summary: execute structured widget commands. Scope: center. --- .../src/ui/widgets/stats/build.rs | 4 +- .../src/ui/widgets/toggles/grid.rs | 36 +++-- .../src/ui/widgets/toggles/mod.rs | 1 + .../src/ui/widgets/toggles/rfkill.rs | 43 ++++++ .../src/ui/widgets/toggles/state.rs | 77 ++++++++-- .../src/ui/widgets/toggles/tests/grid.rs | 15 +- .../src/ui/widgets/toggles/tests/icons.rs | 1 + .../src/ui/widgets/toggles/tests/rfkill.rs | 45 ++++++ .../src/ui/widgets/utils/command/action.rs | 13 +- .../src/ui/widgets/utils/command/capture.rs | 15 +- .../ui/widgets/utils/command/command_parse.rs | 36 ++--- .../ui/widgets/utils/command/exec/builder.rs | 135 ++++++------------ .../ui/widgets/utils/command/exec/runner.rs | 16 ++- .../utils/command/exec/tests/builder.rs | 50 ++++--- .../utils/command/exec/tests/runner.rs | 51 +++++++ .../src/ui/widgets/utils/command/plan.rs | 8 +- .../widgets/utils/command/queue/coalesced.rs | 3 +- .../utils/command/queue/tests/coalesced.rs | 48 +++++-- .../utils/command/queue/tests/delayed.rs | 8 +- .../utils/command/queue/tests/worker.rs | 7 +- .../ui/widgets/utils/command/queue/worker.rs | 8 +- .../ui/widgets/utils/command/tests/action.rs | 5 +- .../ui/widgets/utils/command/tests/capture.rs | 14 +- .../utils/command/tests/command_parse.rs | 60 ++++---- .../ui/widgets/utils/command/tests/plan.rs | 12 +- .../utils/command_slider/actions/schedule.rs | 4 +- .../command_slider/actions/tests/schedule.rs | 5 +- .../command_slider/actions/tests/signals.rs | 12 +- .../utils/command_slider/refresh/request.rs | 8 +- .../utils/command_slider/refresh/runner.rs | 6 +- .../command_slider/refresh/tests/apply.rs | 4 +- .../command_slider/refresh/tests/poll.rs | 7 +- .../command_slider/refresh/tests/request.rs | 9 +- .../command_slider/refresh/tests/runner.rs | 2 +- .../command_slider/refresh/tests/watch.rs | 4 +- .../utils/command_slider/tests/widget.rs | 6 +- .../src/ui/widgets/utils/watch.rs | 13 +- 37 files changed, 510 insertions(+), 281 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs diff --git a/crates/unixnotis-center/src/ui/widgets/stats/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/build.rs index 54e8b6405..a679bb572 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/build.rs @@ -142,7 +142,9 @@ impl StatItem { config .cmd .as_ref() - .and_then(|cmd| BuiltinStat::from_command(cmd)) + .and_then(|cmd| cmd.program()) + .and_then(|program| program.to_str()) + .and_then(BuiltinStat::from_command) }; Self { diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs index 57fde4db9..17de628e1 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs @@ -7,7 +7,7 @@ use gtk::prelude::*; use gtk::Align; use tracing::warn; use unixnotis_core::{ - css::hooks, IconAssetResolver, PanelDebugLevel, ToggleLayout, ToggleWidgetConfig, + css::hooks, CommandSpec, IconAssetResolver, PanelDebugLevel, ToggleLayout, ToggleWidgetConfig, }; use super::super::icon_image::image_from_icon_config; @@ -119,17 +119,17 @@ fn flowbox_columns(columns: usize) -> u32 { } pub(super) fn toggle_action_command<'a>( - toggle_cmd: Option<&'a String>, - on_cmd: Option<&'a String>, - off_cmd: Option<&'a String>, + toggle_cmd: Option<&'a CommandSpec>, + on_cmd: Option<&'a CommandSpec>, + off_cmd: Option<&'a CommandSpec>, active: bool, -) -> Option<&'a String> { +) -> Option<&'a CommandSpec> { toggle_cmd.or(if active { on_cmd } else { off_cmd }) } pub(super) const fn should_reset_after_action( - toggle_cmd: Option<&String>, - state_cmd: Option<&String>, + toggle_cmd: Option<&CommandSpec>, + state_cmd: Option<&CommandSpec>, ) -> bool { // Without a state command, the card cannot know whether the action changed system state toggle_cmd.is_some() && state_cmd.is_none() @@ -233,6 +233,7 @@ impl ToggleItem { // Clone command fields once so toggle callback stays allocation-light let guard_clone = guard.clone(); let state_cmd = config.state_cmd.clone(); + let backend = config.backend; let toggle_cmd = config.toggle_cmd.clone(); let on_cmd = config.on_cmd.clone(); let off_cmd = config.off_cmd.clone(); @@ -281,6 +282,7 @@ impl ToggleItem { if let Some(state_cmd) = state_cmd_for_retry.clone() { schedule_toggle_refresh_with_retry( state_cmd, + backend, expected, button.clone(), guard.clone(), @@ -293,7 +295,14 @@ impl ToggleItem { }); } else if let Some(state_cmd) = state_cmd.clone() { // Command-free toggles still use the same reconcile path - schedule_toggle_refresh_with_retry(state_cmd, expected, button, guard, refresh_gen); + schedule_toggle_refresh_with_retry( + state_cmd, + backend, + expected, + button, + guard, + refresh_gen, + ); } else { // No command and no state is inert, so undo the visual edge immediately reset_toggle_visual_state(&button, &guard); @@ -318,6 +327,7 @@ impl ToggleItem { if let Some(state_cmd) = self.config.state_cmd.as_ref() { refresh_toggle_state( state_cmd, + self.config.backend, &self.button, &self.guard, &self.refresh_gen, @@ -372,10 +382,18 @@ impl ToggleItem { let guard = self.guard.clone(); let refresh_gen = self.refresh_gen.clone(); let refresh_gate = self.refresh_gate.clone(); + let backend = self.config.backend; // Watch callbacks trigger the same refresh path as polling so semantics stay identical start_command_watch(watch_cmd, move || { - refresh_toggle_state(&state_cmd, &button, &guard, &refresh_gen, &refresh_gate); + refresh_toggle_state( + &state_cmd, + backend, + &button, + &guard, + &refresh_gen, + &refresh_gate, + ); }) } } diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs b/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs index dcd2e8038..740953f6b 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs @@ -3,6 +3,7 @@ mod css; mod grid; mod icons; +mod rfkill; mod state; pub use grid::ToggleGrid; diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs b/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs new file mode 100644 index 000000000..afc65d5e2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs @@ -0,0 +1,43 @@ +//! Machine-readable rfkill state parsing for the stock airplane toggle + +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct RfkillState { + pub(super) device_count: usize, + pub(super) all_soft_blocked: bool, +} + +impl RfkillState { + pub(super) const fn is_airplane_mode_active(self) -> bool { + // Empty rfkill output never claims that airplane mode is active + self.device_count > 0 && self.all_soft_blocked + } +} + +#[derive(Deserialize)] +struct RfkillDocument { + rfkilldevices: Vec, +} + +#[derive(Deserialize)] +struct RfkillDevice { + soft: String, +} + +pub(super) fn parse_rfkill_state(output: &[u8]) -> Result { + // Structured output avoids localized and deprecated display formatting + let document: RfkillDocument = serde_json::from_slice(output)?; + Ok(RfkillState { + device_count: document.rfkilldevices.len(), + // Airplane mode requires every discovered radio to be soft blocked + all_soft_blocked: document + .rfkilldevices + .iter() + .all(|device| device.soft == "blocked"), + }) +} + +#[cfg(test)] +#[path = "tests/rfkill.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs index a418ea3dd..62fb64ef7 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs @@ -9,9 +9,10 @@ use std::time::Duration; use gtk::glib; use gtk::prelude::*; use tracing::warn; -use unixnotis_core::{css::hooks, util, PanelDebugLevel}; +use unixnotis_core::{css::hooks, util, CommandSpec, PanelDebugLevel, ToggleBackend}; use super::super::utils::run_command_capture_status_async; +use super::rfkill::parse_rfkill_state; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; // Staggered retry delays keep UI responsive without long-lived polling loops @@ -49,7 +50,8 @@ impl ToggleRefreshGate { } pub(super) fn refresh_toggle_state( - cmd: &str, + cmd: &CommandSpec, + backend: Option, button: >k::ToggleButton, guard: &Rc>, refresh_gen: &Rc>, @@ -58,7 +60,7 @@ pub(super) fn refresh_toggle_state( // Bursty watch events only need one running probe and one trailing probe if !refresh_gate.begin_or_queue() { perf_probe::toggle_refresh_queued(); - let cmd_snip = util::log_snippet(cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh queued while in flight cmd=\"{cmd_snip}\"") }); @@ -67,7 +69,7 @@ pub(super) fn refresh_toggle_state( perf_probe::toggle_refresh_start(); // Periodic refresh path keeps UI aligned with external command state - let cmd = cmd.to_string(); + let cmd = cmd.clone(); // Each refresh claims a generation so stale tasks cannot overwrite newer state let gen = next_refresh_generation(refresh_gen); @@ -76,21 +78,35 @@ pub(super) fn refresh_toggle_state( let refresh_gen = refresh_gen.clone(); let refresh_gate = refresh_gate.clone(); let refresh_cmd = cmd.clone(); - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh start cmd=\"{cmd_snip}\"") }); glib::MainContext::default().spawn_local(async move { // Single probe path is used for periodic refresh and watch-trigger refresh - let Some(active) = fetch_toggle_state(&cmd, true).await else { - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + let Some(active) = fetch_toggle_state(&cmd, backend, true).await else { + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); return; }; // Drop stale result when a newer refresh has already started if refresh_gen.get() != gen { - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); return; } @@ -103,12 +119,20 @@ pub(super) fn refresh_toggle_state( } apply_active_class(&button, active); - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); }); } pub(super) fn schedule_toggle_refresh_with_retry( - state_cmd: String, + state_cmd: CommandSpec, + backend: Option, expected: bool, button: gtk::ToggleButton, guard: Rc>, @@ -141,7 +165,7 @@ pub(super) fn schedule_toggle_refresh_with_retry( // Keep warnings bounded to the first failed probe per action let log_failures = attempt == 0; - let Some(active) = fetch_toggle_state(&state_cmd, log_failures).await else { + let Some(active) = fetch_toggle_state(&state_cmd, backend, log_failures).await else { // Probe failed, continue to next retry window continue; }; @@ -184,7 +208,11 @@ fn apply_active_class(button: >k::ToggleButton, active: bool) { } } -async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { +async fn fetch_toggle_state( + cmd: &CommandSpec, + backend: Option, + log_failures: bool, +) -> Option { // Shared fetch routine is used by both periodic refresh and retry path // Command helper returns receiver so execution stays off the GTK thread let rx = run_command_capture_status_async(cmd); @@ -206,6 +234,24 @@ async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { } }; + if backend == Some(ToggleBackend::Rfkill) { + if !output.status.success() { + if log_failures { + warn!(status = ?output.status, "rfkill state command failed"); + } + return None; + } + return match parse_rfkill_state(&output.stdout) { + Ok(state) => Some(state.is_airplane_mode_active()), + Err(err) => { + if log_failures { + warn!(?err, "failed to parse rfkill JSON state"); + } + None + } + }; + } + let success = output.status.success(); let stdout = String::from_utf8_lossy(&output.stdout); @@ -220,7 +266,8 @@ async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { } fn finish_toggle_refresh( - cmd: String, + cmd: CommandSpec, + backend: Option, button: gtk::ToggleButton, guard: Rc>, refresh_gen: Rc>, @@ -228,11 +275,11 @@ fn finish_toggle_refresh( ) { // One queued refresh is enough to bring the toggle back to the newest state if refresh_gate.finish() { - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh consumed pending request cmd=\"{cmd_snip}\"") }); - refresh_toggle_state(&cmd, &button, &guard, &refresh_gen, &refresh_gate); + refresh_toggle_state(&cmd, backend, &button, &guard, &refresh_gen, &refresh_gate); } } diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs index 55d2f7432..583e1cdd7 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs @@ -1,10 +1,11 @@ use super::grid::{should_reset_after_action, toggle_action_command}; +use unixnotis_core::CommandSpec; #[test] fn toggle_action_command_prefers_custom_toggle_command() { - let toggle_cmd = "scripts/do-anything".to_string(); - let on_cmd = "turn-on".to_string(); - let off_cmd = "turn-off".to_string(); + let toggle_cmd = CommandSpec::direct("scripts/do-anything", [] as [&str; 0]); + let on_cmd = CommandSpec::direct("turn-on", [] as [&str; 0]); + let off_cmd = CommandSpec::direct("turn-off", [] as [&str; 0]); assert_eq!( toggle_action_command(Some(&toggle_cmd), Some(&on_cmd), Some(&off_cmd), true), @@ -18,8 +19,8 @@ fn toggle_action_command_prefers_custom_toggle_command() { #[test] fn toggle_action_command_uses_on_off_when_custom_command_is_absent() { - let on_cmd = "turn-on".to_string(); - let off_cmd = "turn-off".to_string(); + let on_cmd = CommandSpec::direct("turn-on", [] as [&str; 0]); + let off_cmd = CommandSpec::direct("turn-off", [] as [&str; 0]); assert_eq!( toggle_action_command(None, Some(&on_cmd), Some(&off_cmd), true), @@ -39,8 +40,8 @@ fn toggle_action_command_allows_state_only_custom_buttons() { #[test] fn stateless_toggle_command_resets_after_action() { - let toggle_cmd = "scripts/do-anything".to_string(); - let state_cmd = "scripts/state".to_string(); + let toggle_cmd = CommandSpec::direct("scripts/do-anything", [] as [&str; 0]); + let state_cmd = CommandSpec::direct("scripts/state", [] as [&str; 0]); assert!(should_reset_after_action(Some(&toggle_cmd), None)); assert!(!should_reset_after_action( diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs index 3a6613656..3ef466a11 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs @@ -8,6 +8,7 @@ fn test_toggle(kind: Option<&str>, label: &str, icon: &str) -> ToggleWidgetConfi label: label.to_string(), icon: icon.to_string(), icon_asset: None, + backend: None, state_cmd: None, toggle_cmd: None, on_cmd: None, diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs new file mode 100644 index 000000000..fa29ac2f5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs @@ -0,0 +1,45 @@ +use super::parse_rfkill_state; + +#[test] +fn rfkill_state_is_active_only_when_every_discovered_device_is_soft_blocked() { + let output = br#"{ + "rfkilldevices": [ + {"id": 0, "type": "wlan", "soft": "blocked", "hard": "unblocked"}, + {"id": 1, "type": "bluetooth", "soft": "blocked", "hard": "blocked"} + ] + }"#; + + let state = parse_rfkill_state(output).expect("parse blocked rfkill state"); + + assert_eq!(state.device_count, 2); + assert!(state.all_soft_blocked); + assert!(state.is_airplane_mode_active()); +} + +#[test] +fn rfkill_state_is_inactive_when_one_device_is_unblocked() { + let output = br#"{ + "rfkilldevices": [ + {"soft": "blocked"}, + {"soft": "unblocked"} + ] + }"#; + + let state = parse_rfkill_state(output).expect("parse mixed rfkill state"); + + assert!(!state.all_soft_blocked); + assert!(!state.is_airplane_mode_active()); +} + +#[test] +fn rfkill_state_is_inactive_when_no_devices_exist() { + let state = parse_rfkill_state(br#"{"rfkilldevices": []}"#).expect("parse empty state"); + + assert_eq!(state.device_count, 0); + assert!(!state.is_airplane_mode_active()); +} + +#[test] +fn malformed_rfkill_json_is_rejected() { + assert!(parse_rfkill_state(br#"{"rfkilldevices": [}"#).is_err()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs index 27df44114..4f821f084 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs @@ -5,7 +5,7 @@ use std::process::Output; use gtk::glib; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; @@ -13,11 +13,10 @@ use super::queue::enqueue_command; use super::{resolve_command_plan, CommandKind}; pub(in crate::ui::widgets) fn run_command_capture_action_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { // Action capture keeps action priority while still reporting completion let (tx, rx) = async_channel::bounded(1); - let cmd = cmd.trim(); if cmd.is_empty() { // Keep the receiver behavior consistent with the non-empty path let _ = tx.send_blocking(Err(io::Error::new( @@ -28,15 +27,15 @@ pub(in crate::ui::widgets) fn run_command_capture_action_async( } let plan = resolve_command_plan(cmd, CommandKind::Action); debug::log(PanelDebugLevel::Verbose, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("enqueue action-capture command: {snippet}") }); - enqueue_command(cmd.to_string(), plan, Some(tx)); + enqueue_command(cmd.clone(), plan, Some(tx)); rx } pub(in crate::ui::widgets) fn run_action_command_with_completion( - cmd: String, + cmd: CommandSpec, context: &'static str, on_complete: F, ) where @@ -44,7 +43,7 @@ pub(in crate::ui::widgets) fn run_action_command_with_completion( { // One helper keeps action completion and failure handling the same across widgets let rx = run_command_capture_action_async(&cmd); - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); glib::MainContext::default().spawn_local(async move { let failed = match rx.recv().await { Ok(Ok(output)) => !output.status.success(), diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs index 9034236dc..4c306d094 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs @@ -4,7 +4,7 @@ use std::io; use std::process::Output; use std::time::Duration; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use super::exec::set_command_config_dir; use super::plan::{resolve_command_plan, CommandKind}; @@ -16,32 +16,31 @@ pub fn configure_command_config_dir(config_dir: std::path::PathBuf) { } pub(in crate::ui::widgets) fn run_command_capture_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Slow, None, "slow") } pub(in crate::ui::widgets) fn run_command_capture_with_timeout_async( - cmd: &str, + cmd: &CommandSpec, timeout: Duration, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Slow, Some(timeout), "custom-timeout") } pub(in crate::ui::widgets) fn run_command_capture_status_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Fast, None, "fast") } fn enqueue_capture( - cmd: &str, + cmd: &CommandSpec, kind: CommandKind, timeout: Option, label: &str, ) -> async_channel::Receiver> { let (tx, rx) = async_channel::bounded(1); - let cmd = cmd.trim(); if cmd.is_empty() { let _ = tx.send_blocking(Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -55,10 +54,10 @@ fn enqueue_capture( plan = plan.with_timeout(timeout); } debug::log(PanelDebugLevel::Verbose, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("enqueue {label} command: {snippet}") }); - enqueue_command(cmd.to_string(), plan, Some(tx)); + enqueue_command(cmd.clone(), plan, Some(tx)); rx } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs index 3d126e9ec..d3698f8b4 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs @@ -1,30 +1,22 @@ -//! Command parsing and heuristics for widget command planning. +//! Typed command heuristics for widget execution planning //! //! Keeps shell parsing and "slow command" classification localized so the -//! enqueue/worker pipeline can stay focused on execution and backpressure. +//! enqueue/worker pipeline can stay focused on execution and backpressure -pub(super) use unixnotis_core::ParsedCommand; -use unixnotis_core::{parse_command, ExecutionMode}; +use std::ffi::OsStr; -pub(super) fn parse_simple_command(cmd: &str) -> Option { - // Runtime consumes the same parsed representation used by preset security checks - let parsed = parse_command(cmd).ok()?; - (parsed.execution_mode == ExecutionMode::Direct).then_some(parsed) -} +use unixnotis_core::CommandSpec; -pub(super) fn is_probably_slow(cmd: &str) -> bool { - // Complex commands (shell meta, unsupported env forms, etc.) are treated as slow to - // avoid under-budgeting timeouts for shells and pipelines - let Some(parsed) = parse_simple_command(cmd) else { +pub(super) fn is_probably_slow(cmd: &CommandSpec) -> bool { + let CommandSpec::Direct { program, args, .. } = cmd else { return true; }; // Compare only executable basename so absolute paths and wrappers still match - let program_name = parsed - .program - .rsplit('/') - .next() - .unwrap_or(parsed.program.as_str()) + let program_name = program + .file_name() + .unwrap_or(program.as_os_str()) + .to_string_lossy() .to_ascii_lowercase(); if program_name == "sleep" { @@ -49,7 +41,7 @@ pub(super) fn is_probably_slow(cmd: &str) -> bool { if matches!(program_name.as_str(), "sh" | "bash" | "zsh" | "fish") { // Shell scripts are treated as slow if the first token is "sleep" - if let Some(script) = shell_script_arg(&parsed.args) { + if let Some(script) = shell_script_arg(args) { if script.split_whitespace().next() == Some("sleep") { return true; } @@ -59,11 +51,11 @@ pub(super) fn is_probably_slow(cmd: &str) -> bool { false } -fn shell_script_arg(args: &[String]) -> Option<&str> { +fn shell_script_arg(args: &[std::ffi::OsString]) -> Option<&str> { let mut iter = args.iter().peekable(); while let Some(arg) = iter.next() { - if arg == "-c" { - return iter.peek().map(|value| value.as_str()); + if arg == OsStr::new("-c") { + return iter.peek().and_then(|value| value.to_str()); } } None diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs index 7613e20c6..575ccdaaa 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs @@ -2,15 +2,13 @@ #[cfg(unix)] use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use tokio::process::Command as TokioCommand; use tracing::warn; -use unixnotis_core::Config; - -use super::super::command_parse::{parse_simple_command, ParsedCommand}; +use unixnotis_core::{filesystem::ContainedPath, CommandSpec, Config}; // Missing target used when a command tries to leave the config dir const BLOCKED_OUTSIDE_ROOT_PROGRAM: &str = ".unixnotis-blocked-command-path"; @@ -31,51 +29,54 @@ pub(in crate::ui::widgets::utils::command) fn set_command_config_dir(config_dir: true } -pub(super) fn spawn_capture_command(cmd: &str) -> std::io::Result { +pub(super) fn spawn_capture_command(cmd: &CommandSpec) -> std::io::Result { let mut command = build_command(cmd); command.stdout(Stdio::piped()).stderr(Stdio::piped()); command.spawn() } -pub(in crate::ui::widgets::utils::command) fn build_command(cmd: &str) -> Command { - if let Some(parsed) = parse_simple_command(cmd) { - // Simple commands avoid shell invocation for safety and performance - let mut command = Command::new(resolve_simple_program(&parsed.program)); - apply_parsed_command_env(&mut command, &parsed); - command.args(&parsed.args); - configure_command(&mut command); - return command; - } - - let _ = log_shell_fallback_once(cmd); - let mut command = Command::new("sh"); - // Non-login shell avoids profile sourcing on every widget refresh - command.arg("-c").arg(cmd); +pub(in crate::ui::widgets::utils::command) fn build_command(cmd: &CommandSpec) -> Command { + let mut command = match cmd { + CommandSpec::Direct { program, args, env } => { + let mut command = Command::new(resolve_direct_program(program)); + command.args(args).envs(env); + command + } + CommandSpec::Shell { script } => { + let _ = log_shell_fallback_once(script); + let mut command = Command::new("sh"); + // Non-login shell avoids profile sourcing on every widget refresh + command.arg("-c").arg(script); + command + } + }; configure_command(&mut command); command } -pub(super) fn spawn_capture_command_async(cmd: &str) -> std::io::Result { +pub(super) fn spawn_capture_command_async( + cmd: &CommandSpec, +) -> std::io::Result { // Mirrors the blocking builder but returns a Tokio child with piped output let mut command = build_tokio_command(cmd); command.stdout(Stdio::piped()).stderr(Stdio::piped()); command.spawn() } -fn build_tokio_command(cmd: &str) -> TokioCommand { - if let Some(parsed) = parse_simple_command(cmd) { - // Tokio command mirrors the blocking path for consistent behavior - let mut command = TokioCommand::new(resolve_simple_program(&parsed.program)); - apply_parsed_command_env_tokio(&mut command, &parsed); - command.args(&parsed.args); - configure_command_tokio(&mut command); - return command; - } - - // Shell fallback keeps blocking and asynchronous behavior aligned - let _ = log_shell_fallback_once(cmd); - let mut command = TokioCommand::new("sh"); - command.arg("-c").arg(cmd); +fn build_tokio_command(cmd: &CommandSpec) -> TokioCommand { + let mut command = match cmd { + CommandSpec::Direct { program, args, env } => { + let mut command = TokioCommand::new(resolve_direct_program(program)); + command.args(args).envs(env); + command + } + CommandSpec::Shell { script } => { + let _ = log_shell_fallback_once(script); + let mut command = TokioCommand::new("sh"); + command.arg("-c").arg(script); + command + } + }; configure_command_tokio(&mut command); command } @@ -129,13 +130,6 @@ fn configure_command(command: &mut Command) { command.process_group(0); } -fn apply_parsed_command_env(command: &mut Command, parsed: &ParsedCommand) { - // Only this child receives command-specific environment overrides - for (name, value) in &parsed.env { - command.env(name, value); - } -} - fn configure_command_tokio(command: &mut TokioCommand) { command.stdin(Stdio::null()); if let Some(config_dir) = command_config_dir() { @@ -146,16 +140,9 @@ fn configure_command_tokio(command: &mut TokioCommand) { command.process_group(0); } -fn apply_parsed_command_env_tokio(command: &mut TokioCommand, parsed: &ParsedCommand) { - // Timeout strategy must not change the child's environment - for (name, value) in &parsed.env { - command.env(name, value); - } -} - -fn resolve_simple_program(program: &str) -> PathBuf { +fn resolve_direct_program(program: &Path) -> PathBuf { // Runtime lookup keeps exported config-relative scripts portable - resolve_simple_program_from_root(command_config_dir().as_deref(), program) + resolve_direct_program_from_root(command_config_dir().as_deref(), program) } pub(in crate::ui::widgets::utils::command) fn command_config_dir() -> Option { @@ -166,58 +153,30 @@ pub(in crate::ui::widgets::utils::command) fn command_config_dir() -> Option, program: &str) -> PathBuf { - let path = Path::new(program); - if !looks_like_relative_path_program(program, path) { - return path.to_path_buf(); +fn resolve_direct_program_from_root(config_dir: Option<&Path>, program: &Path) -> PathBuf { + if !looks_like_relative_path_program(program) { + return program.to_path_buf(); } // Preset imports rewrite bundled scripts to config-root-relative paths if let Some(config_dir) = config_dir { - let rooted = config_dir.join(path); - if command_path_escapes_root(config_dir, &rooted) { + let Ok(contained) = ContainedPath::resolve_relative(config_dir, program) else { warn!( - command = %program, + command = %program.display(), root = %config_dir.display(), "blocked path-like command that escapes the UnixNotis config directory" ); return config_dir.join(BLOCKED_OUTSIDE_ROOT_PROGRAM); - } - return rooted; + }; + return contained.absolute(); } - path.to_path_buf() + program.to_path_buf() } -fn looks_like_relative_path_program(program: &str, path: &Path) -> bool { +fn looks_like_relative_path_program(program: &Path) -> bool { // Bare names still use PATH lookup, while path-like names use the config dir - !path.is_absolute() && (program == "." || program.contains('/')) -} - -fn command_path_escapes_root(config_dir: &Path, rooted_path: &Path) -> bool { - // Catch parent traversal without requiring either path to exist - let normalized_root = normalize_lexical_path(config_dir); - let normalized_candidate = normalize_lexical_path(rooted_path); - !normalized_candidate.starts_with(&normalized_root) -} - -fn normalize_lexical_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - if !normalized.pop() { - normalized.push(component.as_os_str()); - } - } - Component::Normal(part) => normalized.push(part), - Component::RootDir | Component::Prefix(_) => { - normalized.push(component.as_os_str()); - } - } - } - normalized + !program.is_absolute() && (program == Path::new(".") || program.components().count() > 1) } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs index fd0f99034..cd5ad60af 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs @@ -3,6 +3,7 @@ use std::io; use std::process::Output; use std::time::Duration; +use unixnotis_core::CommandSpec; use tokio::runtime::Runtime; use tracing::warn; @@ -31,7 +32,7 @@ pub(in crate::ui::widgets::utils::command) fn build_command_runtime() -> Option< } pub(in crate::ui::widgets::utils::command) fn run_command_with_timeout( - cmd: &str, + cmd: &CommandSpec, timeout: Duration, runtime: Option<&Runtime>, ) -> Result { @@ -43,14 +44,17 @@ pub(in crate::ui::widgets::utils::command) fn run_command_with_timeout( } fn run_command_with_timeout_async( - cmd: &str, + cmd: &CommandSpec, timeout: Duration, runtime: &Runtime, ) -> Result { runtime.block_on(async { run_command_with_timeout_inner(cmd, timeout).await }) } -async fn run_command_with_timeout_inner(cmd: &str, timeout: Duration) -> io::Result { +async fn run_command_with_timeout_inner( + cmd: &CommandSpec, + timeout: Duration, +) -> io::Result { let mut child = spawn_capture_command_async(cmd)?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); @@ -98,7 +102,7 @@ async fn run_command_with_timeout_inner(cmd: &str, timeout: Duration) -> io::Res }) } -fn run_command_with_timeout_blocking(cmd: &str, timeout: Duration) -> io::Result { +fn run_command_with_timeout_blocking(cmd: &CommandSpec, timeout: Duration) -> io::Result { let mut child = spawn_capture_command(cmd)?; let stdout_handle = match child.stdout.take() { Some(stdout) => spawn_reader(stdout), @@ -132,3 +136,7 @@ fn run_command_with_timeout_blocking(cmd: &str, timeout: Duration) -> io::Result stderr, }) } + +#[cfg(test)] +#[path = "tests/runner.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs index 36086e844..11a5554db 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs @@ -2,17 +2,18 @@ use std::path::Path; use super::super::super::test_support::configure_command_test_root; use super::{ - build_command, build_tokio_command, command_config_dir, command_path_escapes_root, - log_shell_fallback_once, resolve_simple_program_from_root, set_command_config_dir, - shell_fallback_cache, shell_fallback_hash, SHELL_FALLBACK_CACHE_LIMIT, + build_command, build_tokio_command, command_config_dir, log_shell_fallback_once, + resolve_direct_program_from_root, set_command_config_dir, shell_fallback_cache, + shell_fallback_hash, SHELL_FALLBACK_CACHE_LIMIT, }; +use unixnotis_core::CommandSpec; #[test] fn resolve_simple_program_roots_relative_script_paths_in_config_dir() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "scripts/demo-widget"), + resolve_direct_program_from_root(Some(config_dir), Path::new("scripts/demo-widget")), config_dir.join("scripts/demo-widget") ); } @@ -22,7 +23,10 @@ fn resolve_simple_program_uses_supplied_config_dir_for_relative_scripts() { let config_dir = Path::new("/tmp/unixnotis-custom-config-root"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "scripts/unixnotis-blue-light-state"), + resolve_direct_program_from_root( + Some(config_dir), + Path::new("scripts/unixnotis-blue-light-state") + ), config_dir.join("scripts/unixnotis-blue-light-state") ); } @@ -32,12 +36,12 @@ fn resolve_simple_program_roots_dot_and_explicit_relative_paths() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "."), + resolve_direct_program_from_root(Some(config_dir), Path::new(".")), config_dir ); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "./scripts/probe"), - config_dir.join("./scripts/probe") + resolve_direct_program_from_root(Some(config_dir), Path::new("./scripts/probe")), + config_dir.join("scripts/probe") ); } @@ -46,17 +50,21 @@ fn resolve_simple_program_blocks_parent_traversal_paths() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "../outside-script"), + resolve_direct_program_from_root(Some(config_dir), Path::new("../outside-script")), config_dir.join(".unixnotis-blocked-command-path") ); } #[test] -fn nested_parent_traversal_is_detected_after_normal_components() { +fn nested_parent_traversal_is_blocked_after_normal_components() { let config_dir = Path::new("/tmp/demo/unixnotis"); - let candidate = config_dir.join("scripts/../../outside-script"); - - assert!(command_path_escapes_root(config_dir, &candidate)); + assert_eq!( + resolve_direct_program_from_root( + Some(config_dir), + Path::new("scripts/../../outside-script") + ), + config_dir.join(".unixnotis-blocked-command-path") + ); } #[test] @@ -104,7 +112,7 @@ fn shell_fallback_hash_distinguishes_command_text() { fn direct_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command("true"); + let command = build_command(&CommandSpec::direct("true", [] as [&str; 0])); assert_eq!(command.get_current_dir(), Some(config_dir.as_path())); } @@ -113,7 +121,7 @@ fn direct_commands_use_the_config_directory_as_their_working_directory() { fn shell_fallback_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command(". ./lib/common.sh"); + let command = build_command(&CommandSpec::shell(". ./lib/common.sh")); assert_eq!(command.get_current_dir(), Some(config_dir.as_path())); } @@ -122,7 +130,7 @@ fn shell_fallback_commands_use_the_config_directory_as_their_working_directory() fn tokio_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_tokio_command("true"); + let command = build_tokio_command(&CommandSpec::direct("true", [] as [&str; 0])); assert_eq!( command.as_std().get_current_dir(), @@ -134,7 +142,10 @@ fn tokio_commands_use_the_config_directory_as_their_working_directory() { fn loader_environment_and_command_cwd_share_the_same_config_root() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command("LD_PRELOAD=./assets/libprobe.so scripts/probe"); + let command = build_command( + &CommandSpec::direct("scripts/probe", [] as [&str; 0]) + .with_env("LD_PRELOAD", "./assets/libprobe.so"), + ); let preload = command .get_envs() .find(|(name, _)| *name == "LD_PRELOAD") @@ -149,7 +160,10 @@ fn loader_environment_and_command_cwd_share_the_same_config_root() { fn tokio_loader_environment_and_command_cwd_share_the_same_config_root() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_tokio_command("LD_PRELOAD=./assets/libprobe.so scripts/probe"); + let command = build_tokio_command( + &CommandSpec::direct("scripts/probe", [] as [&str; 0]) + .with_env("LD_PRELOAD", "./assets/libprobe.so"), + ); let command = command.as_std(); let preload = command .get_envs() diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs new file mode 100644 index 000000000..5d42e5a7e --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs @@ -0,0 +1,51 @@ +use std::io; +use std::time::Duration; + +use unixnotis_core::CommandSpec; + +use super::{build_command_runtime, run_command_with_timeout}; + +#[test] +fn blocking_runner_preserves_literal_direct_arguments() { + let command = CommandSpec::direct("printf", ["battery|charging"]); + + let output = run_command_with_timeout(&command, Duration::ZERO, None) + .expect("run direct command without a deadline"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"battery|charging"); +} + +#[test] +fn asynchronous_runner_preserves_stdout_and_stderr() { + let runtime = build_command_runtime().expect("build command runtime"); + let command = CommandSpec::shell("printf output; printf error >&2"); + + let output = run_command_with_timeout(&command, Duration::from_secs(1), Some(&runtime)) + .expect("run command with Tokio pipe draining"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"output"); + assert_eq!(output.stderr, b"error"); +} + +#[test] +fn blocking_runner_terminates_a_command_after_its_deadline() { + let command = CommandSpec::shell("sleep 2"); + + let error = run_command_with_timeout(&command, Duration::from_millis(20), None) + .expect_err("blocking command should time out"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); +} + +#[test] +fn asynchronous_runner_terminates_a_command_after_its_deadline() { + let runtime = build_command_runtime().expect("build command runtime"); + let command = CommandSpec::shell("sleep 2"); + + let error = run_command_with_timeout(&command, Duration::from_millis(20), Some(&runtime)) + .expect_err("asynchronous command should time out"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs index 52da8b413..b34de2687 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs @@ -3,6 +3,7 @@ use std::io; use std::process::Child; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use unixnotis_core::CommandSpec; use super::command_parse::is_probably_slow; use super::exec::build_command; @@ -54,7 +55,10 @@ impl CommandPlan { Duration::from_millis(jitter_ms) } - pub(in crate::ui::widgets) fn spawn_watch_command(&self, cmd: &str) -> io::Result { + pub(in crate::ui::widgets) fn spawn_watch_command( + &self, + cmd: &CommandSpec, + ) -> io::Result { // Watch commands keep stdout open while stderr stays detached from refresh wakeups let mut command = build_command(cmd); command @@ -72,7 +76,7 @@ impl CommandPlan { } pub(in crate::ui::widgets) fn resolve_command_plan( - cmd: &str, + cmd: &CommandSpec, default_kind: CommandKind, ) -> CommandPlan { let mut kind = default_kind; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs index 964b53813..f7fc403c4 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs @@ -6,6 +6,7 @@ use std::time::Duration; use crossbeam_channel as channel; use tracing::warn; +use unixnotis_core::CommandSpec; use super::worker::CommandJob; use crate::ui::widgets::utils::command::CommandKind; @@ -16,7 +17,7 @@ const COALESCED_RETRY_DELAY_MS: u64 = 25; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub(super) struct RefreshCommandKey { - cmd: String, + cmd: CommandSpec, kind: CommandKind, timeout_ms: Option, } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs index bc73b73a5..bf3ad7763 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs @@ -4,10 +4,11 @@ use std::time::Instant; use super::super::worker::CommandJob; use super::{insert_coalesced_job, CoalescedRefreshState}; use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; +use unixnotis_core::CommandSpec; -fn job(cmd: &str, kind: CommandKind) -> CommandJob { +fn job(cmd: CommandSpec, kind: CommandKind) -> CommandJob { CommandJob { - cmd: cmd.to_string(), + cmd, plan: CommandPlan { kind, timeout_override: None, @@ -24,8 +25,14 @@ fn same_refresh_key_replaces_existing_job() { order: VecDeque::new(), }; - insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); - let outcome = insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), + ); + let outcome = insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), + ); assert_eq!(state.pending.len(), 1); assert_eq!(state.order.len(), 1); @@ -40,8 +47,14 @@ fn distinct_refresh_kinds_keep_separate_jobs() { order: VecDeque::new(), }; - insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); - insert_coalesced_job(&mut state, job("echo a", CommandKind::Slow)); + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), + ); + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Slow), + ); assert_eq!(state.pending.len(), 2); assert_eq!(state.order.len(), 2); @@ -54,13 +67,28 @@ fn full_refresh_queue_evicts_oldest_key() { order: VecDeque::new(), }; for index in 0..256 { - insert_coalesced_job(&mut state, job(&format!("echo {index}"), CommandKind::Fast)); + insert_coalesced_job( + &mut state, + job( + CommandSpec::direct("echo", [index.to_string()]), + CommandKind::Fast, + ), + ); } - let outcome = insert_coalesced_job(&mut state, job("echo newest", CommandKind::Fast)); + let outcome = insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["newest"]), CommandKind::Fast), + ); assert_eq!(state.pending.len(), 256); assert!(outcome.evicted_oldest); - assert!(!state.pending.values().any(|item| item.cmd == "echo 0")); - assert!(state.pending.values().any(|item| item.cmd == "echo newest")); + assert!(!state + .pending + .values() + .any(|item| item.cmd == CommandSpec::direct("echo", ["0"]))); + assert!(state + .pending + .values() + .any(|item| item.cmd == CommandSpec::direct("echo", ["newest"]))); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs index ef54b0c40..816a2408e 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs @@ -8,10 +8,11 @@ use super::{ DelayedState, }; use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; +use unixnotis_core::CommandSpec; fn job(cmd: &str) -> CommandJob { CommandJob { - cmd: cmd.to_string(), + cmd: CommandSpec::direct("echo", [cmd]), plan: CommandPlan { kind: CommandKind::Slow, timeout_override: None, @@ -71,6 +72,9 @@ fn due_job_selection_prefers_deadline_then_sequence() { let index = next_ready_delayed_job_index(&state.pending, now).expect("expected due job"); - assert_eq!(state.pending[index].job.cmd, "echo first"); + assert_eq!( + state.pending[index].job.cmd, + CommandSpec::direct("echo", ["echo first"]) + ); assert_eq!(next_delayed_wake(&state.pending, now), Some(Duration::ZERO)); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs index ee1d5b302..b49595cfa 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs @@ -5,10 +5,15 @@ use super::{ dispatch_ready_job, should_warn_queue_full_from, CommandJob, CommandKind, CommandPlan, CommandWorker, }; +use unixnotis_core::CommandSpec; fn job(cmd: &str, kind: CommandKind) -> CommandJob { CommandJob { - cmd: cmd.to_string(), + cmd: if cmd == "sleep 1" { + CommandSpec::direct("sleep", ["1"]) + } else { + CommandSpec::direct(cmd, [] as [&str; 0]) + }, plan: CommandPlan { kind, timeout_override: None, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs index e4d354e97..f93dd9729 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use crossbeam_channel as channel; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; @@ -25,7 +25,7 @@ const COMMAND_QUEUE_WARN_INTERVAL_SECS: u64 = 5; pub(super) struct CommandJob { // Command text for this run - pub(super) cmd: String, + pub(super) cmd: CommandSpec, pub(super) plan: CommandPlan, pub(super) respond: Option>>, // Used to split wait time from run time @@ -88,7 +88,7 @@ impl CommandWorker { } pub(in crate::ui::widgets::utils::command) fn enqueue_command( - cmd: String, + cmd: CommandSpec, plan: CommandPlan, respond: Option>>, ) { @@ -274,7 +274,7 @@ fn run_worker(rx: channel::Receiver) { } fn handle_job(job: CommandJob, runtime: Option<&tokio::runtime::Runtime>) { - let cmd_snip = util::log_snippet(&job.cmd); + let cmd_snip = util::log_snippet(&job.cmd.display_lossy()); // Wait time includes queue time and slow-job jitter let queue_wait_ms = job.queued_at.elapsed().as_millis(); debug::log(PanelDebugLevel::Verbose, || { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs index 19bc07089..281975011 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs @@ -2,10 +2,11 @@ use std::io::ErrorKind; use super::super::test_support::configure_command_test_root; use super::run_command_capture_action_async; +use unixnotis_core::CommandSpec; #[test] fn empty_action_command_returns_invalid_input_without_enqueueing() { - let response = run_command_capture_action_async(" ") + let response = run_command_capture_action_async(&CommandSpec::direct("", [] as [&str; 0])) .recv_blocking() .expect("action response should remain available") .expect_err("empty command should fail"); @@ -16,7 +17,7 @@ fn empty_action_command_returns_invalid_input_without_enqueueing() { #[test] fn action_command_runs_in_the_action_lane_and_reports_output() { configure_command_test_root(); - let output = run_command_capture_action_async("true") + let output = run_command_capture_action_async(&CommandSpec::direct("true", [] as [&str; 0])) .recv_blocking() .expect("action response should remain available") .expect("true should execute"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs index 28e7b6ac7..094f33163 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs @@ -3,10 +3,11 @@ use std::time::Duration; use super::super::test_support::configure_command_test_root; use super::{run_command_capture_async, run_command_capture_with_timeout_async}; +use unixnotis_core::CommandSpec; #[test] fn empty_capture_command_returns_invalid_input_without_enqueueing() { - let response = run_command_capture_async("\t") + let response = run_command_capture_async(&CommandSpec::direct("", [] as [&str; 0])) .recv_blocking() .expect("capture response should remain available") .expect_err("empty command should fail"); @@ -17,10 +18,13 @@ fn empty_capture_command_returns_invalid_input_without_enqueueing() { #[test] fn custom_capture_timeout_terminates_long_running_command() { configure_command_test_root(); - let response = run_command_capture_with_timeout_async("sleep 1", Duration::from_millis(40)) - .recv_blocking() - .expect("capture response should remain available") - .expect_err("sleep should exceed the custom timeout"); + let response = run_command_capture_with_timeout_async( + &CommandSpec::direct("sleep", ["1"]), + Duration::from_millis(40), + ) + .recv_blocking() + .expect("capture response should remain available") + .expect_err("sleep should exceed the custom timeout"); assert_eq!(response.kind(), ErrorKind::TimedOut); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs index 83a7b9d6c..b8d22a78a 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs @@ -1,43 +1,35 @@ -use super::{is_probably_slow, parse_simple_command}; +use super::is_probably_slow; +use unixnotis_core::CommandSpec; #[test] -fn parse_simple_command_honors_quotes() { - let parsed = parse_simple_command("notify-send \"Hello World\"").expect("parsed command"); - assert_eq!(parsed.program, "notify-send"); - assert!(parsed.env.is_empty()); - assert_eq!(parsed.args, vec!["Hello World"]); +fn slow_classification_uses_the_structured_program() { + assert!(is_probably_slow(&CommandSpec::direct("sleep", ["1"]))); + assert!(is_probably_slow(&CommandSpec::direct( + "nmcli", + ["radio", "wifi"] + ))); + assert!(!is_probably_slow( + &CommandSpec::direct("echo", ["ok"]).with_env("FOO", "bar") + )); + assert!(!is_probably_slow(&CommandSpec::direct( + "echo", + ["I am not sleeping"] + ))); } #[test] -fn parse_simple_command_rejects_shell_meta() { - assert!(parse_simple_command("echo hi | wc -l").is_none()); +fn explicit_shell_commands_use_the_slow_lane() { + assert!(is_probably_slow(&CommandSpec::shell("printf ready"))); } #[test] -fn parse_simple_command_accepts_leading_env_assignments() { - let parsed = parse_simple_command("FOO=bar BAR='two words' notify-send done").expect("parsed"); - - assert_eq!(parsed.program, "notify-send"); - assert_eq!( - parsed.env, - vec![ - ("FOO".to_string(), "bar".to_string()), - ("BAR".to_string(), "two words".to_string()) - ] - ); - assert_eq!(parsed.args, vec!["done"]); -} - -#[test] -fn is_probably_slow_respects_program_tokens() { - assert!(is_probably_slow("sleep 1")); - assert!(is_probably_slow("nmcli radio wifi")); - assert!(!is_probably_slow("FOO=bar echo ok")); - assert!(!is_probably_slow("echo \"I am not sleeping\"")); -} - -#[test] -fn is_probably_slow_handles_shell_sleep_script() { - assert!(is_probably_slow("bash -c \"sleep 1\"")); - assert!(!is_probably_slow("bash -c \"echo sleep\"")); +fn directly_invoked_shells_only_inspect_the_script_argument() { + assert!(is_probably_slow(&CommandSpec::direct( + "bash", + ["-c", "sleep 1"] + ))); + assert!(!is_probably_slow(&CommandSpec::direct( + "bash", + ["-c", "echo sleep"] + ))); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs index 11d5e72af..7a3e2b9e5 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs @@ -1,10 +1,11 @@ use std::time::Duration; use super::{resolve_command_plan, CommandKind}; +use unixnotis_core::CommandSpec; #[test] fn slow_command_promotes_refresh_plan_to_slow_lane() { - let plan = resolve_command_plan("sleep 1", CommandKind::Fast); + let plan = resolve_command_plan(&CommandSpec::direct("sleep", ["1"]), CommandKind::Fast); assert_eq!(plan.kind, CommandKind::Slow); assert_eq!(plan.timeout(), Duration::from_millis(800)); @@ -12,7 +13,7 @@ fn slow_command_promotes_refresh_plan_to_slow_lane() { #[test] fn action_command_keeps_action_lane_even_when_command_is_slow() { - let plan = resolve_command_plan("sleep 1", CommandKind::Action); + let plan = resolve_command_plan(&CommandSpec::direct("sleep", ["1"]), CommandKind::Action); assert_eq!(plan.kind, CommandKind::Action); assert_eq!(plan.timeout(), Duration::from_millis(1_200)); @@ -20,8 +21,11 @@ fn action_command_keeps_action_lane_even_when_command_is_slow() { #[test] fn explicit_timeout_overrides_lane_default() { - let plan = - resolve_command_plan("true", CommandKind::Fast).with_timeout(Duration::from_millis(25)); + let plan = resolve_command_plan( + &CommandSpec::direct("true", [] as [&str; 0]), + CommandKind::Fast, + ) + .with_timeout(Duration::from_millis(25)); assert_eq!(plan.timeout(), Duration::from_millis(25)); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs index 1232defea..aef0316e1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs @@ -4,14 +4,14 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use super::super::{run_action_command_with_completion, value::format_command_value}; -use unixnotis_core::PanelDebugLevel; +use unixnotis_core::{CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; pub(super) fn schedule_command( pending: Rc>>, pending_value: Rc>>, - cmd_template: String, + cmd_template: CommandSpec, value: f64, step: f64, on_complete: Rc, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs index c21eab11a..674d6c96c 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use std::time::Duration; use super::schedule_command; +use unixnotis_core::CommandSpec; #[gtk::test] fn scheduled_command_coalesces_values_and_clears_pending_state() { @@ -23,7 +24,7 @@ fn scheduled_command_coalesces_values_and_clears_pending_state() { schedule_command( pending.clone(), pending_value.clone(), - "test {value} = 17".to_string(), + CommandSpec::direct("test", ["{value}", "=", "17"]), 4.0, 1.0, on_complete.clone(), @@ -31,7 +32,7 @@ fn scheduled_command_coalesces_values_and_clears_pending_state() { schedule_command( pending.clone(), pending_value.clone(), - "test {value} = 17".to_string(), + CommandSpec::direct("test", ["{value}", "=", "17"]), 17.0, 1.0, on_complete, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs index 23d4aebd8..7a4e86065 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::SliderWidgetConfig; +use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::{attach_icon_action, attach_scale_action}; use crate::ui::widgets::utils::command_slider::refresh::{SliderRefreshGate, SliderRefreshMeta}; @@ -41,7 +41,7 @@ fn icon_action_adds_a_static_shell_when_toggle_command_is_absent() { #[gtk::test] fn scale_action_echoes_the_changed_value_immediately() { let config = SliderWidgetConfig { - set_cmd: ":".to_string(), + set_cmd: CommandSpec::direct("true", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; @@ -63,8 +63,8 @@ fn scale_action_echoes_the_changed_value_immediately() { #[gtk::test] fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { let config = SliderWidgetConfig { - get_cmd: "printf 22".to_string(), - set_cmd: "true".to_string(), + get_cmd: CommandSpec::direct("printf", ["22"]), + set_cmd: CommandSpec::direct("true", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; @@ -88,8 +88,8 @@ fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { #[gtk::test] fn failed_scale_action_runs_corrective_refresh() { let config = SliderWidgetConfig { - get_cmd: "printf 22".to_string(), - set_cmd: "false".to_string(), + get_cmd: CommandSpec::direct("printf", ["22"]), + set_cmd: CommandSpec::direct("false", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs index 1c39a224e..8167bd87f 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs @@ -1,11 +1,11 @@ //! Slider refresh request snapshots -use unixnotis_core::{NumericParseMode, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, NumericParseMode, SliderWidgetConfig}; #[derive(Clone)] pub(in super::super) struct SliderRefreshRequest { // Command used to read the current slider value - pub(super) cmd: String, + pub(super) cmd: CommandSpec, // Lower bound used for parser clamping pub(super) min: f64, // Upper bound used for parser clamping @@ -28,9 +28,9 @@ impl SliderRefreshRequest { } } - pub(in super::super) fn command(&self) -> &str { + pub(in super::super) fn command(&self) -> String { // Action diagnostics need the command identity without exposing mutable request fields - &self.cmd + self.cmd.display_lossy() } } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs index 7d6bce2a8..6b191bdf0 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs @@ -31,7 +31,7 @@ pub(in super::super) fn request_refresh( // Collapse bursty requests into one running refresh and one trailing refresh if !refresh.gate.begin_or_queue() { perf_probe::slider_refresh_queued(); - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh queued while in flight cmd=\"{cmd_snip}\"") }); @@ -39,7 +39,7 @@ pub(in super::super) fn request_refresh( } perf_probe::slider_refresh_start(); - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh start cmd=\"{cmd_snip}\"") }); @@ -103,7 +103,7 @@ fn finish_refresh( ) { // One queued refresh is allowed to run after the current one finishes if refresh.gate.finish() { - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh consumed pending request cmd=\"{cmd_snip}\"") }); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs index 9f075a774..c1e039830 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::NumericParseMode; +use unixnotis_core::{CommandSpec, NumericParseMode}; use super::{apply_slider_icon, apply_slider_value, apply_successful_output, note_slider_error}; use crate::ui::widgets::utils::command_slider::refresh::{ @@ -113,7 +113,7 @@ fn slider_error_records_a_retry_deadline() { fn request() -> SliderRefreshRequest { SliderRefreshRequest { - cmd: "read-slider".to_string(), + cmd: CommandSpec::direct("read-slider", [] as [&str; 0]), min: 0.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs index 487d9f0fa..e11525f5c 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs @@ -7,6 +7,7 @@ use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; use crate::ui::widgets::utils::{ start_command_watch, CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK, }; +use unixnotis_core::CommandSpec; #[test] fn polling_without_a_watch_starts_at_the_minimum_deadline() { @@ -63,7 +64,8 @@ fn polling_uses_the_recorded_backoff_deadline() { #[gtk::test] fn active_watch_suppresses_polling_until_it_exits() { - let watch = start_command_watch("sleep 2", || {}).expect("watch should start"); + let watch = start_command_watch(&CommandSpec::direct("sleep", ["2"]), || {}) + .expect("watch should start"); let watch = RefCell::new(Some(watch)); let gate = SliderRefreshGate::new(); let backoff = Rc::new(RefCell::new(RefreshBackoff::default())); @@ -83,7 +85,8 @@ fn active_watch_suppresses_polling_until_it_exits() { #[gtk::test] fn exited_watch_is_removed_before_polling_resumes() { - let watch = start_command_watch("true", || {}).expect("watch should start"); + let watch = start_command_watch(&CommandSpec::direct("true", [] as [&str; 0]), || {}) + .expect("watch should start"); let watch = RefCell::new(Some(watch)); let deadline = Instant::now() + Duration::from_secs(2); while watch.borrow().as_ref().is_some_and(CommandWatch::is_active) && Instant::now() < deadline diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs index 35bd4eaff..9720c2d24 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs @@ -1,11 +1,11 @@ -use unixnotis_core::{NumericParseMode, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, NumericParseMode, SliderWidgetConfig}; use super::SliderRefreshRequest; #[test] fn refresh_request_copies_every_runtime_input_from_config() { let config = SliderWidgetConfig { - get_cmd: "read-custom-value".to_string(), + get_cmd: CommandSpec::direct("read-custom-value", [] as [&str; 0]), min: -12.5, max: 240.0, step: 0.25, @@ -16,7 +16,10 @@ fn refresh_request_copies_every_runtime_input_from_config() { let request = SliderRefreshRequest::from_config(&config); assert_eq!(request.command(), "read-custom-value"); - assert_eq!(request.cmd, "read-custom-value"); + assert_eq!( + request.cmd, + CommandSpec::direct("read-custom-value", [] as [&str; 0]) + ); assert_close(request.min, -12.5); assert_close(request.max, 240.0); assert_close(request.step, 0.25); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs index fcedbaaf2..5392641e7 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs @@ -168,7 +168,7 @@ fn refresh_gate_runs_one_queued_follow_up() { fn request(cmd: &str) -> SliderRefreshRequest { SliderRefreshRequest { - cmd: cmd.to_string(), + cmd: unixnotis_core::parse_legacy_command(cmd).expect("valid test command"), min: 0.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs index 685f2b694..ccb29f253 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs @@ -1,4 +1,4 @@ -use unixnotis_core::SliderWidgetConfig; +use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::set_watch_active; use crate::ui::widgets::utils::command_slider::CommandSlider; @@ -6,7 +6,7 @@ use crate::ui::widgets::utils::command_slider::CommandSlider; #[gtk::test] fn watch_lifecycle_starts_once_and_stops_cleanly() { let config = SliderWidgetConfig { - watch_cmd: Some("sleep 2".to_string()), + watch_cmd: Some(CommandSpec::direct("sleep", ["2"])), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "test-slider"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs index a55737d47..6f70a4a86 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::{css::hooks, SliderWidgetConfig}; +use unixnotis_core::{css::hooks, CommandSpec, SliderWidgetConfig}; use super::CommandSlider; @@ -33,7 +33,7 @@ fn inactive_watch_slider_remains_eligible_for_polling() { #[gtk::test] fn public_refresh_starts_and_completes_slider_update() { let config = SliderWidgetConfig { - get_cmd: "printf 37".to_string(), + get_cmd: CommandSpec::direct("printf", ["37"]), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "volume-slider"); @@ -70,7 +70,7 @@ fn public_refresh_honors_recorded_backoff() { #[gtk::test] fn public_watch_lifecycle_controls_the_owned_handle() { let config = SliderWidgetConfig { - watch_cmd: Some("sleep 2".to_string()), + watch_cmd: Some(CommandSpec::direct("sleep", ["2"])), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "volume-slider"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/watch.rs b/crates/unixnotis-center/src/ui/widgets/utils/watch.rs index 8f66ffe90..d52ff5214 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/watch.rs @@ -10,7 +10,7 @@ use std::time::Duration; use async_channel::{TryRecvError, TrySendError}; use gtk::glib; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; @@ -50,27 +50,26 @@ impl Drop for CommandWatch { } pub(in crate::ui::widgets) fn start_command_watch( - cmd: &str, + cmd: &CommandSpec, on_event: F, ) -> Option { - let cmd = cmd.trim(); if cmd.is_empty() { warn!("watch command was empty"); return None; } debug::log(PanelDebugLevel::Info, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("watch start: {snippet}") }); let plan = resolve_command_plan(cmd, CommandKind::Slow); - let cmd_string = cmd.to_string(); + let cmd_string = cmd.display_lossy(); let cmd_for_thread = cmd_string.clone(); // Spawn watch command with stdout piped so events can be consumed let mut child = match plan.spawn_watch_command(cmd) { Ok(child) => child, Err(err) => { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); warn!(command = %snippet, ?err, "watch command failed to start"); return None; } @@ -79,7 +78,7 @@ pub(in crate::ui::widgets) fn start_command_watch( let stdout = if let Some(stdout) = child.stdout.take() { stdout } else { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); warn!(command = %snippet, "watch command missing stdout"); let _ = child.kill(); let _ = child.wait(); From 54e252cc119c91052b39a0f4ce33d7f3cecca06d Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:32:04 -0500 Subject: [PATCH 019/275] feat(core): centralize lexical path containment Summary: centralize lexical path containment. Scope: core. --- crates/unixnotis-core/src/filesystem/mod.rs | 2 + crates/unixnotis-core/src/filesystem/path.rs | 129 ++++++++++++++++++ .../src/tests/filesystem/path.rs | 71 ++++++++++ 3 files changed, 202 insertions(+) create mode 100644 crates/unixnotis-core/src/filesystem/path.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/path.rs diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 4239f562c..ca87267cc 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -1,5 +1,7 @@ //! Shared filesystem operations with stable directory anchors mod atomic; +mod path; pub use atomic::{make_file_executable, write_file_atomic, write_file_if_missing}; +pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; diff --git a/crates/unixnotis-core/src/filesystem/path.rs b/crates/unixnotis-core/src/filesystem/path.rs new file mode 100644 index 000000000..934b3aed6 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/path.rs @@ -0,0 +1,129 @@ +//! Lexical path normalization and root containment without filesystem access + +use std::path::{Component, Path, PathBuf}; + +use thiserror::Error; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LexicallyNormalizedPath(PathBuf); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContainedPath { + root: LexicallyNormalizedPath, + relative: PathBuf, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum LexicalPathError { + #[error("path parent traversal escapes its lexical root")] + ParentEscape, + #[error("contained path must be relative")] + ExpectedRelative, + #[error("path is outside the supplied root")] + OutsideRoot, +} + +impl LexicallyNormalizedPath { + /// Normalize `.` and `..` components without resolving symlinks + /// + /// # Errors + /// + /// Returns an error when parent traversal would escape the lexical path root + pub fn new(path: impl AsRef) -> Result { + let mut normalized = PathBuf::new(); + for component in path.as_ref().components() { + match component { + Component::CurDir => {} + Component::ParentDir => match normalized.components().next_back() { + Some(Component::Normal(_)) => { + let removed = normalized.pop(); + debug_assert!(removed); + } + _ => return Err(LexicalPathError::ParentEscape), + }, + Component::Normal(part) => normalized.push(part), + Component::RootDir | Component::Prefix(_) => { + normalized.push(component.as_os_str()); + } + } + } + Ok(Self(normalized)) + } + + #[must_use] + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + #[must_use] + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +impl AsRef for LexicallyNormalizedPath { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl ContainedPath { + /// Resolve an absolute or relative candidate beneath one lexical root + /// + /// # Errors + /// + /// Returns an error when normalization fails or the result leaves `root` + pub fn resolve( + root: impl AsRef, + candidate: impl AsRef, + ) -> Result { + let root = LexicallyNormalizedPath::new(root)?; + let candidate = candidate.as_ref(); + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.as_path().join(candidate) + }; + let normalized = LexicallyNormalizedPath::new(joined)?; + let relative = normalized + .as_path() + .strip_prefix(root.as_path()) + .map_err(|_| LexicalPathError::OutsideRoot)? + .to_path_buf(); + Ok(Self { root, relative }) + } + + /// Resolve a candidate that must be relative beneath one lexical root + /// + /// # Errors + /// + /// Returns an error for absolute candidates, traversal, or containment failure + pub fn resolve_relative( + root: impl AsRef, + relative: impl AsRef, + ) -> Result { + if relative.as_ref().is_absolute() { + return Err(LexicalPathError::ExpectedRelative); + } + Self::resolve(root, relative) + } + + #[must_use] + pub fn root(&self) -> &Path { + self.root.as_path() + } + + #[must_use] + pub fn relative(&self) -> &Path { + self.relative.as_path() + } + + #[must_use] + pub fn absolute(&self) -> PathBuf { + self.root.as_path().join(&self.relative) + } +} + +#[cfg(test)] +#[path = "../tests/filesystem/path.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/path.rs b/crates/unixnotis-core/src/tests/filesystem/path.rs new file mode 100644 index 000000000..cc07dc5df --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/path.rs @@ -0,0 +1,71 @@ +use std::path::{Path, PathBuf}; + +use proptest::prelude::*; + +use crate::filesystem::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; + +#[test] +fn lexical_normalization_removes_current_and_internal_parent_components() { + let path = LexicallyNormalizedPath::new("/srv/unixnotis/./scripts/old/../probe") + .expect("normalize contained path"); + + assert_eq!(path.as_path(), Path::new("/srv/unixnotis/scripts/probe")); +} + +#[test] +fn lexical_normalization_can_transfer_owned_path_storage() { + let path = LexicallyNormalizedPath::new("scripts/old/../probe") + .expect("normalize owned path") + .into_path_buf(); + + assert_eq!(path, PathBuf::from("scripts/probe")); +} + +#[test] +fn lexical_normalization_rejects_parent_escape() { + assert_eq!( + LexicallyNormalizedPath::new("../../outside"), + Err(LexicalPathError::ParentEscape) + ); + assert_eq!( + LexicallyNormalizedPath::new("/../../outside"), + Err(LexicalPathError::ParentEscape) + ); +} + +#[test] +fn contained_paths_reject_absolute_and_relative_escape() { + let root = Path::new("/srv/unixnotis"); + + assert_eq!( + ContainedPath::resolve_relative(root, "/tmp/outside"), + Err(LexicalPathError::ExpectedRelative) + ); + assert_eq!( + ContainedPath::resolve_relative(root, "../outside"), + Err(LexicalPathError::OutsideRoot) + ); +} + +proptest! { + #[test] + fn normalization_is_idempotent(parts in prop::collection::vec("[a-z]{1,8}", 0..12)) { + let path = parts.iter().collect::(); + let once = LexicallyNormalizedPath::new(&path).expect("normalize generated path"); + let twice = LexicallyNormalizedPath::new(once.as_path()).expect("normalize normalized path"); + + prop_assert_eq!(once, twice); + } + + #[test] + fn resolved_relative_paths_remain_beneath_root( + parts in prop::collection::vec("[a-z]{1,8}", 0..12) + ) { + let relative = parts.iter().collect::(); + let resolved = ContainedPath::resolve_relative("/srv/unixnotis", relative) + .expect("resolve generated path"); + + prop_assert!(resolved.absolute().starts_with(resolved.root())); + prop_assert!(!resolved.relative().is_absolute()); + } +} From f18cf4650ef4488762c8f945d62888421229a61d Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:32:21 -0500 Subject: [PATCH 020/275] refactor(preset): enforce structured command boundaries Summary: enforce structured command boundaries. Scope: preset. --- .../src/preset/command_rules/checks.rs | 16 +- .../src/preset/command_rules/collect.rs | 51 +- .../src/preset/command_rules/model.rs | 13 +- .../src/preset/command_rules/rewrite.rs | 11 +- .../src/preset/command_rules/tests/cases.rs | 46 +- .../preset/command_rules/tests/env_paths.rs | 547 ------------------ .../preset/command_rules/tests/environment.rs | 133 +++++ .../src/preset/command_rules/tests/layout.rs | 213 +++++++ .../src/preset/command_rules/tests/mod.rs | 5 +- .../src/preset/command_rules/tests/model.rs | 3 +- .../preset/command_rules/tests/path_tokens.rs | 28 + .../src/preset/command_rules/tests/support.rs | 6 + .../preset/command_rules/tests/validation.rs | 186 ++++++ .../src/preset/command_rules/tokens.rs | 232 ++++---- .../src/preset/export/prompts/rewrite.rs | 3 +- .../src/preset/export/script_dependencies.rs | 20 +- .../src/preset/import/review/checks.rs | 6 +- crates/noticenterctl/src/preset/inspect.rs | 9 +- crates/noticenterctl/src/preset/pathing.rs | 56 +- .../noticenterctl/src/preset/tests/inspect.rs | 5 +- .../noticenterctl/src/preset/tests/pathing.rs | 8 +- 21 files changed, 782 insertions(+), 815 deletions(-) delete mode 100644 crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs create mode 100644 crates/noticenterctl/src/preset/command_rules/tests/environment.rs create mode 100644 crates/noticenterctl/src/preset/command_rules/tests/layout.rs create mode 100644 crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs create mode 100644 crates/noticenterctl/src/preset/command_rules/tests/validation.rs diff --git a/crates/noticenterctl/src/preset/command_rules/checks.rs b/crates/noticenterctl/src/preset/command_rules/checks.rs index 8fc8c01e2..603de9031 100644 --- a/crates/noticenterctl/src/preset/command_rules/checks.rs +++ b/crates/noticenterctl/src/preset/command_rules/checks.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::{parse_command, Config}; +use unixnotis_core::Config; use super::super::pathing::normalize_lexical_path; use super::collect::collect_command_references_from_config; @@ -86,19 +86,13 @@ pub fn validate_config_command_paths_stay_in_root( ) -> Result<()> { // Wrapper validation runs before path collection so ambiguous env forms fail closed for reference in collect_command_references_from_config(config) { - let parsed = parse_command(&reference.command).with_context(|| { - format!( - "{mode_label} because {} contains an invalid command", - reference.slot - ) - })?; - validate_env_command_layout(&parsed).map_err(|reason| { + validate_env_command_layout(&reference.command).map_err(|reason| { anyhow!( "{mode_label} because {} contains an unsafe env wrapper: {reason}", reference.slot ) })?; - validate_env_path_semantics(&parsed).map_err(|reason| { + validate_env_path_semantics(&reference.command).map_err(|reason| { anyhow!( "{mode_label} because {} contains unsafe environment path semantics: {reason}", reference.slot @@ -128,7 +122,7 @@ pub fn validate_command_paths_in_config_bytes( // Byte validation is used before imported configuration reaches the live directory let config_text = std::str::from_utf8(config_bytes).context("preset config.toml is not valid UTF-8")?; - let config: Config = - toml::from_str(config_text).context("parse bundled config.toml for command path checks")?; + let config = + Config::parse(config_text).context("parse bundled config.toml for command path checks")?; validate_config_command_paths_stay_in_root(config_dir, &config, mode_label) } diff --git a/crates/noticenterctl/src/preset/command_rules/collect.rs b/crates/noticenterctl/src/preset/command_rules/collect.rs index f8c7bb7e2..76c317b57 100644 --- a/crates/noticenterctl/src/preset/command_rules/collect.rs +++ b/crates/noticenterctl/src/preset/command_rules/collect.rs @@ -1,4 +1,4 @@ -use unixnotis_core::Config; +use unixnotis_core::{CommandSpec, Config}; use super::CommandReference; @@ -11,66 +11,66 @@ pub fn collect_command_references_from_config(config: &Config) -> Vec Vec, base_slot: &str, - get_cmd: &str, - set_cmd: &str, - toggle_cmd: Option<&str>, - watch_cmd: Option<&str>, + get_cmd: &CommandSpec, + set_cmd: &CommandSpec, + toggle_cmd: Option<&CommandSpec>, + watch_cmd: Option<&CommandSpec>, ) { // Sliders always expose read and write commands, so those are always listed commands.push(CommandReference { slot: format!("{base_slot}.get_cmd"), - command: get_cmd.to_string(), + command: get_cmd.clone(), }); commands.push(CommandReference { slot: format!("{base_slot}.set_cmd"), - command: set_cmd.to_string(), + command: set_cmd.clone(), }); push_optional_command(commands, &format!("{base_slot}.toggle_cmd"), toggle_cmd); push_optional_command(commands, &format!("{base_slot}.watch_cmd"), watch_cmd); } -fn push_optional_command(commands: &mut Vec, slot: &str, value: Option<&str>) { +fn push_optional_command( + commands: &mut Vec, + slot: &str, + value: Option<&CommandSpec>, +) { let Some(command) = value else { return; }; - let trimmed = command.trim(); - if trimmed.is_empty() { + if command.is_empty() { // Blank values are treated the same as missing values in reports return; } commands.push(CommandReference { slot: slot.to_string(), - command: trimmed.to_string(), + command: command.clone(), }); } diff --git a/crates/noticenterctl/src/preset/command_rules/model.rs b/crates/noticenterctl/src/preset/command_rules/model.rs index f95f81178..d9df80193 100644 --- a/crates/noticenterctl/src/preset/command_rules/model.rs +++ b/crates/noticenterctl/src/preset/command_rules/model.rs @@ -1,21 +1,22 @@ //! Command references and path findings shared by preset checks use std::path::PathBuf; +use unixnotis_core::CommandSpec; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandReference { // Config field name used in inspect and warning output pub(crate) slot: String, - // Raw command string carried by the parsed config - pub(crate) command: String, + // Typed command carried by the parsed config + pub(crate) command: CommandSpec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OutsideCommandPath { // Config slot that carried the outside path pub(crate) slot: String, - // Raw command string from the config - pub(crate) command: String, + // Typed command from the config + pub(crate) command: CommandSpec, // Resolved first-token path used by the validator pub(crate) resolved_path: PathBuf, } @@ -24,8 +25,8 @@ pub struct OutsideCommandPath { pub struct HostSpecificCommandPath { // Config slot that carried the host-specific path pub(crate) slot: String, - // Raw command string from the config - pub(crate) command: String, + // Typed command from the config + pub(crate) command: CommandSpec, // Resolved first-token path under the config root pub(crate) resolved_path: PathBuf, } diff --git a/crates/noticenterctl/src/preset/command_rules/rewrite.rs b/crates/noticenterctl/src/preset/command_rules/rewrite.rs index 6bc707429..198685d5d 100644 --- a/crates/noticenterctl/src/preset/command_rules/rewrite.rs +++ b/crates/noticenterctl/src/preset/command_rules/rewrite.rs @@ -1,6 +1,6 @@ use std::path::Path; -use unixnotis_core::{Config, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, Config, SliderWidgetConfig}; use super::checks::collect_host_specific_command_paths; use super::tokens::rewrite_command_to_config_relative; @@ -48,16 +48,13 @@ fn rewrite_slider_commands(config_dir: &Path, slider: &mut SliderWidgetConfig) { rewrite_optional_command(config_dir, &mut slider.watch_cmd); } -fn rewrite_optional_command(config_dir: &Path, value: &mut Option) { +fn rewrite_optional_command(config_dir: &Path, value: &mut Option) { let Some(command) = value.as_mut() else { return; }; rewrite_inline_command(config_dir, command); } -fn rewrite_inline_command(config_dir: &Path, command: &mut String) { - let Some(rewritten) = rewrite_command_to_config_relative(config_dir, command) else { - return; - }; - *command = rewritten; +fn rewrite_inline_command(config_dir: &Path, command: &mut CommandSpec) { + let _ = rewrite_command_to_config_relative(config_dir, command); } diff --git a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs index 6200438f4..73b4919c6 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs @@ -1,4 +1,4 @@ -use unixnotis_core::Config; +use unixnotis_core::{CommandSpec, Config}; use super::super::{ collect_command_references_from_config, collect_host_specific_command_paths, @@ -9,7 +9,7 @@ use super::support::temp_root; #[test] fn collects_widget_command_references() { - let config: Config = toml::from_str( + let config = Config::parse( "\ [theme]\nbase_css = \"base.css\"\n\ [[widgets.toggles]]\nlabel = \"Action\"\nicon = \"applications-system-symbolic\"\ntoggle_cmd = \"scripts/action.sh\"\n\ @@ -36,7 +36,7 @@ fn outside_command_paths_include_absolute_plugin_command() { [[widgets.stats]]\nlabel = \"Probe\"\n\ [widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - let parsed = toml::from_str(config).expect("parse config"); + let parsed = Config::parse(config).expect("parse config"); let outside = collect_outside_command_paths(&config_dir, &parsed); assert_eq!(outside.len(), 1); @@ -99,7 +99,7 @@ fn host_specific_command_paths_include_absolute_path_inside_root() { script_path.display().to_string() ); - let parsed = toml::from_str(&config).expect("parse config"); + let parsed = Config::parse(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); @@ -118,7 +118,7 @@ fn rewrite_host_specific_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = Config::parse(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); @@ -128,7 +128,7 @@ fn rewrite_host_specific_command_paths_makes_commands_config_relative() { .as_ref() .expect("plugin") .command, - "scripts/unixnotis-thermal-stat --json" + CommandSpec::direct("scripts/unixnotis-thermal-stat", ["--json"]) ); } @@ -140,14 +140,17 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_assignments() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env MODE='two words' '{}' --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = Config::parse(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.stats[0].cmd.as_deref(), - Some("env 'MODE=two words' 'scripts/probe tool' --json") + parsed.widgets.stats[0].cmd, + Some(CommandSpec::direct( + "env", + ["MODE=two words", "scripts/probe tool", "--json"] + )) ); } @@ -159,14 +162,17 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_options() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env -u HOME MODE=safe {} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = Config::parse(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.stats[0].cmd.as_deref(), - Some("env -u HOME 'MODE=safe' scripts/probe --json") + parsed.widgets.stats[0].cmd, + Some(CommandSpec::direct( + "env", + ["-u", "HOME", "MODE=safe", "scripts/probe", "--json"] + )) ); } @@ -181,13 +187,16 @@ fn rewrite_host_specific_toggle_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = Config::parse(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.toggles[0].toggle_cmd.as_deref(), - Some("scripts/unixnotis-toggle-action --json") + parsed.widgets.toggles[0].toggle_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-toggle-action", + ["--json"] + )) ); } @@ -202,10 +211,13 @@ fn host_specific_command_paths_include_toggle_command() { script_path.display().to_string() ); - let parsed = toml::from_str(&config).expect("parse config"); + let parsed = Config::parse(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); assert_eq!(leaks[0].slot, "widgets.toggles[0].toggle_cmd"); - assert_eq!(leaks[0].command, script_path.display().to_string()); + assert_eq!( + leaks[0].command, + CommandSpec::direct(script_path, [] as [&str; 0]) + ); } diff --git a/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs b/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs deleted file mode 100644 index 95a235f8c..000000000 --- a/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs +++ /dev/null @@ -1,547 +0,0 @@ -use std::path::PathBuf; - -use super::super::tokens::{ - collect_outside_env_path_tokens, first_command_token, is_host_specific_path_token, - looks_like_path_token, split_env_assignment, validate_env_command_layout, - validate_env_path_semantics, -}; -use super::super::validate_command_paths_in_config_bytes; -use super::support::temp_root; -use unixnotis_core::parse_command; - -#[test] -fn validation_rejects_ld_preload_path_that_leaves_root() { - let config_dir = temp_root("ld-preload-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=/tmp/evil.so /bin/true\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject LD_PRELOAD outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_quoted_ld_preload_paths_that_leave_root() { - let config_dir = temp_root("quoted-ld-preload-outside"); - for command in [ - "LD_PRELOAD=\"/tmp/evil.so\" /bin/true", - "LD_PRELOAD='/tmp/evil.so' /bin/true", - "env LD_PRELOAD=/tmp/evil.so /bin/true", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject quoted or env-wrapped preload escape"); - } -} - -#[test] -fn validation_rejects_tilde_program_and_malformed_quoting() { - let config_dir = temp_root("tilde-and-quote"); - let tilde = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"~/outside-script\"\n"; - validate_command_paths_in_config_bytes(&config_dir, tilde, "preset import blocked") - .expect_err("reject tilde program outside config root"); - - let malformed = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = 'echo \"unterminated'\n"; - validate_command_paths_in_config_bytes(&config_dir, malformed, "preset import blocked") - .expect_err("reject malformed command quoting"); -} - -#[test] -fn validation_rejects_home_override_and_env_wrapped_absolute_program() { - let config_dir = temp_root("home-and-env-program"); - for command in ["HOME=/tmp ./script", "env SAFE=value /bin/true"] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject path policy escape"); - } -} - -#[test] -fn env_path_token_collector_finds_ld_preload_outside_root() { - let config_dir = temp_root("ld-preload-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so /bin/true"); - - assert_eq!(outside.len(), 1); - assert_eq!(outside[0].0, "LD_PRELOAD"); - assert_eq!(outside[0].1, PathBuf::from("/tmp/evil.so")); -} - -#[test] -fn validation_rejects_space_separated_ld_preload_path_that_leaves_root() { - let config_dir = temp_root("space-separated-ld-preload"); - let inside = config_dir.join("libsafe.so"); - let command = format!( - "LD_PRELOAD='{} /tmp/libevil.so' scripts/probe", - inside.display() - ); - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - - let error = validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject second preload object outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_semicolon_separated_library_directory_that_leaves_root() { - let config_dir = temp_root("semicolon-library-path"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH='lib;/tmp/evil' scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject semicolon-separated loader directory outside config root"); -} - -#[test] -fn validation_accepts_empty_list_components_with_the_pinned_config_cwd() { - let config_dir = temp_root("empty-loader-component"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH=':lib;' PATH=:bin scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("empty path components should resolve to the pinned config cwd"); -} - -#[test] -fn validation_keeps_single_path_environment_values_unsplit() { - let config_dir = temp_root("single-path-colon"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"HOME=profiles/home:secondary BASH_ENV=scripts/start:up scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("single path values containing colons should remain one relative path"); -} - -#[test] -fn validation_rejects_pythonhome_exec_prefix_outside_root() { - let config_dir = temp_root("pythonhome-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"PYTHONHOME='runtime:/tmp/outside' python3 -c pass\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external Python exec prefix"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_accepts_pythonhome_single_and_relative_prefix_pair() { - let config_dir = temp_root("pythonhome-relative"); - for command in [ - "PYTHONHOME=runtime python3 -c pass", - "PYTHONHOME='runtime:exec-runtime' python3 -c pass", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .unwrap_or_else(|error| panic!("valid PYTHONHOME was rejected for {command}: {error}")); - } -} - -#[test] -fn validation_rejects_pythonhome_empty_or_ambiguous_prefixes() { - for (command, reason) in [ - ( - "PYTHONHOME=':exec-runtime' python3 -c pass", - "PYTHONHOME contains an empty prefix", - ), - ( - "PYTHONHOME='runtime:' python3 -c pass", - "PYTHONHOME contains an empty prefix", - ), - ( - "PYTHONHOME='a:b:c' python3 -c pass", - "PYTHONHOME contains more than one prefix separator", - ), - ] { - let parsed = parse_command(command).expect("parse PYTHONHOME command"); - - assert_eq!( - validate_env_path_semantics(&parsed), - Err(reason), - "wrong PYTHONHOME result for {command}" - ); - } -} - -#[test] -fn validation_rejects_dynamic_loader_tokens_and_ambiguous_bare_objects() { - for command in [ - "LD_PRELOAD='$ORIGIN/libevil.so' /bin/true", - "LD_LIBRARY_PATH='${LIB}' /bin/true", - "LD_AUDIT='$PLATFORM/audit.so' /bin/true", - "LD_PRELOAD=libprobe.so /bin/true", - "LD_AUDIT=audit.so /bin/true", - ] { - let parsed = parse_command(command).expect("parse loader environment command"); - assert!( - validate_env_path_semantics(&parsed).is_err(), - "unsafe loader value was accepted: {command}" - ); - } -} - -#[test] -fn validation_rejects_shell_startup_path_expansions() { - for command in [ - "BASH_ENV='$HOME/evil' /bin/true", - "ENV='$(touch marker)' /bin/true", - "BASH_ENV='~/evil' /bin/true", - ] { - let parsed = parse_command(command).expect("parse shell environment command"); - assert!( - validate_env_path_semantics(&parsed).is_err(), - "expanded shell startup path was accepted: {command}" - ); - } -} - -#[test] -fn env_path_token_collector_ignores_invalid_env_assignment_names() { - let config_dir = temp_root("invalid-env-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "/tmp/with=equals /bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn env_path_token_collector_ignores_commands_with_carriage_returns() { - let config_dir = temp_root("carriage-return-env-token"); - - let outside = - collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so\r/bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn env_path_token_collector_ignores_unknown_env_names() { - let config_dir = temp_root("unknown-env-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "WIDGET_DATA=/tmp/evil /bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn validation_ignores_loader_tokens_in_unknown_environment_variables() { - let parsed = parse_command("WIDGET_DATA='$ORIGIN/data' scripts/probe") - .expect("parse unknown environment variable"); - - validate_env_path_semantics(&parsed) - .expect("unknown variables do not use loader path semantics"); -} - -#[test] -fn env_path_token_collector_fails_closed_for_shell_assignment_scope() { - let config_dir = temp_root("complex-env-token"); - - let outside = - collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so; /bin/true"); - - assert_eq!(outside.len(), 1); - assert_eq!(outside[0].0, "LD_PRELOAD"); -} - -#[test] -fn validation_rejects_bare_library_names_with_ambiguous_loader_search() { - let config_dir = temp_root("bare-env-token"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=libprobe.so scripts/probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject loader object without an explicit path"); - - assert!(error - .to_string() - .contains("unsafe environment path semantics")); -} - -#[test] -fn validation_rejects_colon_separated_env_path_that_leaves_root() { - let config_dir = temp_root("pythonpath-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.cards]]\nlabel = \"Probe\"\ncmd = \"PYTHONPATH=scripts:/tmp/evil python3 -c pass\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject PYTHONPATH outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_accepts_dangerous_env_paths_inside_root() { - let config_dir = temp_root("env-path-inside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=scripts/libprobe.so scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("config-root-relative env paths should be allowed"); -} - -#[test] -fn validation_does_not_mistake_env_option_values_for_the_child_program() { - let config_dir = temp_root("env-option-program"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -u HOME /tmp/outside-probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external child after env option value"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_checks_env_assignments_that_follow_options() { - let config_dir = temp_root("env-option-assignment"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -i LD_PRELOAD=/tmp/evil.so scripts/probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external environment path after env option"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_nonportable_env_reinterpretation_options() { - let config_dir = temp_root("env-nonportable-options"); - for command in [ - "env -C scripts ./probe", - "env --chdir=scripts ./probe", - "env -S 'MODE=safe /tmp/outside-probe'", - "env --split-string='MODE=safe /tmp/outside-probe'", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - let error = validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject nonportable env option"); - - assert!(error.to_string().contains("unsafe env wrapper")); - } -} - -#[test] -fn validation_accepts_supported_env_options_before_a_portable_program() { - let config_dir = temp_root("env-supported-options"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -iv -u HOME MODE=safe scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("supported env options should preserve child discovery"); -} - -#[test] -fn every_supported_env_option_preserves_the_real_child_program() { - for command in [ - "env -- scripts/probe", - "env - scripts/probe", - "env -i scripts/probe", - "env -0 scripts/probe", - "env -v scripts/probe", - "env --ignore-environment scripts/probe", - "env --null scripts/probe", - "env --debug scripts/probe", - "env --list-signal-handling scripts/probe", - "env -u HOME scripts/probe", - "env --unset HOME scripts/probe", - "env -a probe scripts/probe", - "env --argv0 probe scripts/probe", - "env -uHOME scripts/probe", - "env --unset=HOME scripts/probe", - "env -aprobe scripts/probe", - "env --argv0=probe scripts/probe", - "env --block-signal scripts/probe", - "env --block-signal=PIPE scripts/probe", - "env --default-signal scripts/probe", - "env --default-signal=PIPE scripts/probe", - "env --ignore-signal scripts/probe", - "env --ignore-signal=PIPE scripts/probe", - "env -iv0 scripts/probe", - ] { - assert_eq!( - first_command_token(command).as_deref(), - Some("scripts/probe"), - "wrong env child for {command}" - ); - } -} - -#[test] -fn env_layout_counts_assignments_after_every_option() { - for command in [ - "env -- MODE=safe LEVEL=2 scripts/probe", - "env -iv0 MODE=safe LEVEL=2 scripts/probe", - "env --unset=HOME MODE=safe LEVEL=2 scripts/probe", - "env --block-signal=PIPE MODE=safe LEVEL=2 scripts/probe", - ] { - assert_eq!( - first_command_token(command).as_deref(), - Some("scripts/probe"), - "assignment range consumed the wrong child for {command}" - ); - } - - assert_eq!(first_command_token("env MODE=safe LEVEL=2"), None); -} - -#[test] -fn unsupported_and_incomplete_env_options_never_become_child_programs() { - for command in [ - "env -u", - "env --unset", - "env -a", - "env --argv0", - "env --unknown scripts/probe", - "env -ix scripts/probe", - ] { - assert_eq!( - first_command_token(command), - None, - "unsafe env layout was accepted for {command}" - ); - } -} - -#[test] -fn every_nonportable_env_option_form_is_rejected() { - for command in [ - "env -C scripts scripts/probe", - "env -Cscripts scripts/probe", - "env --chdir scripts scripts/probe", - "env --chdir=scripts scripts/probe", - "env -S scripts/probe", - "env -SMODE=safe scripts/probe", - "env --split-string scripts/probe", - "env --split-string=MODE=safe scripts/probe", - ] { - assert_eq!( - first_command_token(command), - None, - "nonportable env layout was accepted for {command}" - ); - } -} - -#[test] -fn nonportable_env_options_keep_specific_actionable_reasons() { - for command in [ - "env -C scripts scripts/probe", - "env -Cscripts scripts/probe", - "env --chdir scripts scripts/probe", - "env --chdir=scripts scripts/probe", - ] { - let parsed = parse_command(command).expect("parse env command"); - assert_eq!( - validate_env_command_layout(&parsed), - Err("env working-directory options are not portable in preset commands"), - "wrong working-directory reason for {command}" - ); - } - - for command in [ - "env -S scripts/probe", - "env -SMODE=safe scripts/probe", - "env --split-string scripts/probe", - "env --split-string=MODE=safe scripts/probe", - ] { - let parsed = parse_command(command).expect("parse env command"); - assert_eq!( - validate_env_command_layout(&parsed), - Err("env split-string options are ambiguous in preset commands"), - "wrong split-string reason for {command}" - ); - } -} - -#[test] -fn env_assignment_names_follow_portable_shell_identifier_rules() { - assert_eq!(split_env_assignment("NAME=value"), Some(("NAME", "value"))); - assert_eq!(split_env_assignment("_NAME=a=b"), Some(("_NAME", "a=b"))); - assert_eq!(split_env_assignment("A1="), Some(("A1", ""))); - - for token in [ - "1NAME=value", - "-NAME=value", - "NA-ME=value", - "=value", - "NAME", - ] { - assert_eq!( - split_env_assignment(token), - None, - "invalid assignment name accepted for {token}" - ); - } -} - -#[test] -fn path_token_detection_covers_every_supported_relative_form() { - for token in ["~", "~/tool", "./tool", "../tool", "dir/tool", "/tool"] { - assert!( - looks_like_path_token(token), - "path form not detected: {token}" - ); - } - for token in ["", "tool", "tool-name", ".", ".."] { - assert!( - !looks_like_path_token(token), - "plain command was treated as a path: {token}" - ); - } -} - -#[test] -fn host_specific_path_detection_excludes_portable_relative_paths() { - for token in ["/usr/bin/tool", "~", "~/bin/tool"] { - assert!( - is_host_specific_path_token(token), - "host path not detected: {token}" - ); - } - for token in ["tool", "./tool", "../tool", "dir/tool"] { - assert!( - !is_host_specific_path_token(token), - "portable path was treated as host-specific: {token}" - ); - } -} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/environment.rs b/crates/noticenterctl/src/preset/command_rules/tests/environment.rs new file mode 100644 index 000000000..f0f034fd7 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/environment.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use super::super::tokens::{collect_outside_env_path_tokens, validate_env_path_semantics}; +use super::support::{parsed_command, temp_root}; +use unixnotis_core::{parse_legacy_command as parse_command, CommandSpec}; + +#[test] +fn env_path_token_collector_finds_ld_preload_outside_root() { + let config_dir = temp_root("ld-preload-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &parsed_command("LD_PRELOAD=/tmp/evil.so /bin/true"), + ); + + assert_eq!(outside.len(), 1); + assert_eq!(outside[0].0, "LD_PRELOAD"); + assert_eq!(outside[0].1, PathBuf::from("/tmp/evil.so")); +} + +#[test] +fn validation_rejects_pythonhome_empty_or_ambiguous_prefixes() { + for (command, reason) in [ + ( + "PYTHONHOME=':exec-runtime' python3 -c pass", + "PYTHONHOME contains an empty prefix", + ), + ( + "PYTHONHOME='runtime:' python3 -c pass", + "PYTHONHOME contains an empty prefix", + ), + ( + "PYTHONHOME='a:b:c' python3 -c pass", + "PYTHONHOME contains more than one prefix separator", + ), + ] { + let parsed = parse_command(command).expect("parse PYTHONHOME command"); + + assert_eq!( + validate_env_path_semantics(&parsed), + Err(reason), + "wrong PYTHONHOME result for {command}" + ); + } +} + +#[test] +fn validation_rejects_dynamic_loader_tokens_and_ambiguous_bare_objects() { + for command in [ + "LD_PRELOAD='$ORIGIN/libevil.so' /bin/true", + "LD_LIBRARY_PATH='${LIB}' /bin/true", + "LD_AUDIT='$PLATFORM/audit.so' /bin/true", + "LD_PRELOAD=libprobe.so /bin/true", + "LD_AUDIT=audit.so /bin/true", + ] { + let parsed = parse_command(command).expect("parse loader environment command"); + assert!( + validate_env_path_semantics(&parsed).is_err(), + "unsafe loader value was accepted: {command}" + ); + } +} + +#[test] +fn validation_rejects_shell_startup_path_expansions() { + for command in [ + "BASH_ENV='$HOME/evil' /bin/true", + "ENV='$(touch marker)' /bin/true", + "BASH_ENV='~/evil' /bin/true", + ] { + let parsed = parse_command(command).expect("parse shell environment command"); + assert!( + validate_env_path_semantics(&parsed).is_err(), + "expanded shell startup path was accepted: {command}" + ); + } +} + +#[test] +fn env_path_token_collector_ignores_invalid_env_assignment_names() { + let config_dir = temp_root("invalid-env-token"); + + let outside = + collect_outside_env_path_tokens(&config_dir, &parsed_command("/tmp/with=equals /bin/true")); + + assert!(outside.is_empty()); +} + +#[test] +fn env_path_token_collector_ignores_commands_with_carriage_returns() { + let config_dir = temp_root("carriage-return-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &CommandSpec::shell("LD_PRELOAD=/tmp/evil.so\r/bin/true"), + ); + + assert!(outside.is_empty()); +} + +#[test] +fn env_path_token_collector_ignores_unknown_env_names() { + let config_dir = temp_root("unknown-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &parsed_command("WIDGET_DATA=/tmp/evil /bin/true"), + ); + + assert!(outside.is_empty()); +} + +#[test] +fn validation_ignores_loader_tokens_in_unknown_environment_variables() { + let parsed = parse_command("WIDGET_DATA='$ORIGIN/data' scripts/probe") + .expect("parse unknown environment variable"); + + validate_env_path_semantics(&parsed) + .expect("unknown variables do not use loader path semantics"); +} + +#[test] +fn env_path_token_collector_fails_closed_for_shell_assignment_scope() { + let config_dir = temp_root("complex-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &CommandSpec::direct("/bin/true", [] as [&str; 0]).with_env("LD_PRELOAD", "/tmp/evil.so"), + ); + + assert_eq!(outside.len(), 1); + assert_eq!(outside[0].0, "LD_PRELOAD"); +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/layout.rs b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs new file mode 100644 index 000000000..8e5c12271 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs @@ -0,0 +1,213 @@ +use super::super::tokens::{ + first_command_token, split_env_assignment, validate_env_command_layout, +}; +use super::super::validate_command_paths_in_config_bytes; +use super::support::{parsed_command, temp_root}; +use unixnotis_core::parse_legacy_command as parse_command; + +#[test] +fn validation_does_not_mistake_env_option_values_for_the_child_program() { + let config_dir = temp_root("env-option-program"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -u HOME /tmp/outside-probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external child after env option value"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_checks_env_assignments_that_follow_options() { + let config_dir = temp_root("env-option-assignment"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -i LD_PRELOAD=/tmp/evil.so scripts/probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external environment path after env option"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_nonportable_env_reinterpretation_options() { + let config_dir = temp_root("env-nonportable-options"); + for command in [ + "env -C scripts ./probe", + "env --chdir=scripts ./probe", + "env -S 'MODE=safe /tmp/outside-probe'", + "env --split-string='MODE=safe /tmp/outside-probe'", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + let error = validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject nonportable env option"); + + assert!(error.to_string().contains("unsafe env wrapper")); + } +} + +#[test] +fn validation_accepts_supported_env_options_before_a_portable_program() { + let config_dir = temp_root("env-supported-options"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -iv -u HOME MODE=safe scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("supported env options should preserve child discovery"); +} + +#[test] +fn every_supported_env_option_preserves_the_real_child_program() { + for command in [ + "env -- scripts/probe", + "env - scripts/probe", + "env -i scripts/probe", + "env -0 scripts/probe", + "env -v scripts/probe", + "env --ignore-environment scripts/probe", + "env --null scripts/probe", + "env --debug scripts/probe", + "env --list-signal-handling scripts/probe", + "env -u HOME scripts/probe", + "env --unset HOME scripts/probe", + "env -a probe scripts/probe", + "env --argv0 probe scripts/probe", + "env -uHOME scripts/probe", + "env --unset=HOME scripts/probe", + "env -aprobe scripts/probe", + "env --argv0=probe scripts/probe", + "env --block-signal scripts/probe", + "env --block-signal=PIPE scripts/probe", + "env --default-signal scripts/probe", + "env --default-signal=PIPE scripts/probe", + "env --ignore-signal scripts/probe", + "env --ignore-signal=PIPE scripts/probe", + "env -iv0 scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)).as_deref(), + Some("scripts/probe"), + "wrong env child for {command}" + ); + } +} + +#[test] +fn env_layout_counts_assignments_after_every_option() { + for command in [ + "env -- MODE=safe LEVEL=2 scripts/probe", + "env -iv0 MODE=safe LEVEL=2 scripts/probe", + "env --unset=HOME MODE=safe LEVEL=2 scripts/probe", + "env --block-signal=PIPE MODE=safe LEVEL=2 scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)).as_deref(), + Some("scripts/probe"), + "assignment range consumed the wrong child for {command}" + ); + } + + assert_eq!( + first_command_token(&parsed_command("env MODE=safe LEVEL=2")), + None + ); +} + +#[test] +fn unsupported_and_incomplete_env_options_never_become_child_programs() { + for command in [ + "env -u", + "env --unset", + "env -a", + "env --argv0", + "env --unknown scripts/probe", + "env -ix scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)), + None, + "unsafe env layout was accepted for {command}" + ); + } +} + +#[test] +fn every_nonportable_env_option_form_is_rejected() { + for command in [ + "env -C scripts scripts/probe", + "env -Cscripts scripts/probe", + "env --chdir scripts scripts/probe", + "env --chdir=scripts scripts/probe", + "env -S scripts/probe", + "env -SMODE=safe scripts/probe", + "env --split-string scripts/probe", + "env --split-string=MODE=safe scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)), + None, + "nonportable env layout was accepted for {command}" + ); + } +} + +#[test] +fn nonportable_env_options_keep_specific_actionable_reasons() { + for command in [ + "env -C scripts scripts/probe", + "env -Cscripts scripts/probe", + "env --chdir scripts scripts/probe", + "env --chdir=scripts scripts/probe", + ] { + let parsed = parse_command(command).expect("parse env command"); + assert_eq!( + validate_env_command_layout(&parsed), + Err("env working-directory options are not portable in preset commands"), + "wrong working-directory reason for {command}" + ); + } + + for command in [ + "env -S scripts/probe", + "env -SMODE=safe scripts/probe", + "env --split-string scripts/probe", + "env --split-string=MODE=safe scripts/probe", + ] { + let parsed = parse_command(command).expect("parse env command"); + assert_eq!( + validate_env_command_layout(&parsed), + Err("env split-string options are ambiguous in preset commands"), + "wrong split-string reason for {command}" + ); + } +} + +#[test] +fn env_assignment_names_follow_portable_shell_identifier_rules() { + assert_eq!(split_env_assignment("NAME=value"), Some(("NAME", "value"))); + assert_eq!(split_env_assignment("_NAME=a=b"), Some(("_NAME", "a=b"))); + assert_eq!(split_env_assignment("A1="), Some(("A1", ""))); + + for token in [ + "1NAME=value", + "-NAME=value", + "NA-ME=value", + "=value", + "NAME", + ] { + assert_eq!( + split_env_assignment(token), + None, + "invalid assignment name accepted for {token}" + ); + } +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/mod.rs b/crates/noticenterctl/src/preset/command_rules/tests/mod.rs index 55bf8cafd..3bd0eeb6f 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/mod.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/mod.rs @@ -1,3 +1,6 @@ mod cases; -mod env_paths; +mod environment; +mod layout; +mod path_tokens; mod support; +mod validation; diff --git a/crates/noticenterctl/src/preset/command_rules/tests/model.rs b/crates/noticenterctl/src/preset/command_rules/tests/model.rs index b69bafbaa..caa03bc37 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/model.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/model.rs @@ -1,12 +1,13 @@ use std::path::PathBuf; use super::{CommandReference, HostSpecificCommandPath, OutsideCommandPath}; +use unixnotis_core::CommandSpec; #[test] fn command_path_findings_preserve_slot_command_and_resolved_target() { let reference = CommandReference { slot: "widgets.volume.get_cmd".to_string(), - command: "scripts/volume".to_string(), + command: CommandSpec::direct("scripts/volume", [] as [&str; 0]), }; let outside = OutsideCommandPath { slot: reference.slot.clone(), diff --git a/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs b/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs new file mode 100644 index 000000000..9362a1fb2 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs @@ -0,0 +1,28 @@ +use super::super::tokens::{is_host_specific_path_token, looks_like_path_token}; + +#[test] +fn path_token_detection_covers_every_supported_relative_form() { + for token in ["~/tool", "./tool", "../tool", "dir/tool", "/tool"] { + assert!( + looks_like_path_token(token), + "path form not detected: {token}" + ); + } + for token in ["", "tool", "tool-name", ".", "..", "~"] { + assert!( + !looks_like_path_token(token), + "plain command was treated as a path: {token}" + ); + } +} + +#[test] +fn host_specific_path_detection_excludes_portable_relative_paths() { + assert!(is_host_specific_path_token("/usr/bin/tool")); + for token in ["tool", "./tool", "../tool", "dir/tool", "~", "~/bin/tool"] { + assert!( + !is_host_specific_path_token(token), + "portable path was treated as host-specific: {token}" + ); + } +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/support.rs b/crates/noticenterctl/src/preset/command_rules/tests/support.rs index 821e2287e..7bd43c8fa 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/support.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/support.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use unixnotis_core::{parse_legacy_command, CommandSpec}; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(super) fn temp_root(name: &str) -> PathBuf { @@ -15,3 +17,7 @@ pub(super) fn temp_root(name: &str) -> PathBuf { "unixnotis-preset-command-rules-{name}-{stamp}-{serial}" )) } + +pub(super) fn parsed_command(command: &str) -> CommandSpec { + parse_legacy_command(command).expect("valid legacy test command") +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/validation.rs b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs new file mode 100644 index 000000000..6899ec09c --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs @@ -0,0 +1,186 @@ +use super::super::validate_command_paths_in_config_bytes; +use super::support::temp_root; + +#[test] +fn validation_rejects_ld_preload_path_that_leaves_root() { + let config_dir = temp_root("ld-preload-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=/tmp/evil.so /bin/true\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject LD_PRELOAD outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_quoted_ld_preload_paths_that_leave_root() { + let config_dir = temp_root("quoted-ld-preload-outside"); + for command in [ + "LD_PRELOAD=\"/tmp/evil.so\" /bin/true", + "LD_PRELOAD='/tmp/evil.so' /bin/true", + "env LD_PRELOAD=/tmp/evil.so /bin/true", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject quoted or env-wrapped preload escape"); + } +} + +#[test] +fn validation_migrates_tilde_syntax_to_shell_and_rejects_malformed_quoting() { + let config_dir = temp_root("tilde-and-quote"); + let tilde = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"~/outside-script\"\n"; + validate_command_paths_in_config_bytes(&config_dir, tilde, "preset import blocked") + .expect("tilde syntax is an explicit shell command after migration"); + + let malformed = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = 'echo \"unterminated'\n"; + validate_command_paths_in_config_bytes(&config_dir, malformed, "preset import blocked") + .expect_err("reject malformed command quoting"); +} + +#[test] +fn validation_rejects_home_override_and_env_wrapped_absolute_program() { + let config_dir = temp_root("home-and-env-program"); + for command in ["HOME=/tmp ./script", "env SAFE=value /bin/true"] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject path policy escape"); + } +} + +#[test] +fn validation_rejects_space_separated_ld_preload_path_that_leaves_root() { + let config_dir = temp_root("space-separated-ld-preload"); + let inside = config_dir.join("libsafe.so"); + let command = format!( + "LD_PRELOAD='{} /tmp/libevil.so' scripts/probe", + inside.display() + ); + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + + let error = validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject second preload object outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_semicolon_separated_library_directory_that_leaves_root() { + let config_dir = temp_root("semicolon-library-path"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH='lib;/tmp/evil' scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject semicolon-separated loader directory outside config root"); +} + +#[test] +fn validation_accepts_empty_list_components_with_the_pinned_config_cwd() { + let config_dir = temp_root("empty-loader-component"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH=':lib;' PATH=:bin scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("empty path components should resolve to the pinned config cwd"); +} + +#[test] +fn validation_keeps_single_path_environment_values_unsplit() { + let config_dir = temp_root("single-path-colon"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"HOME=profiles/home:secondary BASH_ENV=scripts/start:up scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("single path values containing colons should remain one relative path"); +} + +#[test] +fn validation_rejects_pythonhome_exec_prefix_outside_root() { + let config_dir = temp_root("pythonhome-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"PYTHONHOME='runtime:/tmp/outside' python3 -c pass\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external Python exec prefix"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_accepts_pythonhome_single_and_relative_prefix_pair() { + let config_dir = temp_root("pythonhome-relative"); + for command in [ + "PYTHONHOME=runtime python3 -c pass", + "PYTHONHOME='runtime:exec-runtime' python3 -c pass", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .unwrap_or_else(|error| panic!("valid PYTHONHOME was rejected for {command}: {error}")); + } +} + +#[test] +fn validation_rejects_bare_library_names_with_ambiguous_loader_search() { + let config_dir = temp_root("bare-env-token"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=libprobe.so scripts/probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject loader object without an explicit path"); + + assert!(error + .to_string() + .contains("unsafe environment path semantics")); +} + +#[test] +fn validation_rejects_colon_separated_env_path_that_leaves_root() { + let config_dir = temp_root("pythonpath-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.cards]]\nlabel = \"Probe\"\ncmd = \"PYTHONPATH=scripts:/tmp/evil python3 -c pass\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject PYTHONPATH outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_accepts_dangerous_env_paths_inside_root() { + let config_dir = temp_root("env-path-inside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=scripts/libprobe.so scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("config-root-relative env paths should be allowed"); +} diff --git a/crates/noticenterctl/src/preset/command_rules/tokens.rs b/crates/noticenterctl/src/preset/command_rules/tokens.rs index ebfacfc11..a68d428a8 100644 --- a/crates/noticenterctl/src/preset/command_rules/tokens.rs +++ b/crates/noticenterctl/src/preset/command_rules/tokens.rs @@ -1,151 +1,142 @@ +use std::ffi::{OsStr, OsString}; use std::ops::Range; use std::path::{Path, PathBuf}; -use unixnotis_core::{parse_command, util, ExecutionMode, ParsedCommand}; +use unixnotis_core::CommandSpec; use super::super::pathing::{format_relative_path, normalize_lexical_path}; -pub fn resolve_command_path_token(config_dir: &Path, command: &str) -> Option { - let trimmed = command.trim(); - if trimmed.is_empty() { - return None; - } - let parsed = parse_command(trimmed).ok()?; - let first = effective_program(&parsed)?; +pub fn resolve_command_path_token(config_dir: &Path, command: &CommandSpec) -> Option { + let first = effective_program(command)?; + let first = first.to_str()?; if !looks_like_path_token(first) { return None; } - - let expanded = PathBuf::from(util::expand_tilde(first).into_owned()); - if expanded.is_absolute() { - return Some(expanded); + let path = PathBuf::from(first); + if path.is_absolute() { + return Some(path); } - Some(config_dir.join(expanded)) + Some(config_dir.join(path)) } -pub fn collect_outside_env_path_tokens(config_dir: &Path, command: &str) -> Vec<(String, PathBuf)> { - let trimmed = command.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - +pub fn collect_outside_env_path_tokens( + config_dir: &Path, + command: &CommandSpec, +) -> Vec<(String, PathBuf)> { let normalized_root = normalize_lexical_path(config_dir); - let Ok(parsed) = parse_command(trimmed) else { - return Vec::new(); - }; - command_env_assignments(&parsed) + command_env_assignments(command) + .unwrap_or_default() .into_iter() .filter_map(|(name, value)| { - let components = env_path_components(name, value).ok().flatten()?; + let components = env_path_components(&name, &value).ok().flatten()?; let outside_path = components .into_iter() .map(|part| resolve_env_path_value(config_dir, part)) .find(|path| !normalize_lexical_path(path).starts_with(&normalized_root))?; - Some((name.to_string(), outside_path)) + Some((name, outside_path)) }) .collect() } -pub fn rewrite_command_to_config_relative(config_dir: &Path, command: &str) -> Option { - let trimmed = command.trim(); - if trimmed.is_empty() { - return None; - } - - let parsed = parse_command(trimmed).ok()?; - if parsed.execution_mode != ExecutionMode::Direct { - // Rewriting shell syntax token-by-token could change operators or expansion behavior - return None; - } - let first = effective_program(&parsed)?; +pub fn rewrite_command_to_config_relative(config_dir: &Path, command: &mut CommandSpec) -> bool { + let Some(first) = effective_program(command).and_then(OsStr::to_str) else { + return false; + }; if !is_host_specific_path_token(first) { - return None; + return false; } - - let resolved_path = resolve_command_path_token(config_dir, trimmed)?; + let Some(resolved_path) = resolve_command_path_token(config_dir, command) else { + return false; + }; let normalized_root = normalize_lexical_path(config_dir); let normalized_path = normalize_lexical_path(&resolved_path); - // Only paths that really live under the config root can be rewritten safely - let relative_path = normalized_path.strip_prefix(&normalized_root).ok()?; - let rewritten_first = format_relative_path(relative_path); - if rewritten_first.is_empty() { - return None; + let Ok(relative_path) = normalized_path.strip_prefix(&normalized_root) else { + return false; + }; + let rewritten = format_relative_path(relative_path); + if rewritten.is_empty() { + return false; } - // Re-quote parsed tokens so spaces survive without preserving ambiguous source quoting - let mut words = parsed - .env - .iter() - .map(|(name, value)| format!("{name}={value}")) - .collect::>(); - if parsed.program == "env" { - let program_index = effective_program_index(&parsed)?; - words.push(parsed.program); - words.extend(parsed.args.into_iter().enumerate().map(|(index, token)| { - if index == program_index { - rewritten_first.clone() - } else { - token - } - })); + let CommandSpec::Direct { program, args, .. } = command else { + return false; + }; + if program == Path::new("env") { + let Ok(layout) = env_command_layout(args) else { + return false; + }; + let Some(index) = layout.program_index else { + return false; + }; + args[index] = OsString::from(rewritten); } else { - words.push(rewritten_first); - words.extend(parsed.args); + *program = PathBuf::from(rewritten); } - Some(shell_words::join(words)) + true } -pub fn first_command_token(command: &str) -> Option { - // Returning the parsed program prevents quote characters from becoming path data - let parsed = parse_command(command).ok()?; - effective_program(&parsed).map(str::to_string) +pub fn first_command_token(command: &CommandSpec) -> Option { + effective_program(command)?.to_str().map(str::to_string) } -fn command_env_assignments(parsed: &ParsedCommand) -> Vec<(&str, &str)> { - let mut assignments = parsed - .env +fn command_env_assignments(command: &CommandSpec) -> Result, &'static str> { + let CommandSpec::Direct { program, args, env } = command else { + return Ok(Vec::new()); + }; + let mut assignments = env .iter() - .map(|(name, value)| (name.as_str(), value.as_str())) - .collect::>(); - - // `env NAME=value program` applies assignments to the eventual child too - // Shell-mode parsing is conservative because unsafe assignments must fail closed - if parsed.program == "env" { - if let Ok(layout) = env_command_layout(parsed) { - assignments.extend( - parsed.args[layout.assignment_range] - .iter() - .filter_map(|token| split_env_assignment(token)), - ); - } + .map(|(name, value)| { + Ok(( + name.to_str() + .ok_or("environment name is not UTF-8")? + .to_string(), + value + .to_str() + .ok_or("environment value is not UTF-8")? + .to_string(), + )) + }) + .collect::, &'static str>>()?; + + if program == Path::new("env") { + let layout = env_command_layout(args)?; + assignments.extend( + args[layout.assignment_range] + .iter() + .map(|token| token.to_str().ok_or("env argument is not UTF-8")) + .collect::, _>>()? + .into_iter() + .filter_map(split_env_assignment) + .map(|(name, value)| (name.to_string(), value.to_string())), + ); } - assignments + Ok(assignments) } -fn effective_program(parsed: &ParsedCommand) -> Option<&str> { - if parsed.program != "env" { - return Some(parsed.program.as_str()); +fn effective_program(command: &CommandSpec) -> Option<&OsStr> { + let CommandSpec::Direct { program, args, .. } = command else { + return None; + }; + if program != Path::new("env") { + return Some(program.as_os_str()); } - - // The env utility consumes leading assignments before spawning its real program - effective_program_index(parsed).map(|index| parsed.args[index].as_str()) + let index = env_command_layout(args).ok()?.program_index?; + Some(args[index].as_os_str()) } -fn effective_program_index(parsed: &ParsedCommand) -> Option { - env_command_layout(parsed).ok()?.program_index -} - -pub(super) fn validate_env_command_layout(parsed: &ParsedCommand) -> Result<(), &'static str> { - if parsed.program != "env" || parsed.execution_mode != ExecutionMode::Direct { +pub(super) fn validate_env_command_layout(command: &CommandSpec) -> Result<(), &'static str> { + let CommandSpec::Direct { program, args, .. } = command else { + return Ok(()); + }; + if program != Path::new("env") { return Ok(()); } - - env_command_layout(parsed).map(|_| ()) + env_command_layout(args).map(|_| ()) } -pub(super) fn validate_env_path_semantics(parsed: &ParsedCommand) -> Result<(), &'static str> { - for (name, value) in command_env_assignments(parsed) { - let _ = env_path_components(name, value)?; +pub(super) fn validate_env_path_semantics(command: &CommandSpec) -> Result<(), &'static str> { + for (name, value) in command_env_assignments(command)? { + let _ = env_path_components(&name, &value)?; } Ok(()) } @@ -161,9 +152,9 @@ enum EnvOptionStep { Stop, } -fn env_command_layout(parsed: &ParsedCommand) -> Result { +fn env_command_layout(args: &[OsString]) -> Result { let mut option_count = 0usize; - let mut remaining = parsed.args.as_slice(); + let mut remaining = args; loop { match env_option_step(remaining)? { EnvOptionStep::Continue(width) => { @@ -183,25 +174,26 @@ fn env_command_layout(parsed: &ParsedCommand) -> Result Result { - let Some(token) = arguments.first().map(String::as_str) else { +fn env_option_step(arguments: &[OsString]) -> Result { + let Some(token) = arguments.first() else { return Ok(EnvOptionStep::Stop); }; + let token = token.to_str().ok_or("env argument is not UTF-8")?; if token == "--" { return Ok(EnvOptionStep::Finish(1)); } @@ -213,7 +205,6 @@ fn env_option_step(arguments: &[String]) -> Result return Ok(EnvOptionStep::Continue(1)); } if is_separate_value_option(token) { - // The following operand belongs to env rather than the eventual child process return (arguments.len() >= 2) .then_some(EnvOptionStep::Continue(2)) .ok_or("env option is missing its required value"); @@ -314,27 +305,19 @@ fn env_path_components<'a>( .chars() .any(|character| matches!(character, '$' | '`' | '~')) { - // Shells expand these startup-file values before opening them return Err("shell startup environment paths cannot contain expansions"); } let components = match name { - // glibc accepts ASCII whitespace or colons with no escaping for preload objects "LD_PRELOAD" => value .split(|character: char| character == ':' || character.is_ascii_whitespace()) .filter(|component| !component.is_empty()) .collect::>(), - // glibc accepts both directory separators and treats empty fields as the child cwd "LD_LIBRARY_PATH" => value.split([':', ';']).collect::>(), - // These are colon-separated lists on Unix and empty fields resolve from the child cwd "PATH" | "LD_AUDIT" | "PYTHONPATH" | "PERL5LIB" | "RUBYLIB" | "NODE_PATH" | "GCONV_PATH" => value.split(':').collect::>(), - // Python accepts separate installation and platform-specific roots "PYTHONHOME" => python_home_components(value)?, - // These consumers interpret the complete value as one path - "HOME" | "LD_CONFIG_FILE" | "BASH_ENV" | "ENV" | "ZDOTDIR" => { - vec![value] - } + "HOME" | "LD_CONFIG_FILE" | "BASH_ENV" | "ENV" | "ZDOTDIR" => vec![value], _ => return Ok(None), }; @@ -343,10 +326,8 @@ fn env_path_components<'a>( .iter() .any(|component| !component.is_empty() && !component.contains('/')) { - // Bare object names use the system loader search order rather than the config directory return Err("loader object names must use an explicit config-relative path"); } - Ok(Some(components)) } @@ -354,12 +335,9 @@ fn python_home_components(value: &str) -> Result, &'static str> { let mut parts = value.splitn(3, ':'); let prefix = parts.next().unwrap_or_default(); let exec_prefix = parts.next(); - - // More than two roots cannot match Python's documented environment format if parts.next().is_some() { return Err("PYTHONHOME contains more than one prefix separator"); } - match exec_prefix { Some(exec_prefix) if !prefix.is_empty() && !exec_prefix.is_empty() => { Ok(vec![prefix, exec_prefix]) @@ -388,7 +366,6 @@ fn contains_dynamic_loader_token(value: &str) -> bool { fn resolve_env_path_value(config_dir: &Path, value: &str) -> PathBuf { if value.is_empty() { - // Empty list components mean cwd for loaders and path-search consumers return config_dir.to_path_buf(); } let path = PathBuf::from(value); @@ -399,10 +376,9 @@ fn resolve_env_path_value(config_dir: &Path, value: &str) -> PathBuf { } pub fn looks_like_path_token(token: &str) -> bool { - // Every supported relative prefix already contains a path separator - token == "~" || token.contains('/') + token.contains('/') } pub fn is_host_specific_path_token(token: &str) -> bool { - token.starts_with('/') || token == "~" || token.starts_with("~/") + token.starts_with('/') } diff --git a/crates/noticenterctl/src/preset/export/prompts/rewrite.rs b/crates/noticenterctl/src/preset/export/prompts/rewrite.rs index 99478a94c..d14de5033 100644 --- a/crates/noticenterctl/src/preset/export/prompts/rewrite.rs +++ b/crates/noticenterctl/src/preset/export/prompts/rewrite.rs @@ -153,9 +153,10 @@ fn format_host_specific_command_path_lines( .iter() .map(|leak| { // Show exact slot and command for quick review + let command = leak.command.display_lossy(); format!( " - {} = {} (absolute path under the config root; let noticenterctl rewrite it to a config-root-relative command)", - safe_prompt_value(&leak.slot), safe_prompt_value(&leak.command) + safe_prompt_value(&leak.slot), safe_prompt_value(&command) ) }) .collect() diff --git a/crates/noticenterctl/src/preset/export/script_dependencies.rs b/crates/noticenterctl/src/preset/export/script_dependencies.rs index 8d4698263..dea11a51c 100644 --- a/crates/noticenterctl/src/preset/export/script_dependencies.rs +++ b/crates/noticenterctl/src/preset/export/script_dependencies.rs @@ -5,7 +5,8 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::os::fd::OwnedFd; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; +use unixnotis_core::filesystem::ContainedPath; use anyhow::{anyhow, Context, Result}; @@ -232,18 +233,9 @@ fn is_shell_name(name: &str) -> bool { } pub(super) fn normalize_relative_path(path: &Path) -> Option { - let mut parts = Vec::new(); - for component in path.components() { - match component { - Component::Normal(value) => parts.push(value.to_os_string()), - Component::CurDir => {} - // Parent traversal is safe only while a prior config-relative segment remains to pop - Component::ParentDir => { - parts.pop()?; - } - Component::RootDir | Component::Prefix(_) => return None, - } - } - let normalized = parts.into_iter().collect::(); + let normalized = ContainedPath::resolve_relative("", path) + .ok()? + .relative() + .to_path_buf(); (!normalized.as_os_str().is_empty()).then_some(normalized) } diff --git a/crates/noticenterctl/src/preset/import/review/checks.rs b/crates/noticenterctl/src/preset/import/review/checks.rs index 12af1b053..801b7b581 100644 --- a/crates/noticenterctl/src/preset/import/review/checks.rs +++ b/crates/noticenterctl/src/preset/import/review/checks.rs @@ -49,8 +49,8 @@ pub(in crate::preset) fn validate_imported_theme_paths_stay_in_root( // The bundle config is trusted during post-import setup, so its theme targets must stay local let config_text = std::str::from_utf8(config_bytes).context("preset config.toml is not valid UTF-8")?; - let config: Config = - toml::from_str(config_text).context("parse bundled config.toml for import validation")?; + let config = + Config::parse(config_text).context("parse bundled config.toml for import validation")?; validate_config_theme_paths_stay_in_root(config_dir, &config) } @@ -205,7 +205,7 @@ fn collect_explicit_exec_commands_from_config_bytes( .filter(|reference| explicit_slots.contains(&reference.slot)) .map(|reference| ImportedExecCommand { slot: reference.slot, - command: reference.command, + command: reference.command.display_lossy(), }) .collect()) } diff --git a/crates/noticenterctl/src/preset/inspect.rs b/crates/noticenterctl/src/preset/inspect.rs index a21d24e1e..edfa1ca56 100644 --- a/crates/noticenterctl/src/preset/inspect.rs +++ b/crates/noticenterctl/src/preset/inspect.rs @@ -72,10 +72,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for command in commands { + let command_text = command.command.display_lossy(); out.push_str(&format!( " - {} = {}\n", safe_report_value(&command.slot), - safe_report_value(&command.command) + safe_report_value(&command_text) )); } } @@ -90,10 +91,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for warning in outside_paths { + let command_text = warning.command.display_lossy(); out.push_str(&format!( " - {} points outside the config root: {}\n", safe_report_value(&warning.slot), - safe_report_value(&warning.command) + safe_report_value(&command_text) )); } } @@ -110,10 +112,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for leak in leaked_paths { + let command_text = leak.command.display_lossy(); out.push_str(&format!( " - {} uses a host-local config path: {}\n", safe_report_value(&leak.slot), - safe_report_value(&leak.command) + safe_report_value(&command_text) )); } } diff --git a/crates/noticenterctl/src/preset/pathing.rs b/crates/noticenterctl/src/preset/pathing.rs index b25746d92..dd1611e66 100644 --- a/crates/noticenterctl/src/preset/pathing.rs +++ b/crates/noticenterctl/src/preset/pathing.rs @@ -91,28 +91,10 @@ pub(super) fn normalize_relative_path(path: &Path) -> Result { )); } - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - // `.` adds no meaning, so it is stripped out during normalization - Component::CurDir => {} - // `..` would let a bundle or flag escape the config root - Component::ParentDir => { - return Err(anyhow!( - "parent traversal is not allowed in preset paths: {}", - path.display() - )); - } - // Absolute and prefix components are already rejected above - Component::RootDir | Component::Prefix(_) => { - return Err(anyhow!( - "absolute paths are not allowed in preset paths: {}", - path.display() - )); - } - Component::Normal(part) => normalized.push(part), - } - } + let normalized = unixnotis_core::filesystem::ContainedPath::resolve_relative("", path) + .map_err(|error| anyhow!("unsafe preset path {}: {error}", path.display()))? + .relative() + .to_path_buf(); if normalized.as_os_str().is_empty() { return Err(anyhow!("path resolved to an empty relative path")); @@ -121,31 +103,11 @@ pub(super) fn normalize_relative_path(path: &Path) -> Result { } pub(super) fn normalize_lexical_path(path: &Path) -> PathBuf { - // This stays purely lexical so callers can validate paths before the target exists on disk - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - // Keep any platform prefix intact before later segments are folded in - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - // Root anchors the normalized path before normal segments are added - Component::RootDir => normalized.push(Path::new("/")), - // `.` adds no meaning to the final path - Component::CurDir => {} - // Normal path segments are preserved in order - Component::Normal(part) => normalized.push(part), - Component::ParentDir => match normalized.components().next_back() { - // One `..` can fold away one earlier normal segment - Some(Component::Normal(_)) => { - normalized.pop(); - } - // Parent segments at the filesystem root stay pinned there - Some(Component::RootDir | Component::Prefix(_)) => {} - // Relative paths may still carry leading `..` segments at this stage - _ => normalized.push(".."), - }, - } - } - normalized + // Invalid traversal stays visibly unnormalized so containment checks fail closed + unixnotis_core::filesystem::LexicallyNormalizedPath::new(path).map_or_else( + |_| path.to_path_buf(), + unixnotis_core::filesystem::LexicallyNormalizedPath::into_path_buf, + ) } pub(super) fn relative_path_matches_exclusion( diff --git a/crates/noticenterctl/src/preset/tests/inspect.rs b/crates/noticenterctl/src/preset/tests/inspect.rs index 35a9ee8e0..7cf13eed6 100644 --- a/crates/noticenterctl/src/preset/tests/inspect.rs +++ b/crates/noticenterctl/src/preset/tests/inspect.rs @@ -107,7 +107,10 @@ fn inspect_sanitizes_preset_control_sequences_before_terminal_output() { assert!(!report.contains('\u{1b}')); assert!(!report.contains('\u{7}')); assert!(report.contains("preset: demo ]0;owned")); - assert!(report.contains("printf ' ]52;c;AAAA '")); + assert!( + report.contains("printf ]52;c;AAAA"), + "sanitized command missing from report: {report:?}" + ); } #[test] diff --git a/crates/noticenterctl/src/preset/tests/pathing.rs b/crates/noticenterctl/src/preset/tests/pathing.rs index e2c0777ed..0a7c419be 100644 --- a/crates/noticenterctl/src/preset/tests/pathing.rs +++ b/crates/noticenterctl/src/preset/tests/pathing.rs @@ -19,10 +19,10 @@ fn normalize_relative_path_strips_dot_segments() { } #[test] -fn normalize_relative_path_rejects_parent_segments() { - let error = - normalize_relative_path(Path::new("./assets/../bg.png")).expect_err("reject parent"); - assert!(error.to_string().contains("parent traversal")); +fn normalize_relative_path_collapses_contained_parent_segments() { + let normalized = + normalize_relative_path(Path::new("./assets/../bg.png")).expect("normalize parent"); + assert_eq!(normalized, Path::new("bg.png")); } #[test] From ffa50445c0b0a1f29df80034342a8e414a29a3ac Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:32:28 -0500 Subject: [PATCH 021/275] refactor(diagnostics): run trusted typed tool commands Summary: run trusted typed tool commands. Scope: diagnostics. --- .../noticenterctl/src/doctor/logs/systemd.rs | 9 ++-- .../noticenterctl/src/doctor/service/probe.rs | 50 +++++++++---------- .../src/doctor/service/tests/probe.rs | 8 ++- .../noticenterctl/src/system_tools/command.rs | 43 ++++++++++++++++ crates/noticenterctl/src/system_tools/mod.rs | 2 +- .../src/system_tools/tests/command.rs | 34 ++++++++++++- 6 files changed, 112 insertions(+), 34 deletions(-) diff --git a/crates/noticenterctl/src/doctor/logs/systemd.rs b/crates/noticenterctl/src/doctor/logs/systemd.rs index 7090eb0f5..de56b5c7f 100644 --- a/crates/noticenterctl/src/doctor/logs/systemd.rs +++ b/crates/noticenterctl/src/doctor/logs/systemd.rs @@ -7,7 +7,7 @@ use std::time::Duration; use crate::debug_logs::journal::{daemon_unit_from_env, recent_args}; use crate::system_tools; use tokio::io::AsyncReadExt; -use tokio::process::Command; +use unixnotis_core::CommandSpec; use super::super::report::safe_doctor_text; use super::super::report::{DoctorLogResult, DoctorLogSource}; @@ -60,11 +60,10 @@ pub(super) async fn read_recent_journal( unit: &str, ) -> Result { // Fixed trusted lookup prevents a PATH entry from impersonating journalctl - let path = system_tools::trusted_program_path("journalctl") - .ok_or_else(|| "journalctl was not found in trusted system directories".to_string())?; - let mut command = Command::new(path); + let spec = CommandSpec::direct("journalctl", recent_args(unit, JOURNAL_LINE_LIMIT)); + let mut command = system_tools::tokio_command_from_spec(&spec) + .map_err(|error| safe_doctor_text(&error.to_string()))?; command - .args(recent_args(unit, JOURNAL_LINE_LIMIT)) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); diff --git a/crates/noticenterctl/src/doctor/service/probe.rs b/crates/noticenterctl/src/doctor/service/probe.rs index 99bf2bfd9..d97a03c53 100644 --- a/crates/noticenterctl/src/doctor/service/probe.rs +++ b/crates/noticenterctl/src/doctor/service/probe.rs @@ -4,8 +4,8 @@ use std::env; use std::path::Path; use std::time::Duration; -use tokio::process::Command; use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; +use unixnotis_core::CommandSpec; use crate::debug_logs::journal::daemon_unit_from_env; use crate::system_tools; @@ -56,11 +56,11 @@ pub(super) async fn active_candidate( paths: &ServiceManagerPaths, ) -> (bool, Option) { // Candidate probes and final status checks share one command definition - let (program, args) = match status_command(kind, paths) { + let command = match status_command(kind, paths) { Ok(command) => command, Err(error) => return (false, Some(error)), }; - match run_bounded_status(program, &args).await { + match run_bounded_status(&command).await { Ok(output) => { let stdout = sanitize_output(&output.stdout); ( @@ -76,7 +76,7 @@ pub(super) async fn status_check( kind: ServiceManagerKind, paths: &ServiceManagerPaths, ) -> DoctorCheck { - let (program, args) = match status_command(kind, paths) { + let command = match status_command(kind, paths) { Ok(command) => command, Err(error) => { return DoctorCheck::new( @@ -90,7 +90,7 @@ pub(super) async fn status_check( } }; // Probe failures remain warnings so the rest of doctor can explain the install - let output = match run_bounded_status(program, &args).await { + let output = match run_bounded_status(&command).await { Ok(output) => output, Err(error) => { return DoctorCheck::new( @@ -149,7 +149,7 @@ pub(super) async fn status_check( pub(super) fn status_command( kind: ServiceManagerKind, paths: &ServiceManagerPaths, -) -> Result<(&'static str, Vec), String> { +) -> Result { status_command_with_env(kind, paths, |key| env::var(key)) } @@ -157,7 +157,7 @@ pub(super) fn status_command_with_env( kind: ServiceManagerKind, paths: &ServiceManagerPaths, get_var: impl FnOnce(&str) -> Result, -) -> Result<(&'static str, Vec), String> { +) -> Result { // Only systemd consumes the configurable unit name // // Keeping this validation inside the systemd branch prevents an invalid @@ -174,12 +174,12 @@ pub(super) fn status_command_for_unit( kind: ServiceManagerKind, paths: &ServiceManagerPaths, systemd_unit: &str, -) -> (&'static str, Vec) { +) -> CommandSpec { // Every backend uses its documented read-only status command match kind { - ServiceManagerKind::Systemd => ( + ServiceManagerKind::Systemd => CommandSpec::direct( "systemctl", - vec![ + [ "--user".to_string(), "show".to_string(), "--property=LoadState".to_string(), @@ -193,25 +193,25 @@ pub(super) fn status_command_for_unit( systemd_unit.to_string(), ], ), - ServiceManagerKind::Dinit => ( + ServiceManagerKind::Dinit => CommandSpec::direct( "dinitctl", - vec![ + [ "--user".to_string(), "--quiet".to_string(), "is-started".to_string(), SERVICE_NAME.to_string(), ], ), - ServiceManagerKind::Runit => ( + ServiceManagerKind::Runit => CommandSpec::direct( "sv", - vec![ + [ "status".to_string(), paths.artifact_root.join(SERVICE_NAME).display().to_string(), ], ), - ServiceManagerKind::S6 => ( + ServiceManagerKind::S6 => CommandSpec::direct( "s6-svstat", - vec![ + [ "-o".to_string(), "up".to_string(), paths @@ -227,15 +227,15 @@ pub(super) fn status_command_for_unit( } } -async fn run_bounded_status( - program: &str, - args: &[String], -) -> Result { - // Trusted fixed directories prevent PATH replacement from changing doctor behavior - let path = system_tools::trusted_program_path(program) - .ok_or_else(|| format!("{program} was not found in trusted system directories"))?; - let command = Command::new(path).args(args).output(); - tokio::time::timeout(SERVICE_STATUS_TIMEOUT, command) +async fn run_bounded_status(command: &CommandSpec) -> Result { + let program = command + .program() + .and_then(Path::to_str) + .unwrap_or("service manager"); + let process = system_tools::tokio_command_from_spec(command) + .map_err(|error| safe_doctor_text(&error.to_string()))? + .output(); + tokio::time::timeout(SERVICE_STATUS_TIMEOUT, process) .await .map_err(|_elapsed| format!("{program} status probe timed out"))? .map_err(|error| safe_doctor_text(&format!("{program} status probe failed: {error}"))) diff --git a/crates/noticenterctl/src/doctor/service/tests/probe.rs b/crates/noticenterctl/src/doctor/service/tests/probe.rs index a1b5e9e10..5f22e0ae4 100644 --- a/crates/noticenterctl/src/doctor/service/tests/probe.rs +++ b/crates/noticenterctl/src/doctor/service/tests/probe.rs @@ -93,14 +93,18 @@ fn other_status_parsers_require_their_documented_active_shape() { #[test] fn systemd_status_places_options_before_the_protected_unit_operand() { - let (_, args) = status_command_for_unit( + let command = status_command_for_unit( ServiceManagerKind::Systemd, &paths(ServiceManagerKind::Systemd), "custom.service", ); + let args = command.args().expect("direct systemd status command"); assert_eq!(args[args.len() - 2], "--"); - assert_eq!(args.last().map(String::as_str), Some("custom.service")); + assert_eq!( + args.last().and_then(|argument| argument.to_str()), + Some("custom.service") + ); assert!(args[..args.len() - 2] .iter() .all(|argument| argument != "custom.service")); diff --git a/crates/noticenterctl/src/system_tools/command.rs b/crates/noticenterctl/src/system_tools/command.rs index d5a66c79c..e6fae9a08 100644 --- a/crates/noticenterctl/src/system_tools/command.rs +++ b/crates/noticenterctl/src/system_tools/command.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::process::Command; +use unixnotis_core::CommandSpec; pub fn command(program: &str) -> std::io::Result { // Resolve before construction so inherited PATH never selects the executable @@ -15,6 +16,48 @@ pub fn command(program: &str) -> std::io::Result { Ok(Command::new(path)) } +pub fn command_from_spec(spec: &CommandSpec) -> std::io::Result { + let (program, args, env) = direct_parts(spec)?; + let mut command = command(program)?; + command.args(args).envs(env); + Ok(command) +} + +pub fn tokio_command_from_spec(spec: &CommandSpec) -> std::io::Result { + let (program, args, env) = direct_parts(spec)?; + let path = trusted_program_path(program).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{program} not found in trusted system tool directories"), + ) + })?; + let mut command = tokio::process::Command::new(path); + command.args(args).envs(env); + Ok(command) +} + +fn direct_parts( + spec: &CommandSpec, +) -> std::io::Result<( + &str, + &[std::ffi::OsString], + &std::collections::BTreeMap, +)> { + let CommandSpec::Direct { program, args, env } = spec else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "trusted system tool commands must use direct mode", + )); + }; + let program = program.to_str().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "trusted system tool program is not UTF-8", + ) + })?; + Ok((program, args, env)) +} + pub fn trusted_program_path(program: &str) -> Option { // Routing differs only in tests, while validation stays in the shared lookup layer super::routing::trusted_program_path(program) diff --git a/crates/noticenterctl/src/system_tools/mod.rs b/crates/noticenterctl/src/system_tools/mod.rs index 59c59eb0f..8e7e6f4a7 100644 --- a/crates/noticenterctl/src/system_tools/mod.rs +++ b/crates/noticenterctl/src/system_tools/mod.rs @@ -14,7 +14,7 @@ mod routing; #[path = "tests/routing.rs"] pub mod routing; -pub use command::{command, trusted_program_path}; +pub use command::{command, command_from_spec, tokio_command_from_spec, trusted_program_path}; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/system_tools/tests/command.rs b/crates/noticenterctl/src/system_tools/tests/command.rs index 3617d7707..f7bfb6939 100644 --- a/crates/noticenterctl/src/system_tools/tests/command.rs +++ b/crates/noticenterctl/src/system_tools/tests/command.rs @@ -2,7 +2,8 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::time::{SystemTime, UNIX_EPOCH}; -use super::super::{command, routing::use_fake_tool_bin}; +use super::super::{command, command_from_spec, routing::use_fake_tool_bin}; +use unixnotis_core::CommandSpec; struct TempDirGuard { path: std::path::PathBuf, @@ -63,3 +64,34 @@ fn trusted_command_rejects_program_names_with_path_separators() { assert_eq!(error.kind(), std::io::ErrorKind::NotFound); } + +#[test] +fn typed_command_preserves_literal_arguments_and_environment() { + let root = TempDirGuard::new("typed"); + root.write_executable("printf", "#!/bin/sh\nexit 0\n"); + let _tools = use_fake_tool_bin(&root.path); + let spec = CommandSpec::direct("printf", ["battery|charging"]) + .with_env("WIDGET_MODE", "literal value"); + + let command = command_from_spec(&spec).expect("typed trusted command"); + + assert_eq!( + command.get_args().collect::>(), + vec![std::ffi::OsStr::new("battery|charging")] + ); + assert_eq!( + command + .get_envs() + .find(|(name, _)| *name == "WIDGET_MODE") + .and_then(|(_, value)| value), + Some(std::ffi::OsStr::new("literal value")) + ); +} + +#[test] +fn typed_trusted_command_rejects_shell_mode() { + let error = command_from_spec(&CommandSpec::shell("printf unsafe")) + .expect_err("trusted tools must not invoke a shell"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); +} From fb62e78429173bf8aabdbc0714d4dfc475d85f5f Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:32:34 -0500 Subject: [PATCH 022/275] fix(config): bound configuration reads before parsing Summary: bound configuration reads before parsing. Scope: config. --- .../src/config/loading/diagnostics.rs | 29 ++++++++++--------- .../src/config/loading/tests/diagnostics.rs | 10 +++++-- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/unixnotis-core/src/config/loading/diagnostics.rs b/crates/unixnotis-core/src/config/loading/diagnostics.rs index ece00c293..775f55e19 100644 --- a/crates/unixnotis-core/src/config/loading/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/diagnostics.rs @@ -161,20 +161,23 @@ fn adjustment(path: &str, original: Option, effective: Option) - fn adjustment_code(path: &str) -> &'static str { // Specific codes remain stable even when the user-facing wording improves - if path.starts_with("widgets.volume.") - && [ - "enabled", - "get_cmd", - "set_cmd", - "toggle_cmd", - "watch_cmd", - "parse_mode", - ] - .iter() - .any(|field| path.ends_with(field)) - { + if [ + "widgets.volume.enabled", + "widgets.volume.get_cmd", + "widgets.volume.set_cmd", + "widgets.volume.toggle_cmd", + "widgets.volume.watch_cmd", + "widgets.volume.parse_mode", + ] + .iter() + .any(|field| { + path.strip_prefix(field) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('.')) + }) { "config.widgets.volume-backend-selected" - } else if path == "widgets.brightness.watch_cmd" { + } else if path == "widgets.brightness.watch_cmd" + || path.starts_with("widgets.brightness.watch_cmd.") + { "config.widgets.brightness-backend-corrected" } else if path == "widgets.refresh_interval_ms" || path == "widgets.refresh_interval_slow_ms" { "config.widgets.refresh-clamped" diff --git a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs index 5eec9c3c0..ecf28c823 100644 --- a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs @@ -2,6 +2,7 @@ use std::io::{self, Write}; use std::sync::{Arc, Mutex}; use super::*; +use crate::CommandSpec; use crate::{Config, ConfigDiagnosticKind, CURRENT_CONFIG_VERSION}; struct CapturedWriter(Arc>>); @@ -44,10 +45,11 @@ fn current_schema_produces_no_migration_diagnostic() { fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { let mut before = Config::default(); before.widgets.refresh_interval_ms = 1; - before.widgets.volume.get_cmd = "private-volume-command-sentinel".to_string(); + before.widgets.volume.get_cmd = + CommandSpec::direct("private-volume-command-sentinel", [] as [&str; 0]); let mut after = before.clone(); after.widgets.refresh_interval_ms = 100; - after.widgets.volume.get_cmd = "pactl get-sink-volume".to_string(); + after.widgets.volume.get_cmd = CommandSpec::direct("pactl", ["get-sink-volume"]); let diagnostics = adjustment_diagnostics(&before, &after); @@ -57,7 +59,9 @@ fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { && item.effective.as_deref() == Some("100") })); assert!(diagnostics.iter().any(|item| { - item.path.as_deref() == Some("widgets.volume.get_cmd") + item.path + .as_deref() + .is_some_and(|path| path.starts_with("widgets.volume.get_cmd")) && item.code == "config.widgets.volume-backend-selected" })); let rendered = format!("{diagnostics:?}"); From 36b9fabc93864ad98507c3b89e5037bc25f84286 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:32:42 -0500 Subject: [PATCH 023/275] fix(scripts): remove shared blue light temp state Summary: remove shared blue light temp state. Scope: scripts. --- .../assets/scripts/unixnotis-blue-light-lib | 27 +---- .../assets/scripts/unixnotis-blue-light-off | 5 - .../assets/scripts/unixnotis-blue-light-on | 1 - .../src/config/loading/io/tests/blue_light.rs | 107 ++++++++++++++++++ .../src/config/loading/io/tests/mod.rs | 1 + .../src/config/loading/io/tests/scripts.rs | 19 ++-- 6 files changed, 120 insertions(+), 40 deletions(-) create mode 100644 crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib index 49a0f8ee1..3468fd3fe 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib @@ -4,9 +4,6 @@ set -eu : "${UNIXNOTIS_BLUE_LIGHT_TEMP:=4500}" : "${UNIXNOTIS_BLUE_LIGHT_GAMMA:=90}" -STATE_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/unixnotis" -STATE_FILE="$STATE_DIR/blue-light-backend" - has_backend() { command -v "$1" >/dev/null 2>&1 } @@ -43,28 +40,6 @@ selected_backend() { active_backend || installed_backend } -remember_backend() { - # Runtime state keeps off clicks paired with the backend started by on - mkdir -p "$STATE_DIR" - printf '%s\n' "$1" > "$STATE_FILE" -} - -remembered_backend() { - if [ -r "$STATE_FILE" ]; then - read -r backend < "$STATE_FILE" - if [ "$backend" != "" ]; then - printf '%s\n' "$backend" - return 0 - fi - fi - - return 1 -} - -forget_backend() { - rm -f "$STATE_FILE" -} - stop_backend() { case "$1" in hyprsunset) @@ -100,7 +75,7 @@ stop_conflicting_backends() { } stop_active_backends() { - # Off should clean stale state and any active supported backend + # Off stops every supported backend that is currently active for candidate in hyprsunset gammastep wlsunset sunsetr; do if backend_running "$candidate"; then stop_backend "$candidate" diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off index 2fad37b95..e14822531 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off @@ -5,9 +5,4 @@ script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) # shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib . "$script_dir/unixnotis-blue-light-lib" -if backend=$(remembered_backend); then - stop_backend "$backend" - forget_backend -fi - stop_active_backends diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on index 62c50a6b9..207ce0ccc 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on @@ -10,4 +10,3 @@ backend=$(selected_backend) stop_conflicting_backends "$backend" stop_backend "$backend" start_backend "$backend" -remember_backend "$backend" diff --git a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs new file mode 100644 index 000000000..0db3127fa --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs @@ -0,0 +1,107 @@ +//! Behavioral coverage for each shipped blue-light backend + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use super::support::test_root; + +const LIBRARY: &str = include_str!("../../../../../assets/scripts/unixnotis-blue-light-lib"); + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).expect("write fake backend"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod fake backend"); +} + +fn backend_fixture(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = test_root(label); + let bin = root.join("bin"); + let log = root.join("calls.log"); + fs::create_dir_all(&bin).expect("create fake backend directory"); + fs::write(root.join("blue-light-lib"), LIBRARY).expect("write blue-light library"); + write_executable(&bin.join("nohup"), "#!/bin/sh\nexec \"$@\"\n"); + let logger = "#!/bin/sh\nprintf '%s %s\\n' \"${0##*/}\" \"$*\" >> \"$TEST_LOG\"\n"; + for backend in ["hyprsunset", "gammastep", "wlsunset", "sunsetr"] { + write_executable(&bin.join(backend), logger); + } + (root, log) +} + +#[test] +fn every_supported_backend_receives_its_expected_start_arguments() { + let cases = [ + ("hyprsunset", "hyprsunset --temperature 4500"), + ("gammastep", "gammastep -m wayland -l 0:0 -t 4500:4500 -P"), + ("wlsunset", "wlsunset -t 4500 -T 4500 -l 0 -L 0"), + ("sunsetr", "sunsetr test 4500 90"), + ]; + + for (backend, expected) in cases { + let (root, log) = backend_fixture(&format!("blue-light-start-{backend}")); + let status = Command::new("/bin/sh") + .args([ + "-c", + ". \"$1\"; start_backend \"$2\"; wait", + "blue-light-test", + ]) + .arg(root.join("blue-light-lib")) + .arg(backend) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .status() + .expect("run backend start"); + + assert!(status.success(), "backend failed: {backend}"); + assert_eq!( + fs::read_to_string(&log).expect("read backend log").trim(), + expected + ); + let _ = fs::remove_dir_all(root); + } +} + +#[test] +fn stopping_night_mode_visits_every_active_supported_backend() { + let (root, log) = backend_fixture("blue-light-stop-all"); + write_executable(&root.join("bin/pgrep"), "#!/bin/sh\nexit 0\n"); + write_executable( + &root.join("bin/pkill"), + "#!/bin/sh\nprintf 'pkill %s\\n' \"$*\" >> \"$TEST_LOG\"\n", + ); + + let status = Command::new("/bin/sh") + .args(["-c", ". \"$1\"; stop_active_backends", "blue-light-test"]) + .arg(root.join("blue-light-lib")) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .status() + .expect("stop every active backend"); + + assert!(status.success()); + let calls = fs::read_to_string(&log).expect("read stop calls"); + for expected in [ + "pkill -x hyprsunset", + "gammastep -x", + "pkill -x gammastep", + "pkill -x wlsunset", + "sunsetr stop", + "pkill -x sunsetr", + ] { + assert!( + calls.lines().any(|call| call == expected), + "missing {expected}" + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn blue_light_scripts_do_not_use_cross_user_temporary_state() { + for script in crate::DEFAULT_SCRIPTS { + if script.relative_path.contains("blue-light") { + assert!(!script.contents.contains("STATE_FILE")); + assert!(!script.contents.contains("/tmp/unixnotis")); + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index fca862943..083e1028e 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -1,5 +1,6 @@ //! Configuration I/O test declarations +mod blue_light; mod load; mod paths; mod scripts; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs index 5b9b4aefe..1be5bb54c 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs @@ -125,19 +125,22 @@ fn enabled_default_script_commands_have_shipped_files() { .filter(|toggle| toggle.enabled) { for command in [ - toggle.state_cmd.as_deref(), - toggle.toggle_cmd.as_deref(), - toggle.on_cmd.as_deref(), - toggle.off_cmd.as_deref(), - toggle.watch_cmd.as_deref(), + toggle.state_cmd.as_ref(), + toggle.toggle_cmd.as_ref(), + toggle.on_cmd.as_ref(), + toggle.off_cmd.as_ref(), + toggle.watch_cmd.as_ref(), ] .into_iter() .flatten() { - if command.starts_with("scripts/") { + let Some(program) = command.program().and_then(std::path::Path::to_str) else { + continue; + }; + if program.starts_with("scripts/") { assert!( - shipped.contains(&command), - "default command must be shipped: {command}" + shipped.contains(&program), + "default command must be shipped: {program}" ); } } From 6f95b8def7ec8b67733455739bc0c96ace8c5463 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:33:04 -0500 Subject: [PATCH 024/275] refactor(css-check): share escape-aware value scanning Summary: share escape-aware value scanning. Scope: css-check. --- .../css_check/geometry/parse/lengths/edges.rs | 2 + .../css_check/geometry/parse/lengths/mod.rs | 2 +- .../geometry/parse/lengths/resolve_compare.rs | 3 +- .../geometry/parse/lengths/resolve_var.rs | 2 +- .../geometry/parse/lengths/tests/cases.rs | 85 ----- .../geometry/parse/lengths/tests/edges.rs | 71 +++++ .../parse/lengths/tests/expression.rs | 36 +++ .../geometry/parse/lengths/tests/mod.rs | 5 + .../parse/lengths/tests/resolve_compare.rs | 38 +++ .../parse/lengths/tests/resolve_var.rs | 35 ++ .../geometry/parse/lengths/tests/tokenize.rs | 92 ++++++ .../geometry/parse/lengths/tests/units.rs | 29 ++ .../geometry/parse/lengths/tokenize.rs | 298 ++++++++++-------- 13 files changed, 474 insertions(+), 224 deletions(-) delete mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs index feabc80f2..b6498e1f1 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs @@ -85,6 +85,7 @@ pub(in crate::css_check::geometry) fn parse_single_length( // Fall back to the first token so old shorthand behavior stays intact split_css_value_tokens(trimmed) + .ok()? .into_iter() .find_map(|token| parse_length_expression(token, custom_properties, 0)) .and_then(ResolvedCssValue::into_length) @@ -93,6 +94,7 @@ pub(in crate::css_check::geometry) fn parse_single_length( fn parse_length_tokens(value: &str, custom_properties: &CssCustomProperties) -> Vec { // Four tokens are enough for the full CSS box shorthand split_css_value_tokens(value) + .unwrap_or_default() .into_iter() .filter_map(|token| parse_length_expression(token, custom_properties, 0)) .filter_map(ResolvedCssValue::into_length) diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs index faf4b37d6..7d80814c1 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs @@ -15,5 +15,5 @@ pub(in super::super) use edges::{parse_box_edges, parse_box_vertical_edges, pars pub(super) use expression::{parse_length_expression, ResolvedCssValue}; #[cfg(test)] -#[path = "tests/cases.rs"] +#[path = "tests/mod.rs"] mod tests; diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs index bb937e833..631638d19 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs @@ -26,7 +26,7 @@ pub(super) fn resolve_compare_function( } let inner = trimmed.strip_prefix("clamp(")?.strip_suffix(')')?.trim(); - let args = split_top_level_list(inner, ','); + let args = split_top_level_list(inner, ',').ok()?; if args.len() != 3 { return None; } @@ -51,6 +51,7 @@ fn resolve_min_or_max( mode: CompareMode, ) -> Option { let mut values = split_top_level_list(inner, ',') + .ok()? .into_iter() .map(|value| parse_length_expression(value, custom_properties, depth + 1)) .collect::>>()? diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs index 2d5341360..e098aad53 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs @@ -15,7 +15,7 @@ pub(super) fn resolve_custom_property_value( .strip_prefix("var(")? .strip_suffix(')')? .trim(); - let (name, fallback) = split_top_level_once(inner, ','); + let (name, fallback) = split_top_level_once(inner, ',').ok()?; let name = name.trim(); if let Some(value) = custom_properties.get(name) { // Resolved properties recurse through the same parser so nested calc stays supported diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs deleted file mode 100644 index 2106fe6f2..000000000 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs +++ /dev/null @@ -1,85 +0,0 @@ -#![allow( - clippy::float_cmp, - reason = "the parser returns exact decimal literals for these integer and finite CSS inputs" -)] - -use std::collections::HashMap; - -use super::{parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge}; - -#[test] -fn parse_single_length_resolves_calc_compare_and_var_fallbacks() { - let mut properties = HashMap::new(); - properties.insert("--base".to_string(), "calc(10px + 2px)".to_string()); - properties.insert("--chosen".to_string(), "var(--missing, 18px)".to_string()); - - assert_eq!(parse_single_length("var(--base)", &properties), Some(12.0)); - assert_eq!( - parse_single_length("var(--chosen)", &properties), - Some(18.0) - ); - assert_eq!( - parse_single_length("clamp(4px, max(12px, 14px), 20px)", &properties), - Some(14.0) - ); -} - -#[test] -fn parse_single_length_rejects_percentages_units_and_bad_math() { - let properties = HashMap::new(); - - assert_eq!(parse_single_length("80%", &properties), None); - assert_eq!(parse_single_length("1rem", &properties), None); - assert_eq!(parse_single_length("calc(10px / 0)", &properties), None); - assert_eq!(parse_single_length("calc(10px + 2)", &properties), None); -} - -#[test] -fn parse_box_edges_follows_css_horizontal_shorthand_rules() { - let properties = HashMap::new(); - - let one = parse_box_edges("3px", &properties).expect("one value"); - assert_eq!(one.left, 3.0); - assert_eq!(one.right, 3.0); - - let two = parse_box_edges("1px 4px", &properties).expect("two values"); - assert_eq!(two.left, 4.0); - assert_eq!(two.right, 4.0); - - let three = parse_box_edges("1px 4px 7px", &properties).expect("three values"); - assert_eq!(three.left, 4.0); - assert_eq!(three.right, 4.0); - - let four = parse_box_edges("1px 2px 3px 4px", &properties).expect("four values"); - assert_eq!(four.left, 4.0); - assert_eq!(four.right, 2.0); -} - -#[test] -fn parse_box_vertical_edges_follows_css_vertical_shorthand_rules() { - let properties = HashMap::new(); - - let two = parse_box_vertical_edges("6px 9px", &properties).expect("two values"); - assert_eq!(two.top, 6.0); - assert_eq!(two.bottom, 6.0); - - let three = parse_box_vertical_edges("1px 2px 3px", &properties).expect("three values"); - assert_eq!(three.top, 1.0); - assert_eq!(three.bottom, 3.0); - - let four = parse_box_vertical_edges("1px 2px 3px 4px", &properties).expect("four values"); - assert_eq!(four.top, 1.0); - assert_eq!(four.bottom, 3.0); -} - -#[test] -fn set_edge_leaves_existing_value_when_length_cannot_resolve() { - let properties = HashMap::new(); - let mut edge = 8.0; - - set_edge(&mut edge, "var(--missing)", &properties); - assert_eq!(edge, 8.0); - - set_edge(&mut edge, "12px", &properties); - assert_eq!(edge, 12.0); -} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs new file mode 100644 index 000000000..3e3b64a75 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs @@ -0,0 +1,71 @@ +#![allow( + clippy::float_cmp, + reason = "the parser returns exact finite values for these integer CSS inputs" +)] + +use std::collections::HashMap; + +use super::super::{parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge}; + +#[test] +fn parse_single_length_uses_the_first_resolved_shorthand_token() { + let properties = HashMap::new(); + + assert_eq!(parse_single_length("invalid 12px", &properties), Some(12.0)); + assert_eq!(parse_single_length("80% 1rem", &properties), None); +} + +#[test] +fn parse_box_edges_follows_every_css_horizontal_shorthand_shape() { + let properties = HashMap::new(); + + let one = parse_box_edges("3px", &properties).expect("one value"); + assert_eq!((one.left, one.right), (3.0, 3.0)); + + let two = parse_box_edges("1px 4px", &properties).expect("two values"); + assert_eq!((two.left, two.right), (4.0, 4.0)); + + let three = parse_box_edges("1px 4px 7px", &properties).expect("three values"); + assert_eq!((three.left, three.right), (4.0, 4.0)); + + let four = parse_box_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!((four.left, four.right), (4.0, 2.0)); +} + +#[test] +fn parse_box_vertical_edges_follows_every_css_vertical_shorthand_shape() { + let properties = HashMap::new(); + + let one = parse_box_vertical_edges("3px", &properties).expect("one value"); + assert_eq!((one.top, one.bottom), (3.0, 3.0)); + + let two = parse_box_vertical_edges("6px 9px", &properties).expect("two values"); + assert_eq!((two.top, two.bottom), (6.0, 6.0)); + + let three = parse_box_vertical_edges("1px 2px 3px", &properties).expect("three values"); + assert_eq!((three.top, three.bottom), (1.0, 3.0)); + + let four = parse_box_vertical_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!((four.top, four.bottom), (1.0, 3.0)); +} + +#[test] +fn malformed_or_oversized_shorthands_fail_closed() { + let properties = HashMap::new(); + + assert!(parse_box_edges("", &properties).is_none()); + assert!(parse_box_edges("1px 2px 3px 4px 5px", &properties).is_some()); + assert!(parse_box_vertical_edges("var(unterminated", &properties).is_none()); +} + +#[test] +fn set_edge_changes_only_resolved_lengths() { + let properties = HashMap::new(); + let mut edge = 8.0; + + set_edge(&mut edge, "var(--missing)", &properties); + assert_eq!(edge, 8.0); + + set_edge(&mut edge, "12px", &properties); + assert_eq!(edge, 12.0); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs new file mode 100644 index 000000000..903ccec23 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +use super::super::{parse_length_expression, ResolvedCssValue}; + +#[test] +fn arithmetic_parser_preserves_precedence_and_parentheses() { + let properties = HashMap::new(); + + assert_eq!( + parse_length_expression("2 * 3px + 4px", &properties, 0), + Some(ResolvedCssValue::Length(10.0)) + ); + assert_eq!( + parse_length_expression("2 * (3px + 4px)", &properties, 0), + Some(ResolvedCssValue::Length(14.0)) + ); +} + +#[test] +fn arithmetic_parser_rejects_invalid_dimensions_and_division_by_zero() { + let properties = HashMap::new(); + + for expression in ["10px + 2", "10px * 2px", "10px / 0", "10px trailing"] { + assert!( + parse_length_expression(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} + +#[test] +fn arithmetic_parser_limits_recursive_resolution_depth() { + let properties = HashMap::new(); + + assert!(parse_length_expression("12px", &properties, 9).is_none()); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs new file mode 100644 index 000000000..550776f18 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs @@ -0,0 +1,5 @@ +mod edges; +mod expression; +mod resolve_compare; +mod resolve_var; +mod units; diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs new file mode 100644 index 000000000..ebea960e9 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use super::super::{resolve_compare::resolve_compare_function, ResolvedCssValue}; + +#[test] +fn comparison_functions_resolve_nested_length_arguments() { + let properties = HashMap::new(); + + assert_eq!( + resolve_compare_function("min(12px, 8px)", &properties, 0), + Some(ResolvedCssValue::Length(8.0)) + ); + assert_eq!( + resolve_compare_function("max(12px, 8px)", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); + assert_eq!( + resolve_compare_function("clamp(4px, max(12px, 14px), 20px)", &properties, 0), + Some(ResolvedCssValue::Length(14.0)) + ); +} + +#[test] +fn comparison_functions_reject_missing_or_mixed_arguments() { + let properties = HashMap::new(); + + for expression in [ + "min()", + "max(1px, 2)", + "clamp(1px, 2px)", + "clamp(1px, 2, 3px)", + ] { + assert!( + resolve_compare_function(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs new file mode 100644 index 000000000..cd324b0e9 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; + +use super::super::{resolve_var::resolve_custom_property_value, ResolvedCssValue}; + +#[test] +fn custom_property_resolution_prefers_the_defined_value() { + let properties = HashMap::from([("--width".to_string(), "calc(10px + 2px)".to_string())]); + + assert_eq!( + resolve_custom_property_value("var(--width, 30px)", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); +} + +#[test] +fn custom_property_resolution_uses_a_nested_fallback() { + let properties = HashMap::new(); + + assert_eq!( + resolve_custom_property_value("var(--missing, max(12px, 18px))", &properties, 0), + Some(ResolvedCssValue::Length(18.0)) + ); +} + +#[test] +fn custom_property_resolution_rejects_missing_or_malformed_fallbacks() { + let properties = HashMap::new(); + + for expression in ["var(--missing)", "var(--missing, 12px", "var()"] { + assert!( + resolve_custom_property_value(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs new file mode 100644 index 000000000..172eedada --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs @@ -0,0 +1,92 @@ +use super::{ + consume_balanced_group, split_css_value_tokens, split_top_level_list, split_top_level_once, + CssScanError, +}; + +#[test] +fn escaped_quotes_keep_separators_inside_the_same_string() { + let value = r#"var(--label, "quoted\",comma"), 12px"#; + + assert_eq!( + split_top_level_list(value, ',').expect("scan escaped quote"), + vec![r#"var(--label, "quoted\",comma")"#, "12px"] + ); +} + +#[test] +fn escaped_whitespace_does_not_split_a_css_value_token() { + assert_eq!( + split_css_value_tokens(r"10px label\ value 20px").expect("scan escaped space"), + vec!["10px", r"label\ value", "20px"] + ); +} + +#[test] +fn balanced_group_returns_the_byte_after_its_matching_parenthesis() { + let value = "calc(10px + var(--gap, 2px)) tail"; + let start = value.find('(').expect("opening parenthesis"); + + assert_eq!( + consume_balanced_group(value, start), + Some("calc(10px + var(--gap, 2px))".len()) + ); +} + +#[test] +fn top_level_once_ignores_nested_and_quoted_separators() { + let value = r#"--gap, min(10px, "20px,still-string")"#; + + assert_eq!( + split_top_level_once(value, ',').expect("scan var fallback"), + ("--gap", Some(r#" min(10px, "20px,still-string")"#)) + ); +} + +#[test] +fn top_level_once_returns_no_fallback_when_separator_is_absent() { + assert_eq!( + split_top_level_once("--gap", ',').expect("scan value without fallback"), + ("--gap", None) + ); +} + +#[test] +fn bracketed_separators_remain_inside_their_value() { + assert_eq!( + split_top_level_list("selector[data='a,b'], 12px", ',').expect("scan bracketed selector"), + vec!["selector[data='a,b']", "12px"] + ); + assert_eq!( + split_css_value_tokens("selector[data=value with-space] 12px") + .expect("scan bracketed whitespace"), + vec!["selector[data=value with-space]", "12px"] + ); +} + +#[test] +fn malformed_delimiters_and_strings_return_structured_errors() { + assert_eq!( + split_top_level_list("10px), 20px", ','), + Err(CssScanError::ClosingParenthesis(4)) + ); + assert_eq!( + split_css_value_tokens(r#"10px "unfinished"#), + Err(CssScanError::UnterminatedQuote) + ); + assert_eq!( + split_css_value_tokens("calc(10px"), + Err(CssScanError::UnterminatedGroup) + ); + assert_eq!( + split_css_value_tokens("selector[value"), + Err(CssScanError::UnterminatedGroup) + ); + assert_eq!( + split_css_value_tokens("selector]"), + Err(CssScanError::ClosingBracket(8)) + ); + assert_eq!( + split_css_value_tokens("value\\"), + Err(CssScanError::DanglingEscape) + ); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs new file mode 100644 index 000000000..1a244d352 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +use super::super::{units::parse_atomic_value, ResolvedCssValue}; + +#[test] +fn atomic_values_distinguish_pixel_lengths_from_scalars() { + let properties = HashMap::new(); + + assert_eq!( + parse_atomic_value("12PX", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); + assert_eq!( + parse_atomic_value("2.5", &properties, 0), + Some(ResolvedCssValue::Scalar(2.5)) + ); +} + +#[test] +fn atomic_values_reject_percentages_and_unknown_units() { + let properties = HashMap::new(); + + for value in ["", "80%", "1rem", "unknown"] { + assert!( + parse_atomic_value(value, &properties, 0).is_none(), + "{value}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs index 2f2d65c95..db94d0f73 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs @@ -1,173 +1,199 @@ -//! Token splitting helpers for geometry length parsing +//! Shared escape-aware scanner for geometry CSS token boundaries -pub(super) fn consume_balanced_group(input: &str, start: usize) -> Option { - let bytes = input.as_bytes(); - let mut cursor = start; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - while cursor < bytes.len() { - let ch = input[cursor..].chars().next()?; - cursor += ch.len_utf8(); - - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; - } +use thiserror::Error; - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => { - // Group ends only when both paren and bracket nesting are back at zero - paren_depth = paren_depth.saturating_sub(1); - if paren_depth == 0 && bracket_depth == 0 { - return Some(cursor); - } - } - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ => {} - } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct CssScanState { + quote: Option, + escaped: bool, + paren_depth: u32, + bracket_depth: u32, +} + +impl CssScanState { + const fn is_top_level(self) -> bool { + self.quote.is_none() && !self.escaped && self.paren_depth == 0 && self.bracket_depth == 0 } +} - None +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub(super) enum CssScanError { + #[error("CSS contains an unmatched closing parenthesis at byte {0}")] + ClosingParenthesis(usize), + #[error("CSS contains an unmatched closing bracket at byte {0}")] + ClosingBracket(usize), + #[error("CSS contains an unterminated quoted string")] + UnterminatedQuote, + #[error("CSS contains an unterminated group")] + UnterminatedGroup, + #[error("CSS contains a dangling escape")] + DanglingEscape, } -pub(super) fn split_css_value_tokens(value: &str) -> Vec<&str> { - let mut tokens = Vec::new(); - let mut start = None::; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in value.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - if start.is_none() { - start = Some(index); +pub(super) fn scan_css( + input: &str, + mut visitor: impl FnMut(usize, char, &CssScanState), +) -> Result<(), CssScanError> { + // Public scans always consume the full input and validate the final state + scan_css_until(input, |index, character, state| { + visitor(index, character, state); + false + }) +} + +fn scan_css_until( + input: &str, + mut visitor: impl FnMut(usize, char, &CssScanState) -> bool, +) -> Result<(), CssScanError> { + let mut state = CssScanState::default(); + + // Character indices preserve valid UTF-8 slice boundaries for every callback + for (index, character) in input.char_indices() { + if state.escaped { + // Escaped characters are data even when they look like delimiters + if visitor(index, character, &state) { + return Ok(()); } + state.escaped = false; continue; } - match ch { - '"' | '\'' => { - if start.is_none() { - start = Some(index); - } - in_string = Some(ch); + if let Some(quote) = state.quote { + // Quoted delimiters cannot change group depth or split top-level values + match character { + '\\' => state.escaped = true, + current if current == quote => state.quote = None, + _ => {} } - '(' => { - if start.is_none() { - start = Some(index); - } - paren_depth = paren_depth.saturating_add(1); + if visitor(index, character, &state) { + return Ok(()); } - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => { - if start.is_none() { - start = Some(index); - } - bracket_depth = bracket_depth.saturating_add(1); - } - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch.is_whitespace() && paren_depth == 0 && bracket_depth == 0 => { - if let Some(token_start) = start.take() { - // Top-level whitespace is the only real shorthand separator - tokens.push(value[token_start..index].trim()); - } + continue; + } + + // Group depth is updated before visitors inspect the current character + match character { + '\\' => state.escaped = true, + '"' | '\'' => state.quote = Some(character), + '(' => state.paren_depth += 1, + ')' => { + state.paren_depth = state + .paren_depth + .checked_sub(1) + .ok_or(CssScanError::ClosingParenthesis(index))?; } - _ => { - if start.is_none() { - start = Some(index); - } + '[' => state.bracket_depth += 1, + ']' => { + state.bracket_depth = state + .bracket_depth + .checked_sub(1) + .ok_or(CssScanError::ClosingBracket(index))?; } + _ => {} + } + if visitor(index, character, &state) { + return Ok(()); } } - if let Some(token_start) = start { - tokens.push(value[token_start..].trim()); + // Final-state checks turn malformed CSS into one consistent parse failure + if state.escaped { + return Err(CssScanError::DanglingEscape); } - - tokens - .into_iter() - .filter(|token| !token.is_empty()) - .collect() + if state.quote.is_some() { + return Err(CssScanError::UnterminatedQuote); + } + if state.paren_depth != 0 || state.bracket_depth != 0 { + return Err(CssScanError::UnterminatedGroup); + } + Ok(()) } -pub(super) fn split_top_level_once(input: &str, separator: char) -> (&str, Option<&str>) { - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in input.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; +pub(super) fn consume_balanced_group(input: &str, start: usize) -> Option { + // A subslice lets the shared scanner report offsets relative to the opening group + let remaining = input.get(start..)?; + let mut end = None; + scan_css_until(remaining, |index, character, state| { + if end.is_none() && character == ')' && state.is_top_level() { + end = Some(start + index + character.len_utf8()); + return true; } + false + }) + .ok()?; + end +} - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch == separator && paren_depth == 0 && bracket_depth == 0 => { - // Only the first top-level separator matters for var() fallback splitting - let right = index + ch.len_utf8(); - return (&input[..index], Some(&input[right..])); +pub(super) fn split_css_value_tokens(value: &str) -> Result, CssScanError> { + let mut tokens = Vec::new(); + let mut start = None; + // Only unquoted top-level whitespace ends one shorthand token + scan_css(value, |index, character, state| { + if character.is_whitespace() && state.is_top_level() { + if let Some(token_start) = start.take() { + let token = value[token_start..index].trim(); + if !token.is_empty() { + tokens.push(token); + } } - _ => {} + } else if start.is_none() { + start = Some(index); + } + })?; + if let Some(token_start) = start { + let token = value[token_start..].trim(); + if !token.is_empty() { + tokens.push(token); } } + Ok(tokens) +} - (input, None) +pub(super) fn split_top_level_once( + input: &str, + separator: char, +) -> Result<(&str, Option<&str>), CssScanError> { + let mut split = None; + // The first top-level separator owns the entire remaining fallback value + scan_css(input, |index, character, state| { + if split.is_none() && character == separator && state.is_top_level() { + split = Some(index); + } + })?; + Ok(split.map_or((input, None), |index| { + let right = index + separator.len_utf8(); + (&input[..index], Some(&input[right..])) + })) } -pub(super) fn split_top_level_list(input: &str, separator: char) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0usize; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in input.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; +pub(super) fn split_top_level_list( + input: &str, + separator: char, +) -> Result, CssScanError> { + let mut split_points = Vec::new(); + // Nested functions and attribute selectors keep their internal separators + scan_css(input, |index, character, state| { + if character == separator && state.is_top_level() { + split_points.push(index); } + })?; - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch == separator && paren_depth == 0 && bracket_depth == 0 => { - // Top-level commas split function arguments without breaking nested math - let part = input[start..index].trim(); - if !part.is_empty() { - parts.push(part); - } - start = index + ch.len_utf8(); - } - _ => {} + let mut parts = Vec::new(); + let mut start = 0; + for index in split_points { + let part = input[start..index].trim(); + if !part.is_empty() { + parts.push(part); } + start = index + separator.len_utf8(); } - let tail = input[start..].trim(); if !tail.is_empty() { parts.push(tail); } - - parts + Ok(parts) } + +#[cfg(test)] +#[path = "tests/tokenize.rs"] +mod tests; From 11c1093f80995849749a666331c538d61c3e08a7 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 16:33:22 -0500 Subject: [PATCH 025/275] feat(cli): synchronize compositor session state Summary: synchronize compositor session state. Scope: cli. --- crates/noticenterctl/src/app/local.rs | 2 + crates/noticenterctl/src/app/runner.rs | 7 +- crates/noticenterctl/src/app/tests/local.rs | 2 + crates/noticenterctl/src/app/tests/runner.rs | 2 + crates/noticenterctl/src/cli/command.rs | 15 ++++- crates/noticenterctl/src/cli/tests/args.rs | 18 ++++++ crates/noticenterctl/src/dbus/commands.rs | 5 +- crates/noticenterctl/src/main.rs | 1 + .../src/session_environment/backends/dinit.rs | 32 ++++++++++ .../session_environment/backends/envdir.rs | 33 ++++++++++ .../src/session_environment/backends/mod.rs | 12 ++++ .../src/session_environment/backends/runit.rs | 23 +++++++ .../src/session_environment/backends/s6.rs | 35 ++++++++++ .../session_environment/backends/systemd.rs | 35 ++++++++++ .../src/session_environment/manager.rs | 64 +++++++++++++++++++ .../src/session_environment/mod.rs | 12 ++++ .../src/session_environment/process.rs | 29 +++++++++ .../src/session_environment/sync.rs | 25 ++++++++ .../tests/backends/dinit.rs | 26 ++++++++ .../tests/backends/envdir.rs | 13 ++++ .../session_environment/tests/backends/mod.rs | 5 ++ .../tests/backends/runit.rs | 34 ++++++++++ .../session_environment/tests/backends/s6.rs | 25 ++++++++ .../tests/backends/systemd.rs | 27 ++++++++ .../src/session_environment/tests/manager.rs | 50 +++++++++++++++ .../src/session_environment/tests/mod.rs | 6 ++ .../src/session_environment/tests/process.rs | 37 +++++++++++ .../src/session_environment/tests/support.rs | 58 +++++++++++++++++ .../src/session_environment/tests/sync.rs | 10 +++ .../session_environment/tests/variables.rs | 39 +++++++++++ .../src/session_environment/variables.rs | 40 ++++++++++++ .../src/service_manager/envdir.rs | 18 ++++++ .../unixnotis-core/src/service_manager/mod.rs | 2 + .../src/service_manager/tests/envdir.rs | 15 +++++ 34 files changed, 753 insertions(+), 4 deletions(-) create mode 100644 crates/noticenterctl/src/session_environment/backends/dinit.rs create mode 100644 crates/noticenterctl/src/session_environment/backends/envdir.rs create mode 100644 crates/noticenterctl/src/session_environment/backends/mod.rs create mode 100644 crates/noticenterctl/src/session_environment/backends/runit.rs create mode 100644 crates/noticenterctl/src/session_environment/backends/s6.rs create mode 100644 crates/noticenterctl/src/session_environment/backends/systemd.rs create mode 100644 crates/noticenterctl/src/session_environment/manager.rs create mode 100644 crates/noticenterctl/src/session_environment/mod.rs create mode 100644 crates/noticenterctl/src/session_environment/process.rs create mode 100644 crates/noticenterctl/src/session_environment/sync.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/dinit.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/envdir.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/mod.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/runit.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/s6.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/backends/systemd.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/manager.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/mod.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/process.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/support.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/sync.rs create mode 100644 crates/noticenterctl/src/session_environment/tests/variables.rs create mode 100644 crates/noticenterctl/src/session_environment/variables.rs create mode 100644 crates/unixnotis-core/src/service_manager/envdir.rs create mode 100644 crates/unixnotis-core/src/service_manager/tests/envdir.rs diff --git a/crates/noticenterctl/src/app/local.rs b/crates/noticenterctl/src/app/local.rs index f5546f89e..4145b2058 100644 --- a/crates/noticenterctl/src/app/local.rs +++ b/crates/noticenterctl/src/app/local.rs @@ -10,11 +10,13 @@ pub(super) fn handle_local_command( command: Command, mut run_css: impl FnMut(Option) -> Result<()>, mut run_preset: impl FnMut(PresetCommand) -> Result<()>, + mut sync_session: impl FnMut(crate::cli::DoctorServiceManagerArg) -> Result<()>, ) -> Result<()> { // Local commands remain available while the session bus or daemon is unavailable match command { Command::CssCheck { config } => run_css(config), Command::Preset { command } => run_preset(command).context("preset command failed"), + Command::SyncSessionEnvironment { service_manager } => sync_session(service_manager), // The caller routes daemon-backed commands before reaching this helper _ => Ok(()), } diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index 731e1c239..2e8f0bb65 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -18,7 +18,12 @@ pub fn run() -> Result<()> { if command.is_synchronous() { // Preset and CSS work should not pay for an unused asynchronous runtime - handle_local_command(command, crate::css_check::run, crate::preset::run_preset)?; + handle_local_command( + command, + crate::css_check::run, + crate::preset::run_preset, + crate::session_environment::sync, + )?; return Ok(()); } diff --git a/crates/noticenterctl/src/app/tests/local.rs b/crates/noticenterctl/src/app/tests/local.rs index e45f81576..e31b83a60 100644 --- a/crates/noticenterctl/src/app/tests/local.rs +++ b/crates/noticenterctl/src/app/tests/local.rs @@ -18,6 +18,7 @@ fn daemon_command_is_not_dispatched_to_local_handlers() { preset_called = true; Ok(()) }, + |_| Ok(()), ) .expect("ignore daemon command in local dispatcher"); @@ -34,6 +35,7 @@ fn local_handler_error_is_returned_to_the_caller() { Command::CssCheck { config: None }, |_| anyhow::bail!("CSS check failed"), |_| -> Result<()> { Ok(()) }, + |_| -> Result<()> { Ok(()) }, ); let error = result.expect_err("local command failure should be returned"); diff --git a/crates/noticenterctl/src/app/tests/runner.rs b/crates/noticenterctl/src/app/tests/runner.rs index 2c6caf6c4..5d0c01294 100644 --- a/crates/noticenterctl/src/app/tests/runner.rs +++ b/crates/noticenterctl/src/app/tests/runner.rs @@ -18,6 +18,7 @@ fn handle_local_command_runs_css_check_branch() { Ok(()) }, |_| -> Result<()> { panic!("preset runner should not be called for css check") }, + |_| -> Result<()> { panic!("session runner should not be called for css check") }, ) .expect("css check should dispatch"); @@ -43,6 +44,7 @@ fn handle_local_command_runs_preset_branch_with_command_payload() { preset_called.set(true); Ok(()) }, + |_| -> Result<()> { panic!("session runner should not be called for preset command") }, ) .expect("preset should dispatch"); diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index 79465f2a7..ff45a289c 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -76,6 +76,11 @@ pub enum Command { #[arg(long, value_name = "PATH")] config: Option, }, + // Import the compositor session environment and restart the installed user service + SyncSessionEnvironment { + #[arg(long, value_enum, default_value = "auto")] + service_manager: DoctorServiceManagerArg, + }, // Export, inspect, or import a shareable preset bundle Preset { #[command(subcommand)] @@ -105,12 +110,18 @@ impl Command { // Local-only commands should not fail just because D-Bus is unavailable matches!( self, - Self::CssCheck { .. } | Self::Doctor { .. } | Self::Preset { .. } + Self::CssCheck { .. } + | Self::Doctor { .. } + | Self::Preset { .. } + | Self::SyncSessionEnvironment { .. } ) } pub(crate) const fn is_synchronous(&self) -> bool { // Doctor uses local inputs but still needs asynchronous D-Bus and process timeouts - matches!(self, Self::CssCheck { .. } | Self::Preset { .. }) + matches!( + self, + Self::CssCheck { .. } | Self::Preset { .. } | Self::SyncSessionEnvironment { .. } + ) } } diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs index fd93720cb..0189c498c 100644 --- a/crates/noticenterctl/src/cli/tests/args.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -259,6 +259,24 @@ fn parses_doctor_output_and_service_manager_options() { )); } +#[test] +fn parses_session_environment_service_manager_without_shell_payloads() { + let args = Args::try_parse_from([ + "noticenterctl", + "sync-session-environment", + "--service-manager", + "runit", + ]) + .expect("parse session environment command"); + + assert!(matches!( + args.command, + Command::SyncSessionEnvironment { + service_manager: DoctorServiceManagerArg::Runit, + } + )); +} + #[test] fn doctor_and_css_check_accept_explicit_config_paths() { let doctor = Args::try_parse_from([ diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index ce1c207c6..0e9e91336 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -119,7 +119,10 @@ pub(super) async fn handle_command_with_debug_logs( let inhibitors = client.list_inhibitors().await?; print_inhibitors(&inhibitors)?; } - Command::CssCheck { .. } | Command::Doctor { .. } | Command::Preset { .. } => {} + Command::CssCheck { .. } + | Command::Doctor { .. } + | Command::Preset { .. } + | Command::SyncSessionEnvironment { .. } => {} } Ok(()) diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index 005b70559..189dc3942 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -24,6 +24,7 @@ mod debug_logs; mod doctor; mod output; mod preset; +mod session_environment; mod system_tools; use std::process::ExitCode; diff --git a/crates/noticenterctl/src/session_environment/backends/dinit.rs b/crates/noticenterctl/src/session_environment/backends/dinit.rs new file mode 100644 index 000000000..93c6f397b --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/dinit.rs @@ -0,0 +1,32 @@ +//! Dinit user-manager environment import and start flow + +use anyhow::Result; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::super::variables::IMPORT_VARS; + +pub(in crate::session_environment) fn sync_dinit() -> Result<()> { + // Dinit imports named values directly from the current process environment + require_success(&CommandSpec::direct( + "dinitctl", + std::iter::once("--user") + .chain(std::iter::once("setenv")) + .chain(IMPORT_VARS), + ))?; + let restart = CommandSpec::direct( + "dinitctl", + [ + "--user", + "restart", + "--ignore-unstarted", + "unixnotis-daemon", + ], + ); + // An inactive service cannot restart, so a normal start follows every attempt + let _ = run(&restart)?; + require_success(&CommandSpec::direct( + "dinitctl", + ["--user", "start", "unixnotis-daemon"], + )) +} diff --git a/crates/noticenterctl/src/session_environment/backends/envdir.rs b/crates/noticenterctl/src/session_environment/backends/envdir.rs new file mode 100644 index 000000000..3268603e8 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/envdir.rs @@ -0,0 +1,33 @@ +//! Hardened envdir publication below an installed service directory + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::filesystem::write_file_atomic; +use unixnotis_core::service_manager::envdir_file_contents; + +use super::super::variables::IMPORT_VARS; + +pub(in crate::session_environment) fn write_envdir(service: &Path, env_dir: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(service) + .with_context(|| format!("inspect installed service directory {}", service.display()))?; + // The service anchor must be a real directory before any child path is created + if !metadata.file_type().is_dir() { + bail!( + "refusing to write environment outside a regular service directory: {}", + service.display() + ); + } + // PATH remains fixed by the installed run script instead of session input + for name in IMPORT_VARS.into_iter().filter(|name| *name != "PATH") { + let value = env::var(name).ok(); + let contents = envdir_file_contents(value.as_deref()); + let target: PathBuf = env_dir.join(name); + // Atomic descriptor-relative writes reject symlink traversal and partial files + write_file_atomic(&target, contents.as_bytes(), 0o600) + .with_context(|| format!("write service environment file {}", target.display()))?; + } + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/backends/mod.rs b/crates/noticenterctl/src/session_environment/backends/mod.rs new file mode 100644 index 000000000..bab4ad0f0 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/mod.rs @@ -0,0 +1,12 @@ +mod dinit; +mod envdir; +mod runit; +mod s6; +mod systemd; + +pub(super) use dinit::sync_dinit; +#[cfg(test)] +pub(super) use envdir::write_envdir; +pub(super) use runit::sync_runit; +pub(super) use s6::sync_s6; +pub(super) use systemd::sync_systemd; diff --git a/crates/noticenterctl/src/session_environment/backends/runit.rs b/crates/noticenterctl/src/session_environment/backends/runit.rs new file mode 100644 index 000000000..85e9cf199 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/runit.rs @@ -0,0 +1,23 @@ +//! Runit envdir publication and supervisor restart flow + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerPaths; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::envdir::write_envdir; + +pub(in crate::session_environment) fn sync_runit(manager: &ServiceManagerPaths) -> Result<()> { + let service = manager.artifact_root.join("unixnotis-daemon"); + write_envdir(&service, &service.join("env"))?; + let restart = CommandSpec::direct("sv", ["restart".into(), service.as_os_str().to_os_string()]); + // A successful restart avoids a redundant start request + if run(&restart)?.success() { + return Ok(()); + } + // Fresh installations may exist before the service is supervised + require_success(&CommandSpec::direct( + "sv", + ["start".into(), service.into_os_string()], + )) +} diff --git a/crates/noticenterctl/src/session_environment/backends/s6.rs b/crates/noticenterctl/src/session_environment/backends/s6.rs new file mode 100644 index 000000000..5146c5020 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/s6.rs @@ -0,0 +1,35 @@ +//! S6-rc envdir publication and live-tree restart flow + +use anyhow::{Context, Result}; +use unixnotis_core::service_manager::ServiceManagerPaths; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::envdir::write_envdir; + +pub(in crate::session_environment) fn sync_s6(manager: &ServiceManagerPaths) -> Result<()> { + let service = manager.artifact_root.join("sv").join("unixnotis-daemon"); + write_envdir(&service, &service.join("env"))?; + let live = manager + .live_root + .as_deref() + .context("s6 live root was not resolved")?; + // Bringing the compiled service up also refreshes dependency state + require_success(&CommandSpec::direct( + "s6-rc", + [ + "-l".into(), + live.as_os_str().to_os_string(), + "-u".into(), + "change".into(), + "unixnotis-daemon".into(), + ], + ))?; + let live_service = live.join("servicedirs").join("unixnotis-daemon"); + // The direct service restart is best effort after s6-rc succeeds + let _ = run(&CommandSpec::direct( + "s6-svc", + ["-r".into(), live_service.into_os_string()], + ))?; + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/backends/systemd.rs b/crates/noticenterctl/src/session_environment/backends/systemd.rs new file mode 100644 index 000000000..b3aceeb5b --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/systemd.rs @@ -0,0 +1,35 @@ +//! Systemd user-manager environment import and restart flow + +use anyhow::Result; +use unixnotis_core::CommandSpec; + +use crate::system_tools; + +use super::super::process::require_success; +use super::super::variables::IMPORT_VARS; + +pub(in crate::session_environment) fn sync_systemd() -> Result<()> { + // D-Bus activation receives the compositor variables when the helper is installed + if system_tools::trusted_program_path("dbus-update-activation-environment").is_some() { + require_success(&CommandSpec::direct( + "dbus-update-activation-environment", + IMPORT_VARS, + ))?; + } + // The user manager must import the same values before restarting the daemon + require_success(&CommandSpec::direct( + "systemctl", + std::iter::once("--user") + .chain(std::iter::once("import-environment")) + .chain(IMPORT_VARS), + ))?; + require_success(&CommandSpec::direct( + "systemctl", + [ + "--user", + "--no-block", + "restart", + "unixnotis-daemon.service", + ], + )) +} diff --git a/crates/noticenterctl/src/session_environment/manager.rs b/crates/noticenterctl/src/session_environment/manager.rs new file mode 100644 index 000000000..ea45cd194 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/manager.rs @@ -0,0 +1,64 @@ +//! Explicit and artifact-backed service-manager selection + +use std::fs; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::service_manager::{ + resolve_service_manager_paths, ServiceManagerKind, ServiceManagerPaths, +}; + +use crate::cli::DoctorServiceManagerArg; + +pub(super) fn select_manager(requested: DoctorServiceManagerArg) -> Result { + // Explicit CLI choices bypass artifact probing and ambiguity checks + let kind = match requested { + DoctorServiceManagerArg::Auto => detect_installed_manager()?, + DoctorServiceManagerArg::Systemd => ServiceManagerKind::Systemd, + DoctorServiceManagerArg::Dinit => ServiceManagerKind::Dinit, + DoctorServiceManagerArg::Runit => ServiceManagerKind::Runit, + DoctorServiceManagerArg::S6 => ServiceManagerKind::S6, + DoctorServiceManagerArg::Manual => { + bail!("manual launches do not have a service environment to synchronize") + } + }; + resolve_service_manager_paths(kind).context("resolve service-manager paths") +} + +fn detect_installed_manager() -> Result { + // Only installer-owned artifacts count as evidence for automatic selection + let installed = ServiceManagerKind::all() + .into_iter() + .filter(|kind| { + resolve_service_manager_paths(*kind).is_ok_and(|paths| manager_artifact_exists(&paths)) + }) + .collect::>(); + select_detected_manager(&installed) +} + +pub(super) fn select_detected_manager( + installed: &[ServiceManagerKind], +) -> Result { + // Automatic mode is safe only when one installed backend is unambiguous + match installed { + [kind] => Ok(*kind), + [] => bail!( + "no installed UnixNotis user service was found; pass --service-manager explicitly" + ), + _ => { + bail!("multiple UnixNotis user services were found; pass --service-manager explicitly") + } + } +} + +pub(super) fn manager_artifact_exists(paths: &ServiceManagerPaths) -> bool { + // Every manager stores its primary daemon artifact at a stable relative path + let artifact = match paths.kind { + ServiceManagerKind::Systemd => paths.artifact_root.join("unixnotis-daemon.service"), + ServiceManagerKind::Dinit => paths.artifact_root.join("unixnotis-daemon"), + ServiceManagerKind::Runit => paths.artifact_root.join("unixnotis-daemon"), + ServiceManagerKind::S6 => paths.artifact_root.join("sv").join("unixnotis-daemon"), + }; + // Symlink metadata avoids following an attacker-controlled artifact target + fs::symlink_metadata(artifact) + .is_ok_and(|metadata| metadata.file_type().is_file() || metadata.file_type().is_dir()) +} diff --git a/crates/noticenterctl/src/session_environment/mod.rs b/crates/noticenterctl/src/session_environment/mod.rs new file mode 100644 index 000000000..962fbaf1e --- /dev/null +++ b/crates/noticenterctl/src/session_environment/mod.rs @@ -0,0 +1,12 @@ +//! Session environment synchronization without generated shell transactions + +mod backends; +mod manager; +mod process; +mod sync; +mod variables; + +pub use sync::sync; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/session_environment/process.rs b/crates/noticenterctl/src/session_environment/process.rs new file mode 100644 index 000000000..bcf6474e9 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/process.rs @@ -0,0 +1,29 @@ +//! Typed service-manager command execution through trusted tool lookup + +use std::process::ExitStatus; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::CommandSpec; + +use crate::system_tools; + +pub(super) fn run(command: &CommandSpec) -> Result { + // Trusted lookup prevents inherited PATH entries from selecting service tools + let mut process = system_tools::command_from_spec(command) + .with_context(|| format!("resolve trusted {} executable", command.display_lossy()))?; + process.status().with_context(|| { + format!( + "run {} for session environment sync", + command.display_lossy() + ) + }) +} + +pub(super) fn require_success(command: &CommandSpec) -> Result<()> { + // Preserve the native exit status in the user-facing failure report + let status = run(command)?; + if !status.success() { + bail!("{} exited with status {status}", command.display_lossy()); + } + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/sync.rs b/crates/noticenterctl/src/session_environment/sync.rs new file mode 100644 index 000000000..12fcece4c --- /dev/null +++ b/crates/noticenterctl/src/session_environment/sync.rs @@ -0,0 +1,25 @@ +//! Session validation, manager selection, and backend dispatch + +use std::env; + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; + +use crate::cli::DoctorServiceManagerArg; + +use super::backends::{sync_dinit, sync_runit, sync_s6, sync_systemd}; +use super::manager::select_manager; +use super::variables::validate_session_environment; + +pub fn sync(requested: DoctorServiceManagerArg) -> Result<()> { + // Reject detached launches before resolving or mutating service state + validate_session_environment(|name| env::var_os(name))?; + let manager = select_manager(requested)?; + // Each backend owns its native restart and environment publication contract + match manager.kind { + ServiceManagerKind::Systemd => sync_systemd(), + ServiceManagerKind::Dinit => sync_dinit(), + ServiceManagerKind::Runit => sync_runit(&manager), + ServiceManagerKind::S6 => sync_s6(&manager), + } +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs b/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs new file mode 100644 index 000000000..154aef6c2 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs @@ -0,0 +1,26 @@ +use std::fs; + +use super::super::super::backends::sync_dinit; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn dinit_sync_tolerates_an_unstarted_restart_before_starting_service() { + let tools = TempToolDir::new("dinit-sync"); + let log = tools.path().join("commands.log"); + tools.write_executable( + "dinitctl", + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nif [ \"$2\" = \"restart\" ]; then exit 1; fi\nexit 0\n", + log.display() + ), + ); + let _tools = use_fake_tool_bin(tools.path()); + + sync_dinit().expect("synchronize dinit environment"); + + let calls = fs::read_to_string(log).expect("read dinit command log"); + assert!(calls.contains("--user setenv")); + assert!(calls.contains("--user restart --ignore-unstarted unixnotis-daemon")); + assert!(calls.contains("--user start unixnotis-daemon")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs new file mode 100644 index 000000000..0e64eb48d --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs @@ -0,0 +1,13 @@ +use super::super::super::backends::write_envdir; +use super::super::support::TempToolDir; + +#[test] +fn envdir_writer_rejects_a_non_directory_service_anchor() { + let root = TempToolDir::new("envdir-anchor"); + let service = root.write_file("unixnotis-daemon", "not a directory"); + + let error = write_envdir(&service, &root.path().join("env")) + .expect_err("non-directory service must be rejected"); + + assert!(error.to_string().contains("regular service directory")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/mod.rs b/crates/noticenterctl/src/session_environment/tests/backends/mod.rs new file mode 100644 index 000000000..f8f431684 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/mod.rs @@ -0,0 +1,5 @@ +mod dinit; +mod envdir; +mod runit; +mod s6; +mod systemd; diff --git a/crates/noticenterctl/src/session_environment/tests/backends/runit.rs b/crates/noticenterctl/src/session_environment/tests/backends/runit.rs new file mode 100644 index 000000000..e97a81f38 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/runit.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::super::backends::sync_runit; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn runit_sync_writes_private_envdir_files_and_restarts_service() { + let tools = TempToolDir::new("runit-sync"); + tools.write_executable("sv", "#!/bin/sh\nexit 0\n"); + let service = tools.create_dir("services/unixnotis-daemon"); + let manager = ServiceManagerPaths { + kind: ServiceManagerKind::Runit, + artifact_root: service.parent().expect("service parent").to_path_buf(), + live_root: None, + }; + let _tools = use_fake_tool_bin(tools.path()); + + sync_runit(&manager).expect("synchronize runit environment"); + + let environment = service.join("env/WAYLAND_DISPLAY"); + assert!(environment.is_file()); + assert_eq!( + fs::metadata(environment) + .expect("environment metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/s6.rs b/crates/noticenterctl/src/session_environment/tests/backends/s6.rs new file mode 100644 index 000000000..af38e1fc0 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/s6.rs @@ -0,0 +1,25 @@ +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::super::backends::sync_s6; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn s6_sync_writes_envdir_files_and_addresses_the_resolved_live_tree() { + let tools = TempToolDir::new("s6-sync"); + for name in ["s6-rc", "s6-svc"] { + tools.write_executable(name, "#!/bin/sh\nexit 0\n"); + } + let service = tools.create_dir("s6/sv/unixnotis-daemon"); + let live = tools.create_dir("live"); + let manager = ServiceManagerPaths { + kind: ServiceManagerKind::S6, + artifact_root: tools.path().join("s6"), + live_root: Some(live), + }; + let _tools = use_fake_tool_bin(tools.path()); + + sync_s6(&manager).expect("synchronize s6 environment"); + + assert!(service.join("env/XDG_RUNTIME_DIR").is_file()); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs new file mode 100644 index 000000000..ae7e8716f --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs @@ -0,0 +1,27 @@ +use std::fs; + +use super::super::super::backends::sync_systemd; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn systemd_sync_runs_environment_import_and_restart_commands() { + let tools = TempToolDir::new("systemd-sync"); + let log = tools.path().join("commands.log"); + for name in ["dbus-update-activation-environment", "systemctl"] { + tools.write_executable( + name, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + } + let _tools = use_fake_tool_bin(tools.path()); + + sync_systemd().expect("synchronize systemd environment"); + + let calls = fs::read_to_string(log).expect("read systemd command log"); + assert!(calls.contains("--user import-environment")); + assert!(calls.contains("--user --no-block restart unixnotis-daemon.service")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/manager.rs b/crates/noticenterctl/src/session_environment/tests/manager.rs new file mode 100644 index 000000000..2978c8056 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/manager.rs @@ -0,0 +1,50 @@ +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::manager::{manager_artifact_exists, select_detected_manager}; +use super::support::TempToolDir; + +#[test] +fn automatic_manager_selection_accepts_exactly_one_installed_service() { + assert_eq!( + select_detected_manager(&[ServiceManagerKind::Runit]).expect("one manager"), + ServiceManagerKind::Runit + ); +} + +#[test] +fn automatic_manager_selection_rejects_no_installed_service() { + let error = select_detected_manager(&[]).expect_err("missing service must be rejected"); + + assert!(error + .to_string() + .contains("no installed UnixNotis user service")); +} + +#[test] +fn automatic_manager_selection_rejects_ambiguous_installed_services() { + assert!( + select_detected_manager(&[ServiceManagerKind::Systemd, ServiceManagerKind::Dinit]).is_err() + ); +} + +#[test] +fn manager_artifact_detection_accepts_only_expected_files_or_directories() { + let root = TempToolDir::new("manager-artifacts"); + let runit = ServiceManagerPaths { + kind: ServiceManagerKind::Runit, + artifact_root: root.path().join("runit"), + live_root: None, + }; + + assert!(!manager_artifact_exists(&runit)); + root.create_dir("runit/unixnotis-daemon"); + assert!(manager_artifact_exists(&runit)); + + let systemd = ServiceManagerPaths { + kind: ServiceManagerKind::Systemd, + artifact_root: root.path().join("systemd"), + live_root: None, + }; + root.write_file("systemd/unixnotis-daemon.service", "[Unit]\n"); + assert!(manager_artifact_exists(&systemd)); +} diff --git a/crates/noticenterctl/src/session_environment/tests/mod.rs b/crates/noticenterctl/src/session_environment/tests/mod.rs new file mode 100644 index 000000000..8b9eafb87 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/mod.rs @@ -0,0 +1,6 @@ +mod backends; +mod manager; +mod process; +mod support; +mod sync; +mod variables; diff --git a/crates/noticenterctl/src/session_environment/tests/process.rs b/crates/noticenterctl/src/session_environment/tests/process.rs new file mode 100644 index 000000000..31a929701 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/process.rs @@ -0,0 +1,37 @@ +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn run_executes_a_resolved_direct_service_command() { + let tools = TempToolDir::new("process-success"); + tools.write_executable("service-tool", "#!/bin/sh\nexit 0\n"); + let _tools = use_fake_tool_bin(tools.path()); + + let status = run(&CommandSpec::direct("service-tool", ["literal|argument"])) + .expect("run direct service command"); + + assert!(status.success()); +} + +#[test] +fn require_success_reports_a_failed_service_command() { + let tools = TempToolDir::new("process-failure"); + tools.write_executable("service-tool", "#!/bin/sh\nexit 23\n"); + let _tools = use_fake_tool_bin(tools.path()); + + let error = require_success(&CommandSpec::direct("service-tool", [] as [&str; 0])) + .expect_err("failed service command must be rejected"); + + assert!(error.to_string().contains("status")); +} + +#[test] +fn run_rejects_shell_service_commands() { + let error = run(&CommandSpec::shell("service-tool | parser")) + .expect_err("service commands must remain direct"); + + assert!(error.to_string().contains("resolve trusted")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/support.rs b/crates/noticenterctl/src/session_environment/tests/support.rs new file mode 100644 index 000000000..b6ca84d8c --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/support.rs @@ -0,0 +1,58 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) struct TempToolDir { + path: PathBuf, +} + +impl TempToolDir { + pub(super) fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-session-environment-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create temporary tool directory"); + Self { path } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) fn create_dir(&self, relative: impl AsRef) -> PathBuf { + let path = self.path.join(relative); + fs::create_dir_all(&path).expect("create temporary directory"); + path + } + + pub(super) fn write_file(&self, relative: impl AsRef, contents: &str) -> PathBuf { + let path = self.path.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create temporary file parent"); + } + fs::write(&path, contents).expect("write temporary file"); + path + } + + pub(super) fn write_executable(&self, name: &str, contents: &str) { + let path = self.path.join(name); + fs::write(&path, contents).expect("write temporary tool"); + let mut permissions = fs::metadata(&path) + .expect("read temporary tool metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make temporary tool executable"); + } +} + +impl Drop for TempToolDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/crates/noticenterctl/src/session_environment/tests/sync.rs b/crates/noticenterctl/src/session_environment/tests/sync.rs new file mode 100644 index 000000000..514f25fc8 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/sync.rs @@ -0,0 +1,10 @@ +use super::super::sync; +use crate::cli::DoctorServiceManagerArg; + +#[test] +fn top_level_sync_rejects_manual_service_management() { + let error = sync(DoctorServiceManagerArg::Manual) + .expect_err("manual service management cannot be synchronized"); + + assert!(!error.to_string().is_empty()); +} diff --git a/crates/noticenterctl/src/session_environment/tests/variables.rs b/crates/noticenterctl/src/session_environment/tests/variables.rs new file mode 100644 index 000000000..e63151c8d --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/variables.rs @@ -0,0 +1,39 @@ +use std::ffi::OsString; + +use super::super::variables::{missing_session_variables, validate_session_environment}; + +#[test] +fn session_environment_reports_empty_required_values_as_missing() { + let missing = missing_session_variables(|name| match name { + "WAYLAND_DISPLAY" => Some(OsString::from("wayland-1")), + "XDG_RUNTIME_DIR" => Some(OsString::new()), + _ => None, + }); + + assert_eq!(missing, vec!["XDG_RUNTIME_DIR"]); +} + +#[test] +fn session_environment_reports_every_absent_required_value() { + let missing = missing_session_variables(|_| None); + + assert_eq!(missing, vec!["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]); +} + +#[test] +fn complete_session_environment_passes_validation() { + let missing = missing_session_variables(|_| Some(OsString::from("present"))); + + assert!(missing.is_empty()); + validate_session_environment(|_| Some(OsString::from("present"))) + .expect("complete session environment"); +} + +#[test] +fn missing_session_environment_returns_an_actionable_error() { + let error = validate_session_environment(|_| None) + .expect_err("missing session values must be rejected"); + + assert!(error.to_string().contains("WAYLAND_DISPLAY")); + assert!(error.to_string().contains("XDG_RUNTIME_DIR")); +} diff --git a/crates/noticenterctl/src/session_environment/variables.rs b/crates/noticenterctl/src/session_environment/variables.rs new file mode 100644 index 000000000..f033f73a0 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/variables.rs @@ -0,0 +1,40 @@ +//! Session variables shared by service-manager backends + +use std::ffi::OsString; + +use anyhow::{bail, Result}; + +pub(super) const IMPORT_VARS: [&str; 8] = [ + "WAYLAND_DISPLAY", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", + "XDG_SESSION_DESKTOP", + "DISPLAY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "PATH", +]; + +pub(super) fn validate_session_environment( + get_var: impl FnMut(&str) -> Option, +) -> Result<()> { + let missing = missing_session_variables(get_var); + // Both values identify the compositor session and its private runtime root + if !missing.is_empty() { + bail!( + "missing session variables: {}; run from the compositor session", + missing.join(", ") + ); + } + Ok(()) +} + +pub(super) fn missing_session_variables( + mut get_var: impl FnMut(&str) -> Option, +) -> Vec<&'static str> { + // Empty variables are equivalent to absent variables for process launches + ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"] + .into_iter() + .filter(|name| get_var(name).is_none_or(|value| value.is_empty())) + .collect() +} diff --git a/crates/unixnotis-core/src/service_manager/envdir.rs b/crates/unixnotis-core/src/service_manager/envdir.rs new file mode 100644 index 000000000..6b5690107 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/envdir.rs @@ -0,0 +1,18 @@ +//! Shared envdir value encoding for runit and s6 service environments + +/// Convert one optional environment value to chpst/s6-envdir file contents +#[must_use] +pub fn envdir_file_contents(value: Option<&str>) -> String { + value.map_or_else(String::new, |value| { + let first_line = value + .split(['\0', '\n']) + .next() + .unwrap_or_default() + .trim_end_matches([' ', '\t']); + format!("{first_line}\n") + }) +} + +#[cfg(test)] +#[path = "tests/envdir.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/service_manager/mod.rs b/crates/unixnotis-core/src/service_manager/mod.rs index 22e29d345..8773e1511 100644 --- a/crates/unixnotis-core/src/service_manager/mod.rs +++ b/crates/unixnotis-core/src/service_manager/mod.rs @@ -1,5 +1,6 @@ //! Shared service-manager identity and user-path resolution +mod envdir; mod kind; mod paths; @@ -11,3 +12,4 @@ pub use paths::{ #[cfg(test)] mod tests; +pub use envdir::envdir_file_contents; diff --git a/crates/unixnotis-core/src/service_manager/tests/envdir.rs b/crates/unixnotis-core/src/service_manager/tests/envdir.rs new file mode 100644 index 000000000..61fdc4b73 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/tests/envdir.rs @@ -0,0 +1,15 @@ +use super::super::envdir_file_contents; + +#[test] +fn envdir_contents_keep_only_the_trimmed_first_line() { + assert_eq!( + envdir_file_contents(Some("wayland-1 \nignored")), + "wayland-1\n" + ); + assert_eq!(envdir_file_contents(Some("value\0ignored")), "value\n"); +} + +#[test] +fn missing_envdir_value_creates_an_empty_unset_marker() { + assert_eq!(envdir_file_contents(None), ""); +} From 7e8b8dbfbc4a0cad8b85ce51650db7a4341720c9 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:04:52 -0500 Subject: [PATCH 026/275] refactor(installer): use typed service commands Summary: use typed service commands. Scope: installer. --- .../src/session_environment/backends/mod.rs | 4 +- .../tests/backends/envdir.rs | 2 +- .../actions/config/tests/default_template.rs | 6 +- crates/unixnotis-installer/src/main.rs | 1 - .../src/service_manager/backends/runit.rs | 29 +------ .../src/service_manager/backends/s6.rs | 36 ++------- .../service_manager/backends/tests/dinit.rs | 15 +++- .../service_manager/backends/tests/runit.rs | 21 ++--- .../src/service_manager/backends/tests/s6.rs | 19 ++--- .../src/service_manager/contract/command.rs | 43 ++++++---- .../src/service_manager/contract/mod.rs | 5 +- .../src/service_manager/contract/shell.rs | 79 +------------------ .../service_manager/contract/tests/command.rs | 10 --- .../service_manager/contract/tests/shell.rs | 39 +-------- 14 files changed, 67 insertions(+), 242 deletions(-) diff --git a/crates/noticenterctl/src/session_environment/backends/mod.rs b/crates/noticenterctl/src/session_environment/backends/mod.rs index bab4ad0f0..77eeb15df 100644 --- a/crates/noticenterctl/src/session_environment/backends/mod.rs +++ b/crates/noticenterctl/src/session_environment/backends/mod.rs @@ -1,12 +1,10 @@ mod dinit; -mod envdir; +pub(super) mod envdir; mod runit; mod s6; mod systemd; pub(super) use dinit::sync_dinit; -#[cfg(test)] -pub(super) use envdir::write_envdir; pub(super) use runit::sync_runit; pub(super) use s6::sync_s6; pub(super) use systemd::sync_systemd; diff --git a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs index 0e64eb48d..b82495689 100644 --- a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs +++ b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs @@ -1,4 +1,4 @@ -use super::super::super::backends::write_envdir; +use super::super::super::backends::envdir::write_envdir; use super::super::support::TempToolDir; #[test] diff --git a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs index 59ad514db..a3d114286 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs @@ -32,9 +32,9 @@ fn default_config_template_uses_shipped_night_scripts() { // The default config stays functional while backend logic lives in editable scripts assert!(night_block.contains("enabled = true")); - assert!(night_block.contains("state_cmd = \"scripts/unixnotis-blue-light-state\"")); - assert!(night_block.contains("on_cmd = \"scripts/unixnotis-blue-light-on\"")); - assert!(night_block.contains("off_cmd = \"scripts/unixnotis-blue-light-off\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-state\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-on\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-off\"")); assert!(!night_block.contains("gammastep")); assert!(!night_block.contains("hyprsunset")); assert!(!night_block.contains("wlsunset")); diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 40e1f82ee..53404fa79 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -10,7 +10,6 @@ clippy::option_if_let_else, clippy::redundant_else, clippy::ref_option, - clippy::similar_names, clippy::too_many_lines, clippy::unnecessary_wraps, reason = "reviewed installer state-machine, backend, and TUI boundaries keep explicit control flow for auditable lifecycle behavior" diff --git a/crates/unixnotis-installer/src/service_manager/backends/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/runit.rs index 7d7f3aab8..973356709 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/runit.rs @@ -4,9 +4,8 @@ use std::path::{Path, PathBuf}; use crate::system_tools; use super::super::contract::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, CommandSpec, ReadinessIssue, ServiceArtifact, - ServiceArtifactKind, ServiceProbe, MANAGED_DIRECTORY_MARKER, + envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, + ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceProbe, MANAGED_DIRECTORY_MARKER, }; // Runit service directories use the service name directly under the supervision root @@ -120,28 +119,8 @@ pub fn stop_for_reinstall_command(artifact_root: &Path) -> Option { Some(sv_command("stop", artifact_root)) } -pub fn hyprland_startup_commands(artifact_root: &Path, import_vars: &[&str]) -> Vec { - let service = service_dir(artifact_root); - let env_dir = service.join(ENV_DIR); - // Hyprland needs one line, so join shell steps with semicolons instead of newlines - // The envdir checks mirror Rust-side symlink refusal before shell redirection runs - let mut steps = envdir_sync_prelude(&env_dir); - for var in import_vars - .iter() - .copied() - .filter(|name| is_runit_envdir_name(name)) - { - // mktemp writes a fresh file, and mv replaces the env file path without appending - steps.push(render_envdir_shell_update(var)); - } - steps.push(format!( - "sv restart {} || sv start {}", - shell_quote_path(&service), - shell_quote_path(&service) - )); - // Values are read from the live session at runtime, never embedded in config text - let script = steps.join("; "); - vec![format!("sh -lc {}", shell_quote(&script))] +pub fn hyprland_startup_commands(_artifact_root: &Path, _import_vars: &[&str]) -> Vec { + vec!["noticenterctl sync-session-environment --service-manager runit".to_string()] } pub const fn environment_sync_commands() -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/s6.rs index 4c0c94276..acff45889 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/s6.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use crate::system_tools; use super::super::contract::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, - ServiceArtifactKind, ServiceArtifactRefresh, ServiceProbe, MANAGED_DIRECTORY_MARKER, + envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, + ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, + ServiceArtifactRefresh, ServiceProbe, MANAGED_DIRECTORY_MARKER, }; pub const SERVICE_NAME: &str = "unixnotis-daemon"; @@ -130,33 +130,11 @@ pub fn stop_for_reinstall_command(live_dir: &Path) -> Option { } pub fn hyprland_startup_commands( - artifact_root: &Path, - live_dir: &Path, - import_vars: &[&str], + _artifact_root: &Path, + _live_dir: &Path, + _import_vars: &[&str], ) -> Vec { - let env_dir = service_dir(artifact_root).join(ENV_DIR); - let live_service = live_service_dir(live_dir); - // Hyprland uses one exec-once line, so every shell step must be fail-closed - let mut steps = envdir_sync_prelude(&env_dir); - for var in import_vars - .iter() - .copied() - .filter(|name| is_s6_envdir_name(name)) - { - // Missing session vars intentionally become empty envdir files - steps.push(render_envdir_shell_update(var)); - } - steps.push(format!( - "s6-rc -l {} -u change {} || exit 1", - shell_quote_path(live_dir), - shell_quote(SERVICE_NAME) - )); - steps.push(format!( - "s6-svc -r {} || :", - shell_quote_path(&live_service) - )); - let script = steps.join("; "); - vec![format!("sh -lc {}", shell_quote(&script))] + vec!["noticenterctl sync-session-environment --service-manager s6".to_string()] } pub const fn environment_sync_commands() -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs index 1802081c1..3211bc7c1 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs @@ -206,14 +206,21 @@ fn dinit_backend_environment_sync_uses_setenv() { ] ); assert_eq!( - commands[0].envs(), - &[ - ("WAYLAND_DISPLAY".to_string(), "wayland-1".to_string()), - ("XDG_RUNTIME_DIR".to_string(), "/run/user/1000".to_string()), + commands[0] + .envs() + .iter() + .map(|(name, value)| ( + name.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + )) + .collect::>(), + vec![ ( "DBUS_SESSION_BUS_ADDRESS".to_string(), "unix:path=/tmp/unixnotis-bus".to_string(), ), + ("WAYLAND_DISPLAY".to_string(), "wayland-1".to_string()), + ("XDG_RUNTIME_DIR".to_string(), "/run/user/1000".to_string()), ] ); } diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs index 9163c0dc4..d844fbaf8 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs @@ -243,22 +243,11 @@ fn runit_backend_hyprland_startup_lines_update_envdir_and_restart() { let commands = manager.hyprland_startup_commands(&vars); assert_eq!(commands.len(), 1); - assert!(commands[0].starts_with("sh -lc ")); - assert!(!commands[0].contains('\n')); - assert!(commands[0].contains("umask 077")); - assert!(commands[0].contains("[ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("mkdir -p \"$envdir\" || exit 1")); - assert!(commands[0].contains("[ -d \"$envdir\" ] && [ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("/tmp/service root/unixnotis-daemon/env")); - assert!(commands[0].contains("mktemp \"$envdir/.WAYLAND_DISPLAY.XXXXXX\"")); - assert!(commands[0].contains("printenv WAYLAND_DISPLAY > \"$tmp\" || : > \"$tmp\"")); - assert!(commands[0].contains("chmod 600 \"$tmp\"")); - assert!(commands[0].contains("mv -f \"$tmp\" \"$envdir/WAYLAND_DISPLAY\"")); - assert!(commands[0].contains("\"$envdir/WAYLAND_DISPLAY\"")); - assert!(!commands[0].contains(".PATH.XXXXXX")); - assert!(!commands[0].contains("$envdir/PATH")); - assert!(commands[0].contains("sv restart")); - assert!(commands[0].contains("|| sv start")); + assert_eq!( + commands[0], + "noticenterctl sync-session-environment --service-manager runit" + ); + assert!(!commands[0].contains("sh -lc")); } #[test] diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs index 5a878e308..adaa6bdb2 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs @@ -194,21 +194,12 @@ fn s6_backend_hyprland_startup_lines_update_envdir_and_start_service() { let commands = manager.hyprland_startup_commands(&vars); - // Hyprland receives one shell line because it does not manage multi-step service hooks assert_eq!(commands.len(), 1); - assert!(commands[0].starts_with("sh -lc ")); - assert!(commands[0].contains("[ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("mkdir -p \"$envdir\" || exit 1")); - assert!(commands[0].contains("mktemp \"$envdir/.WAYLAND_DISPLAY.XXXXXX\"")); - assert!(!commands[0].contains(".PATH.XXXXXX")); - assert!(!commands[0].contains("s6-db-reload")); - assert!(!commands[0].contains("s6-rc-compile")); - assert!(commands[0].contains("s6-rc -l ")); - assert!(commands[0].contains("/run/user/s6 rc")); - assert!(commands[0].contains("-u change")); - assert!(commands[0].contains("unixnotis-daemon")); - assert!(commands[0].contains("s6-svc -r ")); - assert!(commands[0].contains("/run/user/s6 rc/servicedirs/unixnotis-daemon")); + assert_eq!( + commands[0], + "noticenterctl sync-session-environment --service-manager s6" + ); + assert!(!commands[0].contains("sh -lc")); } #[test] diff --git a/crates/unixnotis-installer/src/service_manager/contract/command.rs b/crates/unixnotis-installer/src/service_manager/contract/command.rs index 2fe6c8d40..a65fb378a 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/command.rs @@ -1,15 +1,12 @@ use std::process::{Command, Stdio}; +use unixnotis_core::CommandSpec as ProcessCommandSpec; #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommandSpec { // Human-readable command shown in logs without exposing inherited environment values label: String, - // Executable name stays separate so tests can assert command construction directly - program: String, - // Arguments are stored as data so no shell parsing is involved - pub(in crate::service_manager::contract) args: Vec, - // Env overrides keep sensitive values out of argv while still giving child tools the session - pub(in crate::service_manager::contract) envs: Vec<(String, String)>, + // Shared process spec keeps executable, arguments, and environment structurally separate + command: ProcessCommandSpec, // Some probes are intentionally quiet to avoid corrupting the TUI suppress_stdout: bool, suppress_stderr: bool, @@ -27,9 +24,10 @@ impl CommandSpec { { Self { label: label.into(), - program: program.into(), - args: args.into_iter().map(|arg| arg.to_string()).collect(), - envs: Vec::new(), + command: ProcessCommandSpec::direct( + program.into(), + args.into_iter().map(|arg| arg.to_string()), + ), suppress_stdout: false, suppress_stderr: false, } @@ -41,7 +39,7 @@ impl CommandSpec { value: impl Into, ) -> Self { // Values live in the child environment instead of the process argument list - self.envs.push((name.into(), value.into())); + self.command = self.command.with_env(name.into(), value.into()); self } @@ -57,17 +55,28 @@ impl CommandSpec { } pub fn program(&self) -> &str { - &self.program + self.command + .program() + .and_then(|program| program.to_str()) + .expect("installer service commands always use UTF-8 direct programs") + } + + pub fn args(&self) -> &[std::ffi::OsString] { + self.command.args().unwrap_or_default() + } + + pub fn envs(&self) -> &std::collections::BTreeMap { + self.command + .env() + .expect("installer service commands are always direct") } pub fn to_command(&self) -> std::io::Result { - let mut command = Command::new(super::command_routing::command_program(&self.program)?); + let program = self.program(); + let mut command = Command::new(super::command_routing::command_program(program)?); // CommandSpec never goes through a shell, which keeps service-manager commands predictable - command.args(&self.args); - for (name, value) in &self.envs { - // Only backend-selected variables are added; inherited process env is left alone - command.env(name, value); - } + command.args(self.args()); + command.envs(self.envs()); if self.suppress_stdout { command.stdout(Stdio::null()); } diff --git a/crates/unixnotis-installer/src/service_manager/contract/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/mod.rs index 5a6f0e86d..608d51080 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/mod.rs @@ -25,10 +25,7 @@ pub use command::CommandSpec; pub use probe::ServiceProbe; pub use readiness::ReadinessIssue; pub use refresh::{S6DatabaseRefresh, ServiceArtifactRefresh}; -pub(super) use shell::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, -}; +pub(super) use shell::{envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/service_manager/contract/shell.rs b/crates/unixnotis-installer/src/service_manager/contract/shell.rs index cac22a6c3..537ae3753 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/shell.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/shell.rs @@ -1,41 +1,11 @@ use std::path::Path; -/// Build the shared envdir setup used by Hyprland bootstrap commands -/// -/// Each returned item is a shell fragment. Backends join them into one -/// `sh -lc` line because Hyprland startup entries are single command strings -pub(in crate::service_manager) fn envdir_sync_prelude(env_dir: &Path) -> Vec { - let envdir = shell_quote_path(env_dir); - - vec![ - "umask 077".to_string(), - format!("envdir={envdir}"), - reject_symlinked_envdir(), - create_envdir(), - verify_real_envdir(), - ] -} - -/// Render one envdir file update for a selected environment variable -/// -/// Missing variables intentionally create empty files. Both chpst and -/// s6-envdir treat empty envdir files as an unset request -pub(in crate::service_manager) fn render_envdir_shell_update(name: &str) -> String { - [ - create_envdir_temp_file(name), - write_envdir_temp_file(name), - chmod_envdir_temp_file(), - replace_envdir_file(name), - ] - .join("; ") -} - /// Convert an env value into envdir file contents /// /// Envdir readers only use the first line and trim trailing blanks. Matching /// that behavior before writing avoids keeping stale shell noise pub(in crate::service_manager) fn envdir_file_contents(value: Option<&str>) -> String { - value.map_or_else(String::new, |value| format!("{}\n", envdir_value(value))) + unixnotis_core::service_manager::envdir_file_contents(value) } /// Return true when a variable name can safely become an envdir file name @@ -45,7 +15,7 @@ pub(in crate::service_manager) fn is_safe_env_name(name: &str) -> bool { return false; }; - // Keep names in ordinary shell-variable form so generated shell stays simple + // Keep names in ordinary environment-variable form so they remain safe file names (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } @@ -72,48 +42,3 @@ pub(in crate::service_manager) fn shell_quote(raw: &str) -> String { quoted.push('\''); quoted } - -fn reject_symlinked_envdir() -> String { - r#"[ ! -L "$envdir" ] || exit 1"#.to_string() -} - -fn create_envdir() -> String { - r#"mkdir -p "$envdir" || exit 1"#.to_string() -} - -fn verify_real_envdir() -> String { - r#"[ -d "$envdir" ] && [ ! -L "$envdir" ] || exit 1"#.to_string() -} - -fn create_envdir_temp_file(name: &str) -> String { - // mktemp creates a fresh path under the already-verified envdir - // The hidden prefix keeps partial writes out of normal envdir reads - format!("tmp=$(mktemp \"$envdir/.{name}.XXXXXX\") || exit") -} - -fn write_envdir_temp_file(name: &str) -> String { - // printenv failure means the variable is absent, not that sync should fail - // Empty envdir files intentionally unset stale values for chpst and s6-envdir - format!("printenv {name} > \"$tmp\" || : > \"$tmp\"") -} - -fn chmod_envdir_temp_file() -> String { - // Keep session paths private even when the user's umask is permissive - // Cleanup on chmod failure avoids leaving a readable temp file behind - "chmod 600 \"$tmp\" || { rm -f \"$tmp\"; exit 1; }".to_string() -} - -fn replace_envdir_file(name: &str) -> String { - // mv replaces the env file path instead of appending or following shell redirects - // Cleanup on mv failure keeps the envdir from filling with stale temp files - format!("mv -f \"$tmp\" \"$envdir/{name}\" || {{ rm -f \"$tmp\"; exit 1; }}") -} - -fn envdir_value(value: &str) -> String { - value - .split(['\0', '\n']) - .next() - .unwrap_or_default() - .trim_end_matches([' ', '\t']) - .to_string() -} diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs index ca3558ff3..1b07bc09a 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs @@ -5,16 +5,6 @@ use super::super::command_routing::use_fake_command_bin; use crate::service_manager::CommandSpec; use crate::test_support::fs::write_executable; -impl CommandSpec { - pub(crate) fn args(&self) -> &[String] { - &self.args - } - - pub(crate) fn envs(&self) -> &[(String, String)] { - &self.envs - } -} - struct TempDirGuard { path: std::path::PathBuf, } diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs index bf5b73341..d0250bdf5 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs @@ -1,41 +1,4 @@ -use std::path::Path; - -use super::super::shell::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, -}; - -#[test] -fn envdir_sync_prelude_renders_readable_guard_steps() { - let steps = envdir_sync_prelude(Path::new("/tmp/service root/env")); - - assert_eq!( - steps, - [ - "umask 077", - "envdir='/tmp/service root/env'", - r#"[ ! -L "$envdir" ] || exit 1"#, - r#"mkdir -p "$envdir" || exit 1"#, - r#"[ -d "$envdir" ] && [ ! -L "$envdir" ] || exit 1"#, - ] - ); -} - -#[test] -fn envdir_shell_update_writes_temp_file_before_replacing_target() { - let update = render_envdir_shell_update("WAYLAND_DISPLAY"); - - // The order matters: create temp, write value, lock permissions, then atomically replace - assert_eq!( - update, - concat!( - r#"tmp=$(mktemp "$envdir/.WAYLAND_DISPLAY.XXXXXX") || exit"#, - r#"; printenv WAYLAND_DISPLAY > "$tmp" || : > "$tmp""#, - r#"; chmod 600 "$tmp" || { rm -f "$tmp"; exit 1; }"#, - r#"; mv -f "$tmp" "$envdir/WAYLAND_DISPLAY" || { rm -f "$tmp"; exit 1; }"# - ) - ); -} +use super::super::shell::{envdir_file_contents, is_safe_env_name, shell_quote}; #[test] fn envdir_file_contents_match_envdir_first_line_semantics() { From bac6324b46ca6cd1cba85832867f96741175b8c9 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:06:10 -0500 Subject: [PATCH 027/275] refactor(stats): separate builtin readers and detection Summary: separate builtin readers and detection. Scope: stats. --- .../src/ui/widgets/stats/build.rs | 4 +- .../src/ui/widgets/stats/builtin/detect.rs | 47 +++++ .../src/ui/widgets/stats/builtin/mod.rs | 11 ++ .../src/ui/widgets/stats/builtin/model.rs | 76 ++++++++ .../readers/battery.rs} | 4 +- .../readers/cpu.rs} | 2 +- .../readers/load.rs} | 2 +- .../readers/memory.rs} | 2 +- .../ui/widgets/stats/builtin/readers/mod.rs | 54 ++++++ .../readers/network.rs} | 19 +- .../src/ui/widgets/stats/mod.rs | 6 +- .../src/ui/widgets/stats/stats_builtin.rs | 177 ------------------ .../src/ui/widgets/stats/{css.rs => style.rs} | 0 .../src/ui/widgets/stats/tests/builtin.rs | 4 +- .../src/ui/widgets/stats/tests/grid.rs | 3 +- 15 files changed, 213 insertions(+), 198 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs rename crates/unixnotis-center/src/ui/widgets/stats/{stats_builtin_battery.rs => builtin/readers/battery.rs} (95%) rename crates/unixnotis-center/src/ui/widgets/stats/{stats_builtin_cpu.rs => builtin/readers/cpu.rs} (88%) rename crates/unixnotis-center/src/ui/widgets/stats/{stats_builtin_load.rs => builtin/readers/load.rs} (82%) rename crates/unixnotis-center/src/ui/widgets/stats/{stats_builtin_memory.rs => builtin/readers/memory.rs} (91%) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs rename crates/unixnotis-center/src/ui/widgets/stats/{stats_builtin_network.rs => builtin/readers/network.rs} (90%) delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs rename crates/unixnotis-center/src/ui/widgets/stats/{css.rs => style.rs} (100%) diff --git a/crates/unixnotis-center/src/ui/widgets/stats/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/build.rs index a679bb572..e445399eb 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/build.rs @@ -5,8 +5,8 @@ use gtk::Align; use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; use super::super::icon_image::image_from_icon_config; -use super::css::stat_kind_css_class; -use super::{collect_builtin_groups, stats_builtin::BuiltinStat, StatGrid, StatItem}; +use super::style::stat_kind_css_class; +use super::{builtin::BuiltinStat, collect_builtin_groups, StatGrid, StatItem}; impl StatGrid { pub fn new( diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs new file mode 100644 index 000000000..c5aad9407 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs @@ -0,0 +1,47 @@ +//! Built-in statistic source detection + +use super::model::{BuiltinStat, BuiltinStatKind}; +use super::readers::extract_iface; + +impl BuiltinStat { + pub(in crate::ui::widgets::stats) fn from_command(cmd: &str) -> Option { + let trimmed = cmd.trim(); + if let Some(rest) = trimmed.strip_prefix("builtin:") { + // Explicit builtin tags bypass filesystem path sniffing + return Self::from_builtin_tag(rest); + } + if trimmed.contains("/proc/stat") { + return Some(Self::new(BuiltinStatKind::Cpu)); + } + if trimmed.contains("/proc/meminfo") { + return Some(Self::new(BuiltinStatKind::Memory)); + } + if trimmed.contains("/proc/loadavg") { + return Some(Self::new(BuiltinStatKind::Load)); + } + if trimmed.contains("/sys/class/power_supply") { + return Some(Self::new(BuiltinStatKind::Battery)); + } + if trimmed.contains("/sys/class/net") && trimmed.contains("statistics") { + let iface = extract_iface(trimmed); + return Some(Self::new(BuiltinStatKind::Network { iface })); + } + None + } + + fn from_builtin_tag(tag: &str) -> Option { + let mut parts = tag.split(':'); + let kind = parts.next()?.trim(); + match kind { + "cpu" => Some(Self::new(BuiltinStatKind::Cpu)), + "mem" | "memory" => Some(Self::new(BuiltinStatKind::Memory)), + "load" => Some(Self::new(BuiltinStatKind::Load)), + "battery" => Some(Self::new(BuiltinStatKind::Battery)), + "net" => { + let iface = parts.next().map(std::string::ToString::to_string); + Some(Self::new(BuiltinStatKind::Network { iface })) + } + _ => None, + } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs new file mode 100644 index 000000000..ac680eb06 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs @@ -0,0 +1,11 @@ +//! Built-in statistic sources and refresh infrastructure + +mod detect; +mod model; +mod readers; + +pub(super) use model::{BuiltinStat, BuiltinStatKey}; + +#[cfg(test)] +#[path = "../tests/builtin.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs new file mode 100644 index 000000000..337866d09 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs @@ -0,0 +1,76 @@ +//! Built-in statistic identity and retained sample state + +use std::time::Instant; + +#[derive(Clone, Debug)] +pub(in crate::ui::widgets::stats) struct BuiltinStat { + pub(super) kind: BuiltinStatKind, + pub(super) state: BuiltinState, +} + +#[derive(Clone, Debug)] +pub(super) enum BuiltinStatKind { + Cpu, + Memory, + Load, + Battery, + Network { iface: Option }, +} + +#[derive(Clone, Debug)] +pub(super) enum BuiltinState { + None, + Cpu { + last_total: u64, + last_idle: u64, + }, + Network { + last_rx: u64, + last_tx: u64, + last_at: Instant, + }, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::ui::widgets::stats) enum BuiltinStatKey { + // Every CPU card reads the same procfs source + Cpu, + // Every memory card reads the same procfs source + Memory, + // Load average is shared across cards too + Load, + // Battery cards share one aggregated battery snapshot + Battery, + // Network cards only share reads when they target the same interface + Network { iface: Option }, +} + +impl BuiltinStat { + pub(super) fn new(kind: BuiltinStatKind) -> Self { + let state = match kind { + BuiltinStatKind::Cpu => BuiltinState::Cpu { + last_total: 0, + last_idle: 0, + }, + BuiltinStatKind::Network { .. } => BuiltinState::Network { + last_rx: 0, + last_tx: 0, + last_at: Instant::now(), + }, + _ => BuiltinState::None, + }; + Self { kind, state } + } + + pub(in crate::ui::widgets::stats) fn key(&self) -> BuiltinStatKey { + match &self.kind { + BuiltinStatKind::Cpu => BuiltinStatKey::Cpu, + BuiltinStatKind::Memory => BuiltinStatKey::Memory, + BuiltinStatKind::Load => BuiltinStatKey::Load, + BuiltinStatKind::Battery => BuiltinStatKey::Battery, + BuiltinStatKind::Network { iface } => BuiltinStatKey::Network { + iface: iface.clone(), + }, + } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs similarity index 95% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs index cebfaf7d1..bbd3bf28a 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs @@ -6,11 +6,11 @@ use std::fs; use std::path::Path; -pub(super) fn read_battery() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_battery() -> Option { read_battery_from(Path::new("/sys/class/power_supply")) } -pub(super) fn read_battery_from(root: &Path) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_battery_from(root: &Path) -> Option { let entries = fs::read_dir(root).ok()?; let mut energy_now_total = 0u64; let mut energy_full_total = 0u64; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs index c081e5e7b..d4def578a 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs @@ -4,7 +4,7 @@ use std::fs; -pub(super) fn read_cpu_sample() -> Option<(u64, u64)> { +pub(in crate::ui::widgets::stats::builtin) fn read_cpu_sample() -> Option<(u64, u64)> { let contents = fs::read_to_string("/proc/stat").ok()?; let line = contents.lines().find(|line| line.starts_with("cpu "))?; let mut parts = line.split_whitespace(); diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs similarity index 82% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs index a6e01c7bb..228298a14 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs @@ -4,7 +4,7 @@ use std::fs; -pub(super) fn read_loadavg() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_loadavg() -> Option { let contents = fs::read_to_string("/proc/loadavg").ok()?; let mut parts = contents.split_whitespace(); let one = parts.next()?; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs similarity index 91% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs index 46e7ef00e..7bc05a2e8 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs @@ -4,7 +4,7 @@ use std::fs; -pub(super) fn read_memory() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_memory() -> Option { let contents = fs::read_to_string("/proc/meminfo").ok()?; let mut total_kb = None; let mut avail_kb = None; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs new file mode 100644 index 000000000..0990b9732 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs @@ -0,0 +1,54 @@ +//! Procfs and sysfs readers for built-in statistic cards + +pub(super) mod battery; +mod cpu; +mod load; +mod memory; +pub(super) mod network; + +pub(super) use battery::read_battery; +pub(super) use cpu::read_cpu_sample; +pub(super) use load::read_loadavg; +pub(super) use memory::read_memory; +pub(super) use network::{extract_iface, read_network}; + +use super::model::{BuiltinStat, BuiltinStatKind, BuiltinState}; + +impl BuiltinStat { + pub(in crate::ui::widgets::stats) fn read(&mut self) -> Option { + match &mut self.kind { + BuiltinStatKind::Cpu => self.read_cpu(), + BuiltinStatKind::Memory => read_memory(), + BuiltinStatKind::Load => read_loadavg(), + BuiltinStatKind::Battery => read_battery(), + BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), + } + } + + fn read_cpu(&mut self) -> Option { + let (total, idle) = read_cpu_sample()?; + let usage = match &mut self.state { + BuiltinState::Cpu { + last_total, + last_idle, + } => { + let usage = if *last_total > 0 && total > *last_total { + // Delta-based usage avoids spikes when the counter wraps + let delta_total = total - *last_total; + let delta_idle = idle.saturating_sub(*last_idle); + 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 + } else if total > 0 { + // First read falls back to absolute usage + 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 + } else { + 0.0 + }; + *last_total = total; + *last_idle = idle; + usage + } + _ => 0.0, + }; + Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs similarity index 90% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs index 4e93ca899..657ab33de 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs @@ -6,9 +6,12 @@ use std::fs; use std::path::Path; use std::time::Instant; -use super::BuiltinState; +use super::super::model::BuiltinState; -pub(super) fn read_network(state: &mut BuiltinState, iface: &mut Option) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_network( + state: &mut BuiltinState, + iface: &mut Option, +) -> Option { if iface.is_none() { // Choose a stable default interface once to avoid flicker between refreshes *iface = pick_default_iface(); @@ -83,14 +86,16 @@ fn pick_default_iface() -> Option { } #[derive(Debug, Clone)] -pub(super) struct IfaceCandidate { +pub(in crate::ui::widgets::stats::builtin) struct IfaceCandidate { // Interface name as reported by sysfs - pub(super) name: String, + pub(in crate::ui::widgets::stats::builtin) name: String, // Raw operstate contents ("up", "down", etc), kept for ranking - pub(super) operstate: String, + pub(in crate::ui::widgets::stats::builtin) operstate: String, } -pub(super) fn pick_default_iface_from(candidates: &[IfaceCandidate]) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn pick_default_iface_from( + candidates: &[IfaceCandidate], +) -> Option { // Filter invalid entries early to keep ranking logic simple let mut ranked: Vec<&IfaceCandidate> = candidates .iter() @@ -166,7 +171,7 @@ fn format_rate(rate: f64) -> String { } } -pub(super) fn extract_iface(cmd: &str) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn extract_iface(cmd: &str) -> Option { let marker = "/sys/class/net/"; let start = cmd.find(marker)? + marker.len(); let rest = &cmd[start..]; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs index 44a91cc4c..8ec287017 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs @@ -1,19 +1,19 @@ //! Statistic widget module wiring mod build; +mod builtin; mod card; -mod css; mod group; mod state; -mod stats_builtin; +mod style; #[cfg(test)] #[path = "tests/grid.rs"] mod tests; mod worker; +use self::builtin::{BuiltinStat, BuiltinStatKey}; use self::group::{collect_builtin_groups, BuiltinRefreshGroup}; pub use self::state::StatGrid; use self::state::StatItem; use self::state::{apply_cached_value, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome}; -use self::stats_builtin::{BuiltinStat, BuiltinStatKey}; use super::utils::RefreshBackoff; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs deleted file mode 100644 index f0a1e3d03..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! In-process stats readers for common widgets -//! -//! Reads system data from procfs/sysfs to avoid spawning shell commands - -#[path = "stats_builtin_battery.rs"] -mod stats_builtin_battery; -#[path = "stats_builtin_cpu.rs"] -mod stats_builtin_cpu; -#[path = "stats_builtin_load.rs"] -mod stats_builtin_load; -#[path = "stats_builtin_memory.rs"] -mod stats_builtin_memory; -#[path = "stats_builtin_network.rs"] -mod stats_builtin_network; - -use std::time::Instant; - -use stats_builtin_battery::read_battery; -use stats_builtin_cpu::read_cpu_sample; -use stats_builtin_load::read_loadavg; -use stats_builtin_memory::read_memory; -use stats_builtin_network::{extract_iface, read_network}; - -#[derive(Clone, Debug)] -pub(super) struct BuiltinStat { - kind: BuiltinStatKind, - state: BuiltinState, -} - -#[derive(Clone, Debug)] -enum BuiltinStatKind { - Cpu, - Memory, - Load, - Battery, - Network { iface: Option }, -} - -#[derive(Clone, Debug)] -enum BuiltinState { - None, - Cpu { - last_total: u64, - last_idle: u64, - }, - Network { - last_rx: u64, - last_tx: u64, - last_at: Instant, - }, -} - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub(super) enum BuiltinStatKey { - // Every CPU card reads the same procfs source - Cpu, - // Every memory card reads the same procfs source - Memory, - // Load average is shared across cards too - Load, - // Battery cards share one aggregated battery snapshot - Battery, - // Network cards only share reads when they target the same interface - Network { iface: Option }, -} - -impl BuiltinStat { - pub(super) fn from_command(cmd: &str) -> Option { - let trimmed = cmd.trim(); - if let Some(rest) = trimmed.strip_prefix("builtin:") { - // Explicit builtin tags bypass filesystem path sniffing - return Self::from_builtin_tag(rest); - } - if trimmed.contains("/proc/stat") { - return Some(Self::new(BuiltinStatKind::Cpu)); - } - if trimmed.contains("/proc/meminfo") { - return Some(Self::new(BuiltinStatKind::Memory)); - } - if trimmed.contains("/proc/loadavg") { - return Some(Self::new(BuiltinStatKind::Load)); - } - if trimmed.contains("/sys/class/power_supply") { - return Some(Self::new(BuiltinStatKind::Battery)); - } - if trimmed.contains("/sys/class/net") && trimmed.contains("statistics") { - let iface = extract_iface(trimmed); - return Some(Self::new(BuiltinStatKind::Network { iface })); - } - None - } - - pub(super) fn read(&mut self) -> Option { - match &mut self.kind { - BuiltinStatKind::Cpu => self.read_cpu(), - BuiltinStatKind::Memory => read_memory(), - BuiltinStatKind::Load => read_loadavg(), - BuiltinStatKind::Battery => read_battery(), - BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), - } - } - - pub(super) fn key(&self) -> BuiltinStatKey { - match &self.kind { - BuiltinStatKind::Cpu => BuiltinStatKey::Cpu, - BuiltinStatKind::Memory => BuiltinStatKey::Memory, - BuiltinStatKind::Load => BuiltinStatKey::Load, - BuiltinStatKind::Battery => BuiltinStatKey::Battery, - BuiltinStatKind::Network { iface } => BuiltinStatKey::Network { - iface: iface.clone(), - }, - } - } - - fn new(kind: BuiltinStatKind) -> Self { - let state = match kind { - BuiltinStatKind::Cpu => BuiltinState::Cpu { - last_total: 0, - last_idle: 0, - }, - BuiltinStatKind::Network { .. } => BuiltinState::Network { - last_rx: 0, - last_tx: 0, - last_at: Instant::now(), - }, - _ => BuiltinState::None, - }; - Self { kind, state } - } - - fn from_builtin_tag(tag: &str) -> Option { - let mut parts = tag.split(':'); - let kind = parts.next()?.trim(); - match kind { - "cpu" => Some(Self::new(BuiltinStatKind::Cpu)), - "mem" | "memory" => Some(Self::new(BuiltinStatKind::Memory)), - "load" => Some(Self::new(BuiltinStatKind::Load)), - "battery" => Some(Self::new(BuiltinStatKind::Battery)), - "net" => { - let iface = parts.next().map(std::string::ToString::to_string); - Some(Self::new(BuiltinStatKind::Network { iface })) - } - _ => None, - } - } - - fn read_cpu(&mut self) -> Option { - let (total, idle) = read_cpu_sample()?; - let usage = match &mut self.state { - BuiltinState::Cpu { - last_total, - last_idle, - } => { - let usage = if *last_total > 0 && total > *last_total { - // Delta-based usage avoids spikes when the counter wraps - let delta_total = total - *last_total; - let delta_idle = idle.saturating_sub(*last_idle); - 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 - } else if total > 0 { - // First read falls back to absolute usage - 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 - } else { - 0.0 - }; - *last_total = total; - *last_idle = idle; - usage - } - _ => 0.0, - }; - Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) - } -} - -#[cfg(test)] -#[path = "tests/builtin.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/css.rs b/crates/unixnotis-center/src/ui/widgets/stats/style.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/stats/css.rs rename to crates/unixnotis-center/src/ui/widgets/stats/style.rs diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs index fa784a0f0..3140f1f13 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs @@ -1,5 +1,5 @@ -use super::stats_builtin_battery::read_battery_from; -use super::stats_builtin_network::{pick_default_iface_from, IfaceCandidate}; +use super::readers::battery::read_battery_from; +use super::readers::network::{pick_default_iface_from, IfaceCandidate}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs index c4c8be6cb..d97aa532e 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs @@ -1,8 +1,7 @@ //! Stat worker tests use super::{ - stats_builtin::BuiltinStatKey, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, - BuiltinSubmitOutcome, + builtin::BuiltinStatKey, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome, }; #[test] From 18ecb29315bcadb0cb75fcae4c49730881f44c02 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:14:17 -0500 Subject: [PATCH 028/275] refactor(stats): separate grid, card, and worker ownership Summary: separate grid, card, and worker ownership. Scope: stats. --- .../src/ui/widgets/stats/build.rs | 163 ---------- .../src/ui/widgets/stats/builtin/group.rs | 92 ++++++ .../src/ui/widgets/stats/builtin/mod.rs | 8 +- .../widgets/stats/builtin/readers/battery.rs | 2 +- .../ui/widgets/stats/builtin/readers/mod.rs | 4 +- .../widgets/stats/builtin/readers/network.rs | 8 +- .../src/ui/widgets/stats/builtin/worker.rs | 86 ++++++ .../src/ui/widgets/stats/card.rs | 279 ------------------ .../src/ui/widgets/stats/card/build.rs | 90 ++++++ .../src/ui/widgets/stats/card/mod.rs | 9 + .../src/ui/widgets/stats/card/model.rs | 34 +++ .../ui/widgets/stats/card/refresh/builtin.rs | 93 ++++++ .../ui/widgets/stats/card/refresh/command.rs | 74 +++++ .../src/ui/widgets/stats/card/refresh/mod.rs | 114 +++++++ .../ui/widgets/stats/card/refresh/plugin.rs | 82 +++++ .../src/ui/widgets/stats/card/render.rs | 30 ++ .../src/ui/widgets/stats/grid/build.rs | 55 ++++ .../src/ui/widgets/stats/grid/mod.rs | 14 + .../src/ui/widgets/stats/grid/refresh.rs | 27 ++ .../src/ui/widgets/stats/grid/schedule.rs | 22 ++ .../src/ui/widgets/stats/group.rs | 43 --- .../src/ui/widgets/stats/mod.rs | 18 +- .../src/ui/widgets/stats/state.rs | 70 ----- .../src/ui/widgets/stats/style.rs | 4 - .../src/ui/widgets/stats/tests/builtin.rs | 104 ++++--- .../widgets/stats/tests/{css.rs => card.rs} | 6 +- .../src/ui/widgets/stats/tests/grid.rs | 52 +--- .../src/ui/widgets/stats/tests/grouping.rs | 24 ++ .../src/ui/widgets/stats/tests/mod.rs | 8 + .../src/ui/widgets/stats/tests/scheduling.rs | 16 + .../src/ui/widgets/stats/tests/support.rs | 40 +++ .../src/ui/widgets/stats/worker.rs | 141 --------- 32 files changed, 995 insertions(+), 817 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/build.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/build.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/model.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/render.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/group.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/state.rs rename crates/unixnotis-center/src/ui/widgets/stats/tests/{css.rs => card.rs} (69%) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/worker.rs diff --git a/crates/unixnotis-center/src/ui/widgets/stats/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/build.rs deleted file mode 100644 index e445399eb..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/build.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Stat grid and card construction - -use gtk::prelude::*; -use gtk::Align; -use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; - -use super::super::icon_image::image_from_icon_config; -use super::style::stat_kind_css_class; -use super::{builtin::BuiltinStat, collect_builtin_groups, StatGrid, StatItem}; - -impl StatGrid { - pub fn new( - configs: &[StatWidgetConfig], - columns: usize, - icon_resolver: &IconAssetResolver, - ) -> Option { - let mut items = Vec::new(); - for config in configs { - if !config.enabled { - continue; - } - // Preserve config order so layout remains predictable for users - items.push(StatItem::new(config.clone(), icon_resolver)); - } - if items.is_empty() { - // Skip widget creation when all stat entries are disabled - return None; - } - - let root = gtk::FlowBox::new(); - root.add_css_class(hooks::stat_card::GRID); - root.set_selection_mode(gtk::SelectionMode::None); - let columns = flowbox_columns(columns); - root.set_max_children_per_line(columns); - root.set_min_children_per_line(columns); - root.set_row_spacing(8); - root.set_column_spacing(8); - root.set_halign(Align::Fill); - root.set_hexpand(true); - - for item in &items { - // Insert in order so per-widget identity stays stable - root.insert(&item.root, -1); - } - - Some(Self { root, items }) - } - - pub const fn root(&self) -> >k::FlowBox { - &self.root - } - - pub fn refresh(&self, base_interval: std::time::Duration, force: bool) { - let now = std::time::Instant::now(); - let builtin_groups = collect_builtin_groups(&self.items, now, force); - - for item in &self.items { - if item.is_grouped_builtin(now, force) { - // Grouped builtin cards are refreshed once per source below - continue; - } - // Per-item refresh keeps slow widgets from blocking the grid - item.refresh(base_interval, force); - } - - for group in builtin_groups.into_values() { - // One sampled builtin value fans out to every matching stat card in the grid - group.refresh(base_interval); - } - } - - pub fn next_refresh_in(&self, now: std::time::Instant) -> Option { - self.items - .iter() - .filter_map(|item| item.next_refresh_in(now)) - .min() - } - - pub fn is_due(&self, now: std::time::Instant) -> bool { - self.next_refresh_in(now) - .is_some_and(|delay| delay.is_zero()) - } -} - -fn flowbox_columns(columns: usize) -> u32 { - u32::try_from(columns.max(1)).unwrap_or(u32::MAX) -} - -impl StatItem { - pub(super) fn new(config: StatWidgetConfig, icon_resolver: &IconAssetResolver) -> Self { - let card = gtk::Box::new(gtk::Orientation::Vertical, 6); - card.add_css_class(hooks::stat_card::ROOT); - if config.plugin.is_some() { - // Plugin cards get a dedicated class so themes can separate them from builtin stats - card.add_css_class(hooks::stat_card::PLUGIN); - } else { - card.add_css_class(hooks::stat_card::BUILTIN); - } - if config.min_height > 0 { - // Respect configured min height to keep cards visually aligned - card.set_size_request(-1, config.min_height); - } - if let Some(kind) = config.kind.as_deref().and_then(stat_kind_css_class) { - // Kind hooks let themes target user-defined stats without relying on order - card.add_css_class(&kind); - } - - let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); - header.add_css_class(hooks::stat_card::HEADER); - if let Some(icon) = image_from_icon_config( - icon_resolver, - &config.label, - config.icon.as_deref(), - config.icon_asset.as_deref(), - 16, - ) { - icon.add_css_class(hooks::stat_card::ICON); - header.append(&icon); - card.add_css_class(hooks::stat_card::HAS_ICON); - } else { - // No-icon cards still expose a hook so spacing can be rebalanced in CSS - card.add_css_class(hooks::stat_card::NO_ICON); - } - - let title = gtk::Label::new(Some(&config.label)); - title.add_css_class(hooks::stat_card::TITLE); - title.set_xalign(0.0); - header.append(&title); - - let value_label = gtk::Label::new(Some("n/a")); - value_label.add_css_class(hooks::stat_card::VALUE); - value_label.set_xalign(0.0); - value_label.set_width_chars(12); - - card.append(&header); - card.append(&value_label); - - let builtin = if config.plugin.is_some() { - // Plugin-backed stats bypass builtin readers to avoid dual data sources - None - } else { - config - .cmd - .as_ref() - .and_then(|cmd| cmd.program()) - .and_then(|program| program.to_str()) - .and_then(BuiltinStat::from_command) - }; - - Self { - config, - // Card widgets and refresh state stay together so one item owns its full lifecycle - root: card, - value_label, - builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), - inflight: std::rc::Rc::new(std::cell::Cell::new(false)), - last_value: std::rc::Rc::new(std::cell::RefCell::new(None)), - refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new( - super::RefreshBackoff::default(), - )), - } - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs new file mode 100644 index 000000000..27eb46306 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs @@ -0,0 +1,92 @@ +//! Refresh grouping for cards backed by the same built-in reader + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use gtk::glib; + +use super::worker::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; +use super::{BuiltinStat, BuiltinStatKey}; +use crate::ui::widgets::stats::card::StatItem; + +pub(in crate::ui::widgets::stats) struct RefreshGroup { + // One reader is enough for every card that points at the same source + stat: BuiltinStat, + // Each card receives the same sample and updated reader state + items: Vec, +} + +pub(in crate::ui::widgets::stats) fn collect_builtin_groups( + items: &[StatItem], + now: Instant, + force: bool, +) -> HashMap { + let mut groups: HashMap = HashMap::new(); + + for item in items { + let Some((key, stat)) = item.take_builtin_refresh(now, force) else { + continue; + }; + + // Keep one reader per source and collect every matching card + match groups.get_mut(&key) { + Some(group) => group.items.push(item.clone()), + None => { + groups.insert( + key, + RefreshGroup { + stat, + items: vec![item.clone()], + }, + ); + } + } + } + + groups +} + +impl RefreshGroup { + pub(in crate::ui::widgets::stats) fn refresh(self, base_interval: Duration) { + let (tx, rx) = async_channel::bounded(1); + let fallback = self.stat.clone(); + let worker = BuiltinWorker::global(); + + match worker.submit(BuiltinJob { + stat: self.stat, + respond: tx, + }) { + SubmitOutcome::Submitted => {} + SubmitOutcome::QueueFull => { + // Restore every card so the next refresh wave can retry + for item in self.items { + item.restore_builtin_error(fallback.clone(), base_interval); + } + return; + } + SubmitOutcome::WorkerUnavailable => { + // Inline fallback samples once before fan-out + let sample = BuiltinSample::read(fallback); + for item in self.items { + item.restore_builtin_sample(sample.clone(), base_interval); + } + return; + } + } + + glib::MainContext::default().spawn_local(async move { + let result = rx.recv().await; + let Ok(sample) = result else { + for item in self.items { + item.restore_builtin_error(fallback.clone(), base_interval); + } + return; + }; + + // Every grouped card receives the same value and reader state + for item in self.items { + item.restore_builtin_sample(sample.clone(), base_interval); + } + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs index ac680eb06..f1c4c172e 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs @@ -1,11 +1,9 @@ //! Built-in statistic sources and refresh infrastructure mod detect; +pub(in crate::ui::widgets::stats) mod group; mod model; -mod readers; +pub(in crate::ui::widgets::stats) mod readers; +pub(in crate::ui::widgets::stats) mod worker; pub(super) use model::{BuiltinStat, BuiltinStatKey}; - -#[cfg(test)] -#[path = "../tests/builtin.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs index bbd3bf28a..e6e3f0bb7 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs @@ -10,7 +10,7 @@ pub(in crate::ui::widgets::stats::builtin) fn read_battery() -> Option { read_battery_from(Path::new("/sys/class/power_supply")) } -pub(in crate::ui::widgets::stats::builtin) fn read_battery_from(root: &Path) -> Option { +pub(in crate::ui::widgets::stats) fn read_battery_from(root: &Path) -> Option { let entries = fs::read_dir(root).ok()?; let mut energy_now_total = 0u64; let mut energy_full_total = 0u64; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs index 0990b9732..3bf64338b 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs @@ -1,10 +1,10 @@ //! Procfs and sysfs readers for built-in statistic cards -pub(super) mod battery; +pub(in crate::ui::widgets::stats) mod battery; mod cpu; mod load; mod memory; -pub(super) mod network; +pub(in crate::ui::widgets::stats) mod network; pub(super) use battery::read_battery; pub(super) use cpu::read_cpu_sample; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs index 657ab33de..8dd0bc427 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs @@ -86,14 +86,14 @@ fn pick_default_iface() -> Option { } #[derive(Debug, Clone)] -pub(in crate::ui::widgets::stats::builtin) struct IfaceCandidate { +pub(in crate::ui::widgets::stats) struct IfaceCandidate { // Interface name as reported by sysfs - pub(in crate::ui::widgets::stats::builtin) name: String, + pub(in crate::ui::widgets::stats) name: String, // Raw operstate contents ("up", "down", etc), kept for ranking - pub(in crate::ui::widgets::stats::builtin) operstate: String, + pub(in crate::ui::widgets::stats) operstate: String, } -pub(in crate::ui::widgets::stats::builtin) fn pick_default_iface_from( +pub(in crate::ui::widgets::stats) fn pick_default_iface_from( candidates: &[IfaceCandidate], ) -> Option { // Filter invalid entries early to keep ranking logic simple diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs new file mode 100644 index 000000000..e57b07614 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs @@ -0,0 +1,86 @@ +//! Bounded worker for built-in statistic samples + +use std::thread; + +use crossbeam_channel::TrySendError; +use tracing::warn; + +use super::BuiltinStat; + +pub(in crate::ui::widgets::stats) struct BuiltinJob { + // Reader state moves to the worker for one sample + pub(in crate::ui::widgets::stats) stat: BuiltinStat, + // One-shot response keeps read failure separate from display policy + pub(in crate::ui::widgets::stats) respond: async_channel::Sender, +} + +#[derive(Clone, Debug)] +pub(in crate::ui::widgets::stats) struct BuiltinSample { + // Updated state must return to the card for the next delta sample + pub(in crate::ui::widgets::stats) stat: BuiltinStat, + // Missing values represent reader failure rather than display text + pub(in crate::ui::widgets::stats) value: Option, +} + +pub(in crate::ui::widgets::stats) struct BuiltinWorker { + // Bounded transport prevents refresh waves from growing memory without limit + pub(in crate::ui::widgets::stats) tx: crossbeam_channel::Sender, + // Failed startup selects the inline fallback path + pub(in crate::ui::widgets::stats) inline_fallback: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::ui::widgets::stats) enum SubmitOutcome { + Submitted, + QueueFull, + WorkerUnavailable, +} + +impl BuiltinWorker { + const QUEUE_CAPACITY: usize = 32; + + pub(in crate::ui::widgets::stats) fn global() -> &'static Self { + static WORKER: std::sync::OnceLock = std::sync::OnceLock::new(); + WORKER.get_or_init(Self::new) + } + + fn new() -> Self { + let (tx, rx) = crossbeam_channel::bounded::(Self::QUEUE_CAPACITY); + // One thread is enough because built-in reads are short and serialized + let spawn = thread::Builder::new() + .name("unixnotis-builtin-stats".to_string()) + .spawn(move || { + for job in &rx { + let _ = job.respond.send_blocking(BuiltinSample::read(job.stat)); + } + }); + let inline_fallback = spawn.is_err(); + if inline_fallback { + warn!("builtin stats worker unavailable; using inline reads"); + } + + Self { + tx, + inline_fallback, + } + } + + pub(in crate::ui::widgets::stats) fn submit(&self, job: BuiltinJob) -> SubmitOutcome { + if self.inline_fallback { + return SubmitOutcome::WorkerUnavailable; + } + // The GTK thread never waits for queue capacity + match self.tx.try_send(job) { + Ok(()) => SubmitOutcome::Submitted, + Err(TrySendError::Full(_job)) => SubmitOutcome::QueueFull, + Err(TrySendError::Disconnected(_job)) => SubmitOutcome::WorkerUnavailable, + } + } +} + +impl BuiltinSample { + pub(in crate::ui::widgets::stats) fn read(mut stat: BuiltinStat) -> Self { + let value = stat.read(); + Self { stat, value } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card.rs b/crates/unixnotis-center/src/ui/widgets/stats/card.rs deleted file mode 100644 index b2b0b627d..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/card.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Stat refresh and label update logic - -use std::time::{Duration, Instant}; - -use gtk::glib; -use gtk::prelude::*; -use tracing::warn; -use unixnotis_core::{PanelDebugLevel, WidgetPluginConfig}; - -use super::super::plugin::{parse_stat_plugin_payload, PluginOutputLimits}; -use super::super::utils::{ - run_command_capture_async, run_command_capture_with_timeout_async, INFLIGHT_REFRESH_RECHECK, -}; -use super::{apply_cached_value, BuiltinStat, BuiltinStatKey, StatItem}; -use crate::diagnostics::panel_debug as debug; - -impl StatItem { - pub(super) fn has_builtin_source(&self) -> bool { - self.config.plugin.is_none() && self.builtin.borrow().is_some() - } - - pub(super) fn is_grouped_builtin(&self, now: Instant, force: bool) -> bool { - if !self.has_builtin_source() { - return false; - } - - if !self.root.is_visible() { - return false; - } - - if self.inflight.get() { - // Builtin groups keep their own in-flight guard, so grouped items can skip the fallback path - return true; - } - - self.refresh_backoff.borrow().should_refresh(now, force) - } - - pub(super) fn take_builtin_refresh( - &self, - now: Instant, - force: bool, - ) -> Option<(BuiltinStatKey, BuiltinStat)> { - if !self.root.is_visible() { - return None; - } - if self.config.plugin.is_some() { - return None; - } - if !self.refresh_backoff.borrow().should_refresh(now, force) { - return None; - } - if self.inflight.get() { - return None; - } - - let builtin = self.builtin.borrow_mut().take()?; - self.inflight.set(true); - Some((builtin.key(), builtin)) - } - - pub(super) fn refresh(&self, base_interval: Duration, force: bool) { - if !self.root.is_visible() { - return; - } - let now = Instant::now(); - // Skip refresh when the backoff window has not elapsed - if !self.refresh_backoff.borrow().should_refresh(now, force) { - return; - } - debug::log(PanelDebugLevel::Verbose, || { - format!("stat refresh: {}", self.config.label) - }); - if self.inflight.get() { - return; - } - if let Some(plugin) = self.config.plugin.as_ref() { - // Plugin source has higher priority than legacy cmd and builtin paths - self.refresh_plugin(plugin, base_interval); - return; - } - if let Some(builtin) = self.builtin.borrow_mut().take() { - self.refresh_builtin(builtin, base_interval); - return; - } - - let Some(cmd) = self.config.cmd.as_ref() else { - // Cards with no source fall back to the placeholder instead of spinning forever - let changed = self.apply_value("n/a"); - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - return; - }; - self.inflight.set(true); - let cmd = cmd.clone(); - let rx = run_command_capture_async(&cmd); - let label = self.value_label.clone(); - let inflight = self.inflight.clone(); - let last_value = self.last_value.clone(); - let refresh_backoff = self.refresh_backoff.clone(); - glib::MainContext::default().spawn_local(async move { - // Receive first so broken worker paths do not leave the card stuck in-flight - let output = if let Ok(output) = rx.recv().await { - output - } else { - inflight.set(false); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - }; - inflight.set(false); - let output = match output { - Ok(output) => output, - Err(err) => { - warn!(?cmd, ?err, "stat command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - if !output.status.success() { - warn!(?cmd, "stat command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - let stdout = String::from_utf8_lossy(&output.stdout); - let value = stdout.trim(); - if value.is_empty() { - // Empty command output keeps the last good value on screen - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, false); - } else { - let changed = last_value.borrow().as_deref() != Some(value); - if changed { - label.set_text(value); - *last_value.borrow_mut() = Some(value.to_string()); - } - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - } - }); - } - - pub(super) fn next_refresh_in(&self, now: Instant) -> Option { - if !self.root.is_visible() { - return None; - } - if self.inflight.get() { - // A slow command should not turn into a four-times-per-second scheduler loop - return Some(INFLIGHT_REFRESH_RECHECK); - } - self.refresh_backoff - .borrow() - .next_due_in(now) - .or(Some(Duration::ZERO)) - } - - pub(super) fn restore_builtin_error(&self, builtin: BuiltinStat, base_interval: Duration) { - self.inflight.set(false); - *self.builtin.borrow_mut() = Some(builtin); - self.refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - } - - pub(super) fn restore_builtin_value( - &self, - builtin: BuiltinStat, - value: &str, - base_interval: Duration, - ) { - self.inflight.set(false); - *self.builtin.borrow_mut() = Some(builtin); - if value.is_empty() { - apply_cached_value(&self.value_label, &self.last_value); - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, false); - return; - } - - let changed = self.last_value.borrow().as_deref() != Some(value); - if changed { - self.value_label.set_text(value); - *self.last_value.borrow_mut() = Some(value.to_string()); - } - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - } - - fn refresh_plugin(&self, plugin: &WidgetPluginConfig, base_interval: Duration) { - self.inflight.set(true); - let command = plugin.command.clone(); - let timeout = Duration::from_millis(plugin.timeout_ms); - let output_limits = PluginOutputLimits { - max_output_bytes: plugin.max_output_bytes, - }; - let rx = run_command_capture_with_timeout_async(&command, timeout); - let label = self.value_label.clone(); - let inflight = self.inflight.clone(); - let last_value = self.last_value.clone(); - let refresh_backoff = self.refresh_backoff.clone(); - glib::MainContext::default().spawn_local(async move { - // Plugin output uses the same cache rules as plain commands - let output = if let Ok(output) = rx.recv().await { - output - } else { - inflight.set(false); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - }; - inflight.set(false); - let output = match output { - Ok(output) => output, - Err(err) => { - warn!(command = %command, ?err, "stat plugin command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - if !output.status.success() { - warn!(command = %command, "stat plugin command returned non-zero status"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - - let parsed = match parse_stat_plugin_payload(&output.stdout, output_limits) { - Ok(parsed) => parsed, - Err(err) => { - warn!(command = %command, %err, "failed to parse stat plugin payload"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - let changed = if last_value.borrow().as_deref() == Some(parsed.text.as_str()) { - false - } else { - label.set_text(&parsed.text); - *last_value.borrow_mut() = Some(parsed.text); - true - }; - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - }); - } - - pub(super) fn apply_value(&self, value: &str) -> bool { - if self.last_value.borrow().as_deref() == Some(value) { - return false; - } - // Cache and label are updated together so later fallback reads stay honest - self.value_label.set_text(value); - *self.last_value.borrow_mut() = Some(value.to_string()); - true - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs new file mode 100644 index 000000000..ca40563c6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs @@ -0,0 +1,90 @@ +//! Statistic card construction + +use gtk::prelude::*; +use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; + +use super::super::builtin::BuiltinStat; +use super::super::style::stat_kind_css_class; +use super::StatItem; +use crate::ui::widgets::icon_image::image_from_icon_config; +use crate::ui::widgets::utils::RefreshBackoff; + +impl StatItem { + pub(in crate::ui::widgets::stats) fn new( + config: StatWidgetConfig, + icon_resolver: &IconAssetResolver, + ) -> Self { + let card = gtk::Box::new(gtk::Orientation::Vertical, 6); + card.add_css_class(hooks::stat_card::ROOT); + if config.plugin.is_some() { + // Plugin cards expose a dedicated theme hook + card.add_css_class(hooks::stat_card::PLUGIN); + } else { + card.add_css_class(hooks::stat_card::BUILTIN); + } + if config.min_height > 0 { + // A minimum height keeps cards aligned within the grid + card.set_size_request(-1, config.min_height); + } + if let Some(kind) = config.kind.as_deref().and_then(stat_kind_css_class) { + // Kind hooks allow stable theme targeting without relying on order + card.add_css_class(&kind); + } + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); + header.add_css_class(hooks::stat_card::HEADER); + if let Some(icon) = image_from_icon_config( + icon_resolver, + &config.label, + config.icon.as_deref(), + config.icon_asset.as_deref(), + 16, + ) { + icon.add_css_class(hooks::stat_card::ICON); + header.append(&icon); + card.add_css_class(hooks::stat_card::HAS_ICON); + } else { + // No-icon cards expose a hook so CSS can rebalance spacing + card.add_css_class(hooks::stat_card::NO_ICON); + } + + let title = gtk::Label::new(Some(&config.label)); + title.add_css_class(hooks::stat_card::TITLE); + title.set_xalign(0.0); + header.append(&title); + + let value_label = gtk::Label::new(Some("n/a")); + value_label.add_css_class(hooks::stat_card::VALUE); + value_label.set_xalign(0.0); + value_label.set_width_chars(12); + + card.append(&header); + card.append(&value_label); + + let builtin = if config.plugin.is_some() { + // Plugin-backed cards bypass built-in readers + None + } else { + config + .cmd + .as_ref() + .and_then(|cmd| cmd.program()) + .and_then(|program| program.to_str()) + .and_then(BuiltinStat::from_command) + }; + + Self { + config, + root: card, + value_label, + builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), + inflight: std::rc::Rc::new(std::cell::Cell::new(false)), + last_value: std::rc::Rc::new(std::cell::RefCell::new(None)), + refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), + } + } + + pub(in crate::ui::widgets::stats) const fn root(&self) -> >k::Box { + &self.root + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs new file mode 100644 index 000000000..6d2d2bf26 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs @@ -0,0 +1,9 @@ +//! Statistic card ownership and refresh behavior + +mod build; +mod model; +mod refresh; +mod render; + +pub(super) use model::StatItem; +use model::StatSourceRef; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs new file mode 100644 index 000000000..7b9ae3d8a --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs @@ -0,0 +1,34 @@ +//! Retained state for one statistic card + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use unixnotis_core::{CommandSpec, StatWidgetConfig, WidgetPluginConfig}; + +use super::super::builtin::BuiltinStat; +use crate::ui::widgets::utils::RefreshBackoff; + +#[derive(Clone)] +pub(in crate::ui::widgets::stats) struct StatItem { + // Raw config supplies source selection and display metadata + pub(super) config: StatWidgetConfig, + // Root card inserted into the grid + pub(super) root: gtk::Box, + // Label receives the latest rendered sample + pub(super) value_label: gtk::Label, + // Built-in reader state is retained across samples + pub(super) builtin: Rc>>, + // In-flight state prevents overlapping refreshes + pub(super) inflight: Rc>, + // Last good value avoids unnecessary relayout + pub(super) last_value: Rc>>, + // Backoff slows sources whose output remains stable + pub(super) refresh_backoff: Rc>, +} + +pub(super) enum StatSourceRef<'a> { + Plugin(&'a WidgetPluginConfig), + Builtin(BuiltinStat), + Command(&'a CommandSpec), + Missing, +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs new file mode 100644 index 000000000..e9bfe4afb --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs @@ -0,0 +1,93 @@ +//! Individual built-in refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::stats::builtin::worker::{ + BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome, +}; +use crate::ui::widgets::stats::builtin::BuiltinStat; + +impl StatItem { + pub(super) fn refresh_builtin(&self, builtin: BuiltinStat, base_interval: Duration) { + self.inflight.set(true); + let (tx, rx) = async_channel::bounded(1); + let fallback = builtin.clone(); + let worker = BuiltinWorker::global(); + + match worker.submit(BuiltinJob { + stat: builtin, + respond: tx, + }) { + SubmitOutcome::Submitted => {} + SubmitOutcome::QueueFull => { + // Queue pressure remains non-blocking on the GTK thread + self.restore_builtin_error(fallback, base_interval); + return; + } + SubmitOutcome::WorkerUnavailable => { + // Inline fallback keeps built-in cards available after startup failure + self.restore_builtin_sample(BuiltinSample::read(fallback), base_interval); + return; + } + } + + let item = self.clone(); + glib::MainContext::default().spawn_local(async move { + // Restore reader state on every exit path + let result = rx.recv().await; + let Ok(sample) = result else { + item.restore_builtin_error(fallback, base_interval); + return; + }; + item.restore_builtin_sample(sample, base_interval); + }); + } + + pub(in crate::ui::widgets::stats) fn restore_builtin_error( + &self, + builtin: BuiltinStat, + base_interval: Duration, + ) { + self.inflight.set(false); + *self.builtin.borrow_mut() = Some(builtin); + self.refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + } + + pub(in crate::ui::widgets::stats) fn restore_builtin_sample( + &self, + sample: BuiltinSample, + base_interval: Duration, + ) { + let BuiltinSample { stat, value } = sample; + let Some(value) = value else { + // Reader failure preserves the last good value and uses error backoff + apply_cached_value(&self.value_label, &self.last_value); + self.restore_builtin_error(stat, base_interval); + return; + }; + + self.inflight.set(false); + *self.builtin.borrow_mut() = Some(stat); + if value.is_empty() { + apply_cached_value(&self.value_label, &self.last_value); + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, false); + return; + } + + let changed = self.last_value.borrow().as_deref() != Some(value.as_str()); + if changed { + self.value_label.set_text(&value); + *self.last_value.borrow_mut() = Some(value); + } + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs new file mode 100644 index 000000000..4da0182ba --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs @@ -0,0 +1,74 @@ +//! Arbitrary command refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; +use tracing::warn; +use unixnotis_core::CommandSpec; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::utils::run_command_capture_async; + +impl StatItem { + pub(super) fn refresh_command(&self, command: &CommandSpec, base_interval: Duration) { + self.inflight.set(true); + let command = command.clone(); + let rx = run_command_capture_async(&command); + let label = self.value_label.clone(); + let inflight = self.inflight.clone(); + let last_value = self.last_value.clone(); + let refresh_backoff = self.refresh_backoff.clone(); + + glib::MainContext::default().spawn_local(async move { + // Receive first so a broken worker cannot leave the card in flight + let output = if let Ok(output) = rx.recv().await { + output + } else { + inflight.set(false); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + }; + inflight.set(false); + let output = match output { + Ok(output) => output, + Err(error) => { + warn!(?command, ?error, "stat command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + if !output.status.success() { + warn!(?command, "stat command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let value = stdout.trim(); + if value.is_empty() { + // Empty output preserves the last good value + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, false); + } else { + let changed = last_value.borrow().as_deref() != Some(value); + if changed { + label.set_text(value); + *last_value.borrow_mut() = Some(value.to_string()); + } + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs new file mode 100644 index 000000000..32f038b7c --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs @@ -0,0 +1,114 @@ +//! Statistic card refresh dispatch and scheduling gates + +mod builtin; +mod command; +mod plugin; + +use std::time::{Duration, Instant}; + +use gtk::prelude::*; +use unixnotis_core::PanelDebugLevel; + +use super::{StatItem, StatSourceRef}; +use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::stats::builtin::{BuiltinStat, BuiltinStatKey}; +use crate::ui::widgets::utils::INFLIGHT_REFRESH_RECHECK; + +impl StatItem { + pub(in crate::ui::widgets::stats) fn has_builtin_source(&self) -> bool { + self.config.plugin.is_none() && self.builtin.borrow().is_some() + } + + pub(in crate::ui::widgets::stats) fn is_grouped_builtin( + &self, + now: Instant, + force: bool, + ) -> bool { + if !self.has_builtin_source() || !self.root.is_visible() { + return false; + } + + if self.inflight.get() { + // Groups keep their own in-flight guard + return true; + } + + self.refresh_backoff.borrow().should_refresh(now, force) + } + + pub(in crate::ui::widgets::stats) fn take_builtin_refresh( + &self, + now: Instant, + force: bool, + ) -> Option<(BuiltinStatKey, BuiltinStat)> { + if !self.root.is_visible() + || self.config.plugin.is_some() + || !self.refresh_backoff.borrow().should_refresh(now, force) + || self.inflight.get() + { + return None; + } + + let builtin = self.builtin.borrow_mut().take()?; + self.inflight.set(true); + Some((builtin.key(), builtin)) + } + + pub(in crate::ui::widgets::stats) fn refresh(&self, base_interval: Duration, force: bool) { + if !self.root.is_visible() { + return; + } + let now = Instant::now(); + if !self.refresh_backoff.borrow().should_refresh(now, force) { + return; + } + debug::log(PanelDebugLevel::Verbose, || { + format!("stat refresh: {}", self.config.label) + }); + if self.inflight.get() { + return; + } + match self.source() { + StatSourceRef::Plugin(plugin) => self.refresh_plugin(plugin, base_interval), + StatSourceRef::Builtin(builtin) => self.refresh_builtin(builtin, base_interval), + StatSourceRef::Command(command) => self.refresh_command(command, base_interval), + StatSourceRef::Missing => self.refresh_missing(base_interval), + } + } + + fn source(&self) -> StatSourceRef<'_> { + if let Some(plugin) = self.config.plugin.as_ref() { + // Plugin configuration always has source precedence + return StatSourceRef::Plugin(plugin); + } + if let Some(builtin) = self.builtin.borrow_mut().take() { + return StatSourceRef::Builtin(builtin); + } + self.config + .cmd + .as_ref() + .map_or(StatSourceRef::Missing, StatSourceRef::Command) + } + + fn refresh_missing(&self, base_interval: Duration) { + // Missing sources settle on the placeholder without spinning + let changed = self.apply_value("n/a"); + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } + + pub(in crate::ui::widgets::stats) fn next_refresh_in(&self, now: Instant) -> Option { + if !self.root.is_visible() { + return None; + } + if self.inflight.get() { + // Slow sources should not create a tight scheduler loop + return Some(INFLIGHT_REFRESH_RECHECK); + } + self.refresh_backoff + .borrow() + .next_due_in(now) + .or(Some(Duration::ZERO)) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs new file mode 100644 index 000000000..d8e1bf8ca --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs @@ -0,0 +1,82 @@ +//! Plugin refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; +use tracing::warn; +use unixnotis_core::WidgetPluginConfig; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::plugin::{parse_stat_plugin_payload, PluginOutputLimits}; +use crate::ui::widgets::utils::run_command_capture_with_timeout_async; + +impl StatItem { + pub(super) fn refresh_plugin(&self, plugin: &WidgetPluginConfig, base_interval: Duration) { + self.inflight.set(true); + let command = plugin.command.clone(); + let timeout = Duration::from_millis(plugin.timeout_ms); + let output_limits = PluginOutputLimits { + max_output_bytes: plugin.max_output_bytes, + }; + let rx = run_command_capture_with_timeout_async(&command, timeout); + let label = self.value_label.clone(); + let inflight = self.inflight.clone(); + let last_value = self.last_value.clone(); + let refresh_backoff = self.refresh_backoff.clone(); + + glib::MainContext::default().spawn_local(async move { + // Plugins use the same cache and backoff policy as commands + let output = if let Ok(output) = rx.recv().await { + output + } else { + inflight.set(false); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + }; + inflight.set(false); + let output = match output { + Ok(output) => output, + Err(error) => { + warn!(command = %command, ?error, "stat plugin command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + if !output.status.success() { + warn!(command = %command, "stat plugin command returned non-zero status"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + + let parsed = match parse_stat_plugin_payload(&output.stdout, output_limits) { + Ok(parsed) => parsed, + Err(error) => { + warn!(command = %command, %error, "failed to parse stat plugin payload"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + let changed = if last_value.borrow().as_deref() == Some(parsed.text.as_str()) { + false + } else { + label.set_text(&parsed.text); + *last_value.borrow_mut() = Some(parsed.text); + true + }; + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs new file mode 100644 index 000000000..1b11f0a23 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs @@ -0,0 +1,30 @@ +//! Statistic card value rendering + +use std::cell::RefCell; +use std::rc::Rc; + +use super::StatItem; + +pub(super) fn apply_cached_value(label: >k::Label, cache: &Rc>>) { + if let Some(value) = cache.borrow().as_ref() { + // Stable values avoid an unnecessary GTK property update + if label.text().as_str() != value { + label.set_text(value); + } + } else if label.text().as_str() != "n/a" { + // Missing samples share one predictable fallback label + label.set_text("n/a"); + } +} + +impl StatItem { + pub(super) fn apply_value(&self, value: &str) -> bool { + if self.last_value.borrow().as_deref() == Some(value) { + return false; + } + // Cache and label change together so fallback reads remain accurate + self.value_label.set_text(value); + *self.last_value.borrow_mut() = Some(value.to_string()); + true + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs new file mode 100644 index 000000000..511044e4b --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs @@ -0,0 +1,55 @@ +//! Statistic grid construction + +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; + +use super::super::card::StatItem; +use super::StatGrid; + +impl StatGrid { + pub fn new( + configs: &[StatWidgetConfig], + columns: usize, + icon_resolver: &IconAssetResolver, + ) -> Option { + let mut items = Vec::new(); + for config in configs { + if !config.enabled { + continue; + } + // Preserve config order so layout remains predictable + items.push(StatItem::new(config.clone(), icon_resolver)); + } + if items.is_empty() { + // Skip widget creation when all stat entries are disabled + return None; + } + + let root = gtk::FlowBox::new(); + root.add_css_class(hooks::stat_card::GRID); + root.set_selection_mode(gtk::SelectionMode::None); + let columns = flowbox_columns(columns); + root.set_max_children_per_line(columns); + root.set_min_children_per_line(columns); + root.set_row_spacing(8); + root.set_column_spacing(8); + root.set_halign(Align::Fill); + root.set_hexpand(true); + + for item in &items { + // Insert in order so card identity stays stable + root.insert(item.root(), -1); + } + + Some(Self { root, items }) + } + + pub const fn root(&self) -> >k::FlowBox { + &self.root + } +} + +pub(in crate::ui::widgets::stats) fn flowbox_columns(columns: usize) -> u32 { + u32::try_from(columns.max(1)).unwrap_or(u32::MAX) +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs new file mode 100644 index 000000000..dc37c7c2d --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs @@ -0,0 +1,14 @@ +//! Statistic grid ownership + +pub(in crate::ui::widgets::stats) mod build; +mod refresh; +pub(in crate::ui::widgets::stats) mod schedule; + +use super::card::StatItem; + +pub struct StatGrid { + // FlowBox root is embedded by the panel widget tree + root: gtk::FlowBox, + // Per-card state is retained for refresh scheduling + items: Vec, +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs new file mode 100644 index 000000000..b926fe2a6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs @@ -0,0 +1,27 @@ +//! Statistic grid refresh waves + +use std::time::{Duration, Instant}; + +use super::super::builtin::group::collect_builtin_groups; +use super::StatGrid; + +impl StatGrid { + pub fn refresh(&self, base_interval: Duration, force: bool) { + let now = Instant::now(); + let builtin_groups = collect_builtin_groups(&self.items, now, force); + + for item in &self.items { + if item.is_grouped_builtin(now, force) { + // Grouped built-ins are refreshed once per source below + continue; + } + // Per-card refresh keeps slow sources from blocking the grid + item.refresh(base_interval, force); + } + + for group in builtin_groups.into_values() { + // One sample fans out to every matching card in the grid + group.refresh(base_interval); + } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs new file mode 100644 index 000000000..05a754086 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs @@ -0,0 +1,22 @@ +//! Statistic grid scheduling + +use std::time::{Duration, Instant}; + +use super::StatGrid; + +impl StatGrid { + pub fn next_refresh_in(&self, now: Instant) -> Option { + self.items + .iter() + .filter_map(|item| item.next_refresh_in(now)) + .min() + } + + pub fn is_due(&self, now: Instant) -> bool { + is_due_delay(self.next_refresh_in(now)) + } +} + +pub(in crate::ui::widgets::stats) fn is_due_delay(delay: Option) -> bool { + delay.is_some_and(|value| value.is_zero()) +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/group.rs b/crates/unixnotis-center/src/ui/widgets/stats/group.rs deleted file mode 100644 index 8139aebd6..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/group.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Shared refresh grouping for cards backed by the same built-in reader - -use std::collections::HashMap; -use std::time::Instant; - -use super::{BuiltinStat, BuiltinStatKey, StatItem}; - -pub(super) struct BuiltinRefreshGroup { - // One live builtin reader is enough for all cards that point at the same source - pub(super) stat: BuiltinStat, - // Every item in the group receives the same sampled value and updated reader state - pub(super) items: Vec, -} - -pub(super) fn collect_builtin_groups( - items: &[StatItem], - now: Instant, - force: bool, -) -> HashMap { - let mut groups: HashMap = HashMap::new(); - - for item in items { - let Some((key, stat)) = item.take_builtin_refresh(now, force) else { - continue; - }; - - // Keep one reader per unique builtin source, then fan the result out to every card - match groups.get_mut(&key) { - Some(group) => group.items.push(item.clone()), - None => { - groups.insert( - key, - BuiltinRefreshGroup { - stat, - items: vec![item.clone()], - }, - ); - } - } - } - - groups -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs index 8ec287017..81aabce70 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs @@ -1,19 +1,11 @@ -//! Statistic widget module wiring +//! Statistic cards and grid orchestration -mod build; mod builtin; mod card; -mod group; -mod state; +mod grid; mod style; + +pub use grid::StatGrid; + #[cfg(test)] -#[path = "tests/grid.rs"] mod tests; -mod worker; - -use self::builtin::{BuiltinStat, BuiltinStatKey}; -use self::group::{collect_builtin_groups, BuiltinRefreshGroup}; -pub use self::state::StatGrid; -use self::state::StatItem; -use self::state::{apply_cached_value, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome}; -use super::utils::RefreshBackoff; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/state.rs b/crates/unixnotis-center/src/ui/widgets/stats/state.rs deleted file mode 100644 index 6edc83bed..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/state.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Retained widget and worker state for statistic cards - -use std::cell::{Cell, RefCell}; -use std::rc::Rc; - -use unixnotis_core::StatWidgetConfig; - -use super::super::utils::RefreshBackoff; -use super::BuiltinStat; - -pub struct StatGrid { - // FlowBox root is embedded by the panel widget tree - pub(super) root: gtk::FlowBox, - // Per-stat item state is retained for refresh scheduling - pub(super) items: Vec, -} - -#[derive(Clone)] -pub(super) struct StatItem { - // Raw config is retained for command and plugin selection plus labels - pub(super) config: StatWidgetConfig, - // Root card inserted into the grid - pub(super) root: gtk::Box, - // Render target for the latest stat value - pub(super) value_label: gtk::Label, - // Optional builtin reader reused across refresh calls - pub(super) builtin: Rc>>, - // Guard prevents overlapping command or builtin reads - pub(super) inflight: Rc>, - // Cached value avoids unnecessary relayout for unchanged results - pub(super) last_value: Rc>>, - // Backoff reduces repeated reads when the value is stable - pub(super) refresh_backoff: Rc>, -} - -pub(super) struct BuiltinStatJob { - // Builtin reader variant to execute on the worker thread - pub(super) stat: BuiltinStat, - // One-shot response channel used to return the sampled value - pub(super) respond: async_channel::Sender<(BuiltinStat, String)>, -} - -pub(super) struct BuiltinStatWorker { - // Bounded queue feeding the dedicated builtin worker thread - pub(super) tx: crossbeam_channel::Sender, - // True when worker startup failed and callers should read inline - pub(super) inline_fallback: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum BuiltinSubmitOutcome { - // Job was accepted by the worker queue - Submitted, - // Queue is healthy but currently saturated - QueueFull, - // Worker is unavailable and caller must use inline fallback - WorkerUnavailable, -} - -pub(super) fn apply_cached_value(label: >k::Label, cache: &Rc>>) { - if let Some(value) = cache.borrow().as_ref() { - // Stable values avoid an unnecessary GTK property update - if label.text().as_str() != value { - label.set_text(value); - } - } else if label.text().as_str() != "n/a" { - // Missing samples share one predictable fallback label - label.set_text("n/a"); - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/style.rs b/crates/unixnotis-center/src/ui/widgets/stats/style.rs index 84ee9828c..c014b79fd 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/style.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/style.rs @@ -3,7 +3,3 @@ pub(super) fn stat_kind_css_class(kind: &str) -> Option { super::super::kind_css::widget_kind_css_class("unixnotis-stat-kind-", kind) } - -#[cfg(test)] -#[path = "tests/css.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs index 3140f1f13..cf6a04f48 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs @@ -1,47 +1,13 @@ -use super::readers::battery::read_battery_from; -use super::readers::network::{pick_default_iface_from, IfaceCandidate}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -struct TempDir { - path: PathBuf, -} - -impl TempDir { - fn new(prefix: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = - std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), stamp)); - fs::create_dir_all(&path).expect("temp dir creation failed"); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } -} +//! Built-in reader and worker tests -impl Drop for TempDir { - fn drop(&mut self) { - // Best-effort cleanup to avoid leaving test artifacts on disk. - let _ = fs::remove_dir_all(&self.path); - } -} - -fn write_device(root: &Path, name: &str, entries: &[(&str, &str)]) { - let device_path = root.join(name); - fs::create_dir_all(&device_path).expect("device directory creation failed"); - for (file, contents) in entries { - fs::write(device_path.join(file), contents).expect("device file write failed"); - } -} +use super::super::builtin::readers::battery::read_battery_from; +use super::super::builtin::readers::network::{pick_default_iface_from, IfaceCandidate}; +use super::super::builtin::worker::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; +use super::super::builtin::BuiltinStat; +use super::support::{write_device, TempDir}; #[test] -fn battery_energy_aggregates_weighted() { +fn battery_energy_values_are_weighted_by_full_capacity() { let temp = TempDir::new("unixnotis-battery-energy"); write_device( temp.path(), @@ -63,12 +29,14 @@ fn battery_energy_aggregates_weighted() { ("energy_full", "40"), ], ); + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + assert_eq!(percent, "40"); } #[test] -fn battery_mixed_units_falls_back_to_capacity() { +fn battery_mixed_units_fall_back_to_reported_capacity() { let temp = TempDir::new("unixnotis-battery-mixed"); write_device( temp.path(), @@ -92,12 +60,14 @@ fn battery_mixed_units_falls_back_to_capacity() { ("capacity", "25"), ], ); + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + assert_eq!(percent, "43"); } #[test] -fn battery_skips_not_present_devices() { +fn battery_reader_skips_devices_reported_as_absent() { let temp = TempDir::new("unixnotis-battery-absent"); write_device( temp.path(), @@ -109,11 +79,12 @@ fn battery_skips_not_present_devices() { ("energy_full", "60"), ], ); + assert!(read_battery_from(temp.path()).is_none()); } #[test] -fn default_iface_prefers_up_physical_over_virtual() { +fn default_interface_prefers_an_active_physical_device() { let candidates = vec![ IfaceCandidate { name: "veth0".to_string(), @@ -124,6 +95,7 @@ fn default_iface_prefers_up_physical_over_virtual() { operstate: "up".to_string(), }, ]; + assert_eq!( pick_default_iface_from(&candidates), Some("wlan0".to_string()) @@ -131,7 +103,7 @@ fn default_iface_prefers_up_physical_over_virtual() { } #[test] -fn default_iface_falls_back_to_physical_when_none_up() { +fn default_interface_prefers_physical_devices_when_all_are_down() { let candidates = vec![ IfaceCandidate { name: "eth0".to_string(), @@ -142,6 +114,7 @@ fn default_iface_falls_back_to_physical_when_none_up() { operstate: "up".to_string(), }, ]; + assert_eq!( pick_default_iface_from(&candidates), Some("eth0".to_string()) @@ -149,7 +122,7 @@ fn default_iface_falls_back_to_physical_when_none_up() { } #[test] -fn default_iface_uses_deterministic_name_tiebreaker() { +fn default_interface_uses_name_as_a_deterministic_tiebreaker() { let candidates = vec![ IfaceCandidate { name: "eth1".to_string(), @@ -160,8 +133,47 @@ fn default_iface_uses_deterministic_name_tiebreaker() { operstate: "down".to_string(), }, ]; + assert_eq!( pick_default_iface_from(&candidates), Some("eth0".to_string()) ); } + +#[test] +fn builtin_worker_reports_a_full_queue_without_blocking() { + let (tx, _worker_rx) = crossbeam_channel::bounded(1); + let worker = BuiltinWorker { + tx, + inline_fallback: false, + }; + let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let (first_tx, _first_rx) = async_channel::bounded(1); + let (second_tx, _second_rx) = async_channel::bounded(1); + + assert_eq!( + worker.submit(BuiltinJob { + stat: first, + respond: first_tx, + }), + SubmitOutcome::Submitted + ); + assert_eq!( + worker.submit(BuiltinJob { + stat: second, + respond: second_tx, + }), + SubmitOutcome::QueueFull + ); +} + +#[test] +fn builtin_sample_preserves_reader_failure_as_missing_data() { + let stat = + BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); + + let sample = BuiltinSample::read(stat); + + assert!(sample.value.is_none()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs similarity index 69% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs rename to crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs index 5f1e634a6..5968eb910 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs @@ -1,7 +1,9 @@ -use super::stat_kind_css_class; +//! Statistic card presentation tests + +use super::super::style::stat_kind_css_class; #[test] -fn stat_kind_css_class_sanitizes_to_stable_token() { +fn card_kind_class_normalizes_theme_tokens() { assert_eq!( stat_kind_css_class("RAM"), Some("unixnotis-stat-kind-ram".to_string()) diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs index d97aa532e..54dda196e 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs @@ -1,51 +1,15 @@ -//! Stat worker tests +//! Statistic grid construction tests -use super::{ - builtin::BuiltinStatKey, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome, -}; +use super::super::grid::build::flowbox_columns; #[test] -fn builtin_worker_queue_full_falls_back() { - let (tx, _worker_rx) = crossbeam_channel::bounded(1); - let worker = BuiltinStatWorker { - tx, - inline_fallback: false, - }; - let stat_a = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let stat_b = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let (tx_a, _rx_a) = async_channel::bounded(1); - let (tx_b, _rx_b) = async_channel::bounded(1); - - // First job fits in the bounded queue - assert_eq!( - worker.submit(BuiltinStatJob { - stat: stat_a, - respond: tx_a, - }), - BuiltinSubmitOutcome::Submitted - ); - // Second job proves the submit path reports saturation instead of blocking - assert_eq!( - worker.submit(BuiltinStatJob { - stat: stat_b, - respond: tx_b, - }), - BuiltinSubmitOutcome::QueueFull - ); +fn grid_columns_normalize_zero_and_preserve_positive_values() { + assert_eq!(flowbox_columns(0), 1); + assert_eq!(flowbox_columns(1), 1); + assert_eq!(flowbox_columns(4), 4); } #[test] -fn builtin_stat_keys_dedupe_matching_sources() { - let cpu_a = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let cpu_b = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let net = BuiltinStat::from_command("builtin:net:wlan0").expect("builtin stat"); - - assert_eq!(cpu_a.key(), BuiltinStatKey::Cpu); - assert_eq!(cpu_a.key(), cpu_b.key()); - assert_eq!( - net.key(), - BuiltinStatKey::Network { - iface: Some("wlan0".to_string()), - } - ); +fn grid_columns_saturate_when_usize_exceeds_u32() { + assert_eq!(flowbox_columns(usize::MAX), u32::MAX); } diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs new file mode 100644 index 000000000..1c9cb01aa --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs @@ -0,0 +1,24 @@ +//! Built-in refresh grouping tests + +use super::super::builtin::{BuiltinStat, BuiltinStatKey}; + +#[test] +fn matching_builtin_sources_produce_the_same_group_key() { + let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + + assert_eq!(first.key(), BuiltinStatKey::Cpu); + assert_eq!(first.key(), second.key()); +} + +#[test] +fn network_group_keys_include_the_interface_name() { + let stat = BuiltinStat::from_command("builtin:net:wlan0").expect("builtin stat"); + + assert_eq!( + stat.key(), + BuiltinStatKey::Network { + iface: Some("wlan0".to_string()), + } + ); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs new file mode 100644 index 000000000..69b155fe8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs @@ -0,0 +1,8 @@ +//! Statistic widget tests mirrored by responsibility + +mod builtin; +mod card; +mod grid; +mod grouping; +mod scheduling; +mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs new file mode 100644 index 000000000..0d0b5753e --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs @@ -0,0 +1,16 @@ +//! Statistic refresh scheduling tests + +use std::time::Duration; + +use super::super::grid::schedule::is_due_delay; + +#[test] +fn zero_delay_is_due_immediately() { + assert!(is_due_delay(Some(Duration::ZERO))); +} + +#[test] +fn missing_or_positive_delay_is_not_due() { + assert!(!is_due_delay(None)); + assert!(!is_due_delay(Some(Duration::from_millis(1)))); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs new file mode 100644 index 000000000..8fae4e9d1 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs @@ -0,0 +1,40 @@ +//! Shared filesystem fixtures for built-in reader tests + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub(super) fn new(prefix: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).expect("temp dir creation failed"); + Self { path } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Cleanup is best effort so a failed assertion remains visible + let _ = fs::remove_dir_all(&self.path); + } +} + +pub(super) fn write_device(root: &Path, name: &str, entries: &[(&str, &str)]) { + let device_path = root.join(name); + fs::create_dir_all(&device_path).expect("device directory creation failed"); + for (file, contents) in entries { + fs::write(device_path.join(file), contents).expect("device file write failed"); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/worker.rs deleted file mode 100644 index a83c43ff7..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/worker.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Builtin stat worker and builtin refresh helpers - -use std::thread; - -use crossbeam_channel::TrySendError; -use gtk::glib; -use tracing::warn; - -use super::{ - BuiltinRefreshGroup, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome, - StatItem, -}; - -impl BuiltinStatWorker { - // Limit queued jobs to avoid unbounded growth if refresh is faster than the worker - const QUEUE_CAPACITY: usize = 32; - - // Single worker avoids per-refresh thread churn while keeping UI updates async - pub(super) fn global() -> &'static Self { - static WORKER: std::sync::OnceLock = std::sync::OnceLock::new(); - WORKER.get_or_init(Self::new) - } - - fn new() -> Self { - let (tx, rx) = crossbeam_channel::bounded::(Self::QUEUE_CAPACITY); - // One worker thread is enough because builtin reads are short and serialized - let spawn = thread::Builder::new() - .name("unixnotis-builtin-stats".to_string()) - .spawn(move || { - for mut job in &rx { - let value = job.stat.read().unwrap_or_else(|| "n/a".to_string()); - let _ = job.respond.send_blocking((job.stat, value)); - } - }); - let inline_fallback = spawn.is_err(); - if inline_fallback { - warn!("builtin stats worker unavailable; using inline reads"); - } - - Self { - tx, - inline_fallback, - } - } - - pub(super) fn submit(&self, job: BuiltinStatJob) -> BuiltinSubmitOutcome { - if self.inline_fallback { - return BuiltinSubmitOutcome::WorkerUnavailable; - } - // Avoid blocking the UI thread when the worker queue is saturated - match self.tx.try_send(job) { - Ok(()) => BuiltinSubmitOutcome::Submitted, - Err(TrySendError::Full(_job)) => BuiltinSubmitOutcome::QueueFull, - // Disconnected queue means the worker path is no longer usable - Err(TrySendError::Disconnected(_job)) => BuiltinSubmitOutcome::WorkerUnavailable, - } - } -} - -impl StatItem { - pub(super) fn refresh_builtin(&self, builtin: BuiltinStat, base_interval: std::time::Duration) { - // Temporarily take builtin state to prevent overlapping reads - self.inflight.set(true); - let (tx, rx) = async_channel::bounded(1); - let mut fallback = builtin.clone(); - let worker = BuiltinStatWorker::global(); - match worker.submit(BuiltinStatJob { - stat: builtin, - respond: tx, - }) { - BuiltinSubmitOutcome::Submitted => {} - BuiltinSubmitOutcome::QueueFull => { - // Queue saturation should stay non-blocking on the GTK thread - self.restore_builtin_error(fallback, base_interval); - return; - } - BuiltinSubmitOutcome::WorkerUnavailable => { - // Inline fallback keeps builtin stats readable when the worker is missing - let value = fallback.read().unwrap_or_else(|| "n/a".to_string()); - self.restore_builtin_value(fallback, &value, base_interval); - return; - } - } - - let item = self.clone(); - glib::MainContext::default().spawn_local(async move { - // Restore builtin state on every exit path so later refreshes can keep working - let result = rx.recv().await; - let Ok((builtin, value)) = result else { - item.restore_builtin_error(fallback, base_interval); - return; - }; - item.restore_builtin_value(builtin, &value, base_interval); - }); - } -} - -impl BuiltinRefreshGroup { - pub(super) fn refresh(self, base_interval: std::time::Duration) { - let (tx, rx) = async_channel::bounded(1); - let mut fallback = self.stat.clone(); - let worker = BuiltinStatWorker::global(); - - match worker.submit(BuiltinStatJob { - stat: self.stat, - respond: tx, - }) { - BuiltinSubmitOutcome::Submitted => {} - BuiltinSubmitOutcome::QueueFull => { - // Restore every grouped item so the next refresh wave can retry cleanly - for item in self.items { - item.restore_builtin_error(fallback.clone(), base_interval); - } - return; - } - BuiltinSubmitOutcome::WorkerUnavailable => { - // Inline fallback still samples the source once, then fans the value out to every card - let value = fallback.read().unwrap_or_else(|| "n/a".to_string()); - for item in self.items { - item.restore_builtin_value(fallback.clone(), &value, base_interval); - } - return; - } - } - - glib::MainContext::default().spawn_local(async move { - let result = rx.recv().await; - let Ok((builtin, value)) = result else { - for item in self.items { - item.restore_builtin_error(fallback.clone(), base_interval); - } - return; - }; - - // Every grouped card receives the same value and updated reader state clone - for item in self.items { - item.restore_builtin_value(builtin.clone(), &value, base_interval); - } - }); - } -} From 8475ca32db0e7ad98a08880c4693b482120a65d1 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:17:57 -0500 Subject: [PATCH 029/275] perf(text): bound normalization and truncation work Summary: bound normalization and truncation work. Scope: text. --- .../unixnotis-core/src/model/image/hints.rs | 15 +++++-------- .../unixnotis-core/src/model/notification.rs | 22 +++++++++++++++---- .../src/model/tests/notification.rs | 20 +++++++++++++++++ crates/unixnotis-core/src/util/diagnostics.rs | 13 +++++------ .../src/daemon/control/sanitize.rs | 12 +++++----- .../src/daemon/notifications/payload.rs | 16 ++++++-------- .../src/daemon/notifications/tests/payload.rs | 12 ++++++++++ .../unixnotis-daemon/src/trial_mode/prompt.rs | 12 ++++++++-- .../src/trial_mode/tests/prompt.rs | 13 +++++++++++ 9 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 1048fe6be..2e3f78708 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -179,17 +179,14 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { return value.to_string(); } - // Find the last valid UTF-8 character boundary that does not exceed max_bytes, - // so slicing never cuts through the middle of a multi-byte character - let end = value - .char_indices() - .map(|(index, _)| index) - .take_while(|index| *index <= max_bytes) - .last() - .unwrap_or(0); + // Back up only across the code point that crosses the byte limit + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } // Return only the byte-safe prefix - value.get(..end).unwrap_or_default().to_string() + value[..end].to_string() } pub(in crate::model) fn owned_to_string(value: &OwnedValue) -> Option { diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 3e4a1dc4a..d7d17fe86 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -159,14 +159,18 @@ fn notification_plain_text(input: &str) -> String { } fn push_tag_spacing(output: &mut String, tag: &str) { + const BLOCK_TAGS: [&str; 5] = ["br", "p", "div", "li", "tr"]; + // Trim "/" first so opening and closing tags use the same spacing rule let tag_name = tag .trim_start_matches('/') .split(|ch: char| ch.is_whitespace() || ch == '/') .next() - .unwrap_or_default() - .to_ascii_lowercase(); - if matches!(tag_name.as_str(), "br" | "p" | "div" | "li" | "tr") { + .unwrap_or_default(); + if BLOCK_TAGS + .iter() + .any(|expected| tag_name.eq_ignore_ascii_case(expected)) + { // These tags normally separate chunks of text output.push('\n'); } @@ -250,7 +254,17 @@ fn collapse_notification_whitespace(input: &str) -> String { } } - output.trim().to_string() + if saw_newline { + // A newline can follow an already-normalized space at the tail + output.pop(); + if output.ends_with(' ') { + output.pop(); + } + } else if saw_space { + output.pop(); + } + + output } /// Serializable view of a notification for D-Bus signals diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 43254e708..af698c8f4 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -122,6 +122,16 @@ fn notification_view_treats_self_closing_break_as_newline() { assert_eq!(view.body, "Line one\nLine two"); } +#[test] +fn notification_view_matches_block_tags_without_allocating_lowercase_names() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Line one
Line two

Line three".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Line one\nLine two\nLine three"); +} + #[test] fn notification_view_preserves_inline_markup_adjacency() { let mut notification = notification_with_image(image_with_raw_bytes()); @@ -152,6 +162,16 @@ fn notification_view_collapses_repeated_block_tag_newlines() { assert_eq!(view.body, "Line one\nLine two"); } +#[test] +fn notification_view_removes_trailing_whitespace_in_place() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = " Alpha \n ".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Alpha"); +} + #[test] fn notification_view_preserves_unterminated_entity_text() { let mut notification = notification_with_image(image_with_raw_bytes()); diff --git a/crates/unixnotis-core/src/util/diagnostics.rs b/crates/unixnotis-core/src/util/diagnostics.rs index 8ad473e6c..177f2524c 100644 --- a/crates/unixnotis-core/src/util/diagnostics.rs +++ b/crates/unixnotis-core/src/util/diagnostics.rs @@ -14,14 +14,11 @@ pub fn diagnostic_mode() -> bool { } fn diagnostic_mode_from(value: Option<&str>) -> bool { - matches!( - value - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" | "yes" | "on" - ) + let value = value.unwrap_or_default().trim(); + value == "1" + || ["true", "yes", "on"] + .iter() + .any(|expected| value.eq_ignore_ascii_case(expected)) } /// Returns the default redaction length for logs diff --git a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs index 0cdcca653..c30ffbfc8 100644 --- a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs @@ -40,12 +40,10 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { return value.to_string(); } - // Keep the largest character boundary at or before the byte limit - let end = value - .char_indices() - .map(|(index, _)| index) - .take_while(|index| *index <= max_bytes) - .last() - .unwrap_or(0); + // Back up only across the code point that crosses the byte limit + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } value[..end].to_string() } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 91d983fda..d290a288a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -163,7 +163,8 @@ fn reply_hint_text(hints: &HashMap, key: &str) -> String { fn parse_actions(raw: Vec) -> Vec { // Actions come in key and label pairs - let mut actions = Vec::with_capacity(raw.len().min(MAX_ACTIONS)); + let action_capacity = (raw.len() / 2).min(MAX_ACTIONS); + let mut actions = Vec::with_capacity(action_capacity); let mut iter = raw.into_iter(); // The protocol sends actions as [key, label, key, label, ...] @@ -254,14 +255,11 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { return value.to_string(); } - // Walk character ends instead of decrementing a byte index; a mutated loop - // counter must not be able to hang while handling untrusted notification text - let end = value - .char_indices() - .map(|(index, ch)| index + ch.len_utf8()) - .take_while(|end| *end <= max_bytes) - .last() - .unwrap_or(0); + // At most three continuation bytes can sit between the limit and a boundary + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } value[..end].to_string() } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index 8b72493c3..24d8b974c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -157,6 +157,18 @@ fn parse_actions_ignores_dangling_key_without_label() { assert_eq!(actions[0].label, "Open"); } +#[test] +fn parse_actions_reserves_capacity_for_complete_pairs_only() { + let actions = parse_actions(vec![ + "default".to_string(), + "Open".to_string(), + "orphan-key".to_string(), + ]); + + assert_eq!(actions.len(), 1); + assert_eq!(actions.capacity(), 1); +} + #[test] fn sanitize_hints_drops_untrusted_and_bounds_strings() { let mut hints = HashMap::::new(); diff --git a/crates/unixnotis-daemon/src/trial_mode/prompt.rs b/crates/unixnotis-daemon/src/trial_mode/prompt.rs index efc0890bb..e2110c996 100644 --- a/crates/unixnotis-daemon/src/trial_mode/prompt.rs +++ b/crates/unixnotis-daemon/src/trial_mode/prompt.rs @@ -12,7 +12,15 @@ pub(super) fn confirm_trial() -> Result { io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; - let input = input.trim().to_ascii_lowercase(); // Any response outside y/yes is treated as no - Ok(matches!(input.as_str(), "y" | "yes")) + Ok(is_trial_confirmation(&input)) } + +fn is_trial_confirmation(input: &str) -> bool { + let input = input.trim(); + input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") +} + +#[cfg(test)] +#[path = "tests/prompt.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs b/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs new file mode 100644 index 000000000..53026dca6 --- /dev/null +++ b/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs @@ -0,0 +1,13 @@ +use super::is_trial_confirmation; + +#[test] +fn trial_confirmation_accepts_trimmed_ascii_yes_values() { + assert!(is_trial_confirmation(" y ")); + assert!(is_trial_confirmation("YES")); +} + +#[test] +fn trial_confirmation_rejects_empty_and_unrecognized_values() { + assert!(!is_trial_confirmation("")); + assert!(!is_trial_confirmation("true")); +} From 2af6012547b0047d935e763eef988ae73887aa5e Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:18:35 -0500 Subject: [PATCH 030/275] fix(sound): preserve OS-native playback paths Summary: preserve OS-native playback paths. Scope: sound. --- crates/unixnotis-daemon/src/sound/command.rs | 54 +++++++++++-------- .../src/sound/tests/command.rs | 12 +++++ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/crates/unixnotis-daemon/src/sound/command.rs b/crates/unixnotis-daemon/src/sound/command.rs index 47c8c512a..42be65711 100644 --- a/crates/unixnotis-daemon/src/sound/command.rs +++ b/crates/unixnotis-daemon/src/sound/command.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::process::Stdio; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -19,12 +20,12 @@ pub(super) fn play_with_canberra(source: SoundSource) { let mut args = Vec::new(); match source { SoundSource::Name(name) => { - args.push("-i".to_string()); - args.push(name); + args.push(OsString::from("-i")); + args.push(OsString::from(name)); } SoundSource::File(path) => { - args.push("-f".to_string()); - args.push(path.to_string_lossy().to_string()); + args.push(OsString::from("-f")); + args.push(path.into_os_string()); } } spawn_sound_command("canberra", "canberra-gtk-play", &args); @@ -36,7 +37,7 @@ pub(super) fn play_with_pw_play(source: SoundSource) { warn!("pw-play backend does not support sound-name hints"); return; }; - let args = vec![path.to_string_lossy().to_string()]; + let args = vec![path.into_os_string()]; spawn_sound_command("pw-play", "pw-play", &args); } @@ -46,7 +47,7 @@ pub(super) fn play_with_paplay(source: SoundSource) { warn!("paplay backend does not support sound-name hints"); return; }; - let args = vec![path.to_string_lossy().to_string()]; + let args = vec![path.into_os_string()]; spawn_sound_command("paplay", "paplay", &args); } @@ -56,7 +57,7 @@ fn sound_semaphore() -> &'static Arc { SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(SOUND_MAX_CONCURRENT))) } -fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { +fn spawn_sound_command(backend: &'static str, program: &str, args: &[OsString]) { let limiter = sound_semaphore().clone(); // try_acquire keeps this call non-blocking on hot paths let permit = if let Ok(permit) = limiter.try_acquire_owned() { @@ -65,21 +66,9 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { debug!(backend, "sound command skipped (concurrency limit reached)"); return; }; - let command_str = if args.is_empty() { - program.to_string() - } else { - format!("{program} {}", args.join(" ")) - }; + let command_str = sound_command_display(program, args); let command_snip = util::log_snippet(&command_str); - let mut command = Command::new(program); - command - .args(args) - // Child process has no need for inherited stdio streams - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - // Ensure child exits if task is dropped early - .kill_on_drop(true); + let mut command = build_sound_command(program, args); match command.spawn() { Ok(child) => { let pid = child.id(); @@ -106,6 +95,29 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { } } +fn build_sound_command(program: &str, args: &[OsString]) -> Command { + let mut command = Command::new(program); + command + // OsString keeps every valid Unix path byte intact + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // Dropped tasks must not leave playback children behind + .kill_on_drop(true); + command +} + +fn sound_command_display(program: &str, args: &[OsString]) -> String { + let mut display = program.to_string(); + for argument in args { + // Lossy text is restricted to bounded diagnostics, never execution + display.push(' '); + display.push_str(&argument.to_string_lossy()); + } + display +} + async fn reap_sound_child( backend: &'static str, command_snip: String, diff --git a/crates/unixnotis-daemon/src/sound/tests/command.rs b/crates/unixnotis-daemon/src/sound/tests/command.rs index 01b0325ac..25364dc0a 100644 --- a/crates/unixnotis-daemon/src/sound/tests/command.rs +++ b/crates/unixnotis-daemon/src/sound/tests/command.rs @@ -1,5 +1,17 @@ use super::*; +#[cfg(unix)] +#[test] +fn sound_command_preserves_non_utf8_argument_bytes() { + use std::os::unix::ffi::OsStringExt; + + let path = OsString::from_vec(b"/tmp/sound-\xff.ogg".to_vec()); + let command = build_sound_command("true", std::slice::from_ref(&path)); + let args = command.as_std().get_args().collect::>(); + + assert_eq!(args, vec![path.as_os_str()]); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn reaps_short_lived_command() { From 392f88499c8776b7b3614dd7565dd77ceaab6b1d Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:21:11 -0500 Subject: [PATCH 031/275] perf: reduce queue and collection churn Summary: reduce queue and collection churn. Scope: repository. --- .../noticenterctl/src/preset/export/assets.rs | 2 +- .../src/media/runtime/snapshot.rs | 2 +- .../src/ui/notifications/store/update.rs | 5 ++- .../widgets/utils/command/queue/coalesced.rs | 36 ++++++++++--------- .../utils/command/queue/tests/coalesced.rs | 11 +++--- .../src/config/validation/schema.rs | 4 +-- crates/unixnotis-core/src/reconnect.rs | 2 +- .../src/store/inhibitor_api.rs | 4 +-- 8 files changed, 36 insertions(+), 30 deletions(-) diff --git a/crates/noticenterctl/src/preset/export/assets.rs b/crates/noticenterctl/src/preset/export/assets.rs index ec66a54b3..e15bcabe5 100644 --- a/crates/noticenterctl/src/preset/export/assets.rs +++ b/crates/noticenterctl/src/preset/export/assets.rs @@ -26,7 +26,7 @@ pub(super) fn collect_existing_icon_assets( paths.push(relative); } } - paths.sort(); + paths.sort_unstable(); paths.dedup(); Ok(paths) } diff --git a/crates/unixnotis-center/src/media/runtime/snapshot.rs b/crates/unixnotis-center/src/media/runtime/snapshot.rs index b3c78bb28..743076a04 100644 --- a/crates/unixnotis-center/src/media/runtime/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/snapshot.rs @@ -18,7 +18,7 @@ pub(super) async fn send_snapshot_if_changed( // Identical snapshots do not need another UI event or list rebuild path return; } - *last_snapshot = snapshot.clone(); + last_snapshot.clone_from(&snapshot); if snapshot.is_empty() { if let Err(err) = sender.send(UiEvent::MediaCleared).await { // Closed UI channels are normal during teardown, but the drop should stay visible diff --git a/crates/unixnotis-center/src/ui/notifications/store/update.rs b/crates/unixnotis-center/src/ui/notifications/store/update.rs index e0a458a64..0484b9151 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/update.rs @@ -3,7 +3,6 @@ //! Keeps list-store mutation logic separate from data mutation methods use std::collections::{HashMap, HashSet}; -use std::ops::Not; use std::rc::Rc; use gio::prelude::ListModelExt; @@ -277,7 +276,7 @@ impl NotificationList { } fn group_ids_are_visible(&self, ids: &[u32]) -> bool { - self.visible_ids_for_group(ids).is_empty().not() + !self.visible_ids_for_group(ids).is_empty() } pub(in crate::ui::notifications) fn update_empty_overlay(&self) { @@ -307,7 +306,7 @@ const fn has_pending_items(count: usize) -> bool { } const fn range_count_mismatch(actual: usize, expected: usize) -> bool { - actual.abs_diff(expected) > 0 + actual != expected } fn intern_key_is_live(key: &Rc) -> bool { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs index f7fc403c4..1c1140b8a 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs @@ -120,14 +120,13 @@ impl CoalescedRefreshQueue { Err(channel::TrySendError::Full(job)) => { // Worker queue is still full, so put it back let mut state = self.state.lock().expect("coalesced refresh lock poisoned"); - if !state.pending.contains_key(&key) - && state.pending.len() >= COALESCED_REFRESH_CAPACITY - { + let already_pending = state.pending.contains_key(&key); + if !already_pending && state.pending.len() >= COALESCED_REFRESH_CAPACITY { if let Some(oldest) = state.order.pop_front() { state.pending.remove(&oldest); } } - if !state.pending.contains_key(&key) { + if !already_pending { state.order.push_front(key.clone()); } state.pending.insert(key, job); @@ -146,23 +145,28 @@ pub(super) fn insert_coalesced_job( job: CommandJob, ) -> CoalescedInsertOutcome { let key = RefreshCommandKey::from_job(&job); - let replaced_existing = state.pending.contains_key(&key); + if let Some(existing) = state.pending.get_mut(&key) { + // Replacing in place avoids a second hash lookup and keeps queue order stable + *existing = job; + return CoalescedInsertOutcome { + replaced_existing: true, + evicted_oldest: false, + }; + } + let mut evicted_oldest = false; - if !replaced_existing { - if state.pending.len() >= COALESCED_REFRESH_CAPACITY { - if let Some(oldest) = state.order.pop_front() { - // Drop the oldest job when full - state.pending.remove(&oldest); - evicted_oldest = true; - } + if state.pending.len() >= COALESCED_REFRESH_CAPACITY { + if let Some(oldest) = state.order.pop_front() { + // Drop the oldest job when full + state.pending.remove(&oldest); + evicted_oldest = true; } - // First seen key goes to the back - state.order.push_back(key.clone()); } - // Replacing the old job drops stale refresh work + // First-seen keys enter at the back of the drain order + state.order.push_back(key.clone()); state.pending.insert(key, job); CoalescedInsertOutcome { - replaced_existing, + replaced_existing: false, evicted_oldest, } } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs index bf3ad7763..f27ac2bf3 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs @@ -29,13 +29,16 @@ fn same_refresh_key_replaces_existing_job() { &mut state, job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), ); - let outcome = insert_coalesced_job( - &mut state, - job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), - ); + let replacement = job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast); + let replacement_queued_at = replacement.queued_at; + let outcome = insert_coalesced_job(&mut state, replacement); assert_eq!(state.pending.len(), 1); assert_eq!(state.order.len(), 1); + assert_eq!( + state.pending.values().next().map(|item| item.queued_at), + Some(replacement_queued_at) + ); assert!(outcome.replaced_existing); assert!(!outcome.evicted_oldest); } diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index ef5bec0e2..d99879e8e 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -26,7 +26,7 @@ pub(in crate::config) fn deserialize_config_with_migrations( // Card restoration happens after deserialization, so record it outside the TOML diff migrated_paths.push("widgets.cards".to_string()); } - migrated_paths.sort(); + migrated_paths.sort_unstable(); migrated_paths.dedup(); let mut ignored_keys = Vec::new(); let deserializer = document.into_deserializer(); @@ -60,7 +60,7 @@ fn collect_changed_paths( (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => { // Union traversal catches inserted, removed, and changed child keys let mut keys = before.keys().chain(after.keys()).collect::>(); - keys.sort(); + keys.sort_unstable(); keys.dedup(); for key in keys { let child = if path.is_empty() { diff --git a/crates/unixnotis-core/src/reconnect.rs b/crates/unixnotis-core/src/reconnect.rs index f41f0039e..314b27cfe 100644 --- a/crates/unixnotis-core/src/reconnect.rs +++ b/crates/unixnotis-core/src/reconnect.rs @@ -94,7 +94,7 @@ pub fn jitter_duration(max_ms: u64) -> Duration { if max_ms == 0 { return Duration::ZERO; } - let jitter_ms = next_jitter_seed().wrapping_rem(max_ms); + let jitter_ms = next_jitter_seed() % max_ms; Duration::from_millis(jitter_ms) } diff --git a/crates/unixnotis-daemon/src/store/inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitor_api.rs index 61f601e90..f85544c2a 100644 --- a/crates/unixnotis-daemon/src/store/inhibitor_api.rs +++ b/crates/unixnotis-daemon/src/store/inhibitor_api.rs @@ -67,8 +67,8 @@ impl NotificationStore { inhibitor.owner.clone(), )); } - // Stable order keeps CLI output and tests deterministic - inhibitors.sort_by_key(|(id, _, _, _)| *id); + // Unique monotonic IDs provide deterministic output without stable sorting + inhibitors.sort_unstable_by_key(|(id, _, _, _)| *id); inhibitors } From dfadc93a85ab1a6bc0bc7a3193441fcd5a89754c Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 17:22:30 -0500 Subject: [PATCH 032/275] refactor(stats): isolate builtin reader dispatch Summary: isolate builtin reader dispatch. Scope: stats. --- .../ui/widgets/stats/builtin/readers/cpu.rs | 4 +- .../widgets/stats/builtin/readers/dispatch.rs | 44 +++++++++++++++++++ .../ui/widgets/stats/builtin/readers/mod.rs | 42 +----------------- 3 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs index d4def578a..e3643eb44 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs @@ -1,6 +1,6 @@ -//! CPU reader helpers for builtin stats. +//! CPU reader helpers for built-in stats //! -//! Reads /proc/stat and returns total/idle counters for usage calculation. +//! Reads /proc/stat and returns total and idle counters for usage calculation use std::fs; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs new file mode 100644 index 000000000..fd44d0ef0 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs @@ -0,0 +1,44 @@ +//! Built-in reader dispatch and stateful sample formatting + +use super::{read_battery, read_cpu_sample, read_loadavg, read_memory, read_network}; +use crate::ui::widgets::stats::builtin::model::{BuiltinStat, BuiltinStatKind, BuiltinState}; + +impl BuiltinStat { + pub(in crate::ui::widgets::stats) fn read(&mut self) -> Option { + match &mut self.kind { + BuiltinStatKind::Cpu => self.read_cpu(), + BuiltinStatKind::Memory => read_memory(), + BuiltinStatKind::Load => read_loadavg(), + BuiltinStatKind::Battery => read_battery(), + BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), + } + } + + fn read_cpu(&mut self) -> Option { + let (total, idle) = read_cpu_sample()?; + let usage = match &mut self.state { + BuiltinState::Cpu { + last_total, + last_idle, + } => { + let usage = if *last_total > 0 && total > *last_total { + // Delta-based usage avoids spikes when the counter wraps + let delta_total = total - *last_total; + let delta_idle = idle.saturating_sub(*last_idle); + 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 + } else if total > 0 { + // The first read falls back to absolute usage + 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 + } else { + 0.0 + }; + // Updated counters become the baseline for the next delta + *last_total = total; + *last_idle = idle; + usage + } + _ => 0.0, + }; + Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs index 3bf64338b..9604e14e0 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs @@ -2,6 +2,7 @@ pub(in crate::ui::widgets::stats) mod battery; mod cpu; +mod dispatch; mod load; mod memory; pub(in crate::ui::widgets::stats) mod network; @@ -11,44 +12,3 @@ pub(super) use cpu::read_cpu_sample; pub(super) use load::read_loadavg; pub(super) use memory::read_memory; pub(super) use network::{extract_iface, read_network}; - -use super::model::{BuiltinStat, BuiltinStatKind, BuiltinState}; - -impl BuiltinStat { - pub(in crate::ui::widgets::stats) fn read(&mut self) -> Option { - match &mut self.kind { - BuiltinStatKind::Cpu => self.read_cpu(), - BuiltinStatKind::Memory => read_memory(), - BuiltinStatKind::Load => read_loadavg(), - BuiltinStatKind::Battery => read_battery(), - BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), - } - } - - fn read_cpu(&mut self) -> Option { - let (total, idle) = read_cpu_sample()?; - let usage = match &mut self.state { - BuiltinState::Cpu { - last_total, - last_idle, - } => { - let usage = if *last_total > 0 && total > *last_total { - // Delta-based usage avoids spikes when the counter wraps - let delta_total = total - *last_total; - let delta_idle = idle.saturating_sub(*last_idle); - 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 - } else if total > 0 { - // First read falls back to absolute usage - 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 - } else { - 0.0 - }; - *last_total = total; - *last_idle = idle; - usage - } - _ => 0.0, - }; - Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) - } -} From d26bde8852a39bed2088a2a42178e032a8d005f8 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 20 Jul 2026 18:00:40 -0500 Subject: [PATCH 033/275] test: cover optimized boundary behavior Summary: cover optimized boundary behavior. Scope: repository. --- .../src/ui/widgets/stats/card/model.rs | 14 +++--- .../src/ui/widgets/stats/card/refresh/mod.rs | 2 +- .../src/ui/widgets/stats/tests/card.rs | 47 +++++++++++++++++++ .../src/ui/widgets/stats/tests/support.rs | 29 ++++++++++++ .../src/config/validation/tests/schema.rs | 11 +++++ crates/unixnotis-core/src/filesystem/path.rs | 4 +- .../unixnotis-core/src/model/image/hints.rs | 9 +++- crates/unixnotis-core/src/process/legacy.rs | 5 +- .../src/process/tests/legacy.rs | 9 ++-- .../src/daemon/control/sanitize.rs | 9 +++- .../src/daemon/notifications/payload.rs | 9 +++- .../src/daemon/notifications/tests/payload.rs | 7 +-- .../src/sound/tests/command.rs | 2 + .../src/paths/tests/s6_live.rs | 2 +- 14 files changed, 137 insertions(+), 22 deletions(-) diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs index 7b9ae3d8a..a0eef14f3 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs @@ -11,19 +11,19 @@ use crate::ui::widgets::utils::RefreshBackoff; #[derive(Clone)] pub(in crate::ui::widgets::stats) struct StatItem { // Raw config supplies source selection and display metadata - pub(super) config: StatWidgetConfig, + pub(in crate::ui::widgets::stats) config: StatWidgetConfig, // Root card inserted into the grid - pub(super) root: gtk::Box, + pub(in crate::ui::widgets::stats) root: gtk::Box, // Label receives the latest rendered sample - pub(super) value_label: gtk::Label, + pub(in crate::ui::widgets::stats) value_label: gtk::Label, // Built-in reader state is retained across samples - pub(super) builtin: Rc>>, + pub(in crate::ui::widgets::stats) builtin: Rc>>, // In-flight state prevents overlapping refreshes - pub(super) inflight: Rc>, + pub(in crate::ui::widgets::stats) inflight: Rc>, // Last good value avoids unnecessary relayout - pub(super) last_value: Rc>>, + pub(in crate::ui::widgets::stats) last_value: Rc>>, // Backoff slows sources whose output remains stable - pub(super) refresh_backoff: Rc>, + pub(in crate::ui::widgets::stats) refresh_backoff: Rc>, } pub(super) enum StatSourceRef<'a> { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs index 32f038b7c..5f8892b9d 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs @@ -90,7 +90,7 @@ impl StatItem { .map_or(StatSourceRef::Missing, StatSourceRef::Command) } - fn refresh_missing(&self, base_interval: Duration) { + pub(in crate::ui::widgets::stats) fn refresh_missing(&self, base_interval: Duration) { // Missing sources settle on the placeholder without spinning let changed = self.apply_value("n/a"); self.refresh_backoff diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs index 5968eb910..3ffecbd51 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs @@ -1,6 +1,11 @@ //! Statistic card presentation tests +use std::time::Duration; + +use super::super::builtin::worker::BuiltinSample; +use super::super::builtin::BuiltinStat; use super::super::style::stat_kind_css_class; +use super::support::stat_item; #[test] fn card_kind_class_normalizes_theme_tokens() { @@ -14,3 +19,45 @@ fn card_kind_class_normalizes_theme_tokens() { ); assert_eq!(stat_kind_css_class(" !!! "), None); } + +#[gtk::test] +fn missing_card_source_renders_the_placeholder() { + let item = stat_item(None, None); + + item.refresh_missing(Duration::from_secs(1)); + + assert_eq!(item.value_label.text(), "n/a"); + assert_eq!(item.last_value.borrow().as_deref(), Some("n/a")); +} + +#[gtk::test] +fn failed_builtin_sample_preserves_the_last_good_value() { + let item = stat_item(None, Some("42%")); + let stat = + BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); + + item.restore_builtin_sample(BuiltinSample { stat, value: None }, Duration::from_secs(1)); + + assert_eq!(item.value_label.text(), "42%"); + assert_eq!(item.last_value.borrow().as_deref(), Some("42%")); + assert!(!item.inflight.get()); + assert!(item.builtin.borrow().is_some()); +} + +#[gtk::test] +fn successful_builtin_sample_replaces_a_changed_value() { + let item = stat_item(None, Some("41%")); + let stat = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + + item.restore_builtin_sample( + BuiltinSample { + stat, + value: Some("42%".to_string()), + }, + Duration::from_secs(1), + ); + + assert_eq!(item.value_label.text(), "42%"); + assert_eq!(item.last_value.borrow().as_deref(), Some("42%")); + assert!(!item.inflight.get()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs index 8fae4e9d1..8a0c73bf4 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs @@ -4,6 +4,35 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use super::super::builtin::BuiltinStat; +use super::super::card::StatItem; +use crate::ui::widgets::utils::RefreshBackoff; +use unixnotis_core::StatWidgetConfig; + +static GTK_INIT: std::sync::Once = std::sync::Once::new(); + +pub(super) fn init_gtk() { + GTK_INIT.call_once(|| { + gtk::init().expect("gtk should initialize under the test display"); + }); +} + +pub(super) fn stat_item(builtin: Option, value: Option<&str>) -> StatItem { + init_gtk(); + let rendered = value.unwrap_or("n/a"); + StatItem { + config: StatWidgetConfig::default(), + root: gtk::Box::new(gtk::Orientation::Vertical, 0), + value_label: gtk::Label::new(Some(rendered)), + builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), + inflight: std::rc::Rc::new(std::cell::Cell::new(true)), + last_value: std::rc::Rc::new(std::cell::RefCell::new( + value.map(std::string::ToString::to_string), + )), + refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), + } +} + pub(super) struct TempDir { path: PathBuf, } diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index 5115300d5..e79a100fe 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -130,6 +130,17 @@ fn explicit_legacy_values_remain_authoritative_during_migration() { assert_eq!(config.panel.empty_offset_top, 77); } +#[test] +fn root_scalar_changes_do_not_report_an_empty_migration_path() { + let before = toml::Value::Integer(1); + let after = toml::Value::Integer(2); + let mut paths = Vec::new(); + + collect_changed_paths("", Some(&before), Some(&after), &mut paths); + + assert!(paths.is_empty()); +} + #[test] fn empty_unversioned_config_receives_complete_legacy_defaults() { let (config, ignored) = deserialize_config("").expect("migrate empty legacy config"); diff --git a/crates/unixnotis-core/src/filesystem/path.rs b/crates/unixnotis-core/src/filesystem/path.rs index 934b3aed6..2a0a217d8 100644 --- a/crates/unixnotis-core/src/filesystem/path.rs +++ b/crates/unixnotis-core/src/filesystem/path.rs @@ -37,7 +37,7 @@ impl LexicallyNormalizedPath { Component::ParentDir => match normalized.components().next_back() { Some(Component::Normal(_)) => { let removed = normalized.pop(); - debug_assert!(removed); + debug_assert!(removed, "normal path component must be removable"); } _ => return Err(LexicalPathError::ParentEscape), }, @@ -88,7 +88,7 @@ impl ContainedPath { let relative = normalized .as_path() .strip_prefix(root.as_path()) - .map_err(|_| LexicalPathError::OutsideRoot)? + .map_err(|_outside_root| LexicalPathError::OutsideRoot)? .to_path_buf(); Ok(Self { root, relative }) } diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 2e3f78708..9334dc2e1 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -181,9 +181,16 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { // Back up only across the code point that crosses the byte limit let mut end = max_bytes; - while !value.is_char_boundary(end) { + for _ in 0..3 { + if value.is_char_boundary(end) { + break; + } end -= 1; } + debug_assert!( + value.is_char_boundary(end), + "bounded backup must reach the current UTF-8 character boundary" + ); // Return only the byte-safe prefix value[..end].to_string() diff --git a/crates/unixnotis-core/src/process/legacy.rs b/crates/unixnotis-core/src/process/legacy.rs index d2d889cff..8e4707c2a 100644 --- a/crates/unixnotis-core/src/process/legacy.rs +++ b/crates/unixnotis-core/src/process/legacy.rs @@ -156,6 +156,9 @@ fn contains_shell_syntax(command: &str) -> bool { } // shell_words validates these states before this classifier runs - debug_assert!(quote.is_none() && !escaped); + debug_assert!( + quote.is_none() && !escaped, + "validated legacy command must finish outside quoted or escaped input" + ); false } diff --git a/crates/unixnotis-core/src/process/tests/legacy.rs b/crates/unixnotis-core/src/process/tests/legacy.rs index 14e93a07f..0b5436f25 100644 --- a/crates/unixnotis-core/src/process/tests/legacy.rs +++ b/crates/unixnotis-core/src/process/tests/legacy.rs @@ -163,10 +163,11 @@ fn environment_assignment_names_follow_portable_identifier_rules() { "=value /bin/true", ] { let parsed = parse_legacy_command(command).expect("parse non-assignment token"); - assert_eq!( - parsed.program(), - Some(Path::new(command.split_whitespace().next().unwrap())) - ); + let first_token = command + .split_whitespace() + .next() + .expect("test command must contain a program token"); + assert_eq!(parsed.program(), Some(Path::new(first_token))); assert!(parsed.env().expect("direct environment").is_empty()); } } diff --git a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs index c30ffbfc8..7f6bc9865 100644 --- a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs @@ -42,8 +42,15 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { // Back up only across the code point that crosses the byte limit let mut end = max_bytes; - while !value.is_char_boundary(end) { + for _ in 0..3 { + if value.is_char_boundary(end) { + break; + } end -= 1; } + debug_assert!( + value.is_char_boundary(end), + "bounded backup must reach the current UTF-8 character boundary" + ); value[..end].to_string() } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index d290a288a..4c6bef79d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -257,9 +257,16 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { // At most three continuation bytes can sit between the limit and a boundary let mut end = max_bytes; - while !value.is_char_boundary(end) { + for _ in 0..3 { + if value.is_char_boundary(end) { + break; + } end -= 1; } + debug_assert!( + value.is_char_boundary(end), + "bounded backup must reach the current UTF-8 character boundary" + ); value[..end].to_string() } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index 24d8b974c..afc0f6d95 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -162,11 +162,12 @@ fn parse_actions_reserves_capacity_for_complete_pairs_only() { let actions = parse_actions(vec![ "default".to_string(), "Open".to_string(), - "orphan-key".to_string(), + "dismiss".to_string(), + "Dismiss".to_string(), ]); - assert_eq!(actions.len(), 1); - assert_eq!(actions.capacity(), 1); + assert_eq!(actions.len(), 2); + assert_eq!(actions.capacity(), 2); } #[test] diff --git a/crates/unixnotis-daemon/src/sound/tests/command.rs b/crates/unixnotis-daemon/src/sound/tests/command.rs index 25364dc0a..80e0d56fa 100644 --- a/crates/unixnotis-daemon/src/sound/tests/command.rs +++ b/crates/unixnotis-daemon/src/sound/tests/command.rs @@ -8,8 +8,10 @@ fn sound_command_preserves_non_utf8_argument_bytes() { let path = OsString::from_vec(b"/tmp/sound-\xff.ogg".to_vec()); let command = build_sound_command("true", std::slice::from_ref(&path)); let args = command.as_std().get_args().collect::>(); + let display = sound_command_display("true", std::slice::from_ref(&path)); assert_eq!(args, vec![path.as_os_str()]); + assert_eq!(display, "true /tmp/sound-�.ogg"); } #[cfg(target_os = "linux")] diff --git a/crates/unixnotis-installer/src/paths/tests/s6_live.rs b/crates/unixnotis-installer/src/paths/tests/s6_live.rs index 32de2566d..29e5b0d2d 100644 --- a/crates/unixnotis-installer/src/paths/tests/s6_live.rs +++ b/crates/unixnotis-installer/src/paths/tests/s6_live.rs @@ -247,7 +247,7 @@ fn install_paths_allow_explicit_symlinked_s6_live_root() { .start_command() .expect("s6 start command") .args()[1], - linked_live.to_string_lossy() + linked_live.as_os_str() ); restore_env("UNIXNOTIS_SERVICE_MANAGER", previous_manager); From 1c15c8771c08afa8a330db22244699bbc8646061 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 21 Jul 2026 23:18:57 -0500 Subject: [PATCH 034/275] fix(commands): classify shell wrappers and timeouts consistently Summary: classify shell wrappers and timeouts consistently. Scope: commands. --- .../ui/widgets/utils/command/command_parse.rs | 29 +++++-------------- .../utils/command/tests/command_parse.rs | 17 +++++++---- .../ui/widgets/utils/command/tests/plan.rs | 11 +++++++ crates/unixnotis-core/src/process/spec.rs | 8 ++++- .../unixnotis-core/src/process/tests/spec.rs | 9 ++++-- 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs index d3698f8b4..3958e716a 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs @@ -3,12 +3,16 @@ //! Keeps shell parsing and "slow command" classification localized so the //! enqueue/worker pipeline can stay focused on execution and backpressure -use std::ffi::OsStr; - use unixnotis_core::CommandSpec; pub(super) fn is_probably_slow(cmd: &CommandSpec) -> bool { - let CommandSpec::Direct { program, args, .. } = cmd else { + // Shared shell detection owns every direct interpreter spelling + // Shell startup and script execution belong on the wider timeout budget + if cmd.invokes_shell() { + return true; + } + + let CommandSpec::Direct { program, .. } = cmd else { return true; }; @@ -39,28 +43,9 @@ pub(super) fn is_probably_slow(cmd: &CommandSpec) -> bool { return true; } - if matches!(program_name.as_str(), "sh" | "bash" | "zsh" | "fish") { - // Shell scripts are treated as slow if the first token is "sleep" - if let Some(script) = shell_script_arg(args) { - if script.split_whitespace().next() == Some("sleep") { - return true; - } - } - } - false } -fn shell_script_arg(args: &[std::ffi::OsString]) -> Option<&str> { - let mut iter = args.iter().peekable(); - while let Some(arg) = iter.next() { - if arg == OsStr::new("-c") { - return iter.peek().and_then(|value| value.to_str()); - } - } - None -} - #[cfg(test)] #[path = "tests/command_parse.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs index b8d22a78a..f617e8359 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs @@ -23,13 +23,20 @@ fn explicit_shell_commands_use_the_slow_lane() { } #[test] -fn directly_invoked_shells_only_inspect_the_script_argument() { +fn direct_shell_wrappers_share_the_slow_lane_classification() { + for shell in ["sh", "ash", "bash", "dash", "fish", "ksh", "zsh"] { + assert!( + is_probably_slow(&CommandSpec::direct(shell, ["-c", "sleep 1"])), + "{shell} -c must receive the slow command budget" + ); + } + assert!(is_probably_slow(&CommandSpec::direct( - "bash", - ["-c", "sleep 1"] + "/bin/dash", + ["-c", "printf ready"] ))); assert!(!is_probably_slow(&CommandSpec::direct( - "bash", - ["-c", "echo sleep"] + "dash", + ["-x", "script"] ))); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs index 7a3e2b9e5..3999f9e85 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs @@ -11,6 +11,17 @@ fn slow_command_promotes_refresh_plan_to_slow_lane() { assert_eq!(plan.timeout(), Duration::from_millis(800)); } +#[test] +fn direct_dash_wrapper_receives_the_slow_timeout_budget() { + let plan = resolve_command_plan( + &CommandSpec::direct("dash", ["-c", "sleep 1"]), + CommandKind::Fast, + ); + + assert_eq!(plan.kind, CommandKind::Slow); + assert_eq!(plan.timeout(), Duration::from_millis(800)); +} + #[test] fn action_command_keeps_action_lane_even_when_command_is_slow() { let plan = resolve_command_plan(&CommandSpec::direct("sleep", ["1"]), CommandKind::Action); diff --git a/crates/unixnotis-core/src/process/spec.rs b/crates/unixnotis-core/src/process/spec.rs index 19b75d9e0..6b0bf1a80 100644 --- a/crates/unixnotis-core/src/process/spec.rs +++ b/crates/unixnotis-core/src/process/spec.rs @@ -65,18 +65,24 @@ impl CommandSpec { } #[must_use] + /// Reports whether command text crosses an explicit shell boundary pub fn invokes_shell(&self) -> bool { match self { Self::Shell { .. } => true, Self::Direct { program, args, .. } => { + // Basenames keep absolute interpreter paths and PATH lookups equivalent let shell = program .file_name() .and_then(OsStr::to_str) .is_some_and(|name| { - matches!(name, "sh" | "ash" | "bash" | "dash" | "ksh" | "zsh") + matches!( + name, + "sh" | "ash" | "bash" | "dash" | "fish" | "ksh" | "zsh" + ) }); shell && args.iter().any(|argument| { + // Combined flags such as `-lc` still enable script evaluation argument.to_str().is_some_and(|argument| { argument .strip_prefix('-') diff --git a/crates/unixnotis-core/src/process/tests/spec.rs b/crates/unixnotis-core/src/process/tests/spec.rs index 50fcb30d0..b5a114e14 100644 --- a/crates/unixnotis-core/src/process/tests/spec.rs +++ b/crates/unixnotis-core/src/process/tests/spec.rs @@ -51,8 +51,13 @@ fn placeholder_replacement_updates_explicit_shell_script_without_reclassificatio #[test] fn shell_detection_includes_direct_interpreter_invocations() { assert!(CommandSpec::shell("printf ready").invokes_shell()); - assert!(CommandSpec::direct("/bin/sh", ["-c", "printf ready"]).invokes_shell()); - assert!(CommandSpec::direct("bash", ["-lc", "printf ready"]).invokes_shell()); + for shell in ["sh", "ash", "bash", "dash", "fish", "ksh", "zsh"] { + assert!( + CommandSpec::direct(shell, ["-c", "printf ready"]).invokes_shell(), + "{shell} -c must retain the explicit shell boundary" + ); + } + assert!(CommandSpec::direct("/bin/bash", ["-lc", "printf ready"]).invokes_shell()); assert!(!CommandSpec::direct("sh", ["-x", "script"]).invokes_shell()); assert!(!CommandSpec::direct("printf", ["sh -c"]).invokes_shell()); } From e5f436a35703a1a19c7422769997af1a6cd0d737 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 21 Jul 2026 23:19:37 -0500 Subject: [PATCH 035/275] refactor(stats): isolate refresh state and coverage Summary: isolate refresh state and coverage. Scope: stats. --- .../src/ui/widgets/stats/builtin/model.rs | 4 + .../widgets/stats/builtin/readers/battery.rs | 4 + .../ui/widgets/stats/builtin/readers/mod.rs | 3 + .../widgets/stats/builtin/readers/network.rs | 4 + .../stats/builtin/readers/tests/battery.rs | 81 ++++++++ .../stats/builtin/readers/tests/mod.rs | 3 + .../stats/builtin/readers/tests/network.rs | 60 ++++++ .../stats/builtin/readers/tests/support.rs | 44 +++++ .../grouping.rs => builtin/tests/model.rs} | 4 +- .../ui/widgets/stats/builtin/tests/worker.rs | 42 ++++ .../src/ui/widgets/stats/builtin/worker.rs | 4 + .../ui/widgets/stats/card/refresh/dispatch.rs | 110 +++++++++++ .../src/ui/widgets/stats/card/refresh/mod.rs | 111 +---------- .../card.rs => card/refresh/tests/builtin.rs} | 30 +-- .../stats/card/refresh/tests/dispatch.rs | 15 ++ .../widgets/stats/card/refresh/tests/mod.rs | 5 + .../stats/card/refresh/tests/support.rs | 30 +++ .../src/ui/widgets/stats/grid/build.rs | 4 + .../src/ui/widgets/stats/grid/mod.rs | 10 +- .../src/ui/widgets/stats/grid/model.rs | 10 + .../src/ui/widgets/stats/grid/schedule.rs | 4 + .../{tests/grid.rs => grid/tests/build.rs} | 2 +- .../scheduling.rs => grid/tests/schedule.rs} | 2 +- .../src/ui/widgets/stats/mod.rs | 3 - .../src/ui/widgets/stats/style.rs | 4 + .../src/ui/widgets/stats/tests/builtin.rs | 179 ------------------ .../src/ui/widgets/stats/tests/mod.rs | 8 - .../src/ui/widgets/stats/tests/style.rs | 16 ++ .../src/ui/widgets/stats/tests/support.rs | 69 ------- 29 files changed, 459 insertions(+), 406 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs rename crates/unixnotis-center/src/ui/widgets/stats/{tests/grouping.rs => builtin/tests/model.rs} (86%) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs rename crates/unixnotis-center/src/ui/widgets/stats/{tests/card.rs => card/refresh/tests/builtin.rs} (58%) create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs rename crates/unixnotis-center/src/ui/widgets/stats/{tests/grid.rs => grid/tests/build.rs} (87%) rename crates/unixnotis-center/src/ui/widgets/stats/{tests/scheduling.rs => grid/tests/schedule.rs} (86%) delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs delete mode 100644 crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs index 337866d09..7db181450 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs @@ -74,3 +74,7 @@ impl BuiltinStat { } } } + +#[cfg(test)] +#[path = "tests/model.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs index e6e3f0bb7..7ec634ba5 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs @@ -100,3 +100,7 @@ fn read_power_supply_value(path: &Path) -> Option { let contents = fs::read_to_string(path).ok()?; contents.trim().parse::().ok() } + +#[cfg(test)] +#[path = "tests/battery.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs index 9604e14e0..65a65e497 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs @@ -12,3 +12,6 @@ pub(super) use cpu::read_cpu_sample; pub(super) use load::read_loadavg; pub(super) use memory::read_memory; pub(super) use network::{extract_iface, read_network}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs index 8dd0bc427..f78216098 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs @@ -182,3 +182,7 @@ pub(in crate::ui::widgets::stats::builtin) fn extract_iface(cmd: &str) -> Option Some(iface.to_string()) } } + +#[cfg(test)] +#[path = "tests/network.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs new file mode 100644 index 000000000..ef3dc9040 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs @@ -0,0 +1,81 @@ +//! Battery reader tests + +use super::super::tests::support::{write_device, TempDir}; +use super::read_battery_from; + +#[test] +fn battery_energy_values_are_weighted_by_full_capacity() { + let temp = TempDir::new("unixnotis-battery-energy"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "30"), + ("energy_full", "60"), + ], + ); + write_device( + temp.path(), + "BAT1", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "10"), + ("energy_full", "40"), + ], + ); + + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + + assert_eq!(percent, "40"); +} + +#[test] +fn battery_mixed_units_fall_back_to_reported_capacity() { + let temp = TempDir::new("unixnotis-battery-mixed"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "30"), + ("energy_full", "60"), + ("capacity", "60"), + ], + ); + write_device( + temp.path(), + "BAT1", + &[ + ("type", "Battery"), + ("present", "1"), + ("charge_now", "10"), + ("charge_full", "40"), + ("capacity", "25"), + ], + ); + + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + + assert_eq!(percent, "43"); +} + +#[test] +fn battery_reader_skips_devices_reported_as_absent() { + let temp = TempDir::new("unixnotis-battery-absent"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "0"), + ("energy_now", "30"), + ("energy_full", "60"), + ], + ); + + assert!(read_battery_from(temp.path()).is_none()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs new file mode 100644 index 000000000..2c2400e32 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs @@ -0,0 +1,3 @@ +//! Shared built-in reader test support + +pub(super) mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs new file mode 100644 index 000000000..a91171bf2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs @@ -0,0 +1,60 @@ +//! Network reader selection tests + +use super::{pick_default_iface_from, IfaceCandidate}; + +#[test] +fn default_interface_prefers_an_active_physical_device() { + let candidates = vec![ + IfaceCandidate { + name: "veth0".to_string(), + operstate: "up".to_string(), + }, + IfaceCandidate { + name: "wlan0".to_string(), + operstate: "up".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("wlan0".to_string()) + ); +} + +#[test] +fn default_interface_prefers_physical_devices_when_all_are_down() { + let candidates = vec![ + IfaceCandidate { + name: "eth0".to_string(), + operstate: "down".to_string(), + }, + IfaceCandidate { + name: "docker0".to_string(), + operstate: "up".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("eth0".to_string()) + ); +} + +#[test] +fn default_interface_uses_name_as_a_deterministic_tiebreaker() { + let candidates = vec![ + IfaceCandidate { + name: "eth1".to_string(), + operstate: "down".to_string(), + }, + IfaceCandidate { + name: "eth0".to_string(), + operstate: "down".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("eth0".to_string()) + ); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs new file mode 100644 index 000000000..7daac08e7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs @@ -0,0 +1,44 @@ +//! Filesystem fixtures for procfs and sysfs readers + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(in crate::ui::widgets::stats::builtin::readers) struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub(in crate::ui::widgets::stats::builtin::readers) fn new(prefix: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).expect("temp dir creation failed"); + Self { path } + } + + pub(in crate::ui::widgets::stats::builtin::readers) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Cleanup is best effort so a failed assertion remains visible + let _ = fs::remove_dir_all(&self.path); + } +} + +pub(in crate::ui::widgets::stats::builtin::readers) fn write_device( + root: &Path, + name: &str, + entries: &[(&str, &str)], +) { + let device_path = root.join(name); + fs::create_dir_all(&device_path).expect("device directory creation failed"); + for (file, contents) in entries { + fs::write(device_path.join(file), contents).expect("device file write failed"); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs similarity index 86% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs index 1c9cb01aa..ca6d01e22 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs @@ -1,6 +1,6 @@ -//! Built-in refresh grouping tests +//! Built-in statistic identity tests -use super::super::builtin::{BuiltinStat, BuiltinStatKey}; +use super::super::{BuiltinStat, BuiltinStatKey}; #[test] fn matching_builtin_sources_produce_the_same_group_key() { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs new file mode 100644 index 000000000..cecd131a4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs @@ -0,0 +1,42 @@ +//! Built-in statistic worker tests + +use super::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; +use crate::ui::widgets::stats::builtin::BuiltinStat; + +#[test] +fn builtin_worker_reports_a_full_queue_without_blocking() { + let (tx, _worker_rx) = crossbeam_channel::bounded(1); + let worker = BuiltinWorker { + tx, + inline_fallback: false, + }; + let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let (first_tx, _first_rx) = async_channel::bounded(1); + let (second_tx, _second_rx) = async_channel::bounded(1); + + assert_eq!( + worker.submit(BuiltinJob { + stat: first, + respond: first_tx, + }), + SubmitOutcome::Submitted + ); + assert_eq!( + worker.submit(BuiltinJob { + stat: second, + respond: second_tx, + }), + SubmitOutcome::QueueFull + ); +} + +#[test] +fn builtin_sample_preserves_reader_failure_as_missing_data() { + let stat = + BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); + + let sample = BuiltinSample::read(stat); + + assert!(sample.value.is_none()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs index e57b07614..533093cac 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs @@ -84,3 +84,7 @@ impl BuiltinSample { Self { stat, value } } } + +#[cfg(test)] +#[path = "tests/worker.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs new file mode 100644 index 000000000..5bb117b35 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs @@ -0,0 +1,110 @@ +//! Statistic card source dispatch and scheduling gates + +use std::time::{Duration, Instant}; + +use gtk::prelude::*; +use unixnotis_core::PanelDebugLevel; + +use super::super::{StatItem, StatSourceRef}; +use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::stats::builtin::{BuiltinStat, BuiltinStatKey}; +use crate::ui::widgets::utils::INFLIGHT_REFRESH_RECHECK; + +impl StatItem { + pub(in crate::ui::widgets::stats) fn has_builtin_source(&self) -> bool { + self.config.plugin.is_none() && self.builtin.borrow().is_some() + } + + pub(in crate::ui::widgets::stats) fn is_grouped_builtin( + &self, + now: Instant, + force: bool, + ) -> bool { + if !self.has_builtin_source() || !self.root.is_visible() { + return false; + } + + if self.inflight.get() { + // Groups keep their own in-flight guard + return true; + } + + self.refresh_backoff.borrow().should_refresh(now, force) + } + + pub(in crate::ui::widgets::stats) fn take_builtin_refresh( + &self, + now: Instant, + force: bool, + ) -> Option<(BuiltinStatKey, BuiltinStat)> { + if !self.root.is_visible() + || self.config.plugin.is_some() + || !self.refresh_backoff.borrow().should_refresh(now, force) + || self.inflight.get() + { + return None; + } + + let builtin = self.builtin.borrow_mut().take()?; + self.inflight.set(true); + Some((builtin.key(), builtin)) + } + + pub(in crate::ui::widgets::stats) fn refresh(&self, base_interval: Duration, force: bool) { + if !self.root.is_visible() { + return; + } + let now = Instant::now(); + if !self.refresh_backoff.borrow().should_refresh(now, force) { + return; + } + debug::log(PanelDebugLevel::Verbose, || { + format!("stat refresh: {}", self.config.label) + }); + if self.inflight.get() { + return; + } + match self.source() { + StatSourceRef::Plugin(plugin) => self.refresh_plugin(plugin, base_interval), + StatSourceRef::Builtin(builtin) => self.refresh_builtin(builtin, base_interval), + StatSourceRef::Command(command) => self.refresh_command(command, base_interval), + StatSourceRef::Missing => self.refresh_missing(base_interval), + } + } + + fn source(&self) -> StatSourceRef<'_> { + if let Some(plugin) = self.config.plugin.as_ref() { + // Plugin configuration always has source precedence + return StatSourceRef::Plugin(plugin); + } + if let Some(builtin) = self.builtin.borrow_mut().take() { + return StatSourceRef::Builtin(builtin); + } + self.config + .cmd + .as_ref() + .map_or(StatSourceRef::Missing, StatSourceRef::Command) + } + + pub(in crate::ui::widgets::stats) fn refresh_missing(&self, base_interval: Duration) { + // Missing sources settle on the placeholder without spinning + let changed = self.apply_value("n/a"); + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } + + pub(in crate::ui::widgets::stats) fn next_refresh_in(&self, now: Instant) -> Option { + if !self.root.is_visible() { + return None; + } + if self.inflight.get() { + // Slow sources should not create a tight scheduler loop + return Some(INFLIGHT_REFRESH_RECHECK); + } + self.refresh_backoff + .borrow() + .next_due_in(now) + .or(Some(Duration::ZERO)) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs index 5f8892b9d..f413749ca 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs @@ -2,113 +2,8 @@ mod builtin; mod command; +mod dispatch; mod plugin; -use std::time::{Duration, Instant}; - -use gtk::prelude::*; -use unixnotis_core::PanelDebugLevel; - -use super::{StatItem, StatSourceRef}; -use crate::diagnostics::panel_debug as debug; -use crate::ui::widgets::stats::builtin::{BuiltinStat, BuiltinStatKey}; -use crate::ui::widgets::utils::INFLIGHT_REFRESH_RECHECK; - -impl StatItem { - pub(in crate::ui::widgets::stats) fn has_builtin_source(&self) -> bool { - self.config.plugin.is_none() && self.builtin.borrow().is_some() - } - - pub(in crate::ui::widgets::stats) fn is_grouped_builtin( - &self, - now: Instant, - force: bool, - ) -> bool { - if !self.has_builtin_source() || !self.root.is_visible() { - return false; - } - - if self.inflight.get() { - // Groups keep their own in-flight guard - return true; - } - - self.refresh_backoff.borrow().should_refresh(now, force) - } - - pub(in crate::ui::widgets::stats) fn take_builtin_refresh( - &self, - now: Instant, - force: bool, - ) -> Option<(BuiltinStatKey, BuiltinStat)> { - if !self.root.is_visible() - || self.config.plugin.is_some() - || !self.refresh_backoff.borrow().should_refresh(now, force) - || self.inflight.get() - { - return None; - } - - let builtin = self.builtin.borrow_mut().take()?; - self.inflight.set(true); - Some((builtin.key(), builtin)) - } - - pub(in crate::ui::widgets::stats) fn refresh(&self, base_interval: Duration, force: bool) { - if !self.root.is_visible() { - return; - } - let now = Instant::now(); - if !self.refresh_backoff.borrow().should_refresh(now, force) { - return; - } - debug::log(PanelDebugLevel::Verbose, || { - format!("stat refresh: {}", self.config.label) - }); - if self.inflight.get() { - return; - } - match self.source() { - StatSourceRef::Plugin(plugin) => self.refresh_plugin(plugin, base_interval), - StatSourceRef::Builtin(builtin) => self.refresh_builtin(builtin, base_interval), - StatSourceRef::Command(command) => self.refresh_command(command, base_interval), - StatSourceRef::Missing => self.refresh_missing(base_interval), - } - } - - fn source(&self) -> StatSourceRef<'_> { - if let Some(plugin) = self.config.plugin.as_ref() { - // Plugin configuration always has source precedence - return StatSourceRef::Plugin(plugin); - } - if let Some(builtin) = self.builtin.borrow_mut().take() { - return StatSourceRef::Builtin(builtin); - } - self.config - .cmd - .as_ref() - .map_or(StatSourceRef::Missing, StatSourceRef::Command) - } - - pub(in crate::ui::widgets::stats) fn refresh_missing(&self, base_interval: Duration) { - // Missing sources settle on the placeholder without spinning - let changed = self.apply_value("n/a"); - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - } - - pub(in crate::ui::widgets::stats) fn next_refresh_in(&self, now: Instant) -> Option { - if !self.root.is_visible() { - return None; - } - if self.inflight.get() { - // Slow sources should not create a tight scheduler loop - return Some(INFLIGHT_REFRESH_RECHECK); - } - self.refresh_backoff - .borrow() - .next_due_in(now) - .or(Some(Duration::ZERO)) - } -} +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs similarity index 58% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs rename to crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs index 3ffecbd51..54c767bb5 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/card.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs @@ -1,34 +1,10 @@ -//! Statistic card presentation tests +//! Built-in statistic card refresh tests use std::time::Duration; -use super::super::builtin::worker::BuiltinSample; -use super::super::builtin::BuiltinStat; -use super::super::style::stat_kind_css_class; use super::support::stat_item; - -#[test] -fn card_kind_class_normalizes_theme_tokens() { - assert_eq!( - stat_kind_css_class("RAM"), - Some("unixnotis-stat-kind-ram".to_string()) - ); - assert_eq!( - stat_kind_css_class("RAM %#$ Thing"), - Some("unixnotis-stat-kind-ram-thing".to_string()) - ); - assert_eq!(stat_kind_css_class(" !!! "), None); -} - -#[gtk::test] -fn missing_card_source_renders_the_placeholder() { - let item = stat_item(None, None); - - item.refresh_missing(Duration::from_secs(1)); - - assert_eq!(item.value_label.text(), "n/a"); - assert_eq!(item.last_value.borrow().as_deref(), Some("n/a")); -} +use crate::ui::widgets::stats::builtin::worker::BuiltinSample; +use crate::ui::widgets::stats::builtin::BuiltinStat; #[gtk::test] fn failed_builtin_sample_preserves_the_last_good_value() { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs new file mode 100644 index 000000000..cd4c739ac --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs @@ -0,0 +1,15 @@ +//! Statistic card dispatch tests + +use std::time::Duration; + +use super::support::stat_item; + +#[gtk::test] +fn missing_card_source_renders_the_placeholder() { + let item = stat_item(None, None); + + item.refresh_missing(Duration::from_secs(1)); + + assert_eq!(item.value_label.text(), "n/a"); + assert_eq!(item.last_value.borrow().as_deref(), Some("n/a")); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs new file mode 100644 index 000000000..bf7c8a183 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs @@ -0,0 +1,5 @@ +//! Statistic card refresh tests mirrored by source type + +mod builtin; +mod dispatch; +mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs new file mode 100644 index 000000000..f123c497a --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs @@ -0,0 +1,30 @@ +//! Shared GTK card fixtures + +use crate::ui::widgets::stats::builtin::BuiltinStat; +use crate::ui::widgets::stats::card::StatItem; +use crate::ui::widgets::utils::RefreshBackoff; +use unixnotis_core::StatWidgetConfig; + +static GTK_INIT: std::sync::Once = std::sync::Once::new(); + +pub(super) fn init_gtk() { + GTK_INIT.call_once(|| { + gtk::init().expect("gtk should initialize under the test display"); + }); +} + +pub(super) fn stat_item(builtin: Option, value: Option<&str>) -> StatItem { + init_gtk(); + let rendered = value.unwrap_or("n/a"); + StatItem { + config: StatWidgetConfig::default(), + root: gtk::Box::new(gtk::Orientation::Vertical, 0), + value_label: gtk::Label::new(Some(rendered)), + builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), + inflight: std::rc::Rc::new(std::cell::Cell::new(true)), + last_value: std::rc::Rc::new(std::cell::RefCell::new( + value.map(std::string::ToString::to_string), + )), + refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs index 511044e4b..d04821e93 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs @@ -53,3 +53,7 @@ impl StatGrid { pub(in crate::ui::widgets::stats) fn flowbox_columns(columns: usize) -> u32 { u32::try_from(columns.max(1)).unwrap_or(u32::MAX) } + +#[cfg(test)] +#[path = "tests/build.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs index dc37c7c2d..f0d873fed 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs @@ -1,14 +1,8 @@ //! Statistic grid ownership pub(in crate::ui::widgets::stats) mod build; +mod model; mod refresh; pub(in crate::ui::widgets::stats) mod schedule; -use super::card::StatItem; - -pub struct StatGrid { - // FlowBox root is embedded by the panel widget tree - root: gtk::FlowBox, - // Per-card state is retained for refresh scheduling - items: Vec, -} +pub use model::StatGrid; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs new file mode 100644 index 000000000..9bbb6aa17 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs @@ -0,0 +1,10 @@ +//! Retained statistic grid widget state + +use super::super::card::StatItem; + +pub struct StatGrid { + // FlowBox root is embedded by the panel widget tree + pub(super) root: gtk::FlowBox, + // Per-card state is retained for refresh scheduling + pub(super) items: Vec, +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs index 05a754086..bf70e9cd4 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs @@ -20,3 +20,7 @@ impl StatGrid { pub(in crate::ui::widgets::stats) fn is_due_delay(delay: Option) -> bool { delay.is_some_and(|value| value.is_zero()) } + +#[cfg(test)] +#[path = "tests/schedule.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs similarity index 87% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs rename to crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs index 54dda196e..16ea07893 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs @@ -1,6 +1,6 @@ //! Statistic grid construction tests -use super::super::grid::build::flowbox_columns; +use super::flowbox_columns; #[test] fn grid_columns_normalize_zero_and_preserve_positive_values() { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs similarity index 86% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs rename to crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs index 0d0b5753e..554c9600f 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/scheduling.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use super::super::grid::schedule::is_due_delay; +use super::is_due_delay; #[test] fn zero_delay_is_due_immediately() { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs index 81aabce70..b9151e655 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs @@ -6,6 +6,3 @@ mod grid; mod style; pub use grid::StatGrid; - -#[cfg(test)] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/style.rs b/crates/unixnotis-center/src/ui/widgets/stats/style.rs index c014b79fd..f9ff56f63 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/style.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/style.rs @@ -3,3 +3,7 @@ pub(super) fn stat_kind_css_class(kind: &str) -> Option { super::super::kind_css::widget_kind_css_class("unixnotis-stat-kind-", kind) } + +#[cfg(test)] +#[path = "tests/style.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs deleted file mode 100644 index cf6a04f48..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Built-in reader and worker tests - -use super::super::builtin::readers::battery::read_battery_from; -use super::super::builtin::readers::network::{pick_default_iface_from, IfaceCandidate}; -use super::super::builtin::worker::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; -use super::super::builtin::BuiltinStat; -use super::support::{write_device, TempDir}; - -#[test] -fn battery_energy_values_are_weighted_by_full_capacity() { - let temp = TempDir::new("unixnotis-battery-energy"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "30"), - ("energy_full", "60"), - ], - ); - write_device( - temp.path(), - "BAT1", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "10"), - ("energy_full", "40"), - ], - ); - - let percent = read_battery_from(temp.path()).expect("battery percent missing"); - - assert_eq!(percent, "40"); -} - -#[test] -fn battery_mixed_units_fall_back_to_reported_capacity() { - let temp = TempDir::new("unixnotis-battery-mixed"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "30"), - ("energy_full", "60"), - ("capacity", "60"), - ], - ); - write_device( - temp.path(), - "BAT1", - &[ - ("type", "Battery"), - ("present", "1"), - ("charge_now", "10"), - ("charge_full", "40"), - ("capacity", "25"), - ], - ); - - let percent = read_battery_from(temp.path()).expect("battery percent missing"); - - assert_eq!(percent, "43"); -} - -#[test] -fn battery_reader_skips_devices_reported_as_absent() { - let temp = TempDir::new("unixnotis-battery-absent"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "0"), - ("energy_now", "30"), - ("energy_full", "60"), - ], - ); - - assert!(read_battery_from(temp.path()).is_none()); -} - -#[test] -fn default_interface_prefers_an_active_physical_device() { - let candidates = vec![ - IfaceCandidate { - name: "veth0".to_string(), - operstate: "up".to_string(), - }, - IfaceCandidate { - name: "wlan0".to_string(), - operstate: "up".to_string(), - }, - ]; - - assert_eq!( - pick_default_iface_from(&candidates), - Some("wlan0".to_string()) - ); -} - -#[test] -fn default_interface_prefers_physical_devices_when_all_are_down() { - let candidates = vec![ - IfaceCandidate { - name: "eth0".to_string(), - operstate: "down".to_string(), - }, - IfaceCandidate { - name: "docker0".to_string(), - operstate: "up".to_string(), - }, - ]; - - assert_eq!( - pick_default_iface_from(&candidates), - Some("eth0".to_string()) - ); -} - -#[test] -fn default_interface_uses_name_as_a_deterministic_tiebreaker() { - let candidates = vec![ - IfaceCandidate { - name: "eth1".to_string(), - operstate: "down".to_string(), - }, - IfaceCandidate { - name: "eth0".to_string(), - operstate: "down".to_string(), - }, - ]; - - assert_eq!( - pick_default_iface_from(&candidates), - Some("eth0".to_string()) - ); -} - -#[test] -fn builtin_worker_reports_a_full_queue_without_blocking() { - let (tx, _worker_rx) = crossbeam_channel::bounded(1); - let worker = BuiltinWorker { - tx, - inline_fallback: false, - }; - let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let (first_tx, _first_rx) = async_channel::bounded(1); - let (second_tx, _second_rx) = async_channel::bounded(1); - - assert_eq!( - worker.submit(BuiltinJob { - stat: first, - respond: first_tx, - }), - SubmitOutcome::Submitted - ); - assert_eq!( - worker.submit(BuiltinJob { - stat: second, - respond: second_tx, - }), - SubmitOutcome::QueueFull - ); -} - -#[test] -fn builtin_sample_preserves_reader_failure_as_missing_data() { - let stat = - BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); - - let sample = BuiltinSample::read(stat); - - assert!(sample.value.is_none()); -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs deleted file mode 100644 index 69b155fe8..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Statistic widget tests mirrored by responsibility - -mod builtin; -mod card; -mod grid; -mod grouping; -mod scheduling; -mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs new file mode 100644 index 000000000..88a48071b --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs @@ -0,0 +1,16 @@ +//! Statistic style tests + +use super::stat_kind_css_class; + +#[test] +fn card_kind_class_normalizes_theme_tokens() { + assert_eq!( + stat_kind_css_class("RAM"), + Some("unixnotis-stat-kind-ram".to_string()) + ); + assert_eq!( + stat_kind_css_class("RAM %#$ Thing"), + Some("unixnotis-stat-kind-ram-thing".to_string()) + ); + assert_eq!(stat_kind_css_class(" !!! "), None); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs deleted file mode 100644 index 8a0c73bf4..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/support.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Shared filesystem fixtures for built-in reader tests - -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::super::builtin::BuiltinStat; -use super::super::card::StatItem; -use crate::ui::widgets::utils::RefreshBackoff; -use unixnotis_core::StatWidgetConfig; - -static GTK_INIT: std::sync::Once = std::sync::Once::new(); - -pub(super) fn init_gtk() { - GTK_INIT.call_once(|| { - gtk::init().expect("gtk should initialize under the test display"); - }); -} - -pub(super) fn stat_item(builtin: Option, value: Option<&str>) -> StatItem { - init_gtk(); - let rendered = value.unwrap_or("n/a"); - StatItem { - config: StatWidgetConfig::default(), - root: gtk::Box::new(gtk::Orientation::Vertical, 0), - value_label: gtk::Label::new(Some(rendered)), - builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), - inflight: std::rc::Rc::new(std::cell::Cell::new(true)), - last_value: std::rc::Rc::new(std::cell::RefCell::new( - value.map(std::string::ToString::to_string), - )), - refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), - } -} - -pub(super) struct TempDir { - path: PathBuf, -} - -impl TempDir { - pub(super) fn new(prefix: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = std::env::temp_dir().join(format!("{prefix}-{}-{stamp}", std::process::id())); - fs::create_dir_all(&path).expect("temp dir creation failed"); - Self { path } - } - - pub(super) fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - // Cleanup is best effort so a failed assertion remains visible - let _ = fs::remove_dir_all(&self.path); - } -} - -pub(super) fn write_device(root: &Path, name: &str, entries: &[(&str, &str)]) { - let device_path = root.join(name); - fs::create_dir_all(&device_path).expect("device directory creation failed"); - for (file, contents) in entries { - fs::write(device_path.join(file), contents).expect("device file write failed"); - } -} From 589e646286f37662fece9dcdfa3f612f2d8336e0 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 00:13:10 -0500 Subject: [PATCH 036/275] fix(core): identify inline shell command strings precisely Summary: identify inline shell command strings precisely. Scope: core. --- .../ui/widgets/utils/command/command_parse.rs | 2 +- .../src/config/runtime/sanitize/plugins.rs | 2 +- .../src/config/runtime/sanitize/shell.rs | 2 +- .../config/runtime/sanitize/tests/plugins.rs | 21 ++++ crates/unixnotis-core/src/process/legacy.rs | 2 +- crates/unixnotis-core/src/process/spec.rs | 111 +++++++++++++++--- .../src/process/tests/legacy.rs | 2 +- .../unixnotis-core/src/process/tests/spec.rs | 58 ++++++++- 8 files changed, 172 insertions(+), 28 deletions(-) diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs index 3958e716a..921043329 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs @@ -8,7 +8,7 @@ use unixnotis_core::CommandSpec; pub(super) fn is_probably_slow(cmd: &CommandSpec) -> bool { // Shared shell detection owns every direct interpreter spelling // Shell startup and script execution belong on the wider timeout budget - if cmd.invokes_shell() { + if cmd.uses_shell_command_string() { return true; } diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs index 6e05060ee..5a8e69517 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs @@ -122,7 +122,7 @@ fn sanitize_widget_plugin( *plugin = None; return; } - if plugin_cfg.command.invokes_shell() { + if plugin_cfg.command.uses_shell_command_string() { // Shell syntax is not allowed in the plugin command field warn!( widget_type, diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs index 4b3a189f9..aec222016 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs @@ -71,7 +71,7 @@ fn command_requires_shell_opt(value: &Option) -> bool { } fn command_requires_shell(command: &CommandSpec) -> bool { - command.invokes_shell() + command.uses_shell_command_string() } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs index 07da2bb03..fd1ee91fa 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs @@ -61,6 +61,27 @@ fn sanitize_widget_plugin_rejects_direct_shell_interpreters() { assert!(config.widgets.cards[0].plugin.is_none()); } +#[test] +fn sanitize_widget_plugin_keeps_direct_shell_scripts_with_long_options() { + for (shell, option, script) in [ + ("bash", "--norc", "script.sh"), + ("fish", "--no-config", "script.fish"), + ] { + let mut config = Config::default(); + config.widgets.cards[0].plugin = Some(WidgetPluginConfig { + command: CommandSpec::direct(shell, [option, script]), + ..WidgetPluginConfig::default() + }); + + sanitize_config(&mut config); + + assert!( + config.widgets.cards[0].plugin.is_some(), + "{shell} script plugins must remain enabled" + ); + } +} + #[test] fn sanitize_widget_options_caps_decorative_layout_counts() { let mut config = Config::default(); diff --git a/crates/unixnotis-core/src/process/legacy.rs b/crates/unixnotis-core/src/process/legacy.rs index 8e4707c2a..b0de6670a 100644 --- a/crates/unixnotis-core/src/process/legacy.rs +++ b/crates/unixnotis-core/src/process/legacy.rs @@ -57,7 +57,7 @@ fn exact_shell_c_script(spec: &CommandSpec) -> Option<&str> { return None; }; // Environment prefixes and extra operands change shell wrapper semantics - if !env.is_empty() || !spec.invokes_shell() { + if !env.is_empty() || !spec.uses_shell_command_string() { return None; } let [flag, script] = args.as_slice() else { diff --git a/crates/unixnotis-core/src/process/spec.rs b/crates/unixnotis-core/src/process/spec.rs index 6b0bf1a80..429df6bde 100644 --- a/crates/unixnotis-core/src/process/spec.rs +++ b/crates/unixnotis-core/src/process/spec.rs @@ -65,34 +65,32 @@ impl CommandSpec { } #[must_use] - /// Reports whether command text crosses an explicit shell boundary - pub fn invokes_shell(&self) -> bool { + /// Reports whether the command evaluates an inline shell command string + pub fn uses_shell_command_string(&self) -> bool { match self { Self::Shell { .. } => true, Self::Direct { program, args, .. } => { // Basenames keep absolute interpreter paths and PATH lookups equivalent - let shell = program + let Some(shell) = program .file_name() .and_then(OsStr::to_str) - .is_some_and(|name| { - matches!( - name, - "sh" | "ash" | "bash" | "dash" | "fish" | "ksh" | "zsh" - ) - }); - shell - && args.iter().any(|argument| { - // Combined flags such as `-lc` still enable script evaluation - argument.to_str().is_some_and(|argument| { - argument - .strip_prefix('-') - .is_some_and(|flags| flags.contains('c')) - }) - }) + .filter(|name| is_shell_program(name)) + else { + return false; + }; + + shell_args_use_command_string(shell, args) } } } + /// Compatibility alias for the former inline shell command detector + #[deprecated(since = "1.2.0", note = "use uses_shell_command_string")] + #[must_use] + pub fn invokes_shell(&self) -> bool { + self.uses_shell_command_string() + } + #[must_use] pub fn is_empty(&self) -> bool { match self { @@ -172,6 +170,83 @@ impl CommandSpec { } } +fn is_shell_program(name: &str) -> bool { + matches!( + name, + "sh" | "ash" | "bash" | "dash" | "fish" | "ksh" | "zsh" + ) +} + +fn shell_args_use_command_string(shell: &str, args: &[OsString]) -> bool { + let mut option_value_pending = false; + for argument in args { + let Some(argument) = argument.to_str() else { + // TOML arguments are UTF-8, while an opaque programmatic operand ends option parsing + return false; + }; + if option_value_pending { + option_value_pending = false; + continue; + } + if matches!(argument, "-" | "--") { + // Both portable terminators make every following `-c` literal script data + return false; + } + if is_command_string_flag(argument) + || (shell == "fish" && is_fish_command_string_option(argument)) + { + return true; + } + if !argument.starts_with(['-', '+']) { + // The first positional operand is the script path for direct shell execution + return false; + } + option_value_pending = shell_option_takes_next_value(shell, argument); + } + false +} + +fn is_command_string_flag(argument: &str) -> bool { + argument.strip_prefix('-').is_some_and(|flags| { + // Long options such as `--norc` contain a letter c but do not evaluate command text + !flags.starts_with('-') && flags.contains('c') + }) +} + +fn is_fish_command_string_option(argument: &str) -> bool { + // Fish documents a long spelling in addition to the shared short `-c` form + argument == "--command" || argument.starts_with("--command=") +} + +fn shell_option_takes_next_value(shell: &str, argument: &str) -> bool { + match shell { + // Bash accepts option names and startup files as separate operands before `-c` + "bash" => matches!( + argument, + "-o" | "+o" | "-O" | "+O" | "--init-file" | "--rcfile" + ), + // These POSIX-style shells accept a separate value for `-o` + "sh" | "ash" | "dash" => argument == "-o", + "ksh" => matches!(argument, "-o" | "+o" | "-R"), + "zsh" => matches!(argument, "-o" | "+o"), + // Fish accepts both short and long value-taking startup options + "fish" => matches!( + argument, + "-C" | "-d" + | "-o" + | "-p" + | "-f" + | "--init-command" + | "--debug" + | "--debug-output" + | "--profile" + | "--profile-startup" + | "--features" + ), + _ => false, + } +} + fn replace_os(value: &OsStr, placeholder: &str, replacement: &str) -> OsString { // TOML-originated values are UTF-8; non-UTF-8 programmatic values remain byte-for-byte stable value.to_str().map_or_else( diff --git a/crates/unixnotis-core/src/process/tests/legacy.rs b/crates/unixnotis-core/src/process/tests/legacy.rs index 0b5436f25..c7047eeb8 100644 --- a/crates/unixnotis-core/src/process/tests/legacy.rs +++ b/crates/unixnotis-core/src/process/tests/legacy.rs @@ -58,7 +58,7 @@ fn shell_wrappers_with_environment_or_extra_arguments_remain_direct() { let parsed = parse_legacy_command(command).expect("parse shell wrapper"); assert!(!parsed.is_shell(), "{command}"); - assert!(parsed.invokes_shell(), "{command}"); + assert!(parsed.uses_shell_command_string(), "{command}"); } } diff --git a/crates/unixnotis-core/src/process/tests/spec.rs b/crates/unixnotis-core/src/process/tests/spec.rs index b5a114e14..16a372d51 100644 --- a/crates/unixnotis-core/src/process/tests/spec.rs +++ b/crates/unixnotis-core/src/process/tests/spec.rs @@ -50,16 +50,64 @@ fn placeholder_replacement_updates_explicit_shell_script_without_reclassificatio #[test] fn shell_detection_includes_direct_interpreter_invocations() { - assert!(CommandSpec::shell("printf ready").invokes_shell()); + assert!(CommandSpec::shell("printf ready").uses_shell_command_string()); for shell in ["sh", "ash", "bash", "dash", "fish", "ksh", "zsh"] { assert!( - CommandSpec::direct(shell, ["-c", "printf ready"]).invokes_shell(), + CommandSpec::direct(shell, ["-c", "printf ready"]).uses_shell_command_string(), "{shell} -c must retain the explicit shell boundary" ); } - assert!(CommandSpec::direct("/bin/bash", ["-lc", "printf ready"]).invokes_shell()); - assert!(!CommandSpec::direct("sh", ["-x", "script"]).invokes_shell()); - assert!(!CommandSpec::direct("printf", ["sh -c"]).invokes_shell()); + assert!(CommandSpec::direct("/bin/bash", ["-lc", "printf ready"]).uses_shell_command_string()); + assert!(!CommandSpec::direct("sh", ["-x", "script"]).uses_shell_command_string()); + assert!(!CommandSpec::direct("printf", ["sh -c"]).uses_shell_command_string()); +} + +#[test] +fn shell_detection_does_not_treat_long_options_as_short_flag_clusters() { + assert!(!CommandSpec::direct("bash", ["--norc", "script.sh"]).uses_shell_command_string()); + assert!( + !CommandSpec::direct("fish", ["--no-config", "script.fish"]).uses_shell_command_string() + ); +} + +#[test] +fn shell_detection_stops_at_option_and_script_boundaries() { + assert!(!CommandSpec::direct("bash", ["--", "-c", "printf data"]).uses_shell_command_string()); + assert!( + !CommandSpec::direct("bash", ["script.sh", "-c", "literal argument"]) + .uses_shell_command_string() + ); +} + +#[test] +fn shell_detection_skips_option_values_before_command_flags() { + for (shell, option, value) in [ + ("bash", "-O", "extglob"), + ("sh", "-o", "nounset"), + ("ash", "-o", "nounset"), + ("dash", "-o", "nounset"), + ("ksh", "-R", "restricted-root"), + ("zsh", "-o", "SH_WORD_SPLIT"), + ("fish", "--debug", "reader"), + ] { + assert!( + CommandSpec::direct(shell, [option, value, "-c", "printf ready"]) + .uses_shell_command_string(), + "{shell} must resume option scanning after the {option} value" + ); + + assert!( + !CommandSpec::direct(shell, [option, "-c", "script.sh"]).uses_shell_command_string(), + "{shell} must not interpret the {option} value as a command flag" + ); + } + + assert!(CommandSpec::direct("bash", ["-x", "-c", "printf ready"]).uses_shell_command_string()); +} + +#[test] +fn fish_long_command_option_retains_the_command_string_boundary() { + assert!(CommandSpec::direct("fish", ["--command=printf ready"]).uses_shell_command_string()); } #[test] From 88abfd0dae12ff318353a73ff56996e6631d7c79 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 00:15:16 -0500 Subject: [PATCH 037/275] refactor(center): expose panel modules by responsibility Summary: expose panel modules by responsibility. Scope: center. --- crates/unixnotis-center/src/ui/events.rs | 2 +- .../unixnotis-center/src/ui/init/builders.rs | 10 +++--- .../src/ui/init/constructor.rs | 35 +++++++++++++------ .../unixnotis-center/src/ui/media/config.rs | 2 +- .../src/ui/media/widget/parts.rs | 2 +- .../row/notification/update/actions.rs | 2 +- .../src/ui/panel/behavior/autoclose.rs | 3 +- .../src/ui/panel/behavior/keyboard.rs | 3 +- .../src/ui/panel/behavior/mod.rs | 5 +-- .../src/ui/panel/behavior/visibility.rs | 8 +++-- .../src/ui/panel/header/actions.rs | 2 +- .../src/ui/panel/header/search.rs | 2 +- crates/unixnotis-center/src/ui/panel/mod.rs | 30 ++++------------ .../src/ui/reload/config/panel.rs | 13 ++++--- crates/unixnotis-center/src/ui/state.rs | 6 ++-- .../src/ui/widget_builders.rs | 4 +-- 16 files changed, 64 insertions(+), 65 deletions(-) diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 7aae3f2f3..46b7c208c 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -148,7 +148,7 @@ impl UiState { self.work_area = reserved; // Re-apply panel sizing only when the work area actually changes // Avoids redundant calls that can cascade into GTK relayout passes - panel::apply_panel_config(&self.panel, &self.config, self.work_area); + panel::geometry::apply_panel_config(&self.panel, &self.config, self.work_area); let message = format!("work area update: {:?}", self.work_area); self.log_debug(PanelDebugLevel::Info, move || message); } diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index 97455fac0..0157012bd 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -7,7 +7,7 @@ use gtk::prelude::*; use super::super::{icons, media, notifications, panel, widgets, UiStateInit}; pub(super) fn build_notification_list( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, icon_resolver: Rc, ) -> notifications::NotificationList { @@ -37,10 +37,10 @@ pub(super) fn build_notification_list( } pub(super) fn build_media_widget( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, ) -> Option { - let panel_width = panel::requested_panel_width(&panel.root); + let panel_width = panel::geometry::requested_panel_width(&panel.root); let media = init.media_handle.as_ref().map(|handle| { media::MediaWidget::new( &panel.sections.media_container, @@ -68,7 +68,7 @@ pub(super) struct ExtraWidgets { } pub(super) fn build_widget_sections( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, icon_resolver: &unixnotis_core::IconAssetResolver, ) -> ExtraWidgets { @@ -102,7 +102,7 @@ pub(super) fn icon_resolver_for_widgets( } } -pub(super) fn has_visible_widget_section(panel: &panel::PanelWidgets) -> bool { +pub(super) fn has_visible_widget_section(panel: &panel::widgets::PanelWidgets) -> bool { // Empty-state spacing depends on whether any upper panel section is visible panel.sections.quick_controls.get_visible() || panel.sections.media_container.get_visible() diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index b1bbddd21..554af4ecf 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -22,7 +22,7 @@ impl UiState { } // Build the panel widget tree first so child widgets can be attached safely - let panel = panel::build_panel_widgets(&init.app, &init.config); + let panel = panel::build::build_panel_widgets(&init.app, &init.config); let icon_resolver = Rc::new(icons::IconResolver::new()); debug::set_level(PanelDebugLevel::Off); let list = build_notification_list(&panel, &init, icon_resolver.clone()); @@ -35,28 +35,41 @@ impl UiState { let extra_widgets = build_widget_sections(&panel, &init, &widget_icon_resolver); list.set_empty_layout(has_visible_widget_section(&panel)); - panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); - let dnd_duration_menu = panel::connect_dnd_menu( + panel::header::actions::connect_dnd_toggle( + &panel, + dnd_guard.clone(), + init.command_tx.clone(), + ); + let dnd_duration_menu = panel::header::dnd::connect_dnd_menu( &panel.header.actions.dnd_toggle, &init.config.panel, init.command_tx.clone(), ); - panel::connect_clear_button(&panel.header.actions.clear_button, init.command_tx.clone()); - panel::connect_clear_button(&panel.sections.clear_header_button, init.command_tx.clone()); - panel::connect_close_button(&panel, init.command_tx.clone()); - panel::connect_widget_collapse_toggle( + panel::header::actions::connect_clear_button( + &panel.header.actions.clear_button, + init.command_tx.clone(), + ); + panel::header::actions::connect_clear_button( + &panel.sections.clear_header_button, + init.command_tx.clone(), + ); + panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); + panel::header::search::connect_widget_collapse_toggle( &panel.header.actions.focus_toggle, init.event_tx.clone(), ); - panel::connect_filter_entry(&panel.header.search.entry, init.event_tx.clone()); - panel::connect_search_toggle( + panel::header::search::connect_filter_entry( + &panel.header.search.entry, + init.event_tx.clone(), + ); + panel::header::search::connect_search_toggle( &panel.header.actions.search_toggle, &panel.header.search.revealer, &panel.header.search.entry, search_toggle_guard.clone(), ); - panel::connect_auto_close(&panel, &init, panel_visible_flag.clone()); - panel::connect_keyboard_shortcuts(&panel, init.command_tx.clone()); + panel::behavior::autoclose::connect_auto_close(&panel, &init, panel_visible_flag.clone()); + panel::behavior::keyboard::connect_keyboard_shortcuts(&panel, init.command_tx.clone()); if init.config.panel.respect_work_area { // Work area is refreshed early to ensure the panel anchors correctly diff --git a/crates/unixnotis-center/src/ui/media/config.rs b/crates/unixnotis-center/src/ui/media/config.rs index b6ccbbbd6..c5c50af55 100644 --- a/crates/unixnotis-center/src/ui/media/config.rs +++ b/crates/unixnotis-center/src/ui/media/config.rs @@ -19,7 +19,7 @@ impl UiState { self.panel.sections.media_container.set_visible(true); // The resolved request stays stable even when a child reports a wider natural allocation - let panel_width = super::super::panel::requested_panel_width(&self.panel.root); + let panel_width = super::super::panel::geometry::requested_panel_width(&self.panel.root); if self.media_layout_changed(config) { self.rebuild_media_widget(config, panel_width); return; diff --git a/crates/unixnotis-center/src/ui/media/widget/parts.rs b/crates/unixnotis-center/src/ui/media/widget/parts.rs index a79fc8570..7472ee5c5 100644 --- a/crates/unixnotis-center/src/ui/media/widget/parts.rs +++ b/crates/unixnotis-center/src/ui/media/widget/parts.rs @@ -6,7 +6,7 @@ use gtk::prelude::*; use gtk::{Align, Overflow, PolicyType}; use crate::media::MediaHandle; -use crate::ui::panel::input::ClickCooldown; +use crate::ui::panel::behavior::input::ClickCooldown; use super::super::artwork::MediaArtState; use super::super::marquee::MarqueeLabel; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 0d46cb4e7..57e447d79 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -10,7 +10,7 @@ use tracing::debug; use unixnotis_core::NotificationView; use crate::control::UiCommand; -use crate::ui::panel::input::ClickCooldown; +use crate::ui::panel::behavior::input::ClickCooldown; use crate::ui::try_send_command; use super::super::reply::{configure_inline_reply, connect_inline_reply_button}; diff --git a/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs index 936fc1b8b..9bfe034ac 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs @@ -6,9 +6,10 @@ use std::sync::Arc; use gtk::prelude::*; use crate::control::UiCommand; -use crate::ui::panel::PanelWidgets; use crate::ui::{hyprland, try_send_command, UiStateInit}; +use super::super::widgets::PanelWidgets; + fn connect_blur_close( command_tx: tokio::sync::mpsc::Sender, visible_flag: Arc, diff --git a/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs index cf57d11db..856e3c638 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs @@ -4,9 +4,10 @@ use gtk::gdk; use gtk::prelude::*; use crate::control::UiCommand; -use crate::ui::panel::PanelWidgets; use crate::ui::try_send_command; +use super::super::widgets::PanelWidgets; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum KeyboardPanelAction { // Search is open, so Escape closes search before closing the whole panel diff --git a/crates/unixnotis-center/src/ui/panel/behavior/mod.rs b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs index c4cc61b17..1c15cb9b5 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs @@ -1,9 +1,6 @@ //! Panel interaction behavior grouped away from widget construction -mod autoclose; +pub(in crate::ui) mod autoclose; pub(in crate::ui) mod input; pub(in crate::ui) mod keyboard; mod visibility; - -pub(in crate::ui) use autoclose::connect_auto_close; -pub(in crate::ui) use keyboard::connect_keyboard_shortcuts; diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index 65a5edabd..a26c654ca 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -94,7 +94,11 @@ impl UiState { self.work_area = crate::ui::hyprland::reserved_work_area_sync( self.config.panel.output.as_deref(), ); - crate::ui::panel::apply_panel_config(&self.panel, &self.config, self.work_area); + crate::ui::panel::geometry::apply_panel_config( + &self.panel, + &self.config, + self.work_area, + ); } // Only show the window after geometry is correct to avoid visible jitter self.panel.window.set_visible(true); @@ -114,7 +118,7 @@ impl UiState { // Hide first so any teardown work does not trigger visible reflow self.panel.window.set_visible(false); // Reset transient search UI so each open starts from the full notification list - crate::ui::panel::set_search_open( + crate::ui::panel::header::search::set_search_open( &self.panel.header.actions.search_toggle, &self.panel.header.search.revealer, &self.panel.header.search.entry, diff --git a/crates/unixnotis-center/src/ui/panel/header/actions.rs b/crates/unixnotis-center/src/ui/panel/header/actions.rs index dfd03f354..8dce8aab3 100644 --- a/crates/unixnotis-center/src/ui/panel/header/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/header/actions.rs @@ -11,9 +11,9 @@ use unixnotis_core::{ PanelConfig, }; +use super::super::widgets::PanelWidgets; use crate::control::UiCommand; use crate::ui::panel::behavior::input::ClickCooldown; -use crate::ui::panel::PanelWidgets; use crate::ui::try_send_command; const CONTROL_CLICK_GUARD_MS: u64 = 180; diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs index d7410e016..0f2d3303a 100644 --- a/crates/unixnotis-center/src/ui/panel/header/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -8,9 +8,9 @@ use async_channel::TrySendError; use gtk::prelude::*; use unixnotis_core::{css::hooks, PanelConfig}; +use super::super::body::WIDGET_REVEAL_TRANSITION_MS; use crate::control::UiEvent; use crate::ui::panel::behavior::input::{ClickCooldown, LatestBoolEventGate}; -use crate::ui::panel::WIDGET_REVEAL_TRANSITION_MS; pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index 03af3f4c9..c1650401f 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -1,29 +1,13 @@ //! Panel layout and widget construction for the center window //! -//! The folder root stays focused on module wiring and the public panel surface +//! The folder root contains module wiring only -mod apply; +pub(in crate::ui) mod apply; pub(in crate::ui) mod behavior; -mod body; -mod build; -mod geometry; -mod header; +pub(in crate::ui) mod body; +pub(in crate::ui) mod build; +pub(in crate::ui) mod geometry; +pub(in crate::ui) mod header; mod notice; mod state; -mod widgets; - -pub use self::apply::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; -pub use self::body::apply_widget_density; -pub use self::body::{notification_header_row_visible, WIDGET_REVEAL_TRANSITION_MS}; -pub use self::build::build_panel_widgets; -pub use self::geometry::{apply_panel_config, requested_panel_width}; -pub use self::widgets::PanelWidgets; -pub(in crate::ui) use behavior::input; -pub(in crate::ui) use behavior::{connect_auto_close, connect_keyboard_shortcuts}; -pub(in crate::ui) use header::actions::{ - connect_clear_button, connect_close_button, connect_dnd_toggle, -}; -pub(in crate::ui) use header::dnd::{connect_dnd_menu, DndCountdown, DndDurationMenu}; -pub(in crate::ui) use header::search::{ - connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, set_search_open, -}; +pub(in crate::ui) mod widgets; diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs index 6cccfae1a..4993ab983 100644 --- a/crates/unixnotis-center/src/ui/reload/config/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -3,13 +3,12 @@ use gtk::prelude::*; use unixnotis_core::{css::hooks, Config, PanelDebugLevel, PanelWidgetSection}; -use crate::ui::panel::notification_header_row_visible; use crate::ui::{panel, UiState}; impl UiState { pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { // Geometry goes first so later sections can size themselves from the final panel width - panel::apply_panel_config(&self.panel, config, self.work_area); + panel::geometry::apply_panel_config(&self.panel, config, self.work_area); self.panel.header.title.set_label(&config.panel.title); self.panel.header.subtitle.set_label(&config.panel.subtitle); self.panel @@ -33,7 +32,7 @@ impl UiState { .set_visible(!self.panel.header.search.entry.text().is_empty()); let search_open = config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); - panel::set_search_open( + panel::header::search::set_search_open( &self.panel.header.actions.search_toggle, &self.panel.header.search.revealer, &self.panel.header.search.entry, @@ -44,7 +43,7 @@ impl UiState { .header .action_row .set_visible(config.panel.action_row_visible); - panel::apply_reloaded_panel_chrome(&self.panel, &config.panel); + panel::apply::apply_reloaded_panel_chrome(&self.panel, &config.panel); self.panel .sections .notification_header @@ -56,7 +55,7 @@ impl UiState { self.panel .sections .notification_header_row - .set_visible(notification_header_row_visible(&config.panel)); + .set_visible(panel::body::notification_header_row_visible(&config.panel)); self.update_section_header( &self.panel.sections.toggle_section_header, &config.panel.quick_actions_label, @@ -84,9 +83,9 @@ impl UiState { .sections .notification_container .set_vexpand(config.panel.notification_list_expand); - panel::apply_reloaded_body_order(&self.panel, &config.panel.section_order); + panel::apply::apply_reloaded_body_order(&self.panel, &config.panel.section_order); self.apply_widget_order(&config.panel.widget_order); - panel::apply_widget_density( + panel::body::apply_widget_density( &self.panel.sections.widget_stack, &self.panel.sections.quick_controls, &self.panel.sections.media_container, diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 5f8f2fb83..929987737 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -20,8 +20,8 @@ pub struct UiState { pub(super) config_path: std::path::PathBuf, pub(super) css: CssManager, // This owner must drop before the panel so its manually parented popover can detach - pub(super) dnd_duration_menu: panel::DndDurationMenu, - pub(super) panel: panel::PanelWidgets, + pub(super) dnd_duration_menu: panel::header::dnd::DndDurationMenu, + pub(super) panel: panel::widgets::PanelWidgets, pub(super) list: notifications::NotificationList, // Shared resolver keeps icon cache and inflight decode tracking centralized pub(super) icon_resolver: Rc, @@ -29,7 +29,7 @@ pub struct UiState { pub(super) widget_icon_resolver: IconAssetResolver, pub(super) dnd_guard: Rc>, // One countdown owns its deadline so completed GLib sources are never removed twice - pub(super) dnd_expiration_source: Option, + pub(super) dnd_expiration_source: Option, pub(super) search_toggle_guard: Rc>, pub(super) panel_visible: bool, pub(super) panel_visible_flag: Arc, diff --git a/crates/unixnotis-center/src/ui/widget_builders.rs b/crates/unixnotis-center/src/ui/widget_builders.rs index 9ed6841f4..4f7e8cc00 100644 --- a/crates/unixnotis-center/src/ui/widget_builders.rs +++ b/crates/unixnotis-center/src/ui/widget_builders.rs @@ -10,7 +10,7 @@ use unixnotis_core::{css::hooks, Config, IconAssetResolver}; use super::{panel, widgets}; pub(super) fn build_quick_controls( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, config: &Config, ) -> ( Option, @@ -41,7 +41,7 @@ pub(super) fn build_quick_controls( } pub(super) fn build_extra_widgets( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, config: &Config, icon_resolver: &IconAssetResolver, ) -> ( From c86defee3a4915eee5cd2fdb3698a9b6593c91d2 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 00:16:10 -0500 Subject: [PATCH 038/275] perf(panel): reduce hover and overlay scroll work Summary: reduce hover and overlay scroll work. Scope: panel. --- crates/unixnotis-center/src/ui/panel/build.rs | 5 ----- .../src/ui/panel/tests/body.rs | 11 ++++++++++ crates/unixnotis-core/assets/media.css | 6 ++---- crates/unixnotis-core/assets/panel.css | 13 ++++-------- crates/unixnotis-core/assets/widgets.css | 21 +++++-------------- .../unixnotis-core/src/embedded/tests/css.rs | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index f85fd44dc..55f6c0fd9 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -17,11 +17,6 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window.set_resizable(false); window.set_title(Some("UnixNotis Center")); window.add_css_class(hooks::panel_shell::WINDOW); - if let Some(settings) = gtk::Settings::default() { - // GTK global setting that controls whether scrollbars overlay content - // Enabled here to keep scrollbar behavior consistent across widgets - settings.set_property("gtk-overlay-scrolling", true); - } window.init_layer_shell(); window.set_namespace(Some("unixnotis-panel")); diff --git a/crates/unixnotis-center/src/ui/panel/tests/body.rs b/crates/unixnotis-center/src/ui/panel/tests/body.rs index 6603ddf7d..4cc8ec435 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/body.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/body.rs @@ -34,6 +34,17 @@ fn compact_widget_density_updates_spacing_and_state_class() { .has_css_class(hooks::panel_shell::WIDGET_DENSITY_COMFORTABLE)); } +#[gtk::test] +fn notification_scroller_keeps_scrollbar_space_without_global_settings() { + let sections = build_panel_sections(&PanelConfig::default(), WidgetDensity::Comfortable); + + assert!(!sections.scroller.is_overlay_scrolling()); + assert_eq!( + sections.scroller.vscrollbar_policy(), + gtk::PolicyType::Always + ); +} + #[test] fn notification_header_row_uses_section_label_when_section_is_visible() { let config = PanelConfig { diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 19c1c2033..4e2981e31 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -243,7 +243,7 @@ border-radius: 16px; border-radius: var(--unixnotis-media-card-radius); box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-media-card:hover { @@ -277,7 +277,7 @@ color: alpha(#ffffff, 0.85); border-radius: 999px; border-radius: var(--unixnotis-media-button-radius); - transition: background-color 0.12s ease-out, border-color 0.12s ease-out, transform 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out; + transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } .unixnotis-media-button:hover, @@ -289,7 +289,6 @@ border-bottom-color: alpha(#ffffff, 0.03); box-shadow: 0 4px 10px -5px alpha(#000000, 0.4), inset 0 1px 0 alpha(#ffffff, 0.05); color: #ffffff; - transform: translateY(-1px); } .unixnotis-media-button.primary { @@ -304,7 +303,6 @@ border-color: alpha(#ffffff, 0.90); color: #020617; box-shadow: 0 6px 14px -2px alpha(#000000, 0.45); - transform: translateY(-1.5px) scale(1.04); } .unixnotis-media-button:focus, diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 66290d2c5..6bf436539 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -668,7 +668,7 @@ entry selection { border-radius: 10px; box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.75); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action:hover { @@ -678,7 +678,6 @@ entry selection { border-right: 1px solid alpha(#ffffff, 0.06); border-bottom: 1px solid alpha(#ffffff, 0.04); color: #ffffff; - transform: translateY(-0.5px); } .unixnotis-panel-action:checked { @@ -701,7 +700,7 @@ entry selection { border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: 10px; box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action-close:hover, @@ -743,7 +742,7 @@ entry selection { border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: 16px; box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); - transition: background-image 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; + transition: border-color 0.15s ease-out; } .unixnotis-panel-card:hover { @@ -753,7 +752,6 @@ entry selection { border-right: 1px solid alpha(#ffffff, 0.06); border-bottom: 1px solid alpha(#ffffff, 0.03); box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04), 0 10px 24px -16px alpha(#000000, 0.85); - transform: translateY(-1.5px); } .unixnotis-panel-card.active { @@ -772,7 +770,6 @@ entry selection { border-right: 1px solid alpha(#ffffff, 0.08); border-bottom: 1px solid alpha(#ffffff, 0.04); box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05), 0 12px 28px -14px alpha(#000000, 0.9); - transform: translateY(-1.5px); } .unixnotis-panel-card.stacked, @@ -835,17 +832,15 @@ scrollbar slider { border: none; min-width: 4px; min-height: 4px; - transition: background-color 0.15s ease-out, min-width 0.15s ease-out, box-shadow 0.15s ease-out; + transition: background-color 0.15s ease-out; } scrollbar slider:hover { background: alpha(#52d9da, 0.85); box-shadow: 0 0 8px alpha(#52d9da, 0.50); - min-width: 6px; } scrollbar slider:active { background: #52d9da; box-shadow: 0 0 12px alpha(#52d9da, 0.80); - min-width: 6px; } diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index 8c2bf6990..0b99399be 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -821,7 +821,7 @@ margin-top: -4px; /* Center 12px knob over 4px trough */ margin-bottom: -4px; box-shadow: 0 1.5px 3.5px alpha(#000000, 0.40); - transition: min-width 0.15s ease-out, min-height 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; + transition: border-color 0.15s ease-out; } .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider { @@ -832,10 +832,6 @@ border-color: alpha(#ff9f0a, 0.6); } -.unixnotis-quick-slider-scale slider:hover { - transform: scale(1.10); -} - .unixnotis-quick-slider-volume:hover slider { border-color: #00a2ff; box-shadow: 0 0 6px alpha(#00a2ff, 0.5), 0 1.5px 3.5px alpha(#000000, 0.40); @@ -859,7 +855,7 @@ min-width: 4px; border-radius: 999px 999px 0 0; margin: 0 1.5px; - transition: background-color 0.15s ease-out, box-shadow 0.15s ease-out, min-height 0.15s ease-out; + transition: background-color 0.15s ease-out; } .unixnotis-quick-slider-segment:nth-child(1) { min-height: 2px; } @@ -923,7 +919,7 @@ border-radius: 14px; box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.7); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-toggle:hover, @@ -937,7 +933,6 @@ border-right: 1px solid alpha(#ffffff, 0.06); border-bottom: 1px solid alpha(#ffffff, 0.04); color: #ffffff; - transform: translateY(-0.5px); } .unixnotis-toggle:checked, @@ -957,7 +952,6 @@ .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked:hover { background-image: linear-gradient(135deg, #00c6ff, #0072ff); box-shadow: 0 8px 20px -6px alpha(#0072ff, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked { @@ -969,7 +963,6 @@ .unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked:hover { background-image: linear-gradient(135deg, #60a5fa, #2563eb); box-shadow: 0 8px 20px -6px alpha(#2563eb, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-airplane:checked { @@ -981,7 +974,6 @@ .unixnotis-toggle.unixnotis-toggle-kind-airplane:checked:hover { background-image: linear-gradient(135deg, #fbbf24, #b45309); box-shadow: 0 8px 20px -6px alpha(#b45309, 0.6), inset 0 1px 0 alpha(#ffffff, 0.20); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-night:checked { @@ -993,7 +985,6 @@ .unixnotis-toggle.unixnotis-toggle-kind-night:checked:hover { background-image: linear-gradient(135deg, #a78bfa, #5b21b6); box-shadow: 0 8px 20px -6px alpha(#5b21b6, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); } .unixnotis-toggle-icon { @@ -1017,7 +1008,7 @@ border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: 14px; box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-stat-card:hover { @@ -1027,7 +1018,6 @@ border-right: 1px solid alpha(#ffffff, 0.06); border-bottom: 1px solid alpha(#ffffff, 0.04); box-shadow: 0 8px 20px -12px alpha(#000000, 0.6); - transform: translateY(-1.5px); } .unixnotis-stat-icon { @@ -1112,7 +1102,7 @@ border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: 16px; box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-info-card:hover { @@ -1122,7 +1112,6 @@ border-right: 1px solid alpha(#ffffff, 0.06); border-bottom: 1px solid alpha(#ffffff, 0.04); box-shadow: 0 6px 14px -6px alpha(#000000, 0.6); - transform: translateY(-0.5px); } .unixnotis-info-card:hover .unixnotis-info-icon { diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 5274be8e1..d355b30f9 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -50,3 +50,21 @@ fn dnd_menu_hover_and_keyboard_focus_share_one_visual_rule() { assert!(DEFAULT_PANEL_CSS.contains(shared_selector)); assert!(!DEFAULT_PANEL_CSS.contains("box-shadow: inset 2px 0")); } + +#[test] +fn stock_panel_hover_styles_avoid_transform_and_geometry_animation() { + for (name, css) in [ + ("panel", DEFAULT_PANEL_CSS), + ("widgets", DEFAULT_WIDGETS_CSS), + ("media", DEFAULT_MEDIA_CSS), + ] { + assert!( + !css.contains("\n transform:"), + "{name} CSS should not move widgets during hover" + ); + } + + assert!(!DEFAULT_PANEL_CSS.contains("transition: background-image")); + assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-width")); + assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-height")); +} From 693782f99425f3bab85b6418127743e64431502a Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 01:36:46 -0500 Subject: [PATCH 039/275] feat(panel): add a reduced-motion policy Summary: add a reduced-motion policy. Scope: panel. --- crates/unixnotis-center/src/ui/panel/build.rs | 1 + crates/unixnotis-center/src/ui/panel/mod.rs | 1 + crates/unixnotis-center/src/ui/panel/motion.rs | 17 +++++++++++++++++ .../src/ui/panel/tests/motion.rs | 17 +++++++++++++++++ .../src/ui/reload/config/panel.rs | 1 + .../src/ui/reload/config/tests/panel.rs | 5 +++++ crates/unixnotis-core/assets/motion-policy.css | 7 +++++++ .../unixnotis-core/src/config/panel/config.rs | 3 +++ .../src/config/panel/tests/config.rs | 10 ++++++++++ crates/unixnotis-core/src/css/hooks/classes.rs | 1 + crates/unixnotis-core/src/css/tests/hooks.rs | 1 + crates/unixnotis-core/src/embedded/css.rs | 5 +++++ crates/unixnotis-core/src/embedded/tests/css.rs | 11 ++++++++++- crates/unixnotis-core/src/process/spec.rs | 2 +- .../src/actions/config/provision.rs | 14 ++++++++++++-- .../actions/config/tests/default_template.rs | 8 ++++++++ crates/unixnotis-ui/src/css/manager/layers.rs | 2 ++ .../src/css/manager/stack/display.rs | 8 ++++++++ .../unixnotis-ui/src/css/manager/stack/model.rs | 4 ++++ .../src/css/manager/stack/reload.rs | 7 ++++++- .../src/css/manager/stack/tests/display.rs | 15 ++++++++++++++- .../src/css/manager/stack/tests/reload.rs | 13 +++++++++---- 22 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/motion.rs create mode 100644 crates/unixnotis-center/src/ui/panel/tests/motion.rs create mode 100644 crates/unixnotis-core/assets/motion-policy.css diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index 55f6c0fd9..cb8b34b41 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -49,6 +49,7 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg let root = gtk::Box::new(gtk::Orientation::Vertical, 12); root.add_css_class(hooks::panel_shell::ROOT); + super::motion::apply_reduced_motion(&root, config.panel.reduced_motion); root.set_focusable(true); root.set_hexpand(true); root.set_vexpand(true); diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index c1650401f..e40f2db71 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -8,6 +8,7 @@ pub(in crate::ui) mod body; pub(in crate::ui) mod build; pub(in crate::ui) mod geometry; pub(in crate::ui) mod header; +pub(in crate::ui) mod motion; mod notice; mod state; pub(in crate::ui) mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/motion.rs b/crates/unixnotis-center/src/ui/panel/motion.rs new file mode 100644 index 000000000..42a13c17d --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/motion.rs @@ -0,0 +1,17 @@ +//! Panel-local motion preference styling + +use gtk::prelude::*; +use unixnotis_core::css::hooks; + +pub(in crate::ui) fn apply_reduced_motion(root: >k::Box, reduced_motion: bool) { + if reduced_motion { + // One stable class lets the internal policy layer cover custom and stock themes + root.add_css_class(hooks::panel_shell::REDUCED_MOTION); + } else { + root.remove_css_class(hooks::panel_shell::REDUCED_MOTION); + } +} + +#[cfg(test)] +#[path = "tests/motion.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/motion.rs b/crates/unixnotis-center/src/ui/panel/tests/motion.rs new file mode 100644 index 000000000..d9416c6b2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/motion.rs @@ -0,0 +1,17 @@ +//! Panel motion preference tests + +use gtk::prelude::*; +use unixnotis_core::css::hooks; + +use super::apply_reduced_motion; + +#[gtk::test] +fn reduced_motion_class_tracks_the_runtime_preference() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + + apply_reduced_motion(&root, true); + assert!(root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); + + apply_reduced_motion(&root, false); + assert!(!root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs index 4993ab983..0cc457f1a 100644 --- a/crates/unixnotis-center/src/ui/reload/config/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -9,6 +9,7 @@ impl UiState { pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { // Geometry goes first so later sections can size themselves from the final panel width panel::geometry::apply_panel_config(&self.panel, config, self.work_area); + panel::motion::apply_reduced_motion(&self.panel.root, config.panel.reduced_motion); self.panel.header.title.set_label(&config.panel.title); self.panel.header.subtitle.set_label(&config.panel.subtitle); self.panel diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs index 4a973d655..d98e080bb 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs @@ -9,6 +9,7 @@ fn reloaded_panel_applies_copy_and_widget_density() { let mut config = state.config.clone(); config.panel.title = "Operations".to_string(); config.panel.subtitle = "Live state".to_string(); + config.panel.reduced_motion = true; config.widgets.density = WidgetDensity::Compact; state.apply_reloaded_panel(&config); @@ -16,6 +17,10 @@ fn reloaded_panel_applies_copy_and_widget_density() { assert_eq!(state.panel.header.title.text(), "Operations"); assert_eq!(state.panel.header.subtitle.text(), "Live state"); assert!(state.panel.header.subtitle.get_visible()); + assert!(state + .panel + .root + .has_css_class(unixnotis_core::hooks::panel_shell::REDUCED_MOTION)); assert_eq!(state.panel.sections.widget_stack.spacing(), 6); } diff --git a/crates/unixnotis-core/assets/motion-policy.css b/crates/unixnotis-core/assets/motion-policy.css new file mode 100644 index 000000000..fee47bd7c --- /dev/null +++ b/crates/unixnotis-core/assets/motion-policy.css @@ -0,0 +1,7 @@ +/* Runtime motion policy stays above editable theme layers for accessibility */ +.unixnotis-panel.unixnotis-reduced-motion, +.unixnotis-panel.unixnotis-reduced-motion * { + transition: none; + animation: none; + transform: none; +} diff --git a/crates/unixnotis-core/src/config/panel/config.rs b/crates/unixnotis-core/src/config/panel/config.rs index 9ef175e1a..162e4f41f 100644 --- a/crates/unixnotis-core/src/config/panel/config.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -41,6 +41,8 @@ pub struct PanelConfig { pub search_visible: bool, /// Show the compact utility action row below the header pub action_row_visible: bool, + /// Disable panel motion effects without requiring GTK 4.20 media queries + pub reduced_motion: bool, /// Wrap the notification list in a titled section pub notification_section_visible: bool, /// Let the notification list consume remaining vertical panel space @@ -120,6 +122,7 @@ impl Default for PanelConfig { search_magnifier_icon: "system-search-symbolic".to_string(), search_visible: false, action_row_visible: true, + reduced_motion: false, notification_section_visible: false, notification_list_expand: true, notification_metadata_visible: false, diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs index 81cf00163..c438a4a60 100644 --- a/crates/unixnotis-core/src/config/panel/tests/config.rs +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -34,6 +34,7 @@ fn default_panel_config_keeps_expected_layout_and_text_contract() { assert_eq!(panel.search_placeholder, "Search app, title, or message"); assert_eq!(panel.search_magnifier_icon, "system-search-symbolic"); assert!(panel.action_row_visible); + assert!(!panel.reduced_motion); assert!(panel.notification_list_expand); assert!(panel.close_on_click_outside); assert!(panel.respect_work_area); @@ -47,6 +48,15 @@ fn partial_panel_values_use_current_presentation_defaults() { assert_eq!(panel.quick_actions_label, "Quick settings"); assert_eq!(panel.system_status_label, "System health"); assert_eq!(panel.empty_offset_top, 24); + assert!(!panel.reduced_motion); +} + +#[test] +fn panel_config_parses_reduced_motion_preference() { + let panel: PanelConfig = + toml::from_str("reduced_motion = true").expect("reduced motion should parse"); + + assert!(panel.reduced_motion); } #[test] diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 2699cfa77..6ca1b93ee 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -46,6 +46,7 @@ pub mod panel_shell { // Panel shell hooks keep split panel files on one stable class contract pub const WINDOW: &str = "unixnotis-panel-window"; pub const ROOT: &str = "unixnotis-panel"; + pub const REDUCED_MOTION: &str = "unixnotis-reduced-motion"; pub const HEADER: &str = "unixnotis-panel-header"; pub const HEADER_TOP: &str = "unixnotis-panel-header-top"; pub const TITLE_STACK: &str = "unixnotis-panel-title-stack"; diff --git a/crates/unixnotis-core/src/css/tests/hooks.rs b/crates/unixnotis-core/src/css/tests/hooks.rs index 78354ce50..76e030f41 100644 --- a/crates/unixnotis-core/src/css/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/tests/hooks.rs @@ -42,6 +42,7 @@ fn hook_names_stay_unique() { panel_action::LABEL_HIDDEN, panel_shell::WINDOW, panel_shell::ROOT, + panel_shell::REDUCED_MOTION, panel_shell::HEADER, panel_shell::HEADER_TOP, panel_shell::TITLE_STACK, diff --git a/crates/unixnotis-core/src/embedded/css.rs b/crates/unixnotis-core/src/embedded/css.rs index 58e6ccaa0..d860dbe06 100644 --- a/crates/unixnotis-core/src/embedded/css.rs +++ b/crates/unixnotis-core/src/embedded/css.rs @@ -20,6 +20,11 @@ pub const INTERNAL_STRUCTURE_CSS: &str = include_str!(concat!( "/assets/internal-structure.css" )); +pub const MOTION_POLICY_CSS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/motion-policy.css" +)); + #[cfg(test)] #[path = "tests/css.rs"] mod tests; diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index d355b30f9..3e514a852 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -1,6 +1,6 @@ use super::{ DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, - INTERNAL_STRUCTURE_CSS, + INTERNAL_STRUCTURE_CSS, MOTION_POLICY_CSS, }; #[test] @@ -12,11 +12,20 @@ fn every_embedded_css_layer_contains_real_stylesheet_content() { ("widgets", DEFAULT_WIDGETS_CSS), ("media", DEFAULT_MEDIA_CSS), ("internal structure", INTERNAL_STRUCTURE_CSS), + ("motion policy", MOTION_POLICY_CSS), ] { assert!(!css.trim().is_empty(), "{name} CSS should not be empty"); } } +#[test] +fn motion_policy_disables_theme_motion_under_the_runtime_class() { + assert!(MOTION_POLICY_CSS.contains(".unixnotis-panel.unixnotis-reduced-motion")); + assert!(MOTION_POLICY_CSS.contains("transition: none")); + assert!(MOTION_POLICY_CSS.contains("animation: none")); + assert!(MOTION_POLICY_CSS.contains("transform: none")); +} + #[test] fn internal_structure_css_contains_only_required_fallback_structure() { assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice")); diff --git a/crates/unixnotis-core/src/process/spec.rs b/crates/unixnotis-core/src/process/spec.rs index 429df6bde..35ce5f6af 100644 --- a/crates/unixnotis-core/src/process/spec.rs +++ b/crates/unixnotis-core/src/process/spec.rs @@ -85,7 +85,7 @@ impl CommandSpec { } /// Compatibility alias for the former inline shell command detector - #[deprecated(since = "1.2.0", note = "use uses_shell_command_string")] + #[deprecated(note = "use uses_shell_command_string")] #[must_use] pub fn invokes_shell(&self) -> bool { self.uses_shell_command_string() diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index 404894eb0..a3812926a 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -215,11 +215,21 @@ height = {}\n\ # height_override = 1487\n", config.panel.height ); - + let reduced_motion_line = format!("reduced_motion = {}\n", config.panel.reduced_motion); + let reduced_motion_block = format!( + "# Disable panel transforms and transitions without requiring GTK 4.20\n\ +reduced_motion = {}\n", + config.panel.reduced_motion + ); if !config_toml.contains(&panel_height_line) { return Err(anyhow!("default config template missing panel height line")); } - + if !config_toml.contains(&reduced_motion_line) { + return Err(anyhow!( + "default config template missing reduced motion line" + )); + } config_toml = config_toml.replacen(&panel_height_line, &panel_height_block, 1); + config_toml = config_toml.replacen(&reduced_motion_line, &reduced_motion_block, 1); Ok(config_toml) } diff --git a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs index a3d114286..e583ad126 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs @@ -15,6 +15,14 @@ fn default_config_template_documents_panel_height_modes() { .any(|line| line.trim() == "height_override = 1487")); } +#[test] +fn default_config_template_documents_reduced_motion() { + let config_toml = render_default_config_toml(&Config::default()).expect("render config"); + + assert!(config_toml.contains("# Disable panel transforms and transitions")); + assert!(config_toml.contains("reduced_motion = false")); +} + #[test] fn default_config_template_omits_removed_theme_override_layer() { let config_toml = render_default_config_toml(&Config::default()).expect("render config"); diff --git a/crates/unixnotis-ui/src/css/manager/layers.rs b/crates/unixnotis-ui/src/css/manager/layers.rs index 0212d99e5..066556091 100644 --- a/crates/unixnotis-ui/src/css/manager/layers.rs +++ b/crates/unixnotis-ui/src/css/manager/layers.rs @@ -14,6 +14,8 @@ pub enum CssProviderLayer { Widgets, /// MPRIS media card layer Media, + /// Internal reduced-motion policy loaded above editable theme layers + MotionPolicy, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/unixnotis-ui/src/css/manager/stack/display.rs b/crates/unixnotis-ui/src/css/manager/stack/display.rs index a146d68dd..a04f7e4cd 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/display.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/display.rs @@ -70,6 +70,13 @@ where priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 3, }); } + if self.motion_policy.is_some() { + // Reduced motion is an accessibility contract rather than a theme suggestion + registrations.push(CssProviderRegistration { + layer: CssProviderLayer::MotionPolicy, + priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 4, + }); + } registrations } @@ -81,6 +88,7 @@ where CssProviderLayer::Popup => self.popup.as_ref(), CssProviderLayer::Widgets => self.widgets.as_ref(), CssProviderLayer::Media => self.media.as_ref(), + CssProviderLayer::MotionPolicy => self.motion_policy.as_ref(), } } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/model.rs b/crates/unixnotis-ui/src/css/manager/stack/model.rs index f69f3665e..826527ffa 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/model.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/model.rs @@ -57,6 +57,8 @@ where pub(super) panel: Option

, pub(super) widgets: Option

, pub(super) media: Option

, + // Runtime accessibility policy must override every editable panel theme layer + pub(super) motion_policy: Option

, pub(super) popup: Option

, } @@ -71,6 +73,7 @@ impl CssManagerInner { panel: Some(CssProvider::new()), widgets: Some(CssProvider::new()), media: Some(CssProvider::new()), + motion_policy: Some(CssProvider::new()), popup: None, } } @@ -85,6 +88,7 @@ impl CssManagerInner { panel: None, widgets: None, media: None, + motion_policy: None, popup: Some(CssProvider::new()), } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/reload.rs index c8b21c077..f68dd18bc 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/reload.rs @@ -2,7 +2,7 @@ use unixnotis_core::{ ThemeConfig, ThemePaths, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, - DEFAULT_WIDGETS_CSS, INTERNAL_STRUCTURE_CSS, + DEFAULT_WIDGETS_CSS, INTERNAL_STRUCTURE_CSS, MOTION_POLICY_CSS, }; use super::super::super::loader::{ @@ -122,6 +122,11 @@ where )); } + if let Some(motion_policy) = self.motion_policy.as_ref() { + // This fixed policy is intentionally loaded after every editable panel layer + motion_policy.load_css_data(MOTION_POLICY_CSS); + } + // Callers receive every layer outcome instead of a lossy success count CssReloadReport { layers: loaded } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs index 0212af9d4..2a8c339c8 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs @@ -53,6 +53,7 @@ fn panel_manager_registers_base_panel_widgets_and_media_priorities() { panel: Some(RecordingProvider::new("panel", Rc::clone(&calls))), widgets: Some(RecordingProvider::new("widgets", Rc::clone(&calls))), media: Some(RecordingProvider::new("media", Rc::clone(&calls))), + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&calls))), popup: None, }; @@ -82,6 +83,10 @@ fn panel_manager_registers_base_panel_widgets_and_media_priorities() { layer: CssProviderLayer::Media, priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 3, }, + CssProviderRegistration { + layer: CssProviderLayer::MotionPolicy, + priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 4, + }, ] ); } @@ -97,6 +102,7 @@ fn popup_manager_registers_base_and_popup_at_popup_priority() { panel: None, widgets: None, media: None, + motion_policy: None, popup: Some(RecordingProvider::new("popup", Rc::clone(&calls))), }; @@ -128,7 +134,7 @@ fn public_panel_manager_reports_every_registered_provider() { ThemeConfig::default(), ); - assert_eq!(manager.apply_to_display(), 5); + assert_eq!(manager.apply_to_display(), 6); } #[test] @@ -142,6 +148,7 @@ fn provider_lookup_returns_only_layers_owned_by_the_manager() { panel: Some(RecordingProvider::new("panel", Rc::clone(&calls))), widgets: None, media: None, + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&calls))), popup: None, }; @@ -166,4 +173,10 @@ fn provider_lookup_returns_only_layers_owned_by_the_manager() { assert!(manager .provider_for_layer(CssProviderLayer::Popup) .is_none()); + assert_eq!( + manager + .provider_for_layer(CssProviderLayer::MotionPolicy) + .map(|provider| provider.label), + Some("motion") + ); } diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs index 7709e9a5f..197012968 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs @@ -87,6 +87,7 @@ fn panel_manager( panel: Some(RecordingProvider::new("panel", Rc::clone(&loaded))), widgets: Some(RecordingProvider::new("widgets", Rc::clone(&loaded))), media: Some(RecordingProvider::new("media", Rc::clone(&loaded))), + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&loaded))), popup: None, } } @@ -118,16 +119,20 @@ fn panel_reload_loads_base_panel_widgets_and_media_layers() { let labels = loaded.iter().map(|(label, _)| *label).collect::>(); assert_eq!( labels, - vec!["internal", "base", "panel", "widgets", "media"] + vec!["internal", "base", "panel", "widgets", "media", "motion"] ); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| css.contains("green"))); assert!(loaded .iter() .find(|(label, _)| *label == "internal") .is_some_and(|(_, css)| css.contains(".unixnotis-reload-notice"))); + assert!(loaded + .iter() + .find(|(label, _)| *label == "motion") + .is_some_and(|(_, css)| css.contains(".unixnotis-reduced-motion"))); fs::remove_dir_all(root).expect("remove css manager test root"); } @@ -150,11 +155,11 @@ fn update_theme_changes_the_paths_used_by_the_next_reload() { let loaded = loaded.borrow(); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| css.contains("blue"))); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| !css.contains("red"))); fs::remove_dir_all(old_root).expect("remove old css manager test root"); From d7d85ec7bbe29ae54e5ba7f53f4af71260537bc4 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 01:36:57 -0500 Subject: [PATCH 040/275] fix(installer): reject privileged execution early Summary: reject privileged execution early. Scope: installer. --- crates/unixnotis-installer/src/main.rs | 4 ++++ crates/unixnotis-installer/src/privilege.rs | 15 +++++++++++++++ .../unixnotis-installer/src/tests/privilege.rs | 16 ++++++++++++++++ crates/unixnotis-installer/tests/cli.rs | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 crates/unixnotis-installer/src/privilege.rs create mode 100644 crates/unixnotis-installer/src/tests/privilege.rs diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 53404fa79..93bf8a7d3 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -23,6 +23,7 @@ mod detect; mod managed_binaries; mod model; mod paths; +mod privilege; mod release; mod safe_write; mod service_manager; @@ -43,6 +44,9 @@ use crate::terminal::TerminalGuard; use crate::trial::run_trial; fn main() -> Result<()> { + // Root execution turns user-controlled paths into privileged mutation targets + privilege::reject_root_install(rustix::process::geteuid().as_raw())?; + let cli = match cli::parse_env_args()? { CliAction::Run(args) => args, CliAction::Help => { diff --git a/crates/unixnotis-installer/src/privilege.rs b/crates/unixnotis-installer/src/privilege.rs new file mode 100644 index 000000000..0f3b9c170 --- /dev/null +++ b/crates/unixnotis-installer/src/privilege.rs @@ -0,0 +1,15 @@ +//! Installer privilege-boundary checks + +use anyhow::{bail, Result}; + +pub(crate) fn reject_root_install(euid: u32) -> Result<()> { + if euid == 0 { + bail!("unixnotis-installer is user-level; do not run it as root or through sudo"); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/privilege.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/tests/privilege.rs b/crates/unixnotis-installer/src/tests/privilege.rs new file mode 100644 index 000000000..08e47522c --- /dev/null +++ b/crates/unixnotis-installer/src/tests/privilege.rs @@ -0,0 +1,16 @@ +use super::reject_root_install; + +#[test] +fn root_effective_uid_is_rejected_with_user_level_guidance() { + let error = reject_root_install(0).expect_err("root must be rejected"); + + assert_eq!( + error.to_string(), + "unixnotis-installer is user-level; do not run it as root or through sudo" + ); +} + +#[test] +fn normal_user_effective_uid_is_accepted() { + assert!(reject_root_install(1000).is_ok()); +} diff --git a/crates/unixnotis-installer/tests/cli.rs b/crates/unixnotis-installer/tests/cli.rs index 3670bf7cc..5cbfd1f8d 100644 --- a/crates/unixnotis-installer/tests/cli.rs +++ b/crates/unixnotis-installer/tests/cli.rs @@ -1,15 +1,25 @@ #[cfg(test)] mod tests { use std::error::Error; + use std::os::unix::process::CommandExt; use std::process::Command; type TestResult = Result<(), Box>; + fn installer_command_as_non_root() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_unixnotis-installer")); + + // Root-based CI must exercise the same user-level entrypoint as a desktop session + if rustix::process::geteuid().is_root() { + command.uid(65_534); + } + + command + } + #[test] fn installer_help_prints_usage_from_entrypoint() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-installer")) - .arg("--help") - .output()?; + let output = installer_command_as_non_root().arg("--help").output()?; assert!(output.status.success()); let stdout = String::from_utf8(output.stdout)?; From c4441e6d4ec76ec9b5c91e75f0a52e5648d2fa5b Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 01:47:46 -0500 Subject: [PATCH 041/275] feat(ui): apply reduced motion immediately Summary: apply reduced motion immediately. Scope: ui. --- .../unixnotis-center/src/ui/init/builders.rs | 7 +- .../src/ui/init/constructor.rs | 1 + .../unixnotis-center/src/ui/media/config.rs | 3 + .../unixnotis-center/src/ui/media/marquee.rs | 88 ++++++++++++++----- .../src/ui/media/tests/marquee.rs | 73 ++++++++++++++- .../src/ui/media/widget/controller.rs | 4 + crates/unixnotis-center/src/ui/mod.rs | 1 + crates/unixnotis-center/src/ui/motion.rs | 26 ++++++ .../src/ui/notifications/model/item.rs | 4 + .../src/ui/notifications/model/tests/item.rs | 4 + .../src/ui/notifications/model/types.rs | 2 + .../row/notification/reply/build.rs | 3 +- .../row/notification/reply/state.rs | 8 ++ .../row/notification/reply/tests/mod.rs | 1 + .../row/notification/reply/tests/motion.rs | 59 +++++++++++++ .../row/notification/tests/support.rs | 2 + .../row/notification/update/row.rs | 2 + .../src/ui/notifications/store/blocks.rs | 1 + .../src/ui/notifications/store/lifecycle.rs | 1 + .../src/ui/notifications/store/mutation.rs | 1 + .../src/ui/notifications/tests/support.rs | 1 + .../src/ui/notifications/view/build.rs | 6 +- .../src/ui/notifications/view/tests/build.rs | 15 ++++ crates/unixnotis-center/src/ui/panel/build.rs | 8 +- .../src/ui/panel/header/search.rs | 50 ++++++----- .../ui/panel/header/tests/search_signals.rs | 36 +++++++- .../unixnotis-center/src/ui/panel/motion.rs | 29 +++++- .../unixnotis-center/src/ui/panel/notice.rs | 4 +- .../src/ui/panel/tests/motion.rs | 6 +- .../src/ui/reload/config/panel.rs | 2 +- .../src/ui/reload/config/tests/panel.rs | 6 ++ .../src/ui/reload/config/widgets.rs | 1 + .../unixnotis-center/src/ui/tests/motion.rs | 14 +++ .../src/actions/config/provision.rs | 2 +- .../actions/config/tests/default_template.rs | 2 +- 35 files changed, 414 insertions(+), 59 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/motion.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs create mode 100644 crates/unixnotis-center/src/ui/tests/motion.rs diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index 0157012bd..96cd03b2a 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -19,6 +19,7 @@ pub(super) fn build_notification_list( notification_metadata: init.config.panel.notification_metadata.clone(), notification_corners: init.config.theme.notification_corners, show_notification_thumbnails: init.config.panel.notification_thumbnails_visible, + reduced_motion: init.config.panel.reduced_motion, empty_text: init.config.panel.empty_text.clone(), no_matching_text: init.config.panel.no_matching_text.clone(), empty_offset_top: init.config.panel.empty_offset_top, @@ -42,12 +43,14 @@ pub(super) fn build_media_widget( ) -> Option { let panel_width = panel::geometry::requested_panel_width(&panel.root); let media = init.media_handle.as_ref().map(|handle| { - media::MediaWidget::new( + let media = media::MediaWidget::new( &panel.sections.media_container, handle.clone(), panel_width, &init.config.media, - ) + ); + media.set_reduced_motion(init.config.panel.reduced_motion); + media }); if media.is_none() { diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 554af4ecf..f93c1969b 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -56,6 +56,7 @@ impl UiState { panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); panel::header::search::connect_widget_collapse_toggle( &panel.header.actions.focus_toggle, + &panel.sections.widget_revealer, init.event_tx.clone(), ); panel::header::search::connect_filter_entry( diff --git a/crates/unixnotis-center/src/ui/media/config.rs b/crates/unixnotis-center/src/ui/media/config.rs index c5c50af55..2d9d442ae 100644 --- a/crates/unixnotis-center/src/ui/media/config.rs +++ b/crates/unixnotis-center/src/ui/media/config.rs @@ -66,6 +66,7 @@ impl UiState { panel_width, &config.media, ); + media.set_reduced_motion(config.panel.reduced_motion); if !snapshot.is_empty() { // The visible player is restored so reload does not blank the current card media.restore_snapshot(&snapshot); @@ -79,6 +80,7 @@ impl UiState { // Reuse the existing shell when only width or metadata flags changed debug!("media layout updated"); media.apply_layout(panel_width, &config.media); + media.set_reduced_motion(config.panel.reduced_motion); } (None, Some(handle)) => { debug!("media widget created"); @@ -88,6 +90,7 @@ impl UiState { panel_width, &config.media, ); + media.set_reduced_motion(config.panel.reduced_motion); self.media = Some(media); } (None, None) => { diff --git a/crates/unixnotis-center/src/ui/media/marquee.rs b/crates/unixnotis-center/src/ui/media/marquee.rs index 26e7b47da..a3e6cc48a 100644 --- a/crates/unixnotis-center/src/ui/media/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/marquee.rs @@ -22,7 +22,8 @@ struct MarqueeState { last_tick: Option, hold_until: Option, reset_pending: bool, - enabled: bool, + overflows: bool, + reduced_motion: bool, is_ticking: bool, is_mapped: bool, tick_source: Option, @@ -73,7 +74,7 @@ impl MarqueeLabel { let state = Rc::new(RefCell::new(MarqueeState { reset_pending: true, - enabled: false, + overflows: false, is_mapped: root.is_mapped(), tick_source: None, char_limit, @@ -95,7 +96,7 @@ impl MarqueeLabel { move |_| { let mut state = mapped_state.borrow_mut(); state.is_mapped = true; - let should_start = state.enabled && !state.is_ticking; + let should_start = state.overflows && !state.reduced_motion && !state.is_ticking; drop(state); if should_start { start_ticking_inner(mapped_state.clone(), mapped_label.clone()); @@ -112,13 +113,7 @@ impl MarqueeLabel { state.is_mapped = false; // Stop ticking immediately when the widget is unmapped to avoid background work // Unmapped widgets should not keep timers alive - if let Some(source_id) = state.tick_source.take() { - source_id.remove(); - state.is_ticking = false; - state.last_tick = None; - state.hold_until = None; - perf_probe::marquee_stop(); - } + stop_ticking_state(&mut state); } )); @@ -148,7 +143,7 @@ impl MarqueeLabel { return; } let char_limit = state.char_limit; - state.enabled = marquee_should_tick( + state.overflows = marquee_should_tick( char_limit, text.chars().count(), text_width, @@ -159,7 +154,8 @@ impl MarqueeLabel { state.hold_until = None; state.last_tick = None; state.full_text = text.to_string(); - state.buffer = if state.enabled { + let animate = state.overflows && !state.reduced_motion; + state.buffer = if animate { let padded = format!("{text} "); padded.chars().collect() } else { @@ -167,7 +163,7 @@ impl MarqueeLabel { }; state.last_rendered_offset = usize::MAX; - let enabled = state.enabled; + let enabled = animate; let mapped = state.is_mapped; let ticking = state.is_ticking; @@ -206,18 +202,62 @@ impl MarqueeLabel { self.update_limits(max_width, char_limit); } + pub fn set_reduced_motion(&self, reduced_motion: bool) { + let mut state = self.state.borrow_mut(); + if state.reduced_motion == reduced_motion { + return; + } + + state.reduced_motion = reduced_motion; + state.reset_pending = true; + state.offset = 0.0; + state.last_tick = None; + state.hold_until = None; + state.last_rendered_offset = usize::MAX; + + if reduced_motion { + // Stop before restoring text so no queued callback can move the stable label again + stop_ticking_state(&mut state); + state.buffer.clear(); + self.label.set_text(&state.full_text); + return; + } + + let should_start = state.overflows && state.is_mapped; + if state.overflows { + let padded = format!("{} ", state.full_text); + state.buffer = padded.chars().collect(); + render_visible(&mut state, 0); + self.label.set_text(&state.render_buf); + } else { + self.label.set_text(&state.full_text); + } + drop(state); + + if should_start { + self.start_ticking(); + } + } + fn start_ticking(&self) { start_ticking_inner(self.state.clone(), self.label.clone()); } fn stop_ticking(&self) { let mut state = self.state.borrow_mut(); - if let Some(source_id) = state.tick_source.take() { - source_id.remove(); - } - state.is_ticking = false; - state.last_tick = None; - state.hold_until = None; + stop_ticking_state(&mut state); + } +} + +fn stop_ticking_state(state: &mut MarqueeState) { + let was_ticking = state.is_ticking; + if let Some(source_id) = state.tick_source.take() { + source_id.remove(); + } + state.is_ticking = false; + state.last_tick = None; + state.hold_until = None; + if was_ticking { perf_probe::marquee_stop(); } } @@ -234,7 +274,12 @@ fn marquee_should_tick( fn start_ticking_inner(state: Rc>, label: gtk::Label) { { let mut state = state.borrow_mut(); - if state.is_ticking { + if state.is_ticking + || state.tick_source.is_some() + || state.reduced_motion + || !state.overflows + || !state.is_mapped + { return; } state.is_ticking = true; @@ -247,11 +292,12 @@ fn start_ticking_inner(state: Rc>, label: gtk::Label) { perf_probe::marquee_tick(); let mut state = state_tick.borrow_mut(); - if !state.enabled || !state.is_mapped { + if !state.overflows || state.reduced_motion || !state.is_mapped { state.is_ticking = false; state.tick_source = None; state.last_tick = None; state.hold_until = None; + perf_probe::marquee_stop(); return glib::ControlFlow::Break; } diff --git a/crates/unixnotis-center/src/ui/media/tests/marquee.rs b/crates/unixnotis-center/src/ui/media/tests/marquee.rs index e85914433..31bd31a94 100644 --- a/crates/unixnotis-center/src/ui/media/tests/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/tests/marquee.rs @@ -1,4 +1,4 @@ -use super::marquee_should_tick; +use super::{marquee_should_tick, MarqueeLabel}; #[test] fn marquee_starts_when_short_title_exceeds_pixel_budget() { @@ -21,3 +21,74 @@ fn marquee_stays_idle_when_text_fits_both_limits() { fn disabled_marquee_never_starts_for_overflowing_text() { assert!(!marquee_should_tick(0, 40, 300, 81)); } + +#[gtk::test] +fn reduced_motion_keeps_overflowing_text_stable_without_a_timer() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_reduced_motion(true); + + marquee.set_text("Long title"); + + let state = marquee.state.borrow(); + assert!(state.overflows); + assert!(state.reduced_motion); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert_eq!(marquee.label.text(), "Long title"); +} + +#[gtk::test] +fn runtime_reduced_motion_cancels_and_restores_one_marquee_source() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_text("Long title"); + assert!(marquee.state.borrow().tick_source.is_some()); + + marquee.set_reduced_motion(true); + { + let state = marquee.state.borrow(); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert_eq!(state.offset, 0.0); + } + assert_eq!(marquee.label.text(), "Long title"); + + marquee.set_reduced_motion(false); + let restarted_source = marquee + .state + .borrow() + .tick_source + .as_ref() + .expect("overflow should restart one source") + .as_raw(); + marquee.set_reduced_motion(false); + assert_eq!( + marquee + .state + .borrow() + .tick_source + .as_ref() + .expect("repeated preference should retain the source") + .as_raw(), + restarted_source + ); + + // Removing the source keeps it from escaping the test main context + marquee.set_reduced_motion(true); +} + +#[gtk::test] +fn disabling_reduced_motion_does_not_start_a_timer_when_text_fits() { + let marquee = MarqueeLabel::new("test-marquee", 400, 32); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_reduced_motion(true); + marquee.set_text("Short title"); + + marquee.set_reduced_motion(false); + + let state = marquee.state.borrow(); + assert!(!state.overflows); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); +} diff --git a/crates/unixnotis-center/src/ui/media/widget/controller.rs b/crates/unixnotis-center/src/ui/media/widget/controller.rs index 1c1407672..811fd3e62 100644 --- a/crates/unixnotis-center/src/ui/media/widget/controller.rs +++ b/crates/unixnotis-center/src/ui/media/widget/controller.rs @@ -77,6 +77,10 @@ impl MediaWidget { self.root.set_visible(false); } + pub(in crate::ui) fn set_reduced_motion(&self, reduced_motion: bool) { + self.card.title_label.set_reduced_motion(reduced_motion); + } + pub(in crate::ui) fn matches_layout(&self, config: &MediaConfig) -> bool { self.shell == MediaShellConfig::from_config(config) } diff --git a/crates/unixnotis-center/src/ui/mod.rs b/crates/unixnotis-center/src/ui/mod.rs index b09b73358..27ff2d753 100644 --- a/crates/unixnotis-center/src/ui/mod.rs +++ b/crates/unixnotis-center/src/ui/mod.rs @@ -10,6 +10,7 @@ mod reload; mod init; mod media; +mod motion; mod notifications; mod panel; mod state; diff --git a/crates/unixnotis-center/src/ui/motion.rs b/crates/unixnotis-center/src/ui/motion.rs new file mode 100644 index 000000000..b64fee8a2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/motion.rs @@ -0,0 +1,26 @@ +//! Shared GTK motion-policy operations + +pub(super) fn apply_revealer_preference( + revealer: >k::Revealer, + standard_duration_ms: u32, + reduced_motion: bool, +) { + revealer.set_transition_duration(if reduced_motion { + 0 + } else { + standard_duration_ms + }); + + if !reduced_motion || revealer.is_child_revealed() == revealer.reveals_child() { + return; + } + + // Reapplying the target through an immediate edge finishes an animation already in flight + let target = revealer.reveals_child(); + revealer.set_reveal_child(!target); + revealer.set_reveal_child(target); +} + +#[cfg(test)] +#[path = "tests/motion.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 2e757c710..ad2b977c6 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -22,6 +22,8 @@ pub struct RowPresentation { // Optional lanes are disabled by default to preserve the compact stock card pub show_metadata: bool, pub show_thumbnail: bool, + // Runtime motion policy keeps recycled row revealers in sync with panel settings + pub reduced_motion: bool, // Shared config avoids cloning every metadata string into every row snapshot pub metadata: Rc, // Card clipping follows theme reloads through the same row refresh path @@ -34,6 +36,7 @@ impl Default for RowPresentation { received_at_ms: 0, show_metadata: false, show_thumbnail: false, + reduced_motion: false, metadata: Rc::new(NotificationMetadataConfig::default()), card_corners: CutCorners::default(), } @@ -45,6 +48,7 @@ impl PartialEq for RowPresentation { self.received_at_ms == other.received_at_ms && self.show_metadata == other.show_metadata && self.show_thumbnail == other.show_thumbnail + && self.reduced_motion == other.reduced_motion && Rc::ptr_eq(&self.metadata, &other.metadata) && self.card_corners == other.card_corners } diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index b4badf526..419112e69 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -154,6 +154,10 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { changed.presentation.show_thumbnail = true; assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); + changed.presentation.reduced_motion = true; + assert!(!base.is_equivalent(&changed)); + let mut changed = base; changed.notification = Some(notification(1)); assert!(!RowData::notification( diff --git a/crates/unixnotis-center/src/ui/notifications/model/types.rs b/crates/unixnotis-center/src/ui/notifications/model/types.rs index 7cad39bb3..18cb2d9b2 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/types.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/types.rs @@ -52,6 +52,7 @@ pub struct NotificationList { pub(in crate::ui::notifications) notification_metadata: Rc, pub(in crate::ui::notifications) notification_corners: CutCorners, pub(in crate::ui::notifications) show_notification_thumbnails: bool, + pub(in crate::ui::notifications) reduced_motion: bool, pub(in crate::ui::notifications) max_active: usize, pub(in crate::ui::notifications) max_entries: usize, } @@ -65,6 +66,7 @@ pub struct NotificationListConfig { pub notification_metadata: NotificationMetadataConfig, pub notification_corners: CutCorners, pub show_notification_thumbnails: bool, + pub reduced_motion: bool, pub empty_text: String, pub no_matching_text: String, pub empty_offset_top: i32, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs index 4f0e9b4c7..016357081 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs @@ -7,7 +7,7 @@ use crate::control::UiCommand; use super::lifecycle::{cancel_inline_reply, submit_reply, MAX_REPLY_BYTES}; use super::presentation::{clear_reply_error, DEFAULT_PLACEHOLDER, DEFAULT_SUBMIT_LABEL}; -use super::state::{InlineReplyWidgets, ReplyState}; +use super::state::{InlineReplyWidgets, ReplyState, INLINE_REPLY_TRANSITION_MS}; // GTK limits characters while the protocol boundary limits encoded bytes const MAX_REPLY_CHARS: i32 = 4 * 1024; @@ -18,6 +18,7 @@ pub(in super::super) fn build_inline_reply( // Build the hidden form once so row updates only change state and metadata let revealer = gtk::Revealer::new(); revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_transition_duration(INLINE_REPLY_TRANSITION_MS); revealer.set_reveal_child(false); let form = gtk::Box::new(gtk::Orientation::Vertical, 4); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs index b9b2fa60b..8e6c2bcbe 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs @@ -5,6 +5,10 @@ use std::rc::{Rc, Weak}; use unixnotis_core::NotificationView; +use crate::ui::motion::apply_revealer_preference; + +pub(super) const INLINE_REPLY_TRANSITION_MS: u32 = 250; + #[derive(Clone)] pub(super) struct ReplyState { // Numeric identity is retained for the command sent to the daemon @@ -54,4 +58,8 @@ impl InlineReplyWidgets { state, } } + + pub(in super::super) fn set_reduced_motion(&self, reduced_motion: bool) { + apply_revealer_preference(&self.revealer, INLINE_REPLY_TRANSITION_MS, reduced_motion); + } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs index 185bdd311..e77eb24a8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs @@ -3,6 +3,7 @@ mod availability; mod generation; mod keyboard; +mod motion; mod presentation; mod recovery; mod submission; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs new file mode 100644 index 000000000..324114818 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs @@ -0,0 +1,59 @@ +//! Inline reply reduced-motion tests + +use std::rc::Rc; + +use unixnotis_core::Action; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::super::state::INLINE_REPLY_TRANSITION_MS; +use super::{ + build_notification_row, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_revealer_tracks_runtime_reduced_motion() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + let notification = Rc::new(notification); + + update_notification_row( + &row, + &row_data( + notification.clone(), + RowFlags { + is_active: true, + reduced_motion: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + assert_eq!(row.inline_reply.revealer.transition_duration(), 0); + + update_notification_row( + &row, + &row_data( + notification, + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + assert_eq!( + row.inline_reply.revealer.transition_duration(), + INLINE_REPLY_TRANSITION_MS + ); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 0bb09b113..8ea282418 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -39,6 +39,7 @@ pub(super) struct RowFlags { pub(super) stack_depth: u8, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, + pub(super) reduced_motion: bool, pub(super) metadata: Option, pub(super) card_corners: unixnotis_core::CutCorners, } @@ -55,6 +56,7 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R received_at_ms: current_millis(), show_metadata: flags.show_metadata, show_thumbnail: flags.show_thumbnail, + reduced_motion: flags.reduced_motion, metadata: Rc::new(flags.metadata.unwrap_or_default()), card_corners: flags.card_corners, }, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index cbf5e96dd..a0a044df9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -20,6 +20,8 @@ pub(in crate::ui::notifications) fn update_notification_row( icon_resolver: &IconResolver, command_tx: &mpsc::Sender, ) { + row.inline_reply + .set_reduced_motion(data.presentation.reduced_motion); // Model changes may briefly update a recycled row without notification data let Some(notification_snapshot) = data.notification.as_ref() else { return; diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 60c978fb0..689d1cc15 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -56,6 +56,7 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, }; diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 4a58bbb15..3b2e2e3c9 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -94,6 +94,7 @@ impl NotificationList { received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, }; diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index f06e9413c..a39ac91d0 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -88,6 +88,7 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, }; diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 9d676de5d..8895fc3ae 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -26,6 +26,7 @@ pub(super) fn list_config() -> NotificationListConfig { notification_metadata: unixnotis_core::NotificationMetadataConfig::default(), notification_corners: unixnotis_core::CutCorners::default(), show_notification_thumbnails: false, + reduced_motion: false, empty_text: "No notifications".to_string(), no_matching_text: "No matching notifications".to_string(), empty_offset_top: 24, diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index 663869881..d6f560e44 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -129,6 +129,7 @@ impl NotificationList { notification_metadata: Rc::new(config.notification_metadata), notification_corners: config.notification_corners, show_notification_thumbnails: config.show_notification_thumbnails, + reduced_motion: config.reduced_motion, max_active: config.max_active, max_entries: config.max_entries, } @@ -141,13 +142,15 @@ impl NotificationList { != config.show_notification_metadata || self.notification_metadata.as_ref() != &config.notification_metadata || self.notification_corners != config.notification_corners - || self.show_notification_thumbnails != config.show_notification_thumbnails; + || self.show_notification_thumbnails != config.show_notification_thumbnails + || self.reduced_motion != config.reduced_motion; self.show_notification_metadata = config.show_notification_metadata; if self.notification_metadata.as_ref() != &config.notification_metadata { self.notification_metadata = Rc::new(config.notification_metadata.clone()); } self.notification_corners = config.notification_corners; self.show_notification_thumbnails = config.show_notification_thumbnails; + self.reduced_motion = config.reduced_motion; if self.empty_text != config.empty_text { self.empty_text = config.empty_text.clone(); } @@ -163,6 +166,7 @@ impl NotificationList { self.apply_limits(config.max_active, config.max_entries); if presentation_changed { // Existing rows need fresh RowData so optional lanes hide or show immediately + self.dirty_groups.extend(self.grouped_cache.keys().cloned()); self.request_rebuild(); } } diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index 705b78b66..8210b233e 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -83,6 +83,21 @@ fn apply_config_requests_rebuild_when_metadata_text_or_corner_geometry_changes() assert!(list.needs_rebuild()); } +#[gtk::test] +fn apply_config_refreshes_existing_rows_when_reduced_motion_changes() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + let mut config = support::list_config(); + config.reduced_motion = true; + + list.apply_config(&config); + list.flush_rebuild(); + + let row = list.entries.get(&1).expect("notification should remain"); + assert!(row.item.data().presentation.reduced_motion); +} + #[gtk::test] fn set_empty_layout_switches_between_widget_offset_and_centered_empty_state() { let list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index cb8b34b41..83936f0fa 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -49,7 +49,6 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg let root = gtk::Box::new(gtk::Orientation::Vertical, 12); root.add_css_class(hooks::panel_shell::ROOT); - super::motion::apply_reduced_motion(&root, config.panel.reduced_motion); root.set_focusable(true); root.set_hexpand(true); root.set_vexpand(true); @@ -82,14 +81,17 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window.set_child(Some(&overlay)); window.set_visible(false); - PanelWidgets { + let panel = PanelWidgets { window, surface: overlay, root, header, sections, reload_notice, - } + }; + // Apply motion after construction so every long-lived revealer receives the same policy + super::motion::apply_reduced_motion(&panel, config.panel.reduced_motion); + panel } fn build_panel_body_chrome(body_stack: >k::Box) -> gtk::Box { diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs index 0f2d3303a..a0a64fcee 100644 --- a/crates/unixnotis-center/src/ui/panel/header/search.rs +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -84,6 +84,7 @@ pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { pub(in crate::ui) fn connect_widget_collapse_toggle( focus_toggle: >k::ToggleButton, + widget_revealer: >k::Revealer, event_tx: async_channel::Sender, ) { let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); @@ -92,6 +93,7 @@ pub(in crate::ui) fn connect_widget_collapse_toggle( let accepted_collapsed = Rc::new(Cell::new(false)); // Restore guard prevents a rejected click rollback from re-entering this handler let collapse_restore = Rc::new(Cell::new(false)); + let collapse_revealer = widget_revealer.clone(); focus_toggle.connect_toggled(move |button| { if collapse_restore.replace(false) { @@ -100,7 +102,7 @@ pub(in crate::ui) fn connect_widget_collapse_toggle( let collapsed = button.is_active(); // Ignore clicks while the previous reveal animation is still changing layout - if !collapse_click_gate.try_start() { + if !try_start_reveal_transition(&collapse_click_gate, &collapse_revealer) { let accepted = accepted_collapsed.get(); if collapsed != accepted { // Roll back only the rejected edge so the UI mirrors the running transition @@ -111,15 +113,7 @@ pub(in crate::ui) fn connect_widget_collapse_toggle( } accepted_collapsed.set(collapsed); - // Disable the control until GTK finishes the matching reveal transition - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); + hold_button_for_reveal_transition(button, &collapse_revealer); collapse_gate.request_widgets_collapsed(&event_tx, collapsed); }); } @@ -214,7 +208,7 @@ pub(in crate::ui) fn connect_search_toggle( return; } - if !search_click_gate.try_start() { + if !try_start_reveal_transition(&search_click_gate, &toggled_revealer) { // The revealer records the last accepted transition target let accepted = toggled_revealer.reveals_child(); if reveal != accepted { @@ -225,15 +219,7 @@ pub(in crate::ui) fn connect_search_toggle( return; } - // Freeze the toggle while its revealer animates to the accepted state - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); + hold_button_for_reveal_transition(button, &toggled_revealer); apply_search_open_state(&toggled_revealer, &toggled_entry, reveal); if reveal { // Selecting existing text makes the next query replace it immediately @@ -243,6 +229,30 @@ pub(in crate::ui) fn connect_search_toggle( }); } +fn try_start_reveal_transition(gate: &ClickCooldown, revealer: >k::Revealer) -> bool { + if revealer.transition_duration() == 0 { + // Immediate transitions have no in-flight layout window to guard + gate.release(); + return true; + } + + gate.try_start() +} + +fn hold_button_for_reveal_transition(button: >k::ToggleButton, revealer: >k::Revealer) { + let duration_ms = revealer.transition_duration(); + if duration_ms == 0 { + return; + } + + // The control is held only for the transition duration currently applied to its revealer + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once(Duration::from_millis(u64::from(duration_ms)), move || { + button_enable.set_sensitive(true); + }); +} + fn apply_search_open_state( search_revealer: >k::Revealer, search_entry: >k::SearchEntry, diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs index 91b213d1c..c3c6d14d6 100644 --- a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs @@ -139,8 +139,10 @@ fn stop_search_closes_a_preexisting_toggle_revealer_mismatch() { #[gtk::test] fn widget_collapse_toggle_sends_the_accepted_state_and_rejects_a_burst() { let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(180); let (event_tx, event_rx) = async_channel::bounded(2); - connect_widget_collapse_toggle(&toggle, event_tx); + connect_widget_collapse_toggle(&toggle, &revealer, event_tx); toggle.set_active(true); assert!(!toggle.is_sensitive()); @@ -151,6 +153,38 @@ fn widget_collapse_toggle_sends_the_accepted_state_and_rejects_a_burst() { assert!(next_widgets_collapsed(&event_rx)); } +#[gtk::test] +fn reduced_motion_search_toggle_accepts_an_immediate_reversal() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(0); + let entry = gtk::SearchEntry::new(); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + toggle.set_active(false); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(toggle.is_sensitive()); +} + +#[gtk::test] +fn reduced_motion_widget_toggle_accepts_the_latest_state_without_a_cooldown() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(0); + let (event_tx, event_rx) = async_channel::bounded(2); + connect_widget_collapse_toggle(&toggle, &revealer, event_tx); + + toggle.set_active(true); + toggle.set_active(false); + + assert!(!toggle.is_active()); + assert!(toggle.is_sensitive()); + assert!(!next_widgets_collapsed(&event_rx)); +} + fn next_filter(event_rx: &async_channel::Receiver) -> String { let deadline = Instant::now() + Duration::from_secs(1); loop { diff --git a/crates/unixnotis-center/src/ui/panel/motion.rs b/crates/unixnotis-center/src/ui/panel/motion.rs index 42a13c17d..0baf13d40 100644 --- a/crates/unixnotis-center/src/ui/panel/motion.rs +++ b/crates/unixnotis-center/src/ui/panel/motion.rs @@ -1,9 +1,34 @@ -//! Panel-local motion preference styling +//! Panel-local motion preference policy use gtk::prelude::*; use unixnotis_core::css::hooks; -pub(in crate::ui) fn apply_reduced_motion(root: >k::Box, reduced_motion: bool) { +use super::body::WIDGET_REVEAL_TRANSITION_MS; +use super::header::search::SEARCH_REVEAL_TRANSITION_MS; +use super::notice::RELOAD_NOTICE_TRANSITION_MS; +use super::widgets::PanelWidgets; +use crate::ui::motion::apply_revealer_preference; + +pub(in crate::ui) fn apply_reduced_motion(panel: &PanelWidgets, reduced_motion: bool) { + apply_motion_class(&panel.root, reduced_motion); + apply_revealer_preference( + &panel.sections.widget_revealer, + WIDGET_REVEAL_TRANSITION_MS as u32, + reduced_motion, + ); + apply_revealer_preference( + &panel.header.search.revealer, + SEARCH_REVEAL_TRANSITION_MS as u32, + reduced_motion, + ); + apply_revealer_preference( + &panel.reload_notice.revealer, + RELOAD_NOTICE_TRANSITION_MS, + reduced_motion, + ); +} + +fn apply_motion_class(root: >k::Box, reduced_motion: bool) { if reduced_motion { // One stable class lets the internal policy layer cover custom and stock themes root.add_css_class(hooks::panel_shell::REDUCED_MOTION); diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index 1759830ae..ec8229264 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -3,6 +3,8 @@ use gtk::prelude::*; use unixnotis_core::css::hooks; +pub(super) const RELOAD_NOTICE_TRANSITION_MS: u32 = 160; + pub(in crate::ui) struct ReloadNoticeWidgets { pub(in crate::ui) revealer: gtk::Revealer, pub(in crate::ui) shell: gtk::Box, @@ -35,7 +37,7 @@ pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { // A short vertical transition keeps the header position stable let revealer = gtk::Revealer::new(); revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - revealer.set_transition_duration(160); + revealer.set_transition_duration(RELOAD_NOTICE_TRANSITION_MS); revealer.set_reveal_child(false); revealer.set_child(Some(&shell)); diff --git a/crates/unixnotis-center/src/ui/panel/tests/motion.rs b/crates/unixnotis-center/src/ui/panel/tests/motion.rs index d9416c6b2..1b69b59e6 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/motion.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/motion.rs @@ -3,15 +3,15 @@ use gtk::prelude::*; use unixnotis_core::css::hooks; -use super::apply_reduced_motion; +use super::apply_motion_class; #[gtk::test] fn reduced_motion_class_tracks_the_runtime_preference() { let root = gtk::Box::new(gtk::Orientation::Vertical, 0); - apply_reduced_motion(&root, true); + apply_motion_class(&root, true); assert!(root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); - apply_reduced_motion(&root, false); + apply_motion_class(&root, false); assert!(!root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); } diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs index 0cc457f1a..5b149b0fa 100644 --- a/crates/unixnotis-center/src/ui/reload/config/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -9,7 +9,7 @@ impl UiState { pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { // Geometry goes first so later sections can size themselves from the final panel width panel::geometry::apply_panel_config(&self.panel, config, self.work_area); - panel::motion::apply_reduced_motion(&self.panel.root, config.panel.reduced_motion); + panel::motion::apply_reduced_motion(&self.panel, config.panel.reduced_motion); self.panel.header.title.set_label(&config.panel.title); self.panel.header.subtitle.set_label(&config.panel.subtitle); self.panel diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs index d98e080bb..5e35688e5 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs @@ -21,6 +21,12 @@ fn reloaded_panel_applies_copy_and_widget_density() { .panel .root .has_css_class(unixnotis_core::hooks::panel_shell::REDUCED_MOTION)); + assert_eq!( + state.panel.sections.widget_revealer.transition_duration(), + 0 + ); + assert_eq!(state.panel.header.search.revealer.transition_duration(), 0); + assert_eq!(state.panel.reload_notice.revealer.transition_duration(), 0); assert_eq!(state.panel.sections.widget_stack.spacing(), 6); } diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs index 105f78f06..8cec3e950 100644 --- a/crates/unixnotis-center/src/ui/reload/config/widgets.rs +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -33,6 +33,7 @@ impl UiState { notification_metadata: config.panel.notification_metadata.clone(), notification_corners: config.theme.notification_corners, show_notification_thumbnails: config.panel.notification_thumbnails_visible, + reduced_motion: config.panel.reduced_motion, empty_text: config.panel.empty_text.clone(), no_matching_text: config.panel.no_matching_text.clone(), empty_offset_top: config.panel.empty_offset_top, diff --git a/crates/unixnotis-center/src/ui/tests/motion.rs b/crates/unixnotis-center/src/ui/tests/motion.rs new file mode 100644 index 000000000..08a8f13b9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/tests/motion.rs @@ -0,0 +1,14 @@ +//! Shared motion-policy tests + +use super::apply_revealer_preference; + +#[gtk::test] +fn reduced_motion_makes_revealer_transitions_immediate_and_restorable() { + let revealer = gtk::Revealer::new(); + + apply_revealer_preference(&revealer, 180, true); + assert_eq!(revealer.transition_duration(), 0); + + apply_revealer_preference(&revealer, 180, false); + assert_eq!(revealer.transition_duration(), 180); +} diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index a3812926a..bbf0dd27d 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -217,7 +217,7 @@ height = {}\n\ ); let reduced_motion_line = format!("reduced_motion = {}\n", config.panel.reduced_motion); let reduced_motion_block = format!( - "# Disable panel transforms and transitions without requiring GTK 4.20\n\ + "# Disable panel animation and moving text without requiring GTK 4.20\n\ reduced_motion = {}\n", config.panel.reduced_motion ); diff --git a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs index e583ad126..16016ae8d 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs @@ -19,7 +19,7 @@ fn default_config_template_documents_panel_height_modes() { fn default_config_template_documents_reduced_motion() { let config_toml = render_default_config_toml(&Config::default()).expect("render config"); - assert!(config_toml.contains("# Disable panel transforms and transitions")); + assert!(config_toml.contains("# Disable panel animation and moving text")); assert!(config_toml.contains("reduced_motion = false")); } From 82386885f37af5fc8f694df73959d6791558df8d Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 01:51:41 -0500 Subject: [PATCH 042/275] feat(filesystem): preserve modes in atomic user-file writes Summary: preserve modes in atomic user-file writes. Scope: filesystem. --- .../unixnotis-core/src/filesystem/atomic.rs | 43 ++- crates/unixnotis-core/src/filesystem/mod.rs | 5 +- .../src/tests/filesystem/atomic.rs | 40 ++- .../src/actions/build/accel/wrapper.rs | 5 +- .../src/actions/environment/shell_path.rs | 7 +- .../src/actions/hyprland/manage.rs | 15 +- crates/unixnotis-installer/src/main.rs | 2 +- crates/unixnotis-installer/src/safe_write.rs | 268 ------------------ .../src/tests/safe_write.rs | 149 ---------- .../src/tests/write_target.rs | 59 ++++ .../unixnotis-installer/src/write_target.rs | 25 ++ 11 files changed, 184 insertions(+), 434 deletions(-) delete mode 100644 crates/unixnotis-installer/src/safe_write.rs delete mode 100644 crates/unixnotis-installer/src/tests/safe_write.rs create mode 100644 crates/unixnotis-installer/src/tests/write_target.rs create mode 100644 crates/unixnotis-installer/src/write_target.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 420c69af8..5ce87078e 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -22,6 +22,34 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); pub fn write_file_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { let (parent_fd, file_name) = open_parent(path)?; validate_target(&parent_fd, &file_name)?; + write_file_atomic_at(parent_fd, &file_name, contents, mode) +} + +/// Replace a regular file while retaining its current permission bits +/// +/// A missing destination receives `default_mode`. Existing special files and links are rejected +/// through the same descriptor-relative checks as [`write_file_atomic`] +/// +/// # Errors +/// +/// Returns an error when containment checks fail or the temporary write, synchronization, target +/// validation, rename, or parent-directory synchronization cannot complete +pub fn write_file_atomic_preserving_mode( + path: &Path, + contents: &[u8], + default_mode: u32, +) -> io::Result<()> { + let (parent_fd, file_name) = open_parent(path)?; + let mode = existing_target_mode(&parent_fd, &file_name)?.unwrap_or(default_mode); + write_file_atomic_at(parent_fd, &file_name, contents, mode) +} + +fn write_file_atomic_at( + parent_fd: OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: u32, +) -> io::Result<()> { let candidates = temp_candidates(&file_name); let (temp_name, mut temp_file) = reserve_temp(&parent_fd, candidates, mode)?; @@ -33,11 +61,11 @@ pub fn write_file_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result< drop(temp_file); // A second check catches target swaps made while the payload was written - if let Err(error) = validate_target(&parent_fd, &file_name) { + if let Err(error) = validate_target(&parent_fd, file_name) { let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); return Err(error); } - if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, &file_name) { + if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, file_name) { let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); return Err(error.into()); } @@ -178,6 +206,10 @@ fn open_or_create_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result } fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { + existing_target_mode(parent_fd, file_name).map(|_mode| ()) +} + +fn existing_target_mode(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result> { match openat2( parent_fd, file_name, @@ -186,13 +218,14 @@ fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> contained_resolve_flags(), ) { Ok(fd) => { - if fs::File::from(fd).metadata()?.is_file() { - Ok(()) + let metadata = fs::File::from(fd).metadata()?; + if metadata.is_file() { + Ok(Some(metadata.permissions().mode() & 0o777)) } else { Err(unsafe_target_error()) } } - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error.into()), } } diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index ca87267cc..720f64547 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -3,5 +3,8 @@ mod atomic; mod path; -pub use atomic::{make_file_executable, write_file_atomic, write_file_if_missing}; +pub use atomic::{ + make_file_executable, write_file_atomic, write_file_atomic_preserving_mode, + write_file_if_missing, +}; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; diff --git a/crates/unixnotis-core/src/tests/filesystem/atomic.rs b/crates/unixnotis-core/src/tests/filesystem/atomic.rs index b4c727270..a945d827c 100644 --- a/crates/unixnotis-core/src/tests/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/tests/filesystem/atomic.rs @@ -1,6 +1,7 @@ use super::{ anchor_resolve_flags, contained_resolve_flags, file_mode, make_file_executable, open_parent, - reserve_temp, sync_directory, write_file_atomic, write_file_if_missing, + reserve_temp, sync_directory, write_file_atomic, write_file_atomic_preserving_mode, + write_file_if_missing, }; use std::ffi::OsString; use std::fs; @@ -229,3 +230,40 @@ fn atomic_write_replaces_regular_file_and_applies_requested_mode() { assert_eq!(mode, 0o600); let _ = fs::remove_dir_all(root); } + +#[test] +fn preserving_atomic_write_keeps_existing_mode_and_replaces_contents() { + let root = unique_temp_path("atomic-preserve-mode"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("config.toml"); + fs::write(&target, "old").expect("write old file"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set old mode"); + + write_file_atomic_preserving_mode(&target, b"new", 0o644).expect("replace file"); + + assert_eq!(fs::read_to_string(&target).expect("read file"), "new"); + let mode = fs::metadata(&target) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn preserving_atomic_write_uses_default_mode_for_a_missing_file() { + let root = unique_temp_path("atomic-preserve-default"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("config.toml"); + + write_file_atomic_preserving_mode(&target, b"new", 0o640).expect("create file"); + + let mode = fs::metadata(&target) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o640); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs index 7889c7b8c..3d298369d 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs @@ -2,7 +2,7 @@ use std::{fs, path::Path}; -use crate::safe_write::write_text_with_mode; +use unixnotis_core::filesystem::write_file_atomic; pub(in crate::actions::build::accel) fn format_build_accel_config() -> String { // A wrapper script keeps builds working if accelerator tools disappear later @@ -26,7 +26,8 @@ pub(in crate::actions::build::accel) fn write_wrapper_script( // Create the wrapper parent first so the later config write has a valid target fs::create_dir_all(parent).map_err(|err| err.to_string())?; } - write_text_with_mode(wrapper_path, &wrapper_script(), 0o755).map_err(|err| err.to_string()) + write_file_atomic(wrapper_path, wrapper_script().as_bytes(), 0o755) + .map_err(|err| err.to_string()) } pub(in crate::actions::build::accel) fn wrapper_script() -> String { diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index 942bf18cf..dcb3987a5 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -5,9 +5,10 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use unixnotis_core::filesystem::write_file_atomic_preserving_mode; use crate::paths::format_with_home; -use crate::safe_write::{reject_unsafe_write_target, write_text_preserving_mode}; +use crate::write_target::reject_unsafe_write_target; use super::super::{log_line, ActionContext}; @@ -147,7 +148,7 @@ pub(in crate::actions::environment) fn ensure_path_entry_in_file( updated.push_str(&export_line); updated.push('\n'); - write_text_preserving_mode(file, &updated, 0o644) + write_file_atomic_preserving_mode(file, updated.as_bytes(), 0o644) .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; Ok(true) } @@ -212,7 +213,7 @@ pub(in crate::actions::environment) fn remove_path_entry_from_file( } // Write the cleaned startup file back to disk - write_text_preserving_mode(file, &updated, 0o644) + write_file_atomic_preserving_mode(file, updated.as_bytes(), 0o644) .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; Ok(true) } diff --git a/crates/unixnotis-installer/src/actions/hyprland/manage.rs b/crates/unixnotis-installer/src/actions/hyprland/manage.rs index fdc3acc32..3444289ed 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/manage.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/manage.rs @@ -11,7 +11,8 @@ use super::detect::{ use super::paths::{existing_hyprland_config_targets, hyprland_config_target}; use super::write_target::resolve_hyprland_write_path; use crate::paths::format_with_home; -use crate::safe_write::{reject_unsafe_write_target, write_text_preserving_mode}; +use crate::write_target::reject_unsafe_write_target; +use unixnotis_core::filesystem::write_file_atomic_preserving_mode; pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { // Resolve the active top-level config before deciding which syntax to write @@ -98,7 +99,9 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { if additions.is_empty() { // If the live file already has everything, drop stale managed blocks and stop if block_found { - if let Err(err) = write_text_preserving_mode(&write_path, &stripped, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(&write_path, stripped.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), @@ -126,7 +129,9 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { &additions, )); - if let Err(err) = write_text_preserving_mode(&write_path, &updated_contents, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(&write_path, updated_contents.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), @@ -206,7 +211,9 @@ pub(in crate::actions) fn remove_hyprland_autostart(ctx: &mut ActionContext) { continue; } - if let Err(err) = write_text_preserving_mode(&write_path, &strip_result.stripped, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(&write_path, strip_result.stripped.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 93bf8a7d3..8ebbcadac 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -25,7 +25,6 @@ mod model; mod paths; mod privilege; mod release; -mod safe_write; mod service_manager; mod system_tools; mod terminal; @@ -34,6 +33,7 @@ mod terminal; mod test_support; mod trial; mod ui; +mod write_target; use anyhow::Result; diff --git a/crates/unixnotis-installer/src/safe_write.rs b/crates/unixnotis-installer/src/safe_write.rs deleted file mode 100644 index 9e7d35a5d..000000000 --- a/crates/unixnotis-installer/src/safe_write.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Symlink-aware file writes for user-owned config files - -use rustix::fs::{mkdirat, openat2, renameat, unlinkat, AtFlags, Mode, OFlags, ResolveFlags, CWD}; -use std::fs; -use std::io::{self, Write}; -use std::os::fd::OwnedFd; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub fn write_text_preserving_mode( - path: &Path, - contents: &str, - default_mode: u32, -) -> io::Result<()> { - let mode = existing_mode_or_default(path, default_mode)?; - write_text_with_mode(path, contents, mode) -} - -pub fn write_text_with_mode(path: &Path, contents: &str, mode: u32) -> io::Result<()> { - let (parent_fd, file_name) = open_secure_parent(path)?; - validate_target_at(&parent_fd, &file_name)?; - let (temp_name, mut temp_file) = create_atomic_temp_at(&parent_fd, &file_name, mode)?; - let result = (|| -> io::Result<()> { - temp_file.write_all(contents.as_bytes())?; - temp_file.flush()?; - #[cfg(unix)] - { - // Set the mode before rename so the visible file never appears too permissive - temp_file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; - } - temp_file.sync_all()?; - Ok(()) - })(); - - if let Err(err) = result { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - return Err(err); - } - drop(temp_file); - - // Re-check immediately before rename so a late symlink swap is not silently followed - validate_target_at(&parent_fd, &file_name).inspect_err(|_err| { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - })?; - Ok(renameat( - &parent_fd, - temp_name.as_str(), - &parent_fd, - file_name.as_str(), - ) - .inspect_err(|_err| { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - })?) -} - -pub fn reject_unsafe_write_target(path: &Path) -> io::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to write through symlink {}", path.display()), - )); - } - if metadata.is_file() { - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to overwrite non-file {}", path.display()), - )) - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err), - } -} - -fn existing_mode_or_default(path: &Path, default_mode: u32) -> io::Result { - let (parent_fd, file_name) = open_secure_parent(path)?; - match openat2( - &parent_fd, - file_name.as_str(), - // O_PATH inspects metadata without opening FIFO or device contents - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => { - let file = fs::File::from(fd); - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to overwrite non-file {}", path.display()), - )); - } - Ok(file_mode(&metadata)) - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default_mode), - Err(err) => Err(err.into()), - } -} - -#[cfg(unix)] -fn file_mode(metadata: &fs::Metadata) -> u32 { - metadata.permissions().mode() & 0o777 -} - -#[cfg(not(unix))] -fn file_mode(_metadata: &fs::Metadata) -> u32 { - 0o644 -} - -fn create_atomic_temp_at( - parent_fd: &OwnedFd, - file_name: &str, - mode: u32, -) -> io::Result<(String, fs::File)> { - for attempt in 0..16 { - let temp_name = atomic_temp_name(file_name, attempt)?; - match openat2( - parent_fd, - temp_name.as_str(), - OFlags::WRONLY | OFlags::CLOEXEC | OFlags::CREATE | OFlags::EXCL, - Mode::from_raw_mode(mode & 0o777), - secure_resolve_flags(), - ) { - Ok(fd) => return Ok((temp_name, fs::File::from(fd))), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - // Another installer run may have picked the same timestamp; retry with a new suffix - continue; - } - Err(err) => return Err(err.into()), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not create secure temporary file", - )) -} - -fn atomic_temp_name(file_name: &str, attempt: u8) -> io::Result { - atomic_temp_name_at(file_name, attempt, SystemTime::now()) -} - -fn atomic_temp_name_at(file_name: &str, attempt: u8, now: SystemTime) -> io::Result { - let stamp = now - .duration_since(UNIX_EPOCH) - .map_err(|error| { - io::Error::other(format!( - "system clock is earlier than the Unix epoch: {error}" - )) - })? - .as_nanos(); - Ok(format!( - ".{file_name}.{}.{}.{}.tmp", - std::process::id(), - stamp, - attempt - )) -} - -fn open_secure_parent(path: &Path) -> io::Result<(OwnedFd, String)> { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target file name is invalid"))? - .to_string(); - let parent = path - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target path has no parent"))?; - let mut current_fd = if path.is_absolute() { - openat2( - CWD, - "/", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - ResolveFlags::empty(), - )? - } else { - openat2( - CWD, - ".", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - ResolveFlags::empty(), - )? - }; - - for component in parent.components() { - match component { - std::path::Component::Prefix(_) - | std::path::Component::RootDir - | std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "secure write path cannot contain parent traversal", - )); - } - std::path::Component::Normal(part) => { - current_fd = open_or_create_child_dir(¤t_fd, part)?; - } - } - } - Ok((current_fd, file_name)) -} - -fn open_or_create_child_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result { - match openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => Ok(fd), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - mkdirat(parent_fd, name, Mode::from_raw_mode(0o755))?; - Ok(openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - secure_resolve_flags(), - )?) - } - Err(err) => Err(err.into()), - } -} - -fn validate_target_at(parent_fd: &OwnedFd, file_name: &str) -> io::Result<()> { - match openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => { - let metadata = fs::File::from(fd).metadata()?; - if metadata.is_file() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "refusing to overwrite non-file target", - )) - } - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - } -} - -const fn secure_resolve_flags() -> ResolveFlags { - ResolveFlags::BENEATH - .union(ResolveFlags::NO_SYMLINKS) - .union(ResolveFlags::NO_MAGICLINKS) -} - -#[cfg(test)] -#[path = "tests/safe_write.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/tests/safe_write.rs b/crates/unixnotis-installer/src/tests/safe_write.rs deleted file mode 100644 index 9f9789996..000000000 --- a/crates/unixnotis-installer/src/tests/safe_write.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::fs; -use std::os::unix::fs::{symlink, PermissionsExt}; -use std::os::unix::net::UnixListener; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc; -use std::time::{Duration, UNIX_EPOCH}; - -use rustix::fs::{mkfifoat, open, Mode, OFlags, CWD}; - -use super::{ - atomic_temp_name_at, existing_mode_or_default, validate_target_at, write_text_preserving_mode, - write_text_with_mode, -}; - -fn test_root(label: &str) -> std::path::PathBuf { - static NEXT_ROOT: AtomicUsize = AtomicUsize::new(0); - let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "unixnotis-safe-write-{label}-{}-{sequence}", - std::process::id() - )); - fs::create_dir_all(&root).expect("create test root"); - root -} - -#[test] -fn atomic_temp_name_returns_an_error_when_clock_precedes_unix_epoch() { - let before_epoch = UNIX_EPOCH - .checked_sub(Duration::from_secs(1)) - .expect("construct pre-epoch timestamp"); - - let error = atomic_temp_name_at("config.toml", 0, before_epoch) - .expect_err("pre-epoch clock should return an error"); - - assert_eq!(error.kind(), std::io::ErrorKind::Other); - assert!(error.to_string().contains("earlier than the Unix epoch")); -} - -#[test] -fn secure_write_rejects_symlinked_ancestor_without_touching_target() { - let root = test_root("ancestor-symlink"); - let real_parent = root.join("real"); - let linked_parent = root.join("linked"); - fs::create_dir_all(&real_parent).expect("create real parent"); - symlink(&real_parent, &linked_parent).expect("create parent symlink"); - - let error = write_text_with_mode(&linked_parent.join("config.toml"), "unsafe", 0o644) - .expect_err("reject symlinked ancestor"); - - assert_eq!( - error.raw_os_error(), - Some(rustix::io::Errno::LOOP.raw_os_error()) - ); - assert!(!real_parent.join("config.toml").exists()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn secure_write_preserves_existing_mode_and_replaces_contents() { - let root = test_root("preserve-mode"); - let target = root.join("config.toml"); - fs::write(&target, "old").expect("write original"); - fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set mode"); - - write_text_preserving_mode(&target, "new", 0o644).expect("secure replace"); - - assert_eq!(fs::read_to_string(&target).expect("read target"), "new"); - assert_eq!( - fs::metadata(&target) - .expect("target metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn metadata_validation_rejects_fifo_without_waiting_for_a_writer() { - let root = test_root("fifo-target"); - let target = root.join("config.fifo"); - mkfifoat(CWD, &target, Mode::RUSR | Mode::WUSR).expect("create FIFO target"); - let worker_target = target.clone(); - let (result_tx, result_rx) = mpsc::channel(); - let worker = std::thread::spawn(move || { - let result = write_text_preserving_mode(&worker_target, "new", 0o644); - result_tx.send(result).expect("send FIFO validation result"); - }); - - let result = match result_rx.recv_timeout(Duration::from_secs(2)) { - Ok(result) => result, - Err(error) => { - // A writer releases a regressed read-only FIFO open before the test reports failure - let _writer = open( - &target, - OFlags::WRONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, - Mode::empty(), - ) - .expect("unblock FIFO reader"); - let _ = result_rx.recv_timeout(Duration::from_secs(2)); - worker.join().expect("join unblocked FIFO worker"); - panic!("FIFO validation exceeded its focused deadline: {error}"); - } - }; - worker.join().expect("join FIFO validation worker"); - - assert!(result.expect_err("reject FIFO target").kind() == std::io::ErrorKind::InvalidInput); - fs::remove_dir_all(root).expect("remove FIFO test root"); -} - -#[test] -fn metadata_validation_rejects_socket_device_and_final_symlink_targets() { - let root = test_root("special-targets"); - let socket = root.join("installer.sock"); - let _listener = UnixListener::bind(&socket).expect("bind socket target"); - let sentinel = root.join("sentinel.txt"); - let link = root.join("linked.txt"); - fs::write(&sentinel, "sentinel").expect("write sentinel"); - symlink(&sentinel, &link).expect("create final symlink"); - - for target in [&socket, Path::new("/dev/null"), &link] { - assert!( - write_text_with_mode(target, "new", 0o644).is_err(), - "special target should be rejected: {}", - target.display() - ); - } - assert_eq!( - fs::read_to_string(&sentinel).expect("read sentinel"), - "sentinel" - ); - fs::remove_dir_all(root).expect("remove special target test root"); -} - -#[test] -fn metadata_validation_does_not_treat_other_open_errors_as_missing_files() { - let root = test_root("metadata-open-errors"); - let overlong_name = "x".repeat(300); - - assert!(existing_mode_or_default(&root.join(&overlong_name), 0o644).is_err()); - - let parent_fd = open(&root, OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty()) - .expect("open validation parent"); - assert!(validate_target_at(&parent_fd, &overlong_name).is_err()); - - fs::remove_dir_all(root).expect("remove metadata error test root"); -} diff --git a/crates/unixnotis-installer/src/tests/write_target.rs b/crates/unixnotis-installer/src/tests/write_target.rs new file mode 100644 index 000000000..aa2dd2511 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/write_target.rs @@ -0,0 +1,59 @@ +//! Write-target preflight tests + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::reject_unsafe_write_target; + +fn test_root(label: &str) -> PathBuf { + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); + let root = PathBuf::from("target").join(format!( + "unixnotis-write-target-{label}-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create test root"); + root +} + +#[test] +fn regular_and_missing_write_targets_are_accepted() { + let root = test_root("accepted"); + let regular = root.join("config.toml"); + fs::write(®ular, "config").expect("write regular target"); + + reject_unsafe_write_target(®ular).expect("regular file should be accepted"); + reject_unsafe_write_target(&root.join("missing.toml")) + .expect("missing file should be accepted"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_and_non_file_write_targets_are_rejected() { + let root = test_root("rejected"); + let regular = root.join("config.toml"); + let link = root.join("linked.toml"); + let directory = root.join("directory.toml"); + fs::write(®ular, "config").expect("write regular target"); + symlink(®ular, &link).expect("create target symlink"); + fs::create_dir(&directory).expect("create directory target"); + + assert_eq!( + reject_unsafe_write_target(&link) + .expect_err("target symlink should fail") + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!( + reject_unsafe_write_target(&directory) + .expect_err("directory target should fail") + .kind(), + std::io::ErrorKind::InvalidInput + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/write_target.rs b/crates/unixnotis-installer/src/write_target.rs new file mode 100644 index 000000000..903abe5b4 --- /dev/null +++ b/crates/unixnotis-installer/src/write_target.rs @@ -0,0 +1,25 @@ +//! Preflight checks for user-owned files edited by the installer + +use std::fs; +use std::io; +use std::path::Path; + +pub fn reject_unsafe_write_target(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to write through symlink {}", path.display()), + )), + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to overwrite non-file {}", path.display()), + )), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +#[path = "tests/write_target.rs"] +mod tests; From b36fe0085e65ddb2be018b63bf829f4d954f24c3 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 01:56:09 -0500 Subject: [PATCH 043/275] refactor(installer): harden service and config publication Summary: harden service and config publication. Scope: installer. --- .../unixnotis-core/src/filesystem/atomic.rs | 25 ++++-- crates/unixnotis-core/src/filesystem/mod.rs | 2 +- .../src/tests/filesystem/atomic.rs | 48 ++++++++++- .../src/actions/build/accel/tests/write.rs | 27 +++---- .../src/actions/build/accel/wrapper.rs | 6 +- .../src/actions/build/accel/write.rs | 80 ++----------------- .../src/actions/config/backup/mod.rs | 2 - .../src/actions/config/backup/restore.rs | 14 ++-- .../src/actions/config/backup/settings.rs | 4 +- .../src/actions/config/backup/tests/mod.rs | 1 - .../src/actions/config/backup/tests/write.rs | 58 -------------- .../src/actions/config/backup/write.rs | 63 --------------- .../src/actions/config/provision.rs | 50 +++++++----- .../src/actions/install/service/dirs.rs | 4 +- .../src/actions/install/service/files.rs | 44 +++++----- .../actions/install/tests/service/writes.rs | 38 +++++++++ 16 files changed, 187 insertions(+), 279 deletions(-) delete mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/write.rs delete mode 100644 crates/unixnotis-installer/src/actions/config/backup/write.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 5ce87078e..5b5d10bcf 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -116,6 +116,23 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res /// Returns an error when the path escapes through a link, is not a regular file, or cannot be /// opened and updated through its stable descriptor pub fn make_file_executable(path: &Path) -> io::Result<()> { + let file = open_regular_file(path)?; + let mode = file.metadata()?.permissions().mode() | 0o111; + file.set_permissions(fs::Permissions::from_mode(mode)) +} + +/// Set permission bits on an existing regular file without following links +/// +/// # Errors +/// +/// Returns an error when the path escapes through a link, is not a regular file, or cannot be +/// opened and updated through its stable descriptor +pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { + let file = open_regular_file(path)?; + file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) +} + +fn open_regular_file(path: &Path) -> io::Result { let (parent_fd, file_name) = open_parent(path)?; let fd = openat2( &parent_fd, @@ -128,14 +145,10 @@ pub fn make_file_executable(path: &Path) -> io::Result<()> { contained_resolve_flags(), )?; let file = fs::File::from(fd); - let metadata = file.metadata()?; - if !metadata.is_file() { + if !file.metadata()?.is_file() { return Err(unsafe_target_error()); } - - // Descriptor-based chmod prevents a path swap from redirecting the permission update - let mode = metadata.permissions().mode() | 0o111; - file.set_permissions(fs::Permissions::from_mode(mode)) + Ok(file) } fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 720f64547..761cc378a 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -4,7 +4,7 @@ mod atomic; mod path; pub use atomic::{ - make_file_executable, write_file_atomic, write_file_atomic_preserving_mode, + make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, }; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; diff --git a/crates/unixnotis-core/src/tests/filesystem/atomic.rs b/crates/unixnotis-core/src/tests/filesystem/atomic.rs index a945d827c..d3ac7c713 100644 --- a/crates/unixnotis-core/src/tests/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/tests/filesystem/atomic.rs @@ -1,7 +1,7 @@ use super::{ anchor_resolve_flags, contained_resolve_flags, file_mode, make_file_executable, open_parent, - reserve_temp, sync_directory, write_file_atomic, write_file_atomic_preserving_mode, - write_file_if_missing, + reserve_temp, set_file_mode, sync_directory, write_file_atomic, + write_file_atomic_preserving_mode, write_file_if_missing, }; use std::ffi::OsString; use std::fs; @@ -151,6 +151,50 @@ fn executable_update_rejects_symlink_without_touching_its_target() { let _ = fs::remove_dir_all(root); } +#[test] +fn mode_update_applies_exact_permissions_to_a_regular_file() { + let root = unique_temp_path("atomic-mode-update"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("run"); + fs::write(&target, "service").expect("write service file"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set original mode"); + + set_file_mode(&target, 0o755).expect("set service mode"); + + assert_eq!( + fs::metadata(&target) + .expect("service metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mode_update_rejects_a_symlink_without_touching_its_target() { + let root = unique_temp_path("atomic-mode-symlink"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let link = root.join("run"); + fs::write(&outside, "service").expect("write outside file"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); + symlink(&outside, &link).expect("create service link"); + + set_file_mode(&link, 0o755).expect_err("service link should fail"); + + assert_eq!( + fs::metadata(&outside) + .expect("outside metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn create_if_missing_propagates_non_collision_open_error() { let root = unique_temp_path("atomic-if-missing-error"); diff --git a/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs b/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs index e72b03600..b3f834c19 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs @@ -4,7 +4,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::super::super::{write_build_accel_config, BuildAccelDetection, BuildAccelOutcome}; use super::super::detect::detect_build_accel_config_status; use super::super::model::BuildAccelConfigStatus; -use super::super::write::atomic_temp_path; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -178,19 +177,18 @@ fn write_build_accel_config_rejects_wrapper_symlink_without_touching_target() { #[cfg(unix)] #[test] -fn write_build_accel_config_bypasses_preexisting_temp_symlink_without_touching_it() { - let root = test_root("build-accel-temp-symlink"); +fn write_build_accel_config_rejects_config_symlink_without_touching_target() { + let root = test_root("build-accel-config-symlink"); let cargo_dir = root.join(".cargo"); let config_path = cargo_dir.join("config.toml"); let protected = root.join("protected"); fs::create_dir_all(&cargo_dir).expect("cargo dir"); fs::write( - &config_path, - "# Generated by unixnotis-installer\nold = true\n", + &protected, + "# Generated by unixnotis-installer\nprotected = true\n", ) - .expect("config"); - fs::write(&protected, "protected").expect("protected"); - symlink(&protected, atomic_temp_path(&config_path)).expect("temp symlink"); + .expect("protected config"); + symlink(&protected, &config_path).expect("config symlink"); let detection = BuildAccelDetection { sccache_installed: true, mold_installed: false, @@ -201,14 +199,15 @@ fn write_build_accel_config_bypasses_preexisting_temp_symlink_without_touching_i let outcome = write_build_accel_config(&root, &detection); - assert!(matches!(outcome, BuildAccelOutcome::UpdatedExisting { .. })); - assert!(fs::read_to_string(&config_path) - .expect("config updated") - .contains("rustc-wrapper")); + assert!(matches!(outcome, BuildAccelOutcome::Failed(_))); assert_eq!( - fs::read_to_string(&protected).expect("protected remains"), - "protected" + fs::read_to_string(&protected).expect("protected config remains"), + "# Generated by unixnotis-installer\nprotected = true\n" ); + assert!(fs::symlink_metadata(&config_path) + .expect("config link remains") + .file_type() + .is_symlink()); let _ = fs::remove_dir_all(root); } diff --git a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs index 3d298369d..a59416dd7 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs @@ -1,6 +1,6 @@ //! Wrapper script generation for optional build acceleration -use std::{fs, path::Path}; +use std::path::Path; use unixnotis_core::filesystem::write_file_atomic; @@ -22,10 +22,6 @@ pub(in crate::actions::build::accel) fn format_build_accel_config() -> String { pub(in crate::actions::build::accel) fn write_wrapper_script( wrapper_path: &Path, ) -> Result<(), String> { - if let Some(parent) = wrapper_path.parent() { - // Create the wrapper parent first so the later config write has a valid target - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - } write_file_atomic(wrapper_path, wrapper_script().as_bytes(), 0o755) .map_err(|err| err.to_string()) } diff --git a/crates/unixnotis-installer/src/actions/build/accel/write.rs b/crates/unixnotis-installer/src/actions/build/accel/write.rs index bd85adb07..e7c2fc74d 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/write.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/write.rs @@ -1,8 +1,9 @@ //! Build acceleration config writes and updates -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; +use std::fs; +use std::path::Path; + +use unixnotis_core::filesystem::write_file_atomic; use super::model::{BuildAccelDetection, BuildAccelOutcome}; use super::wrapper::{format_build_accel_config, write_wrapper_script}; @@ -25,18 +26,12 @@ pub fn write_build_accel_config( } let content = format_build_accel_config(); - if let Some(parent) = config_path.parent() { - // Create `.cargo/` before any write so both wrapper and config land in one known place - if let Err(err) = fs::create_dir_all(parent) { - return BuildAccelOutcome::Failed(err.to_string()); - } - } // Write the wrapper first so the config never points at a missing script if let Err(err) = write_wrapper_script(&wrapper_path) { return BuildAccelOutcome::Failed(err); } - if let Err(err) = write_atomic(&config_path, &content) { + if let Err(err) = write_file_atomic(&config_path, content.as_bytes(), 0o644) { return BuildAccelOutcome::Failed(err.to_string()); } @@ -70,7 +65,7 @@ fn update_existing_config( if let Err(err) = write_wrapper_script(wrapper_path) { return BuildAccelOutcome::Failed(err); } - if let Err(err) = write_atomic(config_path, &content) { + if let Err(err) = write_file_atomic(config_path, content.as_bytes(), 0o644) { return BuildAccelOutcome::Failed(err.to_string()); } @@ -80,66 +75,3 @@ fn update_existing_config( used_mold: detection.mold_installed, } } - -fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> { - // A sibling temp file keeps rename atomic on common Unix filesystems - let parent = path.parent().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing parent directory") - })?; - fs::create_dir_all(parent)?; - let (tmp_path, mut temp_file) = create_atomic_temp_file(path)?; - temp_file - .write_all(contents.as_bytes()) - .inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - temp_file.flush().inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - drop(temp_file); - fs::rename(&tmp_path, path).inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - Ok(()) -} - -pub(super) fn atomic_temp_path(path: &Path) -> PathBuf { - // Temp paths must be predictable to clean up, but create_new keeps existing paths untrusted - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let tmp_name = format!("{file_name}.tmp-{}", std::process::id()); - path.with_file_name(tmp_name) -} - -fn create_atomic_temp_file(path: &Path) -> std::io::Result<(PathBuf, std::fs::File)> { - for attempt in 0..16 { - let temp_path = atomic_temp_path_attempt(path, attempt); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { - Ok(file) => return Ok((temp_path, file)), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "could not allocate a safe build config temporary path", - )) -} - -fn atomic_temp_path_attempt(path: &Path, attempt: u8) -> PathBuf { - if attempt == 0 { - return atomic_temp_path(path); - } - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - path.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/mod.rs index a68405b2c..382f5d436 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/mod.rs @@ -4,7 +4,6 @@ mod restore; mod retention; mod settings; mod snapshot; -mod write; // Keep config reads separate from dated backup directory churn pub(in crate::actions::config) use settings::{ensure_installer_config, load_installer_config}; @@ -14,7 +13,6 @@ pub(in crate::actions::config) use snapshot::backup_existing_file; pub use restore::restore_config; pub use snapshot::list_backup_dirs_for_ui; -pub use write::write_atomic; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 5b18f06ca..9dfa8f493 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -4,13 +4,13 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::write_file_atomic; use unixnotis_core::Config; use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; use super::retention::BACKUP_PREFIX; -use super::write::write_atomic; pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { let Some(backup_dir) = ctx.restore_backup.clone() else { @@ -32,7 +32,6 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { return Err(anyhow!("backup directory name is not recognized")); } - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; log_line( ctx, format!("Restoring config from {}", format_with_home(&backup_dir)), @@ -43,7 +42,8 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { if config_backup.exists() { let contents = fs::read_to_string(&config_backup) .with_context(|| "failed to read backup config.toml")?; - write_atomic(&config_path, &contents).with_context(|| "failed to restore config.toml")?; + write_file_atomic(&config_path, contents.as_bytes(), 0o644) + .with_context(|| "failed to restore config.toml")?; log_line( ctx, format!("Restored config.toml -> {}", format_with_home(&config_path)), @@ -103,14 +103,10 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { ); continue; } - if let Some(parent) = target.parent() { - // Create parents for custom theme paths before writing restored content - fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent dir for {name}"))?; - } let contents = fs::read_to_string(&source).with_context(|| format!("failed to read backup {name}"))?; - write_atomic(&target, &contents).with_context(|| format!("failed to restore {name}"))?; + write_file_atomic(&target, contents.as_bytes(), 0o644) + .with_context(|| format!("failed to restore {name}"))?; log_line( ctx, format!("Restored {} -> {}", name, format_with_home(&target)), diff --git a/crates/unixnotis-installer/src/actions/config/backup/settings.rs b/crates/unixnotis-installer/src/actions/config/backup/settings.rs index 9090f1623..ea88a13c8 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/settings.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/settings.rs @@ -5,11 +5,11 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; +use unixnotis_core::filesystem::write_file_atomic; use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; -use super::write::write_atomic; const INSTALLER_CONFIG_FILE: &str = "installer.toml"; const INSTALLER_CONFIG_TEMPLATE: &str = r"# UnixNotis installer settings @@ -53,7 +53,7 @@ pub(in crate::actions::config) fn ensure_installer_config( return Ok(config_path); } - write_atomic(&config_path, INSTALLER_CONFIG_TEMPLATE) + write_file_atomic(&config_path, INSTALLER_CONFIG_TEMPLATE.as_bytes(), 0o644) .with_context(|| "failed to write installer.toml")?; log_line( ctx, diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs index fbf70fde4..56ff12fce 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs @@ -1,3 +1,2 @@ mod restore; mod retention; -mod write; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs deleted file mode 100644 index df8985641..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::write::{atomic_temp_path, write_atomic}; -use std::fs; -use std::path::PathBuf; - -#[cfg(unix)] -use std::os::unix::fs::symlink; - -#[cfg(unix)] -#[test] -fn write_atomic_bypasses_preexisting_temp_symlink_without_touching_it() { - let root = test_root("backup-atomic-temp-symlink"); - let target = root.join("config.toml"); - let protected = root.join("protected"); - let temp_path = atomic_temp_path(&target); - fs::write(&target, "old").expect("target"); - fs::write(&protected, "protected").expect("protected"); - symlink(&protected, &temp_path).expect("temp symlink"); - - write_atomic(&target, "new").expect("alternate temp path"); - - assert_eq!(fs::read_to_string(&target).expect("target updated"), "new"); - assert_eq!( - fs::read_to_string(&protected).expect("protected remains"), - "protected" - ); - assert!(fs::symlink_metadata(&temp_path) - .expect("temp remains") - .file_type() - .is_symlink()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn write_atomic_bypasses_stale_temp_regular_file() { - let root = test_root("backup-atomic-temp-regular"); - let target = root.join("config.toml"); - let temp_path = atomic_temp_path(&target); - fs::write(&target, "old").expect("target"); - fs::write(&temp_path, "stale").expect("stale temp"); - - write_atomic(&target, "new").expect("alternate temp path"); - - assert_eq!(fs::read_to_string(&target).expect("target updated"), "new"); - assert_eq!( - fs::read_to_string(&temp_path).expect("temp remains"), - "stale" - ); - let _ = fs::remove_dir_all(root); -} - -fn test_root(name: &str) -> PathBuf { - // Target-local roots keep symlink tests contained inside the repository build directory - let root = - PathBuf::from("target").join(format!("unixnotis-installer-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("test root"); - root -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/write.rs b/crates/unixnotis-installer/src/actions/config/backup/write.rs deleted file mode 100644 index fa2f45742..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/write.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Shared atomic writes for backup-related file updates - -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::Path; - -pub fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> { - // A sibling temp file avoids leaving a partially written target behind - let (temp_path, mut temp_file) = create_atomic_temp_file(path)?; - temp_file - .write_all(contents.as_bytes()) - .inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - })?; - temp_file.flush().inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - })?; - drop(temp_file); - fs::rename(&temp_path, path).inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - }) -} - -pub(super) fn atomic_temp_path(path: &Path) -> std::path::PathBuf { - // The name stays beside the target so the final rename remains on the same filesystem - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let temp_name = format!("{file_name}.tmp-{}", std::process::id()); - path.with_file_name(temp_name) -} - -fn create_atomic_temp_file(path: &Path) -> io::Result<(std::path::PathBuf, fs::File)> { - for attempt in 0..16 { - let temp_path = atomic_temp_path_attempt(path, attempt); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { - Ok(file) => return Ok((temp_path, file)), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a safe backup temporary path", - )) -} - -fn atomic_temp_path_attempt(path: &Path, attempt: u8) -> std::path::PathBuf { - if attempt == 0 { - return atomic_temp_path(path); - } - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - path.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index bbf0dd27d..1ca4880bc 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -1,9 +1,9 @@ //! Config and theme file creation or reset logic -use std::fs; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::write_file_atomic; use unixnotis_core::Config; use crate::paths::format_with_home; @@ -11,7 +11,6 @@ use crate::paths::format_with_home; use super::super::{log_line, ActionContext}; use super::backup::{ backup_existing_file, create_backup_dir, ensure_installer_config, load_installer_config, - write_atomic, }; pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { @@ -23,9 +22,6 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { format!("Config directory: {}", format_with_home(&config_dir)), ); - // Create the config root first so later file writes do not race missing parents - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; - if config_path.exists() { log_line( ctx, @@ -34,7 +30,8 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { } else { // Write a default config so there is always a working base to edit let config_toml = render_default_config_toml(&config)?; - write_atomic(&config_path, &config_toml).with_context(|| "failed to write config.toml")?; + write_file_atomic(&config_path, config_toml.as_bytes(), 0o644) + .with_context(|| "failed to write config.toml")?; log_line( ctx, format!("Config file created: {}", format_with_home(&config_path)), @@ -85,7 +82,6 @@ pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; let config_path = Config::default_config_path().map_err(|err| anyhow!(err.to_string()))?; - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; ensure_installer_config(ctx, &config_dir)?; let installer_config = load_installer_config(&config_dir, ctx); @@ -95,7 +91,8 @@ pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { backup_existing_file(ctx, &config_path, "config.toml", backup_dir.as_deref())?; let config_toml = render_default_config_toml(&config)?; - write_atomic(&config_path, &config_toml).with_context(|| "failed to write config.toml")?; + write_file_atomic(&config_path, config_toml.as_bytes(), 0o644) + .with_context(|| "failed to write config.toml")?; log_line( ctx, format!( @@ -141,19 +138,36 @@ pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { )?; backup_default_scripts(ctx, &config_dir, backup_dir.as_deref())?; - write_atomic(&theme_paths.base_css, unixnotis_core::DEFAULT_BASE_CSS) - .with_context(|| "failed to write base.css")?; - write_atomic(&theme_paths.panel_css, unixnotis_core::DEFAULT_PANEL_CSS) - .with_context(|| "failed to write panel.css")?; - write_atomic(&theme_paths.popup_css, unixnotis_core::DEFAULT_POPUP_CSS) - .with_context(|| "failed to write popup.css")?; - write_atomic( + write_file_atomic( + &theme_paths.base_css, + unixnotis_core::DEFAULT_BASE_CSS.as_bytes(), + 0o644, + ) + .with_context(|| "failed to write base.css")?; + write_file_atomic( + &theme_paths.panel_css, + unixnotis_core::DEFAULT_PANEL_CSS.as_bytes(), + 0o644, + ) + .with_context(|| "failed to write panel.css")?; + write_file_atomic( + &theme_paths.popup_css, + unixnotis_core::DEFAULT_POPUP_CSS.as_bytes(), + 0o644, + ) + .with_context(|| "failed to write popup.css")?; + write_file_atomic( &theme_paths.widgets_css, - unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS.as_bytes(), + 0o644, ) .with_context(|| "failed to write widgets.css")?; - write_atomic(&theme_paths.media_css, unixnotis_core::DEFAULT_MEDIA_CSS) - .with_context(|| "failed to write media.css")?; + write_file_atomic( + &theme_paths.media_css, + unixnotis_core::DEFAULT_MEDIA_CSS.as_bytes(), + 0o644, + ) + .with_context(|| "failed to write media.css")?; write_default_scripts(&config_dir)?; log_line( diff --git a/crates/unixnotis-installer/src/actions/install/service/dirs.rs b/crates/unixnotis-installer/src/actions/install/service/dirs.rs index 93d29cd08..7dbde25e8 100644 --- a/crates/unixnotis-installer/src/actions/install/service/dirs.rs +++ b/crates/unixnotis-installer/src/actions/install/service/dirs.rs @@ -5,13 +5,13 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::write_file_atomic; use crate::paths::format_with_home; use crate::service_manager::{ managed_directory_marker, managed_directory_marker_is_valid, MANAGED_DIRECTORY_MARKER_CONTENTS, }; -use super::super::super::config::backup::write_atomic; use super::files::ensure_regular_artifact_file_path; pub(in crate::actions::install::service) fn write_directory_artifact(path: &Path) -> Result { @@ -45,7 +45,7 @@ pub(in crate::actions::install::service) fn write_managed_directory(path: &Path) Ok(existing) if existing == MANAGED_DIRECTORY_MARKER_CONTENTS => false, Ok(_) | Err(_) => { // The marker itself is written atomically so partial writes do not grant ownership - write_atomic(&marker, MANAGED_DIRECTORY_MARKER_CONTENTS) + write_file_atomic(&marker, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) .with_context(|| format!("failed to write {}", format_with_home(&marker)))?; true } diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 6094abac6..6878e2520 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -7,12 +7,13 @@ use std::os::unix::fs::PermissionsExt; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, +}; use crate::paths::format_with_home; use crate::service_manager::MANAGED_DIRECTORY_MARKER_CONTENTS; -use super::super::super::config::backup::write_atomic; - pub(in crate::actions::install::service) fn write_regular_service_file( path: &Path, contents: &str, @@ -38,30 +39,29 @@ pub(in crate::actions::install::service) fn write_regular_service_file( } None => false, }; - let changed = match fs::read_to_string(path) { + let contents_changed = match fs::read_to_string(path) { // Stable contents keep reinstall quiet and avoid unnecessary manager reloads Ok(existing) if existing == contents => false, - Ok(_) | Err(_) => { - // Atomic writes avoid half-written service definitions on interruption - write_atomic(path, contents) - .with_context(|| format!("failed to write {artifact_label}"))?; - true - } + Ok(_) | Err(_) => true, }; - if let Some(mode) = mode { - // Only artifacts that requested a mode receive chmod + if contents_changed { + // Explicit modes keep service scripts independent of process umask + match mode { + Some(mode) => write_file_atomic(path, contents.as_bytes(), mode), + None => write_file_atomic_preserving_mode(path, contents.as_bytes(), 0o644), + } + .with_context(|| format!("failed to write {artifact_label}"))?; + } else if mode_changed { #[cfg(unix)] - { - if changed || mode_changed { - // Mode is explicit because service scripts must not depend on process umask - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; - } + if let Some(mode) = mode { + // Descriptor-based chmod keeps a swapped pathname from redirecting the update + set_file_mode(path, mode) + .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; } } - Ok(changed || mode_changed) + Ok(contents_changed || mode_changed) } pub(in crate::actions::install::service) fn write_shared_service_file( @@ -87,8 +87,8 @@ pub(in crate::actions::install::service) fn write_shared_service_file( } // Missing shared files can be seeded because no user contents are being replaced - write_atomic(path, contents).with_context(|| format!("failed to write {artifact_label}"))?; - apply_artifact_mode_if_needed(path, mode)?; + write_file_atomic(path, contents.as_bytes(), mode.unwrap_or(0o644)) + .with_context(|| format!("failed to write {artifact_label}"))?; if let Some(marker) = created_marker { write_shared_creation_marker(marker)?; } @@ -134,7 +134,7 @@ fn apply_artifact_mode_if_needed(path: &Path, mode: Option) -> Result<()> { { if current_mode(path)? != Some(mode) { // Shared support files still need explicit modes when the backend requests one - fs::set_permissions(path, fs::Permissions::from_mode(mode)) + set_file_mode(path, mode) .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; } Ok(()) @@ -151,7 +151,7 @@ fn apply_artifact_mode_if_needed(path: &Path, mode: Option) -> Result<()> { fn write_shared_creation_marker(path: &Path) -> Result<()> { ensure_regular_artifact_file_path(path)?; - write_atomic(path, MANAGED_DIRECTORY_MARKER_CONTENTS) + write_file_atomic(path, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) .with_context(|| format!("failed to write {}", format_with_home(path))) } diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index abafb1cf9..28bca76e3 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -120,6 +120,44 @@ fn write_service_artifact_reports_executable_mode_changes() { let _ = fs::remove_dir_all(&root); } +#[test] +fn write_service_artifact_preserves_mode_when_no_mode_is_requested() { + let root = test_root("install-service-preserve-file-mode"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let artifact = ServiceArtifact { + path: root.join("service.conf"), + kind: ServiceArtifactKind::File, + contents: Some("new contents\n".to_string()), + mode: None, + }; + fs::create_dir_all(&root).expect("make service root"); + fs::write(&artifact.path, "old contents\n").expect("seed service file"); + fs::set_permissions(&artifact.path, fs::Permissions::from_mode(0o640)) + .expect("seed service file mode"); + + let changed = write_service_artifact(&ctx, &artifact).expect("service file should update"); + + assert!(changed); + assert_eq!( + fs::read_to_string(&artifact.path).expect("read updated service file"), + "new contents\n" + ); + assert_eq!( + fs::metadata(&artifact.path) + .expect("service file metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn write_managed_directory_artifact_creates_ownership_marker() { let root = test_root("install-service-managed-directory"); From 5c3baca11dee4f2fa7f0bcc4effe0a1d634be124 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:01:34 -0500 Subject: [PATCH 044/275] refactor(installer): publish binaries and backups through stable descriptors Summary: publish binaries and backups through stable descriptors. Scope: installer. --- .../unixnotis-core/src/filesystem/atomic.rs | 65 +++++++--- .../unixnotis-core/src/filesystem/install.rs | 31 +++++ crates/unixnotis-core/src/filesystem/mod.rs | 2 + .../src/tests/filesystem/install.rs | 122 ++++++++++++++++++ .../src/actions/config/backup/snapshot.rs | 6 +- .../src/actions/config/backup/tests/mod.rs | 2 + .../actions/config/backup/tests/snapshot.rs | 72 +++++++++++ .../actions/config/backup/tests/support.rs | 33 +++++ .../src/actions/install/binaries.rs | 91 +------------ .../src/actions/install/tests/binaries.rs | 67 +++------- crates/unixnotis-installer/src/main.rs | 1 - crates/unixnotis-installer/src/privilege.rs | 2 +- .../src/tests/support/fs.rs | 11 +- 13 files changed, 349 insertions(+), 156 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/install.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/install.rs create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/support.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 5b5d10bcf..8c86946f7 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -20,9 +20,7 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// Returns an error when containment checks fail or the temporary write, synchronization, target /// validation, rename, or parent-directory synchronization cannot complete pub fn write_file_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { - let (parent_fd, file_name) = open_parent(path)?; - validate_target(&parent_fd, &file_name)?; - write_file_atomic_at(parent_fd, &file_name, contents, mode) + publish_file_atomic(path, mode, |file| file.write_all(contents)) } /// Replace a regular file while retaining its current permission bits @@ -41,19 +39,31 @@ pub fn write_file_atomic_preserving_mode( ) -> io::Result<()> { let (parent_fd, file_name) = open_parent(path)?; let mode = existing_target_mode(&parent_fd, &file_name)?.unwrap_or(default_mode); - write_file_atomic_at(parent_fd, &file_name, contents, mode) + write_file_atomic_at(parent_fd, &file_name, mode, |file| file.write_all(contents)) +} + +pub(super) fn publish_file_atomic( + path: &Path, + mode: u32, + write_payload: impl FnOnce(&mut fs::File) -> io::Result<()>, +) -> io::Result<()> { + let (parent_fd, file_name) = open_parent(path)?; + validate_target(&parent_fd, &file_name)?; + write_file_atomic_at(parent_fd, &file_name, mode, write_payload) } fn write_file_atomic_at( parent_fd: OwnedFd, file_name: &OsString, - contents: &[u8], mode: u32, + write_payload: impl FnOnce(&mut fs::File) -> io::Result<()>, ) -> io::Result<()> { - let candidates = temp_candidates(&file_name); + let candidates = temp_candidates(file_name); let (temp_name, mut temp_file) = reserve_temp(&parent_fd, candidates, mode)?; - if let Err(error) = write_and_sync(&mut temp_file, contents, mode) { + if let Err(error) = + write_payload(&mut temp_file).and_then(|()| set_mode_and_sync(&temp_file, mode)) + { drop(temp_file); let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); return Err(error); @@ -99,7 +109,10 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res Err(error) => return Err(error.into()), }; let mut file = fs::File::from(fd); - if let Err(error) = write_and_sync(&mut file, contents, mode) { + if let Err(error) = file + .write_all(contents) + .and_then(|()| set_mode_and_sync(&file, mode)) + { drop(file); let _ = unlinkat(&parent_fd, &file_name, AtFlags::empty()); return Err(error); @@ -132,8 +145,8 @@ pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) } -fn open_regular_file(path: &Path) -> io::Result { - let (parent_fd, file_name) = open_parent(path)?; +pub(super) fn open_regular_file(path: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent_existing(path)?; let fd = openat2( &parent_fd, &file_name, @@ -152,6 +165,20 @@ fn open_regular_file(path: &Path) -> io::Result { } fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingParent::Create) +} + +fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingParent::Reject) +} + +#[derive(Clone, Copy)] +enum MissingParent { + Create, + Reject, +} + +fn open_parent_with(path: &Path, missing_parent: MissingParent) -> io::Result<(OwnedFd, OsString)> { let file_name = path .file_name() .filter(|name| !name.is_empty()) @@ -188,13 +215,19 @@ fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { "atomic write path cannot contain parent traversal", )); } - Component::Normal(name) => parent_fd = open_or_create_dir(&parent_fd, name)?, + Component::Normal(name) => { + parent_fd = open_directory_component(&parent_fd, name, missing_parent)?; + } } } Ok((parent_fd, file_name)) } -fn open_or_create_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result { +fn open_directory_component( + parent_fd: &OwnedFd, + name: &std::ffi::OsStr, + missing_parent: MissingParent, +) -> io::Result { match openat2( parent_fd, name, @@ -203,7 +236,10 @@ fn open_or_create_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result contained_resolve_flags(), ) { Ok(fd) => Ok(fd), - Err(error) if error.kind() == io::ErrorKind::NotFound => { + Err(error) + if error.kind() == io::ErrorKind::NotFound + && matches!(missing_parent, MissingParent::Create) => + { mkdirat(parent_fd, name, Mode::from_raw_mode(0o755))?; openat2( parent_fd, @@ -308,8 +344,7 @@ fn reserve_temp( )) } -fn write_and_sync(file: &mut fs::File, contents: &[u8], mode: u32) -> io::Result<()> { - file.write_all(contents)?; +fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { // Mode is fixed before publication so readers never observe broad temporary permissions file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; file.sync_all() diff --git a/crates/unixnotis-core/src/filesystem/install.rs b/crates/unixnotis-core/src/filesystem/install.rs new file mode 100644 index 000000000..33275af8e --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/install.rs @@ -0,0 +1,31 @@ +//! Atomic regular-file copies for executable and backup installation + +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::atomic::{open_regular_file, publish_file_atomic}; + +/// Copy one regular file into an atomically published destination +/// +/// Source and destination ancestors must be real directories. The source mode is applied to the +/// staged file before publication, and existing destination links or special files are rejected +/// +/// # Errors +/// +/// Returns an error when either path crosses a link, the source is not a regular file, or copying, +/// synchronizing, validating, renaming, or parent-directory synchronization fails +pub fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<()> { + // Open once so source bytes and permissions come from the same stable object + let mut input = open_regular_file(source)?; + let mode = input.metadata()?.permissions().mode() & 0o777; + + publish_file_atomic(destination, mode, |output| { + io::copy(&mut input, output)?; + Ok(()) + }) +} + +#[cfg(test)] +#[path = "../tests/filesystem/install.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 761cc378a..302928ce7 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -1,10 +1,12 @@ //! Shared filesystem operations with stable directory anchors mod atomic; +mod install; mod path; pub use atomic::{ make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, }; +pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; diff --git a/crates/unixnotis-core/src/tests/filesystem/install.rs b/crates/unixnotis-core/src/tests/filesystem/install.rs new file mode 100644 index 000000000..5ef56b93d --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/install.rs @@ -0,0 +1,122 @@ +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use super::copy_file_atomic; +use crate::test_support::unique_temp_path; + +#[test] +fn atomic_copy_replaces_regular_file_and_preserves_source_mode() { + let root = unique_temp_path("copy-file-replace"); + let source = root.join("release").join("unixnotis-daemon"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(source.parent().expect("source parent")).expect("create source parent"); + fs::create_dir_all(destination.parent().expect("destination parent")) + .expect("create destination parent"); + fs::write(&source, "new binary").expect("write source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o751)).expect("set source mode"); + fs::write(&destination, "old binary").expect("write destination"); + + copy_file_atomic(&source, &destination).expect("copy regular file"); + + assert_eq!( + fs::read_to_string(&destination).expect("read destination"), + "new binary" + ); + assert_eq!( + fs::metadata(&destination) + .expect("destination metadata") + .permissions() + .mode() + & 0o777, + 0o751 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_source_symlink_without_publishing_destination() { + let root = unique_temp_path("copy-file-source-symlink"); + let source_target = root.join("source-target"); + let source_link = root.join("source-link"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source_target, "source").expect("write source target"); + symlink(&source_target, &source_link).expect("create source link"); + + copy_file_atomic(&source_link, &destination).expect_err("source link should fail"); + + assert!(!destination.exists()); + assert_eq!( + fs::read_to_string(source_target).expect("read source target"), + "source" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_destination_symlink_without_changing_its_target() { + let root = unique_temp_path("copy-file-destination-symlink"); + let source = root.join("source"); + let protected = root.join("protected"); + let destination = root.join("destination"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "source").expect("write source"); + fs::write(&protected, "protected").expect("write protected"); + symlink(&protected, &destination).expect("create destination link"); + + copy_file_atomic(&source, &destination).expect_err("destination link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected"), + "protected" + ); + assert!(fs::symlink_metadata(destination) + .expect("destination link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_symlinked_destination_parent() { + let root = unique_temp_path("copy-file-parent-symlink"); + let source = root.join("source"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-bin"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(&source, "source").expect("write source"); + symlink(&outside, &linked_parent).expect("create parent link"); + let destination = linked_parent.join("unixnotis-daemon"); + + copy_file_atomic(&source, &destination).expect_err("linked parent should fail"); + + assert!(!outside.join("unixnotis-daemon").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_directory_source_without_creating_destination() { + let root = unique_temp_path("copy-file-directory-source"); + let source = root.join("source-directory"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(&source).expect("create source directory"); + + copy_file_atomic(&source, &destination).expect_err("directory source should fail"); + + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_does_not_create_a_missing_source_parent() { + let root = unique_temp_path("copy-file-missing-source-parent"); + let missing_parent = root.join("missing-source"); + let source = missing_parent.join("unixnotis-daemon"); + let destination = root.join("bin").join("unixnotis-daemon"); + + copy_file_atomic(&source, &destination).expect_err("missing source should fail"); + + assert!(!missing_parent.exists()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs index 684f4b576..8bcff17a3 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs @@ -1,9 +1,9 @@ //! Backup snapshot helpers for config and theme files -use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use unixnotis_core::filesystem::copy_file_atomic; use unixnotis_core::Config; use crate::paths::format_with_home; @@ -28,8 +28,8 @@ pub(in crate::actions::config) fn backup_existing_file( let file_name = path.file_name().unwrap_or_default().to_string_lossy(); let backup_path = backup_dir.join(file_name.as_ref()); - // Copy first so the live file stays intact until replacement succeeds - fs::copy(path, &backup_path).with_context(|| format!("failed to backup {label}"))?; + // Open the live file once and publish its snapshot without following either path through links + copy_file_atomic(path, &backup_path).with_context(|| format!("failed to backup {label}"))?; log_line( ctx, format!("Backed up {} to {}", label, format_with_home(&backup_path)), diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs index 56ff12fce..f34179177 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs @@ -1,2 +1,4 @@ mod restore; mod retention; +mod snapshot; +mod support; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs new file mode 100644 index 000000000..d61db3053 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use crate::detect::Detection; + +use super::super::snapshot::backup_existing_file; +use super::support::{test_context, test_paths}; + +#[test] +fn backup_snapshot_copies_contents_and_source_mode() { + let root = crate::test_support::fs::unique_temp_path("backup-snapshot-copy"); + let source = root.join("config.toml"); + let backup_dir = root.join("Backup-2026-07-22"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + fs::write(&source, "private config\n").expect("write source config"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o600)).expect("set source mode"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut context = test_context(&detection, &paths); + + backup_existing_file(&mut context, &source, "config.toml", Some(&backup_dir)) + .expect("create backup snapshot"); + + let snapshot = backup_dir.join("config.toml"); + assert_eq!( + fs::read_to_string(&snapshot).expect("read backup snapshot"), + "private config\n" + ); + assert_eq!( + fs::metadata(snapshot) + .expect("snapshot metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn backup_snapshot_rejects_destination_symlink_without_changing_target() { + let root = crate::test_support::fs::unique_temp_path("backup-snapshot-symlink"); + let source = root.join("config.toml"); + let backup_dir = root.join("Backup-2026-07-22"); + let protected = root.join("protected"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + fs::write(&source, "new config\n").expect("write source config"); + fs::write(&protected, "protected\n").expect("write protected file"); + symlink(&protected, backup_dir.join("config.toml")).expect("create snapshot link"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut context = test_context(&detection, &paths); + + backup_existing_file(&mut context, &source, "config.toml", Some(&backup_dir)) + .expect_err("snapshot destination link should fail"); + + assert_eq!( + fs::read_to_string(&protected).expect("read protected file"), + "protected\n" + ); + assert!(fs::symlink_metadata(backup_dir.join("config.toml")) + .expect("snapshot link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs new file mode 100644 index 000000000..20a83fe29 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs @@ -0,0 +1,33 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +pub(super) fn test_paths(root: &std::path::Path) -> InstallPaths { + InstallPaths { + repo_root: root.to_path_buf(), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user(root.join("service")), + } +} + +pub(super) fn test_context<'a>( + detection: &'a Detection, + paths: &'a InstallPaths, +) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(8); + ActionContext { + detection, + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} diff --git a/crates/unixnotis-installer/src/actions/install/binaries.rs b/crates/unixnotis-installer/src/actions/install/binaries.rs index f79fa669e..c5b785375 100644 --- a/crates/unixnotis-installer/src/actions/install/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/binaries.rs @@ -1,10 +1,10 @@ //! Binary install and uninstall helpers -use std::fs::{self, File, OpenOptions}; -use std::io; +use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::copy_file_atomic; use crate::managed_binaries::validate_managed_binary_names; use crate::paths::format_with_home; @@ -22,8 +22,6 @@ pub fn install_binaries(ctx: &mut ActionContext) -> Result<()> { // Cargo metadata is the only reliable way to find the active release target directory let release_dir = resolve_release_dir(ctx)?; - fs::create_dir_all(&ctx.paths.bin_dir).with_context(|| "failed to create bin directory")?; - // Check every source first so install never leaves a half-updated bin directory behind let mut missing = Vec::new(); for binary in &binaries { @@ -115,18 +113,10 @@ fn copy_binary(ctx: &mut ActionContext, source: &Path, destination: &Path) -> Re let source_display = format_with_home(source); let destination_display = format_with_home(destination); - // Stage the copy beside the final file so the rename can replace atomically - let temp_path = stage_binary_copy_with_retry(source, destination).map_err(|err| { - anyhow!("failed to stage {source_display} -> {destination_display}: {err}") + // Core stages beside the destination and validates both paths through stable descriptors + copy_file_atomic(source, destination).map_err(|err| { + anyhow!("failed to install {source_display} -> {destination_display}: {err}") })?; - - // Rename replaces the destination in one step so there is no missing-binary window - if let Err(err) = fs::rename(&temp_path, destination) { - let _ = fs::remove_file(&temp_path); - return Err(anyhow!( - "failed to install {source_display} -> {destination_display}: {err}" - )); - } log_line( ctx, format!( @@ -137,74 +127,3 @@ fn copy_binary(ctx: &mut ActionContext, source: &Path, destination: &Path) -> Re ); Ok(()) } - -pub(super) fn binary_temp_path(destination: &Path) -> PathBuf { - // The temp file sits beside the final binary so rename stays atomic - let temp_name = format!( - "{}.tmp-{}", - destination - .file_name() - .unwrap_or_default() - .to_string_lossy(), - std::process::id() - ); - destination.with_file_name(temp_name) -} - -fn stage_binary_copy(source: &Path, temp_path: &Path) -> io::Result<()> { - // create_new refuses attacker-created symlinks or stale files at the temp path - let mut input = File::open(source)?; - let mut output = OpenOptions::new() - .write(true) - .create_new(true) - .open(temp_path)?; - io::copy(&mut input, &mut output).inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - })?; - output.sync_all().inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - })?; - let permissions = fs::metadata(source)?.permissions(); - fs::set_permissions(temp_path, permissions).inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - }) -} - -pub(in crate::actions::install) fn stage_binary_copy_with_retry( - source: &Path, - destination: &Path, -) -> io::Result { - for attempt in 0..16 { - let temp_path = binary_temp_path_attempt(destination, attempt); - match stage_binary_copy(source, &temp_path) { - Ok(()) => return Ok(temp_path), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a safe temporary binary path", - )) -} - -pub(in crate::actions::install) fn binary_temp_path_attempt( - destination: &Path, - attempt: u8, -) -> PathBuf { - if attempt == 0 { - return binary_temp_path(destination); - } - let file_name = destination - .file_name() - .unwrap_or_default() - .to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - destination.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs index 9e1b1d8c3..844616d4b 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs @@ -3,15 +3,12 @@ use std::fs; use crate::detect::Detection; use crate::model::ActionMode; -use super::super::binaries::{ - binary_temp_path, binary_temp_path_attempt, remove_resolved_binaries, - stage_binary_copy_with_retry, -}; +use super::super::binaries::remove_resolved_binaries; use super::super::{install_binaries, remove_binaries}; use super::support::{test_context, test_paths, test_root, write_fake_workspace}; #[cfg(unix)] -use std::os::unix::fs::symlink; +use std::os::unix::fs::{symlink, PermissionsExt}; #[test] fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { @@ -40,6 +37,8 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { let source = paths.repo_root.join("target").join("release").join(binary); fs::create_dir_all(source.parent().expect("release dir")).expect("make release dir"); fs::write(&source, format!("binary:{binary}")).expect("write fake binary"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set fake binary mode"); } let detection = Detection { @@ -63,6 +62,14 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { fs::read_to_string(&installed).expect("read installed binary"), format!("binary:{binary}") ); + assert_eq!( + fs::metadata(&installed) + .expect("installed binary metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); } let _ = fs::remove_dir_all(&root); @@ -102,7 +109,7 @@ fn install_binaries_copies_from_release_archive_bin_dir() { #[cfg(unix)] #[test] -fn install_binaries_bypasses_preexisting_temp_symlink_without_touching_it() { +fn install_binaries_rejects_destination_symlink_without_touching_its_target() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("install-binaries-temp-symlink"); write_fake_workspace( @@ -129,64 +136,26 @@ fn install_binaries_bypasses_preexisting_temp_symlink_without_touching_it() { } fs::create_dir_all(&paths.bin_dir).expect("bin dir"); let destination = paths.bin_dir.join("unixnotis-daemon"); - let temp_path = binary_temp_path(&destination); let protected = root.join("protected"); fs::write(&protected, "protected").expect("protected"); - symlink(&protected, &temp_path).expect("temp symlink"); + symlink(&protected, &destination).expect("destination symlink"); let detection = Detection { owner: None, daemons: Vec::new(), }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("alternate temp path should bypass stale symlink"); + let error = install_binaries(&mut ctx).expect_err("destination symlink should fail"); + assert!(error.to_string().contains("failed to install")); assert_eq!( fs::read_to_string(&protected).expect("protected remains"), "protected" ); - assert!(fs::symlink_metadata(&temp_path) - .expect("temp symlink remains") + assert!(fs::symlink_metadata(&destination) + .expect("destination symlink remains") .file_type() .is_symlink()); - assert!(destination.exists()); - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn binary_temp_path_attempt_uses_stable_first_path_and_unique_retry_path() { - let destination = std::env::temp_dir().join("unixnotis-daemon"); - - let first = binary_temp_path_attempt(&destination, 0); - let retry = binary_temp_path_attempt(&destination, 1); - - // The stable first path makes stale-file handling deterministic and testable - assert_eq!(first, binary_temp_path(&destination)); - // Retry paths carry the attempt so a collision cannot repeat the first candidate - assert_ne!(retry, first); - assert!(retry - .file_name() - .expect("retry file name") - .to_string_lossy() - .ends_with("-1")); -} - -#[test] -fn stage_binary_copy_propagates_errors_other_than_path_collisions() { - let root = std::env::temp_dir().join(format!( - "unixnotis-installer-binary-stage-error-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let source_dir = root.join("source-directory"); - let destination = root.join("unixnotis-daemon"); - fs::create_dir_all(&source_dir).expect("make invalid directory source"); - - let error = stage_binary_copy_with_retry(&source_dir, &destination) - .expect_err("a source read error must not be treated as a temp collision"); - - assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); - assert!(!destination.exists()); let _ = fs::remove_dir_all(&root); } diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 8ebbcadac..188ff868c 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -5,7 +5,6 @@ clippy::items_after_statements, clippy::match_same_arms, clippy::missing_const_for_fn, - clippy::needless_continue, clippy::needless_pass_by_value, clippy::option_if_let_else, clippy::redundant_else, diff --git a/crates/unixnotis-installer/src/privilege.rs b/crates/unixnotis-installer/src/privilege.rs index 0f3b9c170..8c0f19bf5 100644 --- a/crates/unixnotis-installer/src/privilege.rs +++ b/crates/unixnotis-installer/src/privilege.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Result}; -pub(crate) fn reject_root_install(euid: u32) -> Result<()> { +pub fn reject_root_install(euid: u32) -> Result<()> { if euid == 0 { bail!("unixnotis-installer is user-level; do not run it as root or through sudo"); } diff --git a/crates/unixnotis-installer/src/tests/support/fs.rs b/crates/unixnotis-installer/src/tests/support/fs.rs index 9004fde20..23e3323af 100644 --- a/crates/unixnotis-installer/src/tests/support/fs.rs +++ b/crates/unixnotis-installer/src/tests/support/fs.rs @@ -3,7 +3,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::symlink; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; pub(super) static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -12,6 +12,15 @@ const FAKE_TOOL_DISPATCHER: &str = concat!( "/src/tests/support/fixtures/fake-tool" ); +pub fn unique_temp_path(label: &str) -> PathBuf { + // A process-local sequence keeps parallel filesystem tests on separate paths + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "unixnotis-installer-{label}-{}-{sequence}", + std::process::id() + )) +} + pub fn write_executable(path: &Path, contents: &str) { let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); let file_name = path From 80318c3ff81b4784ab4d22cb2cebcf3fc62a51bf Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:06:19 -0500 Subject: [PATCH 045/275] refactor(installer): remove artifacts through stable anchors Summary: remove artifacts through stable anchors. Scope: installer. --- .../unixnotis-core/src/filesystem/atomic.rs | 9 +- crates/unixnotis-core/src/filesystem/mod.rs | 5 + .../unixnotis-core/src/filesystem/remove.rs | 126 +++++++++++++++ .../src/tests/filesystem/remove.rs | 151 ++++++++++++++++++ .../src/actions/config/state.rs | 7 +- .../src/actions/config/tests/state_cleanup.rs | 24 +++ .../src/actions/install/binaries.rs | 6 +- .../src/actions/install/service/files.rs | 13 +- .../src/actions/install/service/symlinks.rs | 41 ++--- .../src/actions/install/tests/binaries.rs | 30 ++++ .../install/tests/service/uninstall_safety.rs | 60 +++++++ 11 files changed, 433 insertions(+), 39 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/remove.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/remove.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 8c86946f7..b2b22285d 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -168,7 +168,7 @@ fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { open_parent_with(path, MissingParent::Create) } -fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { +pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { open_parent_with(path, MissingParent::Reject) } @@ -279,7 +279,10 @@ fn existing_target_mode(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result } } -fn validate_existing_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { +pub(super) fn validate_existing_target( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result<()> { let fd = openat2( parent_fd, file_name, @@ -350,7 +353,7 @@ fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { file.sync_all() } -fn sync_directory(parent_fd: OwnedFd) -> io::Result<()> { +pub(super) fn sync_directory(parent_fd: OwnedFd) -> io::Result<()> { fs::File::from(parent_fd).sync_all() } diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 302928ce7..d4b7518c4 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -3,6 +3,7 @@ mod atomic; mod install; mod path; +mod remove; pub use atomic::{ make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, @@ -10,3 +11,7 @@ pub use atomic::{ }; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; +pub use remove::{ + read_symlink, remove_regular_file, remove_symlink, remove_symlink_if_target, + RemoveSymlinkOutcome, +}; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs new file mode 100644 index 000000000..1a0f9df47 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -0,0 +1,126 @@ +//! Descriptor-relative removal for regular files and symbolic links + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use rustix::fs::{readlinkat, unlinkat, AtFlags}; + +use super::atomic::{open_parent_existing, sync_directory, validate_existing_target}; + +/// Result of removing a symbolic link with an expected target +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveSymlinkOutcome { + /// No filesystem entry existed at the requested path + Missing, + /// A matching symbolic link was removed + Removed, + /// The link remained because its stored target no longer matched + TargetMismatch(PathBuf), +} + +/// Remove a regular file without following links in its path +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a regular file, or the +/// unlink or parent-directory synchronization fails +pub fn remove_regular_file(path: &Path) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(false); + }; + match validate_existing_target(&parent_fd, &file_name) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + } + + unlinkat(&parent_fd, &file_name, AtFlags::empty())?; + sync_directory(parent_fd)?; + Ok(true) +} + +/// Read a symbolic link target without following links in its parent path +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the +/// link cannot be read +pub fn read_symlink(path: &Path) -> io::Result> { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(None); + }; + match read_symlink_at(&parent_fd, &file_name) { + Ok(target) => Ok(Some(target)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +/// Remove a symbolic link without requiring a specific target +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the +/// unlink or parent-directory synchronization fails +pub fn remove_symlink(path: &Path) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(false); + }; + match read_symlink_at(&parent_fd, &file_name) { + Ok(_target) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + } + + unlinkat(&parent_fd, &file_name, AtFlags::empty())?; + sync_directory(parent_fd)?; + Ok(true) +} + +/// Remove a symbolic link only when its stored target matches exactly +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the +/// unlink or parent-directory synchronization fails +pub fn remove_symlink_if_target( + path: &Path, + expected_target: &Path, +) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(RemoveSymlinkOutcome::Missing); + }; + let actual_target = match read_symlink_at(&parent_fd, &file_name) { + Ok(target) => target, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RemoveSymlinkOutcome::Missing); + } + Err(error) => return Err(error), + }; + if actual_target != expected_target { + return Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)); + } + + unlinkat(&parent_fd, &file_name, AtFlags::empty())?; + sync_directory(parent_fd)?; + Ok(RemoveSymlinkOutcome::Removed) +} + +fn existing_parent(path: &Path) -> io::Result> { + match open_parent_existing(path) { + Ok(parent) => Ok(Some(parent)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn read_symlink_at(parent_fd: &std::os::fd::OwnedFd, file_name: &OsStr) -> io::Result { + let target = readlinkat(parent_fd, file_name, Vec::new())?; + Ok(PathBuf::from(OsString::from_vec(target.into_bytes()))) +} + +#[cfg(test)] +#[path = "../tests/filesystem/remove.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/remove.rs b/crates/unixnotis-core/src/tests/filesystem/remove.rs new file mode 100644 index 000000000..24b109ad1 --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/remove.rs @@ -0,0 +1,151 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use super::{ + read_symlink, remove_regular_file, remove_symlink, remove_symlink_if_target, + RemoveSymlinkOutcome, +}; +use crate::test_support::unique_temp_path; + +#[test] +fn regular_file_removal_is_idempotent() { + let root = unique_temp_path("remove-regular-file"); + let target = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "state").expect("write target"); + + assert!(remove_regular_file(&target).expect("remove regular file")); + assert!(!remove_regular_file(&target).expect("missing file stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_removal_rejects_a_symlink_and_keeps_its_target() { + let root = unique_temp_path("remove-regular-symlink"); + let protected = root.join("protected"); + let link = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected"); + symlink(&protected, &link).expect("create link"); + + remove_regular_file(&link).expect_err("regular removal should reject a link"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected"), + "protected" + ); + assert!(fs::symlink_metadata(link) + .expect("link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_removal_rejects_a_symlinked_parent() { + let root = unique_temp_path("remove-regular-parent-symlink"); + let outside = root.join("outside"); + let linked_parent = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + fs::write(outside.join("state.json"), "state").expect("write outside state"); + symlink(&outside, &linked_parent).expect("create parent link"); + + remove_regular_file(&linked_parent.join("state.json")).expect_err("linked parent should fail"); + + assert_eq!( + fs::read_to_string(outside.join("state.json")).expect("read outside state"), + "state" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_removal_keeps_the_link_target() { + let root = unique_temp_path("remove-symlink"); + let target = root.join("service"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "service").expect("write target"); + symlink(&target, &link).expect("create link"); + + assert!(remove_symlink(&link).expect("remove link")); + assert!(!remove_symlink(&link).expect("missing link stays removed")); + + assert_eq!(fs::read_to_string(target).expect("read target"), "service"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn target_checked_symlink_removal_reports_mismatch_without_removing_link() { + let root = unique_temp_path("remove-symlink-mismatch"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("actual", &link).expect("create link"); + + let outcome = remove_symlink_if_target(&link, std::path::Path::new("expected")) + .expect("inspect link target"); + + assert_eq!( + outcome, + RemoveSymlinkOutcome::TargetMismatch("actual".into()) + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("actual".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn target_checked_symlink_removal_removes_only_an_exact_match() { + let root = unique_temp_path("remove-symlink-match"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("../service", &link).expect("create link"); + + let outcome = remove_symlink_if_target(&link, std::path::Path::new("../service")) + .expect("remove matching link"); + + assert_eq!(outcome, RemoveSymlinkOutcome::Removed); + assert_eq!(read_symlink(&link).expect("link is missing"), None); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_operations_reject_regular_files() { + let root = unique_temp_path("remove-symlink-regular"); + let target = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "regular").expect("write regular file"); + + read_symlink(&target).expect_err("read should reject a regular file"); + remove_symlink(&target).expect_err("removal should reject a regular file"); + remove_symlink_if_target(&target, std::path::Path::new("service")) + .expect_err("target-checked removal should reject a regular file"); + + assert_eq!( + fs::read_to_string(target).expect("read regular file"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn removal_does_not_create_a_missing_parent() { + let root = unique_temp_path("remove-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("state.json"); + + assert!(!remove_regular_file(&target).expect("regular file is missing")); + assert!(!remove_symlink(&target).expect("link is missing")); + assert_eq!(read_symlink(&target).expect("link is missing"), None); + assert_eq!( + remove_symlink_if_target(&target, std::path::Path::new("service")) + .expect("link is missing"), + RemoveSymlinkOutcome::Missing + ); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/state.rs b/crates/unixnotis-installer/src/actions/config/state.rs index fc7e9aa61..12631f901 100644 --- a/crates/unixnotis-installer/src/actions/config/state.rs +++ b/crates/unixnotis-installer/src/actions/config/state.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use unixnotis_core::filesystem::remove_regular_file; use unixnotis_core::util; use crate::paths::format_with_home; @@ -75,11 +76,7 @@ pub(in crate::actions::config) fn remove_state_file( ) -> std::io::Result { let state_file = state_root.join(DND_STATE_FILE); // Remove the persisted DND file first because that is the main cleanup target - let removed_file = match fs::remove_file(&state_file) { - Ok(()) => true, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, - Err(err) => return Err(err), - }; + let removed_file = remove_regular_file(&state_file)?; if !removed_file { // Nothing changed, so there is no follow-up directory cleanup to attempt diff --git a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs index c06cc8579..a4cc79bc4 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs @@ -3,6 +3,7 @@ use super::super::state::{ DirCleanupOutcome, DND_STATE_FILE, }; use std::fs; +use std::os::unix::fs::symlink; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; @@ -124,6 +125,29 @@ fn remove_state_file_propagates_non_missing_filesystem_errors() { let _ = fs::remove_file(&root); } +#[test] +fn remove_state_file_rejects_symlink_without_touching_its_target() { + let root = crate::test_support::fs::unique_temp_path("remove-state-symlink"); + let state_root = root.join("unixnotis"); + let state_file = state_root.join(DND_STATE_FILE); + let protected = root.join("protected"); + fs::create_dir_all(&state_root).expect("create state directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &state_file).expect("create state link"); + + remove_state_file(&state_root).expect_err("state link should be rejected"); + + assert_eq!( + fs::read_to_string(&protected).expect("read protected file"), + "protected" + ); + assert!(fs::symlink_metadata(&state_file) + .expect("state link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + #[test] fn remove_state_uses_xdg_state_home_and_deletes_persisted_state() { let _lock = crate::test_support::env::test_env_lock(); diff --git a/crates/unixnotis-installer/src/actions/install/binaries.rs b/crates/unixnotis-installer/src/actions/install/binaries.rs index c5b785375..5db702261 100644 --- a/crates/unixnotis-installer/src/actions/install/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/binaries.rs @@ -1,10 +1,9 @@ //! Binary install and uninstall helpers -use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::copy_file_atomic; +use unixnotis_core::filesystem::{copy_file_atomic, remove_regular_file}; use crate::managed_binaries::validate_managed_binary_names; use crate::paths::format_with_home; @@ -72,8 +71,7 @@ pub(in crate::actions::install) fn remove_resolved_binaries( .with_context(|| "refusing to remove an unmanaged binary path")?; for binary in binaries { let path = ctx.paths.bin_dir.join(binary); - if path.exists() { - fs::remove_file(&path).with_context(|| "failed to remove binary")?; + if remove_regular_file(&path).with_context(|| "failed to remove binary")? { log_line(ctx, format!("Removed binary {}", format_with_home(&path))); } else { log_line( diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 6878e2520..7e3e03149 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -8,7 +8,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, + remove_regular_file, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, }; use crate::paths::format_with_home; @@ -259,5 +259,14 @@ pub(in crate::actions::install::service) fn remove_regular_service_file(path: &P )); } - fs::remove_file(path).with_context(|| format!("failed to remove {}", format_with_home(path))) + if remove_regular_file(path) + .with_context(|| format!("failed to remove {}", format_with_home(path)))? + { + Ok(()) + } else { + Err(anyhow!( + "service file disappeared before removal at {}", + format_with_home(path) + )) + } } diff --git a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs index 956bf9cb9..af6fa9283 100644 --- a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs +++ b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs @@ -5,6 +5,7 @@ use std::io::ErrorKind; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{remove_symlink_if_target, RemoveSymlinkOutcome}; use crate::paths::format_with_home; @@ -38,36 +39,26 @@ pub(in crate::actions::install) fn remove_service_symlink( path: &Path, expected_target: &Path, ) -> Result<()> { - // Symlink artifacts are removed only when both the type and target still match - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - // Missing links are already gone, which makes uninstall idempotent - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect {}", format_with_home(path))); - } - }; - if !metadata.file_type().is_symlink() { - return Err(anyhow!( - "refusing to remove non-symlink service artifact at {}", - format_with_home(path) - )); - } - - let actual_target = fs::read_link(path) - .with_context(|| format!("failed to read symlink {}", format_with_home(path)))?; - if actual_target != expected_target { - // A changed link target means ownership is no longer proven - return Err(anyhow!( + // Core compares the stored target and unlinks relative to the same stable parent descriptor + match remove_symlink_if_target(path, expected_target) { + Ok(RemoveSymlinkOutcome::Missing | RemoveSymlinkOutcome::Removed) => Ok(()), + Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)) => Err(anyhow!( "refusing to remove symlink {} because it points to {} instead of {}", format_with_home(path), format_with_home(&actual_target), format_with_home(expected_target) - )); + )), + Err(error) if error.kind() == ErrorKind::InvalidInput => Err(anyhow!( + "refusing to remove non-symlink service artifact at {}", + format_with_home(path) + )), + Err(error) => Err(error).with_context(|| { + format!( + "failed to inspect or remove symlink {}", + format_with_home(path) + ) + }), } - - fs::remove_file(path).with_context(|| format!("failed to remove {}", format_with_home(path))) } fn reject_existing_non_symlink(path: &Path) -> Result<()> { diff --git a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs index 844616d4b..097fc5c1d 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs @@ -211,6 +211,36 @@ fn remove_binaries_removes_all_managed_binaries_and_runtime_helpers() { let _ = fs::remove_dir_all(&root); } +#[cfg(unix)] +#[test] +fn remove_binaries_rejects_symlink_without_touching_its_target() { + let root = test_root("remove-binaries-symlink"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + let protected = root.join("protected"); + let installed = paths.bin_dir.join("unixnotis-daemon"); + fs::create_dir_all(&paths.bin_dir).expect("create bin directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &installed).expect("create installed binary link"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + remove_binaries(&mut ctx).expect_err("binary link should be rejected"); + + assert_eq!( + fs::read_to_string(&protected).expect("read protected file"), + "protected" + ); + assert!(fs::symlink_metadata(&installed) + .expect("installed link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + #[test] fn remove_binaries_never_removes_a_file_outside_the_bin_directory() { let root = test_root("remove-binaries-contained"); diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs index 14790ff0c..b65f06963 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs @@ -71,6 +71,37 @@ fn uninstall_does_not_remove_non_matching_symlink() { let _ = fs::remove_dir_all(&root); } +#[test] +fn uninstall_rejects_symlinked_parent_for_symlink_artifact() { + let root = test_root("install-service-keep-linked-symlink-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_link = outside.join("service-link"); + fs::create_dir_all(&outside).expect("make outside directory"); + symlink("service", &outside_link).expect("create outside service link"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-link"), + kind: ServiceArtifactKind::Symlink { + target: "service".into(), + }, + contents: None, + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_link(&outside_link).expect("outside service link remains"), + std::path::PathBuf::from("service") + ); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} + #[test] fn uninstall_rejects_symlink_file_artifact() { let root = test_root("install-service-keep-file-symlink"); @@ -95,6 +126,35 @@ fn uninstall_rejects_symlink_file_artifact() { let _ = fs::remove_dir_all(&root); } +#[test] +fn uninstall_rejects_symlinked_parent_for_file_artifact() { + let root = test_root("install-service-keep-linked-file-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_file = outside.join("service-file"); + fs::create_dir_all(&outside).expect("make outside directory"); + fs::write(&outside_file, "service").expect("write outside service file"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-file"), + kind: ServiceArtifactKind::File, + contents: Some("service".to_string()), + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_to_string(outside_file).expect("outside service file remains"), + "service" + ); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} + #[test] fn uninstall_rejects_unmarked_managed_directory() { let root = test_root("install-service-unmarked-remove"); From 7e476bf9134e930563697a4ba48927a478747729 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:13:52 -0500 Subject: [PATCH 046/275] refactor(installer): manage directories through stable descriptors Summary: manage directories through stable descriptors. Scope: installer. --- .../unixnotis-core/src/filesystem/atomic.rs | 134 ++-------- .../src/filesystem/directory.rs | 250 ++++++++++++++++++ crates/unixnotis-core/src/filesystem/mod.rs | 2 + .../unixnotis-core/src/filesystem/remove.rs | 9 +- .../src/tests/filesystem/atomic.rs | 8 +- .../src/tests/filesystem/directory.rs | 150 +++++++++++ .../src/actions/config/backup/retention.rs | 6 +- .../actions/config/backup/tests/retention.rs | 41 +++ .../src/actions/config/state.rs | 8 +- .../src/actions/environment/shell_path.rs | 5 - .../src/actions/install/service/dirs.rs | 133 +++------- .../src/actions/install/service/files.rs | 16 +- .../install/tests/service/uninstall_safety.rs | 29 +- .../actions/install/tests/service/writes.rs | 2 +- 14 files changed, 545 insertions(+), 248 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/directory.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/directory.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index b2b22285d..6bb82066c 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -1,15 +1,19 @@ //! Durable file replacement that does not follow target symlinks -use rustix::fs::{mkdirat, openat2, renameat, unlinkat, AtFlags, Mode, OFlags, ResolveFlags, CWD}; +use rustix::fs::{openat2, renameat, unlinkat, AtFlags, Mode, OFlags}; use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::os::fd::OwnedFd; use std::os::unix::fs::PermissionsExt; -use std::path::{Component, Path}; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use super::directory::{ + contained_resolve_flags, open_parent, open_parent_existing, sync_directory, +}; + const TEMP_ATTEMPTS: u8 = 16; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -39,7 +43,9 @@ pub fn write_file_atomic_preserving_mode( ) -> io::Result<()> { let (parent_fd, file_name) = open_parent(path)?; let mode = existing_target_mode(&parent_fd, &file_name)?.unwrap_or(default_mode); - write_file_atomic_at(parent_fd, &file_name, mode, |file| file.write_all(contents)) + write_file_atomic_at(&parent_fd, &file_name, mode, |file| { + file.write_all(contents) + }) } pub(super) fn publish_file_atomic( @@ -49,34 +55,34 @@ pub(super) fn publish_file_atomic( ) -> io::Result<()> { let (parent_fd, file_name) = open_parent(path)?; validate_target(&parent_fd, &file_name)?; - write_file_atomic_at(parent_fd, &file_name, mode, write_payload) + write_file_atomic_at(&parent_fd, &file_name, mode, write_payload) } fn write_file_atomic_at( - parent_fd: OwnedFd, + parent_fd: &OwnedFd, file_name: &OsString, mode: u32, write_payload: impl FnOnce(&mut fs::File) -> io::Result<()>, ) -> io::Result<()> { let candidates = temp_candidates(file_name); - let (temp_name, mut temp_file) = reserve_temp(&parent_fd, candidates, mode)?; + let (temp_name, mut temp_file) = reserve_temp(parent_fd, candidates, mode)?; if let Err(error) = write_payload(&mut temp_file).and_then(|()| set_mode_and_sync(&temp_file, mode)) { drop(temp_file); - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error); } drop(temp_file); // A second check catches target swaps made while the payload was written - if let Err(error) = validate_target(&parent_fd, file_name) { - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + if let Err(error) = validate_target(parent_fd, file_name) { + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error); } - if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, file_name) { - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + if let Err(error) = renameat(parent_fd, &temp_name, parent_fd, file_name) { + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error.into()); } sync_directory(parent_fd) @@ -118,7 +124,7 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res return Err(error); } drop(file); - sync_directory(parent_fd)?; + sync_directory(&parent_fd)?; Ok(true) } @@ -164,96 +170,6 @@ pub(super) fn open_regular_file(path: &Path) -> io::Result { Ok(file) } -fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { - open_parent_with(path, MissingParent::Create) -} - -pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { - open_parent_with(path, MissingParent::Reject) -} - -#[derive(Clone, Copy)] -enum MissingParent { - Create, - Reject, -} - -fn open_parent_with(path: &Path, missing_parent: MissingParent) -> io::Result<(OwnedFd, OsString)> { - let file_name = path - .file_name() - .filter(|name| !name.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? - .to_os_string(); - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let mut parent_fd = if path.is_absolute() { - openat2( - CWD, - "/", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - anchor_resolve_flags(), - )? - } else { - openat2( - CWD, - ".", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - anchor_resolve_flags(), - )? - }; - - for component in parent.components() { - match component { - Component::Prefix(_) | Component::RootDir | Component::CurDir => {} - Component::ParentDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "atomic write path cannot contain parent traversal", - )); - } - Component::Normal(name) => { - parent_fd = open_directory_component(&parent_fd, name, missing_parent)?; - } - } - } - Ok((parent_fd, file_name)) -} - -fn open_directory_component( - parent_fd: &OwnedFd, - name: &std::ffi::OsStr, - missing_parent: MissingParent, -) -> io::Result { - match openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - contained_resolve_flags(), - ) { - Ok(fd) => Ok(fd), - Err(error) - if error.kind() == io::ErrorKind::NotFound - && matches!(missing_parent, MissingParent::Create) => - { - mkdirat(parent_fd, name, Mode::from_raw_mode(0o755))?; - openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - contained_resolve_flags(), - ) - .map_err(Into::into) - } - Err(error) => Err(error.into()), - } -} - fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { existing_target_mode(parent_fd, file_name).map(|_mode| ()) } @@ -353,24 +269,10 @@ fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { file.sync_all() } -pub(super) fn sync_directory(parent_fd: OwnedFd) -> io::Result<()> { - fs::File::from(parent_fd).sync_all() -} - const fn file_mode(mode: u32) -> Mode { Mode::from_raw_mode(mode & 0o777) } -const fn contained_resolve_flags() -> ResolveFlags { - ResolveFlags::BENEATH - .union(ResolveFlags::NO_SYMLINKS) - .union(ResolveFlags::NO_MAGICLINKS) -} - -const fn anchor_resolve_flags() -> ResolveFlags { - ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) -} - #[cfg(test)] #[path = "../tests/filesystem/atomic.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs new file mode 100644 index 000000000..03d09c4d7 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -0,0 +1,250 @@ +//! Directory traversal, creation, and removal through stable descriptors + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; +use std::path::{Component, Path}; + +use rustix::fs::{ + fchmod, fsync, mkdirat, openat2, statat, unlinkat, AtFlags, Dir, FileType, Mode, OFlags, + ResolveFlags, CWD, +}; + +/// Create a directory and every missing parent without following links +/// +/// Returns `true` when at least the requested directory had to be created +/// +/// # Errors +/// +/// Returns an error when the path traverses upward or through a link, an existing component is not +/// a directory, or creation, permission repair, or synchronization fails +pub fn create_directory_all(path: &Path, mode: u32) -> io::Result { + let (_directory_fd, created) = open_directory_path(path, MissingDirectory::Create(mode))?; + Ok(created) +} + +/// Remove an empty directory without following links +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not an empty directory, or the +/// removal or parent-directory synchronization fails +pub fn remove_empty_directory(path: &Path) -> io::Result { + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +/// Recursively remove a directory containing only regular files and directories +/// +/// Symbolic links and special files are rejected and left in place +/// +/// # Errors +/// +/// Returns an error when a path component or child has an unsafe shape, an entry changes during +/// traversal, or removal and synchronization cannot complete +pub fn remove_directory_tree(path: &Path) -> io::Result { + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + remove_directory_contents(&directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +pub(super) fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Create(0o755)) +} + +pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Reject) +} + +pub(super) fn sync_directory(directory_fd: &OwnedFd) -> io::Result<()> { + Ok(fsync(directory_fd)?) +} + +pub(super) const fn contained_resolve_flags() -> ResolveFlags { + ResolveFlags::BENEATH + .union(ResolveFlags::NO_SYMLINKS) + .union(ResolveFlags::NO_MAGICLINKS) +} + +pub(super) const fn anchor_resolve_flags() -> ResolveFlags { + ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) +} + +#[derive(Clone, Copy)] +enum MissingDirectory { + Create(u32), + Reject, +} + +fn open_parent_with( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, OsString)> { + let file_name = path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? + .to_os_string(); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let (parent_fd, _created) = open_directory_path(parent, missing_directory)?; + Ok((parent_fd, file_name)) +} + +fn open_directory_path( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, bool)> { + let mut directory_fd = open_anchor(path)?; + let mut created = false; + + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem path cannot contain parent traversal", + )); + } + Component::Normal(name) => { + let (next_fd, component_created) = + open_directory_component(&directory_fd, name, missing_directory)?; + directory_fd = next_fd; + created |= component_created; + } + } + } + + Ok((directory_fd, created)) +} + +fn open_anchor(path: &Path) -> io::Result { + openat2( + CWD, + if path.is_absolute() { "/" } else { "." }, + OFlags::DIRECTORY.union(OFlags::CLOEXEC), + Mode::empty(), + anchor_resolve_flags(), + ) + .map_err(Into::into) +} + +fn open_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, bool)> { + match open_directory_at(parent_fd, name) { + Ok(fd) => Ok((fd, false)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && matches!(missing_directory, MissingDirectory::Create(_)) => + { + let MissingDirectory::Create(mode) = missing_directory else { + unreachable!("guard requires directory creation mode"); + }; + create_directory_component(parent_fd, name, mode) + } + Err(error) => Err(error), + } +} + +fn create_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + mode: u32, +) -> io::Result<(OwnedFd, bool)> { + let created = match mkdirat(parent_fd, name, file_mode(mode)) { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, + Err(error) => return Err(error.into()), + }; + let directory_fd = open_directory_at(parent_fd, name)?; + if created { + // Apply the exact requested mode because mkdir remains subject to the process umask + fchmod(&directory_fd, file_mode(mode))?; + fsync(&directory_fd)?; + fsync(parent_fd)?; + } + Ok((directory_fd, created)) +} + +fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { + openat2( + parent_fd, + name, + OFlags::DIRECTORY + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + ) + .map_err(Into::into) +} + +fn open_target_directory(path: &Path) -> io::Result> { + let (parent_fd, file_name) = match open_parent_existing(path) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + match open_directory_at(&parent_fd, &file_name) { + Ok(directory_fd) => Ok(Some((parent_fd, file_name, directory_fd))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + unlinkat(directory_fd, name, AtFlags::empty())?; + fsync(directory_fd)?; + } else if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + remove_directory_contents(&child_fd)?; + drop(child_fd); + unlinkat(directory_fd, name, AtFlags::REMOVEDIR)?; + fsync(directory_fd)?; + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing unsafe entry inside directory tree: {}", + name.to_string_lossy() + ), + )); + } + } + Ok(()) +} + +const fn file_mode(mode: u32) -> Mode { + Mode::from_raw_mode(mode & 0o777) +} + +#[cfg(test)] +#[path = "../tests/filesystem/directory.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index d4b7518c4..ae18c417c 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -1,6 +1,7 @@ //! Shared filesystem operations with stable directory anchors mod atomic; +mod directory; mod install; mod path; mod remove; @@ -9,6 +10,7 @@ pub use atomic::{ make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, }; +pub use directory::{create_directory_all, remove_directory_tree, remove_empty_directory}; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; pub use remove::{ diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index 1a0f9df47..d95450754 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -7,7 +7,8 @@ use std::path::{Path, PathBuf}; use rustix::fs::{readlinkat, unlinkat, AtFlags}; -use super::atomic::{open_parent_existing, sync_directory, validate_existing_target}; +use super::atomic::validate_existing_target; +use super::directory::{open_parent_existing, sync_directory}; /// Result of removing a symbolic link with an expected target #[derive(Debug, Clone, PartialEq, Eq)] @@ -37,7 +38,7 @@ pub fn remove_regular_file(path: &Path) -> io::Result { } unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(parent_fd)?; + sync_directory(&parent_fd)?; Ok(true) } @@ -75,7 +76,7 @@ pub fn remove_symlink(path: &Path) -> io::Result { } unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(parent_fd)?; + sync_directory(&parent_fd)?; Ok(true) } @@ -104,7 +105,7 @@ pub fn remove_symlink_if_target( } unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(parent_fd)?; + sync_directory(&parent_fd)?; Ok(RemoveSymlinkOutcome::Removed) } diff --git a/crates/unixnotis-core/src/tests/filesystem/atomic.rs b/crates/unixnotis-core/src/tests/filesystem/atomic.rs index d3ac7c713..bb509f5df 100644 --- a/crates/unixnotis-core/src/tests/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/tests/filesystem/atomic.rs @@ -1,6 +1,5 @@ use super::{ - anchor_resolve_flags, contained_resolve_flags, file_mode, make_file_executable, open_parent, - reserve_temp, set_file_mode, sync_directory, write_file_atomic, + file_mode, make_file_executable, reserve_temp, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, }; use std::ffi::OsString; @@ -12,6 +11,9 @@ use std::os::unix::net::UnixStream; use rustix::fs::{mkfifoat, Mode, ResolveFlags, CWD}; +use crate::filesystem::directory::{ + anchor_resolve_flags, contained_resolve_flags, open_parent, sync_directory, +}; use crate::test_support::unique_temp_path; #[test] @@ -228,7 +230,7 @@ fn directory_sync_propagates_invalid_descriptor_type() { let (stream, _peer) = UnixStream::pair().expect("create socket pair"); let fd: OwnedFd = stream.into(); - let error = sync_directory(fd).expect_err("socket cannot be synchronized as a directory"); + let error = sync_directory(&fd).expect_err("socket cannot be synchronized as a directory"); assert_ne!(error.kind(), std::io::ErrorKind::NotFound); } diff --git a/crates/unixnotis-core/src/tests/filesystem/directory.rs b/crates/unixnotis-core/src/tests/filesystem/directory.rs new file mode 100644 index 000000000..107d5d0ef --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/directory.rs @@ -0,0 +1,150 @@ +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{create_directory_all, remove_directory_tree, remove_empty_directory}; +use crate::test_support::unique_temp_path; + +#[test] +fn directory_creation_builds_missing_components_with_requested_mode() { + let root = unique_temp_path("create-directory-tree"); + let target = root.join("parent").join("child"); + + assert!(create_directory_all(&target, 0o750).expect("create directory tree")); + assert!(!create_directory_all(&target, 0o700).expect("existing directory stays unchanged")); + + for directory in [&root, &root.join("parent"), &target] { + assert_eq!( + fs::metadata(directory) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + 0o750 + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_creation_rejects_a_linked_parent() { + let root = unique_temp_path("create-directory-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + symlink(&outside, &linked).expect("create parent link"); + + create_directory_all(&linked.join("child"), 0o755).expect_err("linked parent should fail"); + + assert!(!outside.join("child").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_is_idempotent() { + let root = unique_temp_path("remove-empty-directory"); + let target = root.join("empty"); + fs::create_dir_all(&target).expect("create empty directory"); + + assert!(remove_empty_directory(&target).expect("remove empty directory")); + assert!(!remove_empty_directory(&target).expect("missing directory stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_rejects_nonempty_and_link_targets() { + let root = unique_temp_path("remove-empty-directory-shapes"); + let target = root.join("directory"); + let linked = root.join("linked"); + fs::create_dir_all(&target).expect("create target directory"); + fs::write(target.join("file"), "data").expect("write child"); + symlink(&target, &linked).expect("create directory link"); + + remove_empty_directory(&target).expect_err("nonempty directory should fail"); + remove_empty_directory(&linked).expect_err("directory link should fail"); + + assert!(target.join("file").exists()); + assert!(fs::symlink_metadata(linked) + .expect("link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_deletes_regular_nested_tree() { + let root = unique_temp_path("remove-directory-tree"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("root-file"), "root").expect("write root file"); + fs::write(target.join("nested").join("child-file"), "child").expect("write child file"); + + assert!(remove_directory_tree(&target).expect("remove managed tree")); + assert!(!remove_directory_tree(&target).expect("missing tree stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_child_symlink() { + let root = unique_temp_path("remove-directory-child-link"); + let target = root.join("managed"); + let protected = root.join("protected"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, target.join("linked-child")).expect("create child link"); + + remove_directory_tree(&target).expect_err("child link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_special_child() { + let root = unique_temp_path("remove-directory-special-child"); + let target = root.join("managed"); + let fifo = target.join("fifo"); + fs::create_dir_all(&target).expect("create managed directory"); + mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create fifo child"); + + remove_directory_tree(&target).expect_err("special child should fail"); + + assert!(fs::symlink_metadata(fifo).is_ok()); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_removal_rejects_linked_ancestors_without_touching_target() { + let root = unique_temp_path("remove-directory-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(outside.join("empty")).expect("create outside directory"); + symlink(&outside, &linked).expect("create parent link"); + + remove_empty_directory(&linked.join("empty")).expect_err("linked parent should fail"); + remove_directory_tree(&linked).expect_err("linked root should fail"); + + assert!(outside.join("empty").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_removal_does_not_create_missing_parents() { + let root = unique_temp_path("remove-directory-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("directory"); + + assert!(!remove_empty_directory(&target).expect("empty directory is missing")); + assert!(!remove_directory_tree(&target).expect("directory tree is missing")); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/retention.rs index 6c765a431..ef1a9cb8e 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/retention.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::Local; +use unixnotis_core::filesystem::{create_directory_all, remove_directory_tree}; use crate::paths::format_with_home; @@ -34,7 +35,8 @@ pub(in crate::actions::config) fn create_backup_dir( suffix += 1; } - fs::create_dir_all(&candidate).with_context(|| "failed to create backup directory")?; + // Backups may contain private configuration, so their root is always user-only + create_directory_all(&candidate, 0o700).with_context(|| "failed to create backup directory")?; log_line( ctx, format!("Backup directory created: {}", format_with_home(&candidate)), @@ -92,7 +94,7 @@ pub(in crate::actions::config::backup) fn prune_old_backups_except( if protected_backup.is_some_and(|protected| protected == path) { continue; } - if let Err(err) = fs::remove_dir_all(&path) { + if let Err(err) = remove_directory_tree(&path) { log_line( ctx, format!( diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs index c54c5c5ec..204b36a1d 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs @@ -6,6 +6,7 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; @@ -126,11 +127,51 @@ fn create_backup_dir_keeps_new_directory_when_retention_is_full() { backup_dir.exists(), "new backup directory must survive retention pruning" ); + assert_eq!( + fs::metadata(&backup_dir) + .expect("backup directory metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); assert_eq!(list_backup_dirs(&root).len(), 3); let _ = fs::remove_dir_all(&root); } +#[test] +fn prune_old_backups_rejects_symlink_children_without_touching_target() { + let root = crate::test_support::fs::unique_temp_path("backup-prune-child-link"); + let oldest = root.join("Backup-2026-07-20"); + let newest = root.join("Backup-2026-07-21"); + let protected = root.join("protected"); + fs::create_dir_all(&oldest).expect("create oldest backup"); + fs::create_dir_all(&newest).expect("create newest backup"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, oldest.join("linked-file")).expect("create backup child link"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = super::support::test_paths(&root); + let mut context = super::support::test_context(&detection, &paths); + + prune_old_backups(&mut context, &root, 1).expect("prune remains best effort"); + + assert!(oldest.exists()); + assert!(newest.exists()); + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(fs::symlink_metadata(oldest.join("linked-file")) + .expect("backup child link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + #[test] fn create_backup_dir_returns_none_when_retention_is_disabled() { let _lock = crate::test_support::env::test_env_lock(); diff --git a/crates/unixnotis-installer/src/actions/config/state.rs b/crates/unixnotis-installer/src/actions/config/state.rs index 12631f901..a9186856d 100644 --- a/crates/unixnotis-installer/src/actions/config/state.rs +++ b/crates/unixnotis-installer/src/actions/config/state.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use unixnotis_core::filesystem::remove_regular_file; +use unixnotis_core::filesystem::{remove_empty_directory, remove_regular_file}; use unixnotis_core::util; use crate::paths::format_with_home; @@ -106,9 +106,9 @@ fn cleanup_empty_state_dir(state_root: &Path) -> DirCleanupOutcome { match is_dir_empty(state_root) { Ok(false) => DirCleanupOutcome::KeptNotEmpty, // Only try removing the dir after confirming it is empty - Ok(true) => match fs::remove_dir(state_root) { - Ok(()) => DirCleanupOutcome::Removed, - Err(_) => DirCleanupOutcome::RemoveFailed, + Ok(true) => match remove_empty_directory(state_root) { + Ok(true) => DirCleanupOutcome::Removed, + Ok(false) | Err(_) => DirCleanupOutcome::RemoveFailed, }, // Surface read_dir problems separately so they can be logged upstream Err(_) => DirCleanupOutcome::InspectFailed, diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index dcb3987a5..608b2f6cd 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -130,11 +130,6 @@ pub(in crate::actions::environment) fn ensure_path_entry_in_file( return Ok(false); } - if let Some(parent) = file.parent() { - fs::create_dir_all(parent) - .map_err(|err| anyhow!("failed to create {}: {}", parent.display(), err))?; - } - let export_line = format!( "export PATH=\"{}:$PATH\"", format_path_for_shell_line(home, bin_dir) diff --git a/crates/unixnotis-installer/src/actions/install/service/dirs.rs b/crates/unixnotis-installer/src/actions/install/service/dirs.rs index 7dbde25e8..9a56d224b 100644 --- a/crates/unixnotis-installer/src/actions/install/service/dirs.rs +++ b/crates/unixnotis-installer/src/actions/install/service/dirs.rs @@ -2,10 +2,12 @@ use std::fs; use std::io::ErrorKind; -use std::path::{Component, Path, PathBuf}; +use std::path::Path; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::write_file_atomic; +use unixnotis_core::filesystem::{ + create_directory_all, remove_directory_tree, remove_empty_directory, write_file_atomic, +}; use crate::paths::format_with_home; use crate::service_manager::{ @@ -57,29 +59,16 @@ pub(in crate::actions::install::service) fn write_managed_directory(path: &Path) pub(in crate::actions::install::service) fn ensure_directory_without_symlink( path: &Path, ) -> Result<()> { - // Build the path one component at a time so an existing parent link cannot redirect writes - let mut current = PathBuf::new(); - for component in path.components() { - match component { - // Windows prefixes are kept for correctness even though the installer is Unix-oriented - Component::Prefix(prefix) => current.push(prefix.as_os_str()), - Component::RootDir => current.push(component.as_os_str()), - // Current-directory components do not change the resolved location - Component::CurDir => {} - Component::ParentDir => { - // Parent traversal would make artifact ownership impossible to reason about - return Err(anyhow!( - "refusing parent traversal in service artifact path {}", - format_with_home(path) - )); - } - Component::Normal(part) => { - current.push(part); - inspect_or_create_directory_component(path, ¤t)?; - } - } - } - Ok(()) + // Core keeps one descriptor per component so parent swaps cannot redirect creation + create_directory_all(path, 0o755) + .map(|_created| ()) + .map_err(|error| { + anyhow!( + "refusing unsafe service directory path {}: {}", + format_with_home(path), + error + ) + }) } pub(in crate::actions::install::service) fn service_artifact_path_is_present(path: &Path) -> bool { @@ -106,7 +95,16 @@ pub(in crate::actions::install::service) fn remove_empty_service_directory( )); } - fs::remove_dir(path).with_context(|| format!("failed to remove {}", format_with_home(path))) + if remove_empty_directory(path) + .with_context(|| format!("failed to remove {}", format_with_home(path)))? + { + Ok(()) + } else { + Err(anyhow!( + "service directory disappeared before removal at {}", + format_with_home(path) + )) + } } pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path) -> Result<()> { @@ -129,31 +127,16 @@ pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path )); } - remove_managed_directory_tree(path) - .with_context(|| format!("failed to remove {}", format_with_home(path))) -} - -fn inspect_or_create_directory_component(full_path: &Path, current: &Path) -> Result<()> { - // Every component is checked with symlink_metadata so the link itself is inspected - match fs::symlink_metadata(current) { - // symlink_metadata checks the path itself, not the linked target - Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!( - "refusing symlink parent component {}", - format_with_home(current) - )), - Ok(metadata) if metadata.is_dir() => Ok(()), - Ok(_) => Err(anyhow!( - "refusing non-directory parent component {}", - format_with_home(current) - )), - // Missing components are created one at a time to avoid create_dir_all following links - Err(err) if err.kind() == ErrorKind::NotFound => fs::create_dir(current) - .with_context(|| format!("failed to create {}", format_with_home(current))), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(current))) - } + if remove_directory_tree(path) + .with_context(|| format!("failed to remove {}", format_with_home(path)))? + { + Ok(()) + } else { + Err(anyhow!( + "managed service directory disappeared before removal at {}", + format_with_home(path) + )) } - .with_context(|| format!("while preparing {}", format_with_home(full_path))) } fn ensure_artifact_directory_path(path: &Path) -> Result { @@ -174,53 +157,3 @@ fn ensure_artifact_directory_path(path: &Path) -> Result { } } } - -fn remove_managed_directory_tree(path: &Path) -> Result<()> { - // Each level is inspected before reading children so symlink swaps do not get followed - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("failed to inspect {}", format_with_home(path)))?; - if metadata.file_type().is_symlink() { - return Err(anyhow!( - "refusing symlink inside managed service directory at {}", - format_with_home(path) - )); - } - if !metadata.file_type().is_dir() { - return Err(anyhow!( - "refusing non-directory inside managed service directory at {}", - format_with_home(path) - )); - } - - for entry in - fs::read_dir(path).with_context(|| format!("failed to read {}", format_with_home(path)))? - { - let entry = entry.with_context(|| format!("failed to read {}", format_with_home(path)))?; - let child = entry.path(); - let child_metadata = fs::symlink_metadata(&child) - .with_context(|| format!("failed to inspect {}", format_with_home(&child)))?; - - if child_metadata.file_type().is_symlink() { - // Backend-owned service directories should not need symlink children - // Failing closed avoids deleting or traversing a path that changed under the installer - return Err(anyhow!( - "refusing symlink inside managed service directory at {}", - format_with_home(&child) - )); - } - if child_metadata.file_type().is_dir() { - remove_managed_directory_tree(&child)?; - } else if child_metadata.file_type().is_file() { - fs::remove_file(&child) - .with_context(|| format!("failed to remove {}", format_with_home(&child)))?; - } else { - // Sockets, fifos, and device nodes should not appear in installer-owned service trees - return Err(anyhow!( - "refusing special file inside managed service directory at {}", - format_with_home(&child) - )); - } - } - - fs::remove_dir(path).with_context(|| format!("failed to remove {}", format_with_home(path))) -} diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 7e3e03149..b031edd61 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -8,7 +8,8 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - remove_regular_file, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, + remove_empty_directory, remove_regular_file, set_file_mode, write_file_atomic, + write_file_atomic_preserving_mode, }; use crate::paths::format_with_home; @@ -197,16 +198,9 @@ fn remove_empty_shared_layout_dirs(path: &Path) -> Result<()> { } fn remove_dir_if_empty(path: &Path) -> Result<()> { - match fs::remove_dir(path) { - Ok(()) => Ok(()), - Err(err) - if matches!( - err.kind(), - ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty - ) => - { - Ok(()) - } + match remove_empty_directory(path) { + Ok(true | false) => Ok(()), + Err(err) if matches!(err.kind(), ErrorKind::DirectoryNotEmpty) => Ok(()), Err(err) => { Err(err).with_context(|| format!("failed to remove {}", format_with_home(path))) } diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs index b65f06963..54b649958 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs @@ -228,7 +228,7 @@ fn uninstall_rejects_symlink_inside_managed_directory() { let err = remove_service_artifact(&artifact).expect_err("child link should be rejected"); // The full error chain carries the child-link refusal below the outer removal context - assert!(format!("{err:#}").contains("refusing symlink inside managed service directory")); + assert!(format!("{err:#}").contains("refusing unsafe entry inside directory tree")); assert_eq!( fs::read_link(&child_link).expect("child link should remain untouched"), target @@ -262,7 +262,7 @@ fn uninstall_rejects_socket_inside_managed_directory() { let err = remove_service_artifact(&artifact).expect_err("socket child should be rejected"); // The recursive remover fails closed and does not delete the containing service directory - assert!(format!("{err:#}").contains("refusing special file inside managed service directory")); + assert!(format!("{err:#}").contains("refusing unsafe entry inside directory tree")); assert!(fs::symlink_metadata(&socket_path) .expect("socket child should remain") .file_type() @@ -270,3 +270,28 @@ fn uninstall_rejects_socket_inside_managed_directory() { assert!(service_dir.exists()); let _ = fs::remove_dir_all(&root); } + +#[test] +fn uninstall_rejects_symlinked_parent_for_directory_artifact() { + let root = test_root("install-service-keep-linked-directory-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_directory = outside.join("service-directory"); + fs::create_dir_all(&outside_directory).expect("make outside service directory"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-directory"), + kind: ServiceArtifactKind::Directory, + contents: None, + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert!(outside_directory.exists()); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index 28bca76e3..6ba73fb4d 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -238,7 +238,7 @@ fn write_service_artifact_rejects_symlink_parent_component() { let err = write_service_artifact(&ctx, &artifact).expect_err("symlink parent is unsafe"); // The target directory proves the writer did not follow the linked parent - assert!(format!("{err:#}").contains("refusing symlink parent")); + assert!(format!("{err:#}").contains("refusing unsafe service directory path")); assert!(!target.join("service-file").exists()); let _ = fs::remove_dir_all(&root); } From 50d64e1a4765764eadf3c66abce6f07004c96546 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:20:20 -0500 Subject: [PATCH 047/275] refactor(installer): publish links through stable anchors Summary: publish links through stable anchors. Scope: installer. --- .../unixnotis-core/src/filesystem/atomic.rs | 2 +- crates/unixnotis-core/src/filesystem/mod.rs | 7 +- .../unixnotis-core/src/filesystem/remove.rs | 28 +--- .../unixnotis-core/src/filesystem/symlink.rs | 138 ++++++++++++++++++ .../src/tests/filesystem/remove.rs | 6 +- .../src/tests/filesystem/symlink.rs | 117 +++++++++++++++ .../src/actions/install/service/refresh.rs | 73 +-------- .../src/actions/install/service/symlinks.rs | 51 +++---- .../unixnotis-installer/src/checks/system.rs | 16 +- .../src/checks/tests/system.rs | 34 ++++- crates/unixnotis-installer/src/trial/paths.rs | 17 +-- crates/unixnotis-installer/src/trial/shim.rs | 51 ++++--- .../src/trial/tests/shim.rs | 41 ++++++ 13 files changed, 408 insertions(+), 173 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/symlink.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/symlink.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 6bb82066c..96a62db87 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -220,7 +220,7 @@ fn unsafe_target_error() -> io::Error { ) } -fn temp_candidates(file_name: &OsString) -> impl Iterator + '_ { +pub(super) fn temp_candidates(file_name: &OsString) -> impl Iterator + '_ { (0..TEMP_ATTEMPTS).map(move |attempt| { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index ae18c417c..f841a1fdb 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -5,6 +5,7 @@ mod directory; mod install; mod path; mod remove; +mod symlink; pub use atomic::{ make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, @@ -14,6 +15,8 @@ pub use directory::{create_directory_all, remove_directory_tree, remove_empty_di pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; pub use remove::{ - read_symlink, remove_regular_file, remove_symlink, remove_symlink_if_target, - RemoveSymlinkOutcome, + remove_regular_file, remove_symlink, remove_symlink_if_target, RemoveSymlinkOutcome, +}; +pub use symlink::{ + create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, }; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index d95450754..d08b30226 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -1,14 +1,14 @@ //! Descriptor-relative removal for regular files and symbolic links -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::io; -use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; -use rustix::fs::{readlinkat, unlinkat, AtFlags}; +use rustix::fs::{unlinkat, AtFlags}; use super::atomic::validate_existing_target; use super::directory::{open_parent_existing, sync_directory}; +use super::symlink::read_symlink_at; /// Result of removing a symbolic link with an expected target #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,23 +42,6 @@ pub fn remove_regular_file(path: &Path) -> io::Result { Ok(true) } -/// Read a symbolic link target without following links in its parent path -/// -/// # Errors -/// -/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the -/// link cannot be read -pub fn read_symlink(path: &Path) -> io::Result> { - let Some((parent_fd, file_name)) = existing_parent(path)? else { - return Ok(None); - }; - match read_symlink_at(&parent_fd, &file_name) { - Ok(target) => Ok(Some(target)), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error), - } -} - /// Remove a symbolic link without requiring a specific target /// /// # Errors @@ -117,11 +100,6 @@ fn existing_parent(path: &Path) -> io::Result io::Result { - let target = readlinkat(parent_fd, file_name, Vec::new())?; - Ok(PathBuf::from(OsString::from_vec(target.into_bytes()))) -} - #[cfg(test)] #[path = "../tests/filesystem/remove.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs new file mode 100644 index 000000000..bdde7e8ff --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -0,0 +1,138 @@ +//! Symbolic-link inspection and publication through stable parent descriptors + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use rustix::fs::{readlinkat, renameat, symlinkat, unlinkat, AtFlags}; + +use super::atomic::temp_candidates; +use super::directory::{open_parent, open_parent_existing, sync_directory}; + +/// Result of creating a symbolic link without replacing an existing path +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateSymlinkOutcome { + /// A new link was created + Created, + /// The existing link already stored the requested target + Unchanged, + /// A different link target was preserved + TargetMismatch(PathBuf), +} + +/// Create a symbolic link while preserving every existing path +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, the destination is an existing non-link, or link +/// creation and parent-directory synchronization fail +pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + match read_symlink_at(&parent_fd, &file_name) { + Ok(existing) if existing == target => return Ok(CreateSymlinkOutcome::Unchanged), + Ok(existing) => return Ok(CreateSymlinkOutcome::TargetMismatch(existing)), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + match symlinkat(target, &parent_fd, &file_name) { + Ok(()) => { + sync_directory(&parent_fd)?; + Ok(CreateSymlinkOutcome::Created) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + // A concurrent creator is accepted only when it published the exact requested link + match read_symlink_at(&parent_fd, &file_name)? { + existing if existing == target => Ok(CreateSymlinkOutcome::Unchanged), + existing => Ok(CreateSymlinkOutcome::TargetMismatch(existing)), + } + } + Err(error) => Err(error.into()), + } +} + +/// Atomically create or replace a symbolic link +/// +/// Existing non-link destinations are rejected. A matching existing link is left untouched +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, an existing destination is not a symbolic link, +/// or temporary-link creation, revalidation, rename, cleanup, or synchronization fails +pub fn replace_symlink_atomic(path: &Path, target: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + match read_symlink_at(&parent_fd, &file_name) { + Ok(existing) if existing == target => return Ok(false), + Ok(_existing) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + let temp_name = reserve_temp_symlink(&parent_fd, &file_name, target)?; + if let Err(error) = validate_symlink_or_missing(&parent_fd, &file_name) { + let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + return Err(error); + } + if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, &file_name) { + let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + return Err(error.into()); + } + sync_directory(&parent_fd)?; + Ok(true) +} + +/// Read a symbolic link target without following links in its parent path +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, the target is not a symbolic link, or the link +/// cannot be read +pub fn read_symlink(path: &Path) -> io::Result> { + let (parent_fd, file_name) = match open_parent_existing(path) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + match read_symlink_at(&parent_fd, &file_name) { + Ok(target) => Ok(Some(target)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub(super) fn read_symlink_at(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Result { + let target = readlinkat(parent_fd, file_name, Vec::new())?; + Ok(PathBuf::from(OsString::from_vec(target.into_bytes()))) +} + +fn reserve_temp_symlink( + parent_fd: &OwnedFd, + file_name: &OsString, + target: &Path, +) -> io::Result { + for temp_name in temp_candidates(file_name) { + match symlinkat(target, parent_fd, &temp_name) { + Ok(()) => return Ok(temp_name), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to reserve an exclusive temporary symbolic link", + )) +} + +fn validate_symlink_or_missing(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Result<()> { + match read_symlink_at(parent_fd, file_name) { + Ok(_target) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(test)] +#[path = "../tests/filesystem/symlink.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/remove.rs b/crates/unixnotis-core/src/tests/filesystem/remove.rs index 24b109ad1..e079bee58 100644 --- a/crates/unixnotis-core/src/tests/filesystem/remove.rs +++ b/crates/unixnotis-core/src/tests/filesystem/remove.rs @@ -1,10 +1,8 @@ use std::fs; use std::os::unix::fs::symlink; -use super::{ - read_symlink, remove_regular_file, remove_symlink, remove_symlink_if_target, - RemoveSymlinkOutcome, -}; +use super::{remove_regular_file, remove_symlink, remove_symlink_if_target, RemoveSymlinkOutcome}; +use crate::filesystem::symlink::read_symlink; use crate::test_support::unique_temp_path; #[test] diff --git a/crates/unixnotis-core/src/tests/filesystem/symlink.rs b/crates/unixnotis-core/src/tests/filesystem/symlink.rs new file mode 100644 index 000000000..d0756ab77 --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/symlink.rs @@ -0,0 +1,117 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use super::{ + create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, +}; +use crate::test_support::unique_temp_path; + +#[test] +fn create_symlink_is_idempotent_for_an_exact_target() { + let root = unique_temp_path("create-symlink"); + let link = root.join("service").join("enabled"); + + assert_eq!( + create_symlink_if_missing(&link, Path::new("../run")).expect("create symbolic link"), + CreateSymlinkOutcome::Created + ); + assert_eq!( + create_symlink_if_missing(&link, Path::new("../run")).expect("keep matching symbolic link"), + CreateSymlinkOutcome::Unchanged + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("../run".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn create_symlink_preserves_a_different_target() { + let root = unique_temp_path("create-symlink-mismatch"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("actual", &link).expect("create existing link"); + + let outcome = create_symlink_if_missing(&link, Path::new("expected")) + .expect("inspect existing symbolic link"); + + assert_eq!( + outcome, + CreateSymlinkOutcome::TargetMismatch("actual".into()) + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("actual".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn create_symlink_rejects_an_existing_regular_file() { + let root = unique_temp_path("create-symlink-regular"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&link, "regular").expect("write regular file"); + + create_symlink_if_missing(&link, Path::new("service")) + .expect_err("regular destination should fail"); + + assert_eq!( + fs::read_to_string(link).expect("read regular file"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn create_symlink_rejects_a_linked_parent() { + let root = unique_temp_path("create-symlink-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + symlink(&outside, &linked).expect("create parent link"); + + create_symlink_if_missing(&linked.join("enabled"), Path::new("service")) + .expect_err("linked parent should fail"); + + assert!(!outside.join("enabled").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_symlink_replacement_handles_missing_and_existing_links() { + let root = unique_temp_path("replace-symlink"); + let link = root.join("compiled"); + + assert!(replace_symlink_atomic(&link, Path::new("compiled-one")).expect("create compiled link")); + assert!( + replace_symlink_atomic(&link, Path::new("compiled-two")).expect("replace compiled link") + ); + assert!(!replace_symlink_atomic(&link, Path::new("compiled-two")) + .expect("matching compiled link stays unchanged")); + + assert_eq!( + read_symlink(&link).expect("read compiled link"), + Some("compiled-two".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_symlink_replacement_rejects_a_regular_destination() { + let root = unique_temp_path("replace-symlink-regular"); + let link = root.join("compiled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&link, "regular").expect("write regular destination"); + + replace_symlink_atomic(&link, Path::new("compiled-next")) + .expect_err("regular destination should fail"); + + assert_eq!( + fs::read_to_string(link).expect("read regular destination"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/install/service/refresh.rs b/crates/unixnotis-installer/src/actions/install/service/refresh.rs index 8013a6132..b9b784069 100644 --- a/crates/unixnotis-installer/src/actions/install/service/refresh.rs +++ b/crates/unixnotis-installer/src/actions/install/service/refresh.rs @@ -1,15 +1,12 @@ //! Service-manager refresh execution after artifact changes use std::fs; -use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; - -#[cfg(unix)] -use std::os::unix::fs as unix_fs; +use unixnotis_core::filesystem::replace_symlink_atomic; use crate::paths::format_with_home; use crate::service_manager::{CommandSpec, S6DatabaseRefresh, ServiceArtifactRefresh}; @@ -305,70 +302,16 @@ fn next_s6_compiled_database(plan: &S6DatabaseRefresh) -> Result { fn switch_s6_compiled_link(plan: &S6DatabaseRefresh, compiled: &Path) -> Result<()> { let link = plan.compiled_link(); - reject_unsafe_existing_compiled_link(&link)?; - - let temp_link = plan - .rc_root() - .join(format!(".compiled-unixnotis-next-{}", std::process::id())); - // Only UnixNotis-created symlink temp files can be reused between failed attempts - remove_stale_temp_link(&temp_link)?; - - #[cfg(unix)] - { - // s6-rc-init expects the boot database path to be a symlink to a compiled database - unix_fs::symlink(compiled, &temp_link) - .with_context(|| format!("failed to create {}", format_with_home(&temp_link)))?; - fs::rename(&temp_link, &link).with_context(|| { - format!( - "failed to atomically switch s6 compiled database symlink {}", - format_with_home(&link) - ) - })?; - } - - #[cfg(not(unix))] - { - let _ = compiled; - let _ = temp_link; - return Err(anyhow!( - "s6 database symlinks require Unix filesystem support" - )); - } - + // s6-rc-init expects one stable boot link, so publish the new target in one rename + replace_symlink_atomic(&link, compiled).with_context(|| { + format!( + "failed to atomically switch s6 compiled database symlink {}", + format_with_home(&link) + ) + })?; Ok(()) } -fn reject_unsafe_existing_compiled_link(link: &Path) -> Result<()> { - match fs::symlink_metadata(link) { - // Existing compiled links are expected; regular files or directories are user state - Ok(metadata) if metadata.file_type().is_symlink() => Ok(()), - Ok(_) => Err(anyhow!( - "refusing to replace non-symlink s6 compiled database path {}", - format_with_home(link) - )), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(link))) - } - } -} - -fn remove_stale_temp_link(temp_link: &Path) -> Result<()> { - match fs::symlink_metadata(temp_link) { - // Removing only a symlink keeps a hostile or accidental directory from being replaced - Ok(metadata) if metadata.file_type().is_symlink() => fs::remove_file(temp_link) - .with_context(|| format!("failed to remove {}", format_with_home(temp_link))), - Ok(_) => Err(anyhow!( - "refusing to replace non-symlink temp s6 database path {}", - format_with_home(temp_link) - )), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(temp_link))) - } - } -} - fn path_is_live_directory(path: &Path) -> bool { fs::metadata(path) // s6 live roots are normally symlinks, and the symlink name is the command contract diff --git a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs index af6fa9283..ab1487114 100644 --- a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs +++ b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs @@ -1,11 +1,12 @@ //! Service artifact symlink creation and safe removal -use std::fs; use std::io::ErrorKind; use std::path::Path; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::{remove_symlink_if_target, RemoveSymlinkOutcome}; +use unixnotis_core::filesystem::{ + create_symlink_if_missing, remove_symlink_if_target, CreateSymlinkOutcome, RemoveSymlinkOutcome, +}; use crate::paths::format_with_home; @@ -13,26 +14,27 @@ pub(in crate::actions::install) fn write_service_symlink( path: &Path, target: &Path, ) -> Result { - if let Ok(existing) = fs::read_link(path) { - if existing == target { - // Relative links are compared as stored, matching how the backend declared them - return Ok(false); - } - // A different target means another owner may be using this enablement path - return Err(anyhow!( + // Relative targets are compared exactly as stored by the service backend + match create_symlink_if_missing(path, target) { + Ok(CreateSymlinkOutcome::Created) => Ok(true), + Ok(CreateSymlinkOutcome::Unchanged) => Ok(false), + Ok(CreateSymlinkOutcome::TargetMismatch(existing)) => Err(anyhow!( "cannot replace service symlink {} because it points to {} instead of {}", format_with_home(path), format_with_home(&existing), format_with_home(target) - )); + )), + Err(error) if error.kind() == ErrorKind::InvalidInput => Err(anyhow!( + "cannot replace non-symlink service artifact at {}", + format_with_home(path) + )), + Err(error) => Err(error).with_context(|| { + format!( + "failed to inspect or create symlink {}", + format_with_home(path) + ) + }), } - // Existing non-links are left alone so enablement links cannot overwrite user files - reject_existing_non_symlink(path)?; - - // Create the link exactly as the backend requested, often with a relative target - std::os::unix::fs::symlink(target, path) - .with_context(|| format!("failed to create symlink {}", format_with_home(path)))?; - Ok(true) } pub(in crate::actions::install) fn remove_service_symlink( @@ -60,18 +62,3 @@ pub(in crate::actions::install) fn remove_service_symlink( }), } } - -fn reject_existing_non_symlink(path: &Path) -> Result<()> { - match fs::symlink_metadata(path) { - // Any existing non-link at the enablement path belongs to the user or another manager - Ok(_) => Err(anyhow!( - "cannot replace non-symlink service artifact at {}", - format_with_home(path) - )), - // NotFound means write_service_symlink can safely create the link - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(path))) - } - } -} diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index dc4b5670c..3dffc15f1 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -1,12 +1,12 @@ //! Session and tool availability checks use std::env; -use std::fs::OpenOptions; use std::path::Path; use crate::paths::{InstallPaths, ServiceManagerChoice}; use crate::service_manager::{CommandSpec, ReadinessIssue, ServiceManager}; use crate::system_tools; +use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; use unixnotis_core::program_in_path; use super::CheckItem; @@ -218,15 +218,13 @@ fn path_is_writable(path: &Path) -> bool { } let probe_name = format!(".unixnotis-installer-probe-{}", std::process::id()); let probe_path = target_dir.join(probe_name); - let result = OpenOptions::new() - .create_new(true) - .write(true) - .open(&probe_path); - if result.is_err() { - return false; + match write_file_if_missing(&probe_path, b"", 0o600) { + Ok(true) => { + // Cleanup must succeed before the directory is reported as writable + remove_regular_file(&probe_path).is_ok_and(|removed| removed) + } + Ok(false) | Err(_) => false, } - let _ = std::fs::remove_file(&probe_path); - true } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/checks/tests/system.rs b/crates/unixnotis-installer/src/checks/tests/system.rs index d71f79234..9ec2b7b31 100644 --- a/crates/unixnotis-installer/src/checks/tests/system.rs +++ b/crates/unixnotis-installer/src/checks/tests/system.rs @@ -9,8 +9,9 @@ use crate::service_manager::{ReadinessIssue, ServiceManager}; use crate::test_support::fs::write_executable; use super::{ - command_success, dbus_update_env_check, install_paths_check, readiness_error_detail, - readiness_messages, readiness_warning_detail, service_manager_check_from, + command_success, dbus_update_env_check, install_paths_check, path_is_writable, + readiness_error_detail, readiness_messages, readiness_warning_detail, + service_manager_check_from, }; fn env_lock() -> std::sync::MutexGuard<'static, ()> { @@ -203,6 +204,35 @@ fn install_paths_check_fails_when_service_root_is_not_directory() { let _ = fs::remove_dir_all(root); } +#[test] +fn path_is_writable_accepts_a_real_directory_and_removes_its_probe() { + let root = test_root("writable-path-check"); + fs::create_dir_all(&root).expect("writable directory"); + + assert!(path_is_writable(&root)); + assert_eq!(fs::read_dir(&root).expect("empty directory").count(), 0); + + let _ = fs::remove_dir_all(root); +} + +#[test] +#[cfg(unix)] +fn path_is_writable_rejects_a_symlinked_directory() { + let root = test_root("linked-writable-path-check"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("outside directory"); + std::os::unix::fs::symlink(&outside, &linked).expect("linked directory"); + + assert!(!path_is_writable(&linked)); + assert_eq!( + fs::read_dir(&outside).expect("untouched directory").count(), + 0 + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn command_success_distinguishes_success_failure_and_missing_trusted_tools() { let _lock = env_lock(); diff --git a/crates/unixnotis-installer/src/trial/paths.rs b/crates/unixnotis-installer/src/trial/paths.rs index 19f9737c3..7178348ca 100644 --- a/crates/unixnotis-installer/src/trial/paths.rs +++ b/crates/unixnotis-installer/src/trial/paths.rs @@ -4,6 +4,8 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -68,17 +70,12 @@ pub(super) fn path_dir_is_writable(dir: &Path) -> bool { .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()) )); - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&probe) - { - Ok(_) => { - // Probe file is trial-only and should not outlive the writability check - let _ = fs::remove_file(probe); - true + match write_file_if_missing(&probe, b"", 0o600) { + Ok(true) => { + // Probe success includes contained cleanup so linked directories cannot be accepted + remove_regular_file(&probe).is_ok_and(|removed| removed) } - Err(_) => false, + Ok(false) | Err(_) => false, } } diff --git a/crates/unixnotis-installer/src/trial/shim.rs b/crates/unixnotis-installer/src/trial/shim.rs index 82665494d..790ba6923 100644 --- a/crates/unixnotis-installer/src/trial/shim.rs +++ b/crates/unixnotis-installer/src/trial/shim.rs @@ -1,12 +1,15 @@ //! Temporary `noticenterctl` PATH shim management for trial mode use std::env; +#[cfg(not(unix))] use std::fs; -#[cfg(unix)] -use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, create_symlink_if_missing, read_symlink, remove_symlink_if_target, + CreateSymlinkOutcome, RemoveSymlinkOutcome, +}; use super::paths::{ canonicalize_best_effort, find_command_on_path_with_index, path_dir_is_writable, path_entries, @@ -75,13 +78,24 @@ pub(super) fn ensure_trial_control_access(ctl_bin: &Path) -> Result {} + CreateSymlinkOutcome::Unchanged | CreateSymlinkOutcome::TargetMismatch(_) => { + // A path that appeared after the earlier check is not owned by this trial + println!( + "Trial control command path changed before creation: {}", + shim_path.display() + ); + println!("Use {} directly during trial", ctl_bin.display()); + return Ok(None); + } + } } #[cfg(not(unix))] { @@ -128,7 +142,7 @@ pub(super) fn select_trial_shim_dir( if !preferred_dir.exists() { // Creating ~/.local/bin is safe only after confirming the path can matter - fs::create_dir_all(preferred_dir) + create_directory_all(preferred_dir, 0o755) .map_err(|err| anyhow!("failed to create {}: {}", preferred_dir.display(), err)) .ok()?; } @@ -176,39 +190,30 @@ pub(super) fn trial_control_command_is_compatible(path: &Path, ctl_bin: &Path) - pub(super) fn remove_trial_control_shim(path: &Path, expected_target: &Path) -> Result { #[cfg(unix)] { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(err) => { + let target = match read_symlink(path) { + Ok(Some(target)) => target, + Ok(None) => return Ok(false), + // A replaced regular file is user state, not trial-owned cleanup state + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => return Ok(false), + Err(error) => { return Err(anyhow!( "failed to inspect trial noticenterctl shim at {}: {}", path.display(), - err + error )); } }; - if !metadata.file_type().is_symlink() { - // A replaced regular file is user state, not trial-owned cleanup state - return Ok(false); - } - let target = fs::read_link(path).map_err(|err| { - anyhow!( - "failed to inspect trial noticenterctl shim target at {}: {}", - path.display(), - err - ) - })?; if !trial_shim_target_matches(path, &target, expected_target) { return Ok(false); } - fs::remove_file(path).map_err(|err| { + let outcome = remove_symlink_if_target(path, &target).map_err(|err| { anyhow!( "failed to remove trial noticenterctl shim at {}: {}", path.display(), err ) })?; - Ok(true) + Ok(matches!(outcome, RemoveSymlinkOutcome::Removed)) } #[cfg(not(unix))] diff --git a/crates/unixnotis-installer/src/trial/tests/shim.rs b/crates/unixnotis-installer/src/trial/tests/shim.rs index aa6054c2f..3c61f0a92 100644 --- a/crates/unixnotis-installer/src/trial/tests/shim.rs +++ b/crates/unixnotis-installer/src/trial/tests/shim.rs @@ -71,6 +71,23 @@ fn trial_shim_dir_rejects_local_bin_when_not_on_path() { let _ = fs::remove_dir_all(root); } +#[test] +#[cfg(unix)] +fn trial_shim_dir_rejects_a_symlinked_local_bin() { + let root = temp_dir("linked-local-bin"); + let outside = root.join("outside"); + let local_bin = root.join("local").join("bin"); + fs::create_dir_all(&outside).expect("outside directory"); + fs::create_dir_all(local_bin.parent().expect("local parent")).expect("local parent"); + std::os::unix::fs::symlink(&outside, &local_bin).expect("local bin link"); + + let selected = select_trial_shim_dir(&local_bin, std::slice::from_ref(&local_bin), None); + + assert!(selected.is_none()); + assert!(!outside.join("noticenterctl").exists()); + let _ = fs::remove_dir_all(root); +} + #[test] #[cfg(unix)] fn trial_control_command_accepts_debug_and_release_siblings() { @@ -195,3 +212,27 @@ fn remove_trial_control_shim_reports_non_directory_parent() { .contains("failed to inspect trial noticenterctl shim")); let _ = fs::remove_dir_all(root); } + +#[test] +#[cfg(unix)] +fn remove_trial_control_shim_rejects_a_symlinked_parent() { + let root = temp_dir("remove-linked-shim-parent"); + let target = root.join("target").join("noticenterctl"); + let outside = root.join("outside"); + let outside_shim = outside.join("noticenterctl"); + let linked_parent = root.join("linked-bin"); + fs::create_dir_all(target.parent().expect("target parent")).expect("target parent"); + fs::create_dir_all(&outside).expect("outside directory"); + fs::write(&target, "#!/bin/sh\n").expect("target"); + std::os::unix::fs::symlink(&target, &outside_shim).expect("outside trial shim"); + std::os::unix::fs::symlink(&outside, &linked_parent).expect("linked shim parent"); + let shim = linked_parent.join("noticenterctl"); + + remove_trial_control_shim(&shim, &target).expect_err("linked parent should fail"); + + assert_eq!( + fs::read_link(&outside_shim).expect("outside shim remains"), + target + ); + let _ = fs::remove_dir_all(root); +} From 45f2cb89c808d366ffc7e18bcdb98a74e1891504 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:25:50 -0500 Subject: [PATCH 048/275] feat(filesystem): move and read files beneath stable descriptors Summary: move and read files beneath stable descriptors. Scope: filesystem. --- crates/unixnotis-core/src/filesystem/mod.rs | 4 + crates/unixnotis-core/src/filesystem/read.rs | 44 +++++++ .../unixnotis-core/src/filesystem/rename.rs | 75 +++++++++++ .../src/tests/filesystem/read.rs | 80 ++++++++++++ .../src/tests/filesystem/rename.rs | 123 ++++++++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 crates/unixnotis-core/src/filesystem/read.rs create mode 100644 crates/unixnotis-core/src/filesystem/rename.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/read.rs create mode 100644 crates/unixnotis-core/src/tests/filesystem/rename.rs diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index f841a1fdb..dce14a52d 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -4,7 +4,9 @@ mod atomic; mod directory; mod install; mod path; +mod read; mod remove; +mod rename; mod symlink; pub use atomic::{ @@ -14,9 +16,11 @@ pub use atomic::{ pub use directory::{create_directory_all, remove_directory_tree, remove_empty_directory}; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; +pub use read::read_regular_file_bounded; pub use remove::{ remove_regular_file, remove_symlink, remove_symlink_if_target, RemoveSymlinkOutcome, }; +pub use rename::{rename_regular_file_no_replace, RenameRegularFileOutcome}; pub use symlink::{ create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, }; diff --git a/crates/unixnotis-core/src/filesystem/read.rs b/crates/unixnotis-core/src/filesystem/read.rs new file mode 100644 index 000000000..9a8c0cad4 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/read.rs @@ -0,0 +1,44 @@ +//! Bounded regular-file reads through stable descriptors + +use std::io::{self, Read}; +use std::path::Path; + +use super::atomic::open_regular_file; + +/// Read a regular file without following links and enforce a byte limit +/// +/// # Errors +/// +/// Returns an error when the path crosses a link, the target is not a regular file, the file is +/// larger than `max_bytes`, or the bounded read cannot complete +pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> io::Result> { + // Opening once keeps the size check and payload read tied to one filesystem object + let mut file = open_regular_file(path)?; + let initial_size = file.metadata()?.len(); + if initial_size > max_bytes { + return Err(limit_error(max_bytes)); + } + + // Reserve only the size already observed and keep the extra-byte growth check bounded + let capacity = usize::try_from(initial_size) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "file size does not fit memory"))?; + let mut contents = Vec::with_capacity(capacity); + file.by_ref() + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents)?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(limit_error(max_bytes)); + } + Ok(contents) +} + +fn limit_error(max_bytes: u64) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("regular file exceeds the {max_bytes}-byte limit"), + ) +} + +#[cfg(test)] +#[path = "../tests/filesystem/read.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs new file mode 100644 index 000000000..b82dbdb43 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -0,0 +1,75 @@ +//! No-replace regular-file moves through stable parent descriptors + +use std::io; +use std::path::Path; + +use rustix::fs::{renameat_with, RenameFlags}; + +use super::atomic::validate_existing_target; +use super::directory::{open_parent_existing, sync_directory}; + +/// Result of moving a regular file without replacing another filesystem entry +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenameRegularFileOutcome { + /// The source did not exist when the move reached the filesystem boundary + SourceMissing, + /// The source was moved to the previously unused destination + Renamed, + /// A destination entry already existed and was preserved + DestinationExists, +} + +/// Move a regular file without following links or replacing the destination +/// +/// # Errors +/// +/// Returns an error when either parent crosses a link, the source is not a regular file, or the +/// rename and directory synchronization cannot complete +pub fn rename_regular_file_no_replace( + source: &Path, + destination: &Path, +) -> io::Result { + let (source_parent, source_name) = match open_parent_existing(source) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RenameRegularFileOutcome::SourceMissing); + } + Err(error) => return Err(error), + }; + // Final-component validation rejects source links, directories, and special files + match validate_existing_target(&source_parent, &source_name) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RenameRegularFileOutcome::SourceMissing); + } + Err(error) => return Err(error), + } + + let (destination_parent, destination_name) = open_parent_existing(destination)?; + // Kernel no-replace semantics close the check-then-rename destination race + match renameat_with( + &source_parent, + &source_name, + &destination_parent, + &destination_name, + RenameFlags::NOREPLACE, + ) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + return Ok(RenameRegularFileOutcome::DestinationExists); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RenameRegularFileOutcome::SourceMissing); + } + Err(error) => return Err(error.into()), + } + + // Both directory entries must reach durable storage even when parents differ + sync_directory(&destination_parent)?; + sync_directory(&source_parent)?; + Ok(RenameRegularFileOutcome::Renamed) +} + +#[cfg(test)] +#[path = "../tests/filesystem/rename.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/read.rs b/crates/unixnotis-core/src/tests/filesystem/read.rs new file mode 100644 index 000000000..7706a0c7e --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/read.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use super::read_regular_file_bounded; +use crate::test_support::unique_temp_path; + +#[test] +fn bounded_regular_file_read_accepts_the_exact_limit() { + let root = unique_temp_path("read-regular-exact-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"12345678").expect("write file"); + + let contents = read_regular_file_bounded(&path, 8).expect("read bounded file"); + + assert_eq!(contents, b"12345678"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_file_over_the_limit() { + let root = unique_temp_path("read-regular-over-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"123456789").expect("write file"); + + let error = read_regular_file_bounded(&path, 8).expect_err("oversized file should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_source_symlink() { + let root = unique_temp_path("read-regular-symlink"); + let protected = root.join("protected.css"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &path).expect("create file link"); + + read_regular_file_bounded(&path, 1024).expect_err("source link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_linked_parent() { + let root = unique_temp_path("read-regular-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("style.css"), "outside theme").expect("write outside file"); + symlink(&outside, &linked).expect("create parent link"); + + read_regular_file_bounded(&linked.join("style.css"), 1024) + .expect_err("linked parent should fail"); + + assert_eq!( + fs::read_to_string(outside.join("style.css")).expect("read outside file"), + "outside theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_directory() { + let root = unique_temp_path("read-regular-directory"); + let path = root.join("style.css"); + fs::create_dir_all(&path).expect("create directory target"); + + read_regular_file_bounded(&path, 1024).expect_err("directory should fail"); + + assert!(path.is_dir()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/tests/filesystem/rename.rs b/crates/unixnotis-core/src/tests/filesystem/rename.rs new file mode 100644 index 000000000..9ac918224 --- /dev/null +++ b/crates/unixnotis-core/src/tests/filesystem/rename.rs @@ -0,0 +1,123 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use super::{rename_regular_file_no_replace, RenameRegularFileOutcome}; +use crate::test_support::unique_temp_path; + +#[test] +fn regular_file_rename_moves_source_to_an_unused_destination() { + let root = unique_temp_path("rename-regular-file"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + + let outcome = rename_regular_file_no_replace(&source, &destination).expect("rename file"); + + assert_eq!(outcome, RenameRegularFileOutcome::Renamed); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(destination).expect("read destination"), + "legacy theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_reports_a_missing_source_without_creating_parents() { + let root = unique_temp_path("rename-missing-source"); + let source = root.join("missing").join("style.css"); + let destination = root.join("backup").join("style.css.bak"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("missing source outcome"); + + assert_eq!(outcome, RenameRegularFileOutcome::SourceMissing); + assert!(!root.exists()); +} + +#[test] +fn regular_file_rename_preserves_an_existing_destination() { + let root = unique_temp_path("rename-existing-destination"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + fs::write(&destination, "existing backup").expect("write destination"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("preserve destination"); + + assert_eq!(outcome, RenameRegularFileOutcome::DestinationExists); + assert_eq!( + fs::read_to_string(source).expect("read source"), + "legacy theme" + ); + assert_eq!( + fs::read_to_string(destination).expect("read destination"), + "existing backup" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_source_symlink() { + let root = unique_temp_path("rename-source-symlink"); + let protected = root.join("protected.css"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &source).expect("create source link"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("source link should be rejected"); + + assert!(fs::symlink_metadata(source) + .expect("source link remains") + .file_type() + .is_symlink()); + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_linked_parent() { + let root = unique_temp_path("rename-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + let source = linked.join("style.css"); + let destination = linked.join("style.css.bak"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("style.css"), "outside theme").expect("write outside source"); + symlink(&outside, &linked).expect("create parent link"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_to_string(outside.join("style.css")).expect("read outside source"), + "outside theme" + ); + assert!(!outside.join("style.css.bak").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_directory_source() { + let root = unique_temp_path("rename-directory-source"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&source).expect("create source directory"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("directory source should be rejected"); + + assert!(source.is_dir()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} From 99a475590fab65ff66b27de2069fea64945ef637 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:28:30 -0500 Subject: [PATCH 049/275] refactor(config): harden legacy theme migration Summary: harden legacy theme migration. Scope: config. --- .../config/loading/io/tests/theme_files.rs | 101 +++++++++++++++++- .../src/config/loading/io/theme_files.rs | 57 +++++----- .../src/filesystem/directory.rs | 11 ++ .../unixnotis-core/src/filesystem/remove.rs | 8 ++ .../unixnotis-core/src/filesystem/symlink.rs | 9 ++ .../unixnotis-installer/src/write_target.rs | 3 + 6 files changed, 164 insertions(+), 25 deletions(-) diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs index d04544ea0..9942d8555 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs @@ -2,7 +2,7 @@ use std::fs; -use crate::Config; +use crate::{Config, DEFAULT_BASE_CSS}; use super::support::test_root; @@ -93,3 +93,102 @@ fn ensure_theme_files_keeps_legacy_style_when_backup_already_exists() { let _ = fs::remove_dir_all(root); } + +#[test] +#[cfg(unix)] +fn ensure_theme_files_ignores_a_legacy_symlink_and_keeps_its_target() { + let root = test_root("theme-legacy-link"); + let protected = root.join("protected.css"); + let legacy = root.join("style.css"); + fs::create_dir_all(&root).expect("theme root"); + fs::write(&protected, "/* protected */").expect("protected css"); + std::os::unix::fs::symlink(&protected, &legacy).expect("legacy link"); + + let config = Config::default(); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + config + .ensure_theme_files(&paths) + .expect("theme files should use defaults"); + + assert_eq!( + fs::read_to_string(&paths.base_css).expect("base css"), + DEFAULT_BASE_CSS + ); + assert_eq!( + fs::read_to_string(&protected).expect("protected css"), + "/* protected */" + ); + assert!(fs::symlink_metadata(&legacy) + .expect("legacy link remains") + .file_type() + .is_symlink()); + assert!(!root.join("style.css.bak").exists()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +#[cfg(unix)] +fn ensure_theme_files_preserves_a_dangling_backup_link_and_legacy_source() { + let root = test_root("theme-dangling-backup-link"); + let legacy = root.join("style.css"); + let backup = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("theme root"); + fs::write(&legacy, "/* legacy */").expect("legacy css"); + std::os::unix::fs::symlink("missing.css", &backup).expect("dangling backup link"); + + let config = Config::default(); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + config + .ensure_theme_files(&paths) + .expect("theme files should be provisioned"); + + assert_eq!( + fs::read_to_string(&paths.base_css).expect("base css"), + "/* legacy */" + ); + assert_eq!( + fs::read_to_string(&legacy).expect("legacy css"), + "/* legacy */" + ); + assert_eq!( + fs::read_link(&backup).expect("backup link remains"), + std::path::Path::new("missing.css") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn ensure_theme_files_ignores_an_oversized_legacy_theme() { + const OVERSIZED_LEGACY_BYTES: usize = 16 * 1024 * 1024 + 1; + + let root = test_root("theme-oversized-legacy"); + let legacy = root.join("style.css"); + fs::create_dir_all(&root).expect("theme root"); + fs::write(&legacy, vec![b'x'; OVERSIZED_LEGACY_BYTES]).expect("oversized legacy css"); + + let config = Config::default(); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + config + .ensure_theme_files(&paths) + .expect("theme files should use defaults"); + + assert_eq!( + fs::read_to_string(&paths.base_css).expect("base css"), + DEFAULT_BASE_CSS + ); + assert_eq!( + fs::metadata(&legacy).expect("legacy css remains").len(), + OVERSIZED_LEGACY_BYTES as u64 + ); + assert!(!root.join("style.css.bak").exists()); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs index 65f491a70..1465d7e7a 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_files.rs @@ -1,10 +1,10 @@ //! Provisioning and migration for configured theme files -use std::fs; use std::sync::atomic::{AtomicBool, Ordering}; use tracing::warn; +use crate::filesystem::{read_regular_file_bounded, rename_regular_file_no_replace}; use crate::{ Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, @@ -14,6 +14,7 @@ use super::write::write_if_missing; use super::{ConfigError, ThemePaths}; static LEGACY_RENAME_WARNED: AtomicBool = AtomicBool::new(false); +const MAX_LEGACY_THEME_BYTES: u64 = 16 * 1024 * 1024; impl Config { /// Ensure all theme files exist in the config directory @@ -27,13 +28,7 @@ impl Config { let legacy = config_dir.join("style.css"); let base_exists = theme_paths.base_css.exists(); - let legacy_contents = if base_exists { - None - } else { - fs::read_to_string(&legacy) - .ok() - .filter(|contents| !contents.trim().is_empty()) - }; + let legacy_contents = (!base_exists).then(|| read_legacy_theme(&legacy)).flatten(); write_if_missing( &theme_paths.base_css, @@ -44,26 +39,40 @@ impl Config { write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; - if legacy_contents.is_some() && legacy.exists() { + if legacy_contents.is_some() { let backup = legacy.with_extension("css.bak"); - if !backup.exists() { - if let Err(err) = fs::rename(&legacy, &backup) { - // Non-fatal: leave legacy style.css in place if backup fails - if LEGACY_RENAME_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - ?err, - legacy = %legacy.display(), - backup = %backup.display(), - "failed to rename legacy style.css" - ); - } - } + if let Err(err) = rename_regular_file_no_replace(&legacy, &backup) { + // Base CSS is already safe, so a failed backup move remains non-fatal + warn_legacy_rename_once(&legacy, &backup, &err); } } Ok(()) } } + +fn read_legacy_theme(path: &std::path::Path) -> Option { + // Legacy migration accepts only bounded UTF-8 from one stable regular-file descriptor + let bytes = read_regular_file_bounded(path, MAX_LEGACY_THEME_BYTES).ok()?; + String::from_utf8(bytes) + .ok() + .filter(|contents| !contents.trim().is_empty()) +} + +fn warn_legacy_rename_once( + source: &std::path::Path, + backup: &std::path::Path, + err: &std::io::Error, +) { + if LEGACY_RENAME_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!( + ?err, + legacy = %source.display(), + backup = %backup.display(), + "failed to rename legacy style.css" + ); + } +} diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index 03d09c4d7..b5cbf5620 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -91,6 +91,7 @@ fn open_parent_with( path: &Path, missing_directory: MissingDirectory, ) -> io::Result<(OwnedFd, OsString)> { + // Keeping the final name separate makes every later operation descriptor-relative let file_name = path .file_name() .filter(|name| !name.is_empty()) @@ -108,13 +109,16 @@ fn open_directory_path( path: &Path, missing_directory: MissingDirectory, ) -> io::Result<(OwnedFd, bool)> { + // Absolute and relative paths begin from different trusted anchors let mut directory_fd = open_anchor(path)?; let mut created = false; for component in path.components() { match component { + // Anchors already account for root and current-directory components Component::Prefix(_) | Component::RootDir | Component::CurDir => {} Component::ParentDir => { + // Upward traversal would break the beneath policy of the current descriptor return Err(io::Error::new( io::ErrorKind::InvalidInput, "filesystem path cannot contain parent traversal", @@ -154,6 +158,7 @@ fn open_directory_component( if error.kind() == io::ErrorKind::NotFound && matches!(missing_directory, MissingDirectory::Create(_)) => { + // Creation is attempted only after a no-follow open proves the component absent let MissingDirectory::Create(mode) = missing_directory else { unreachable!("guard requires directory creation mode"); }; @@ -170,6 +175,7 @@ fn create_directory_component( ) -> io::Result<(OwnedFd, bool)> { let created = match mkdirat(parent_fd, name, file_mode(mode)) { Ok(()) => true, + // A concurrent creator still has to pass the same no-follow directory open below Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, Err(error) => return Err(error.into()), }; @@ -184,6 +190,7 @@ fn create_directory_component( } fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { + // NOFOLLOW covers the final component while resolve flags cover every nested lookup openat2( parent_fd, name, @@ -197,6 +204,7 @@ fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { } fn open_target_directory(path: &Path) -> io::Result> { + // Removal never creates missing parents as a side effect let (parent_fd, file_name) = match open_parent_existing(path) { Ok(parent) => parent, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), @@ -210,6 +218,7 @@ fn open_target_directory(path: &Path) -> io::Result io::Result<()> { + // Dir reads from the retained descriptor even if the visible pathname changes later let mut entries = Dir::read_from(directory_fd)?; while let Some(entry) = entries.read() { let entry = entry?; @@ -220,9 +229,11 @@ fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; let file_type = FileType::from_raw_mode(stat.st_mode); if file_type.is_file() { + // Regular children can be unlinked without opening their contents unlinkat(directory_fd, name, AtFlags::empty())?; fsync(directory_fd)?; } else if file_type.is_dir() { + // Child recursion receives another no-follow descriptor before deleting anything let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; remove_directory_contents(&child_fd)?; drop(child_fd); diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index d08b30226..ecee4adb0 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -28,15 +28,18 @@ pub enum RemoveSymlinkOutcome { /// Returns an error when a path component is unsafe, the target is not a regular file, or the /// unlink or parent-directory synchronization fails pub fn remove_regular_file(path: &Path) -> io::Result { + // Missing parents mean the requested file is already absent let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(false); }; + // Final validation distinguishes regular files from links and special objects match validate_existing_target(&parent_fd, &file_name) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error), } + // Unlink and directory sync use the same retained parent descriptor unlinkat(&parent_fd, &file_name, AtFlags::empty())?; sync_directory(&parent_fd)?; Ok(true) @@ -52,6 +55,7 @@ pub fn remove_symlink(path: &Path) -> io::Result { let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(false); }; + // Reading the stored target proves the final entry is a link without following it match read_symlink_at(&parent_fd, &file_name) { Ok(_target) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), @@ -76,6 +80,7 @@ pub fn remove_symlink_if_target( let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(RemoveSymlinkOutcome::Missing); }; + // Capture the exact stored bytes before comparing ownership expectations let actual_target = match read_symlink_at(&parent_fd, &file_name) { Ok(target) => target, Err(error) if error.kind() == io::ErrorKind::NotFound => { @@ -84,15 +89,18 @@ pub fn remove_symlink_if_target( Err(error) => return Err(error), }; if actual_target != expected_target { + // Mismatched links are user state and remain untouched return Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)); } + // Only an exact target match reaches the unlink boundary unlinkat(&parent_fd, &file_name, AtFlags::empty())?; sync_directory(&parent_fd)?; Ok(RemoveSymlinkOutcome::Removed) } fn existing_parent(path: &Path) -> io::Result> { + // The optional form keeps idempotent removal separate from unsafe-shape failures match open_parent_existing(path) { Ok(parent) => Ok(Some(parent)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs index bdde7e8ff..de1389be2 100644 --- a/crates/unixnotis-core/src/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -29,9 +29,12 @@ pub enum CreateSymlinkOutcome { /// Returns an error when a parent crosses a link, the destination is an existing non-link, or link /// creation and parent-directory synchronization fail pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result { + // Parent creation and lookup stay beneath one no-follow directory walk let (parent_fd, file_name) = open_parent(path)?; match read_symlink_at(&parent_fd, &file_name) { + // Exact links are idempotent and avoid a new directory entry Ok(existing) if existing == target => return Ok(CreateSymlinkOutcome::Unchanged), + // A different link is preserved for the caller to report Ok(existing) => return Ok(CreateSymlinkOutcome::TargetMismatch(existing)), Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), @@ -70,11 +73,14 @@ pub fn replace_symlink_atomic(path: &Path, target: &Path) -> io::Result { Err(error) => return Err(error), } + // The replacement is prepared under an exclusive sibling name let temp_name = reserve_temp_symlink(&parent_fd, &file_name, target)?; + // Revalidation prevents known non-link targets from being overwritten if let Err(error) = validate_symlink_or_missing(&parent_fd, &file_name) { let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); return Err(error); } + // One rename publishes the complete link without an absent-target window if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, &file_name) { let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); return Err(error.into()); @@ -90,6 +96,7 @@ pub fn replace_symlink_atomic(path: &Path, target: &Path) -> io::Result { /// Returns an error when a parent crosses a link, the target is not a symbolic link, or the link /// cannot be read pub fn read_symlink(path: &Path) -> io::Result> { + // Inspection never creates a missing parent directory let (parent_fd, file_name) = match open_parent_existing(path) { Ok(parent) => parent, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), @@ -112,6 +119,7 @@ fn reserve_temp_symlink( file_name: &OsString, target: &Path, ) -> io::Result { + // Exclusive candidates make planted temporary names harmless collisions for temp_name in temp_candidates(file_name) { match symlinkat(target, parent_fd, &temp_name) { Ok(()) => return Ok(temp_name), @@ -126,6 +134,7 @@ fn reserve_temp_symlink( } fn validate_symlink_or_missing(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Result<()> { + // Regular files, directories, and special objects fail through readlinkat match read_symlink_at(parent_fd, file_name) { Ok(_target) => Ok(()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), diff --git a/crates/unixnotis-installer/src/write_target.rs b/crates/unixnotis-installer/src/write_target.rs index 903abe5b4..21dab739e 100644 --- a/crates/unixnotis-installer/src/write_target.rs +++ b/crates/unixnotis-installer/src/write_target.rs @@ -5,16 +5,19 @@ use std::io; use std::path::Path; pub fn reject_unsafe_write_target(path: &Path) -> io::Result<()> { + // The final component is classified without following a link into another file match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( io::ErrorKind::InvalidInput, format!("refusing to write through symlink {}", path.display()), )), + // Existing regular files may proceed to the descriptor-contained writer Ok(metadata) if metadata.is_file() => Ok(()), Ok(_) => Err(io::Error::new( io::ErrorKind::InvalidInput, format!("refusing to overwrite non-file {}", path.display()), )), + // Missing files are valid because the writer creates their parents safely Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } From 448d4aec4e38d0e422da8867dd6dcf113e838fe8 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:52:28 -0500 Subject: [PATCH 050/275] test(ui): cover reduced-motion and media lifecycle Summary: cover reduced-motion and media lifecycle. Scope: ui. --- .../unixnotis-center/src/ui/media/config.rs | 4 + .../unixnotis-center/src/ui/media/marquee.rs | 28 ++-- .../src/ui/media/tests/config.rs | 156 ++++++++++++++++++ .../src/ui/media/tests/marquee.rs | 68 +++++++- crates/unixnotis-center/src/ui/motion.rs | 25 ++- .../unixnotis-center/src/ui/tests/motion.rs | 18 +- 6 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/media/tests/config.rs diff --git a/crates/unixnotis-center/src/ui/media/config.rs b/crates/unixnotis-center/src/ui/media/config.rs index 2d9d442ae..62f8968d5 100644 --- a/crates/unixnotis-center/src/ui/media/config.rs +++ b/crates/unixnotis-center/src/ui/media/config.rs @@ -107,3 +107,7 @@ impl UiState { } } } + +#[cfg(test)] +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/media/marquee.rs b/crates/unixnotis-center/src/ui/media/marquee.rs index a3e6cc48a..16c4063a5 100644 --- a/crates/unixnotis-center/src/ui/media/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/marquee.rs @@ -96,7 +96,7 @@ impl MarqueeLabel { move |_| { let mut state = mapped_state.borrow_mut(); state.is_mapped = true; - let should_start = state.overflows && !state.reduced_motion && !state.is_ticking; + let should_start = marquee_can_start(&state); drop(state); if should_start { start_ticking_inner(mapped_state.clone(), mapped_label.clone()); @@ -223,7 +223,6 @@ impl MarqueeLabel { return; } - let should_start = state.overflows && state.is_mapped; if state.overflows { let padded = format!("{} ", state.full_text); state.buffer = padded.chars().collect(); @@ -234,9 +233,7 @@ impl MarqueeLabel { } drop(state); - if should_start { - self.start_ticking(); - } + self.start_ticking(); } fn start_ticking(&self) { @@ -271,15 +268,22 @@ fn marquee_should_tick( char_limit > 0 && (char_count > char_limit || text_width > max_width.max(0)) } +const fn marquee_can_start(state: &MarqueeState) -> bool { + !state.is_ticking + && state.tick_source.is_none() + && !state.reduced_motion + && state.overflows + && state.is_mapped +} + +const fn marquee_should_stop(state: &MarqueeState) -> bool { + !state.overflows || state.reduced_motion || !state.is_mapped +} + fn start_ticking_inner(state: Rc>, label: gtk::Label) { { let mut state = state.borrow_mut(); - if state.is_ticking - || state.tick_source.is_some() - || state.reduced_motion - || !state.overflows - || !state.is_mapped - { + if !marquee_can_start(&state) { return; } state.is_ticking = true; @@ -292,7 +296,7 @@ fn start_ticking_inner(state: Rc>, label: gtk::Label) { perf_probe::marquee_tick(); let mut state = state_tick.borrow_mut(); - if !state.overflows || state.reduced_motion || !state.is_mapped { + if marquee_should_stop(&state) { state.is_ticking = false; state.tick_source = None; state.last_tick = None; diff --git a/crates/unixnotis-center/src/ui/media/tests/config.rs b/crates/unixnotis-center/src/ui/media/tests/config.rs new file mode 100644 index 000000000..fbef79154 --- /dev/null +++ b/crates/unixnotis-center/src/ui/media/tests/config.rs @@ -0,0 +1,156 @@ +//! Media configuration reload tests + +use std::fs; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Config, MediaLayout}; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::media::{MediaCommand, MediaHandle, MediaInfo}; +use crate::ui::{UiState, UiStateInit}; + +static APP_ID: AtomicUsize = AtomicUsize::new(0); + +fn media_state() -> UiState { + let serial = APP_ID.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.media.config.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + // Unrelated widgets stay disabled so this test owns only the media subtree + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let config_dir = std::env::temp_dir().join(format!( + "unixnotis-media-config-test-{}-{serial}", + std::process::id(), + )); + fs::create_dir_all(&config_dir).expect("test config directory should exist"); + let config_path = config_dir.join("config.toml"); + let theme_paths = config + .resolve_theme_paths_from(&config_dir) + .expect("test theme paths should resolve"); + let css = CssManager::new_panel(theme_paths, config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + let (media_tx, _media_rx) = tokio::sync::mpsc::channel::(8); + let media_handle = MediaHandle::connected(media_tx, runtime.handle().clone()); + + UiState::new(UiStateInit { + app, + config, + config_path, + command_tx, + css, + event_tx, + media_handle: Some(media_handle), + runtime, + }) +} + +fn sample_media(title: &str) -> MediaInfo { + MediaInfo { + bus_name: "org.mpris.MediaPlayer2.test".to_string(), + identity: "Test Player".to_string(), + browser_family: None, + owner_pid: None, + title: title.to_string(), + artist: "Artist".to_string(), + playback_status: "Playing".to_string(), + art_source: None, + can_play: true, + can_pause: true, + can_next: true, + can_prev: true, + } +} + +fn find_label_with_class(root: >k::Widget, class_name: &str) -> Option { + if root.has_css_class(class_name) { + return root.clone().downcast::().ok(); + } + + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(label) = find_label_with_class(&widget, class_name) { + return Some(label); + } + child = widget.next_sibling(); + } + None +} + +#[gtk::test] +fn structural_media_reload_replaces_the_existing_shell() { + let mut state = media_state(); + let original = state + .panel + .sections + .media_container + .first_child() + .expect("initial media shell should exist"); + let mut config = state.config.clone(); + config.media.layout = MediaLayout::Inline; + + state.apply_media_config(&config); + + let replacement = state + .panel + .sections + .media_container + .first_child() + .expect("replacement media shell should exist"); + assert_ne!(original, replacement); + assert!(state + .media + .as_ref() + .expect("media widget should remain active") + .matches_layout(&config.media)); +} + +#[gtk::test] +fn light_media_reload_updates_limits_and_reduced_motion_without_rebuilding() { + const LONG_TITLE: &str = "A title that must overflow the configured four character lane"; + + let mut state = media_state(); + state + .media + .as_mut() + .expect("initial media widget should exist") + .update(&[sample_media(LONG_TITLE)]); + let original = state + .panel + .sections + .media_container + .first_child() + .expect("initial media shell should exist"); + let mut config = state.config.clone(); + config.media.title_char_limit = 4; + config.panel.reduced_motion = true; + + state.apply_media_config(&config); + + let retained = state + .panel + .sections + .media_container + .first_child() + .expect("media shell should remain attached"); + assert_eq!(original, retained); + let title = find_label_with_class(retained.as_ref(), hooks::media_shell::TITLE) + .expect("media title label should exist"); + assert_eq!(title.width_chars(), 4); + assert_eq!(title.max_width_chars(), 4); + assert_eq!(title.text(), LONG_TITLE); +} diff --git a/crates/unixnotis-center/src/ui/media/tests/marquee.rs b/crates/unixnotis-center/src/ui/media/tests/marquee.rs index 31bd31a94..cfada4add 100644 --- a/crates/unixnotis-center/src/ui/media/tests/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/tests/marquee.rs @@ -1,4 +1,52 @@ -use super::{marquee_should_tick, MarqueeLabel}; +use super::{ + marquee_can_start, marquee_should_stop, marquee_should_tick, MarqueeLabel, MarqueeState, +}; + +fn ready_marquee_state() -> MarqueeState { + MarqueeState { + overflows: true, + is_mapped: true, + ..MarqueeState::default() + } +} + +#[test] +fn marquee_start_policy_requires_one_idle_visible_overflow() { + let mut state = ready_marquee_state(); + assert!(marquee_can_start(&state)); + + state.is_ticking = true; + assert!(!marquee_can_start(&state)); + state.is_ticking = false; + + state.reduced_motion = true; + assert!(!marquee_can_start(&state)); + state.reduced_motion = false; + + state.overflows = false; + assert!(!marquee_can_start(&state)); + state.overflows = true; + + state.is_mapped = false; + assert!(!marquee_can_start(&state)); +} + +#[test] +fn marquee_stop_policy_covers_every_inactive_state() { + let mut state = ready_marquee_state(); + assert!(!marquee_should_stop(&state)); + + state.overflows = false; + assert!(marquee_should_stop(&state)); + state.overflows = true; + + state.reduced_motion = true; + assert!(marquee_should_stop(&state)); + state.reduced_motion = false; + + state.is_mapped = false; + assert!(marquee_should_stop(&state)); +} #[test] fn marquee_starts_when_short_title_exceeds_pixel_budget() { @@ -50,7 +98,7 @@ fn runtime_reduced_motion_cancels_and_restores_one_marquee_source() { let state = marquee.state.borrow(); assert!(!state.is_ticking); assert!(state.tick_source.is_none()); - assert_eq!(state.offset, 0.0); + assert!(state.offset.abs() <= f64::EPSILON); } assert_eq!(marquee.label.text(), "Long title"); @@ -92,3 +140,19 @@ fn disabling_reduced_motion_does_not_start_a_timer_when_text_fits() { assert!(!state.is_ticking); assert!(state.tick_source.is_none()); } + +#[gtk::test] +fn replacing_overflow_with_short_text_cancels_the_active_source() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_text("Long title"); + assert!(marquee.state.borrow().tick_source.is_some()); + + marquee.set_text("Fit"); + + let state = marquee.state.borrow(); + assert!(!state.overflows); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert_eq!(marquee.label.text(), "Fit"); +} diff --git a/crates/unixnotis-center/src/ui/motion.rs b/crates/unixnotis-center/src/ui/motion.rs index b64fee8a2..372e8f6a4 100644 --- a/crates/unixnotis-center/src/ui/motion.rs +++ b/crates/unixnotis-center/src/ui/motion.rs @@ -11,14 +11,27 @@ pub(super) fn apply_revealer_preference( standard_duration_ms }); - if !reduced_motion || revealer.is_child_revealed() == revealer.reveals_child() { - return; + if let Some([edge, target]) = immediate_reveal_edges( + reduced_motion, + revealer.is_child_revealed(), + revealer.reveals_child(), + ) { + // Reapplying the target through an immediate edge finishes an animation already in flight + revealer.set_reveal_child(edge); + revealer.set_reveal_child(target); } +} - // Reapplying the target through an immediate edge finishes an animation already in flight - let target = revealer.reveals_child(); - revealer.set_reveal_child(!target); - revealer.set_reveal_child(target); +const fn immediate_reveal_edges( + reduced_motion: bool, + child_revealed: bool, + target: bool, +) -> Option<[bool; 2]> { + if reduced_motion && child_revealed != target { + Some([!target, target]) + } else { + None + } } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/tests/motion.rs b/crates/unixnotis-center/src/ui/tests/motion.rs index 08a8f13b9..31804dad3 100644 --- a/crates/unixnotis-center/src/ui/tests/motion.rs +++ b/crates/unixnotis-center/src/ui/tests/motion.rs @@ -1,6 +1,22 @@ //! Shared motion-policy tests -use super::apply_revealer_preference; +use super::{apply_revealer_preference, immediate_reveal_edges}; + +#[test] +fn immediate_reveal_edges_only_finish_inflight_reduced_motion_transitions() { + assert_eq!( + immediate_reveal_edges(true, false, true), + Some([false, true]) + ); + assert_eq!( + immediate_reveal_edges(true, true, false), + Some([true, false]) + ); + assert_eq!(immediate_reveal_edges(true, true, true), None); + assert_eq!(immediate_reveal_edges(true, false, false), None); + assert_eq!(immediate_reveal_edges(false, false, true), None); + assert_eq!(immediate_reveal_edges(false, true, false), None); +} #[gtk::test] fn reduced_motion_makes_revealer_transitions_immediate_and_restorable() { From 841461ebaee4fd8993fb31e91f9bbb04d2ccfa7e Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 02:52:28 -0500 Subject: [PATCH 051/275] test: cover config and installer filesystem lifecycle Summary: cover config and installer filesystem lifecycle. Scope: repository. --- .../config/loading/io/tests/theme_files.rs | 49 +++++++ .../src/config/loading/io/theme_files.rs | 7 +- .../src/config/widgets/stats.rs | 4 + .../unixnotis-core/src/filesystem/atomic.rs | 6 +- .../src/filesystem/directory.rs | 6 +- .../unixnotis-core/src/filesystem/remove.rs | 8 +- .../unixnotis-core/src/filesystem/rename.rs | 30 ++-- .../unixnotis-core/src/filesystem/symlink.rs | 75 ++++++---- .../src/tests/filesystem/remove.rs | 12 ++ .../src/tests/filesystem/rename.rs | 34 +++++ .../src/tests/filesystem/symlink.rs | 79 +++++++++- .../src/actions/config/backup/tests/mod.rs | 1 + .../actions/config/backup/tests/settings.rs | 40 ++++++ .../src/actions/config/tests/mod.rs | 1 + .../src/actions/config/tests/provision.rs | 135 ++++++++++++++++++ .../actions/install/tests/service/writes.rs | 18 +++ 16 files changed, 452 insertions(+), 53 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs create mode 100644 crates/unixnotis-installer/src/actions/config/tests/provision.rs diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs index 9942d8555..072e54b2d 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs @@ -4,6 +4,7 @@ use std::fs; use crate::{Config, DEFAULT_BASE_CSS}; +use super::super::theme_files::warn_legacy_rename_once; use super::support::test_root; #[test] @@ -192,3 +193,51 @@ fn ensure_theme_files_ignores_an_oversized_legacy_theme() { let _ = fs::remove_dir_all(root); } + +#[test] +fn ensure_theme_files_accepts_a_legacy_theme_at_the_exact_size_limit() { + const MAX_LEGACY_BYTES: usize = 16 * 1024 * 1024; + + let root = test_root("theme-exact-limit-legacy"); + let legacy = root.join("style.css"); + fs::create_dir_all(&root).expect("theme root"); + fs::write(&legacy, vec![b'x'; MAX_LEGACY_BYTES]).expect("limit-sized legacy css"); + + let config = Config::default(); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + config + .ensure_theme_files(&paths) + .expect("limit-sized theme should migrate"); + + assert_eq!( + fs::metadata(&paths.base_css).expect("base css").len(), + MAX_LEGACY_BYTES as u64 + ); + assert_eq!( + fs::metadata(root.join("style.css.bak")) + .expect("legacy backup") + .len(), + MAX_LEGACY_BYTES as u64 + ); + assert!(!legacy.exists()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn legacy_rename_warning_is_emitted_only_once_per_process() { + let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test failure"); + + assert!(warn_legacy_rename_once( + std::path::Path::new("style.css"), + std::path::Path::new("style.css.bak"), + &error, + )); + assert!(!warn_legacy_rename_once( + std::path::Path::new("style.css"), + std::path::Path::new("style.css.bak"), + &error, + )); +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs index 1465d7e7a..2b3d94cba 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_files.rs @@ -59,11 +59,11 @@ fn read_legacy_theme(path: &std::path::Path) -> Option { .filter(|contents| !contents.trim().is_empty()) } -fn warn_legacy_rename_once( +pub(super) fn warn_legacy_rename_once( source: &std::path::Path, backup: &std::path::Path, err: &std::io::Error, -) { +) -> bool { if LEGACY_RENAME_WARNED .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) .is_ok() @@ -74,5 +74,8 @@ fn warn_legacy_rename_once( backup = %backup.display(), "failed to rename legacy style.css" ); + true + } else { + false } } diff --git a/crates/unixnotis-core/src/config/widgets/stats.rs b/crates/unixnotis-core/src/config/widgets/stats.rs index a76431e2e..297946375 100644 --- a/crates/unixnotis-core/src/config/widgets/stats.rs +++ b/crates/unixnotis-core/src/config/widgets/stats.rs @@ -84,3 +84,7 @@ impl Default for StatWidgetConfig { } } } + +#[cfg(test)] +#[path = "tests/stats.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 96a62db87..39a0514a3 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -190,8 +190,10 @@ fn existing_target_mode(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result Err(unsafe_target_error()) } } - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error.into()), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(error.into()), + }, } } diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index b5cbf5620..956c4837f 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -176,8 +176,10 @@ fn create_directory_component( let created = match mkdirat(parent_fd, name, file_mode(mode)) { Ok(()) => true, // A concurrent creator still has to pass the same no-follow directory open below - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, - Err(error) => return Err(error.into()), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => false, + _ => return Err(error.into()), + }, }; let directory_fd = open_directory_at(parent_fd, name)?; if created { diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index ecee4adb0..ba6685a0d 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -83,10 +83,10 @@ pub fn remove_symlink_if_target( // Capture the exact stored bytes before comparing ownership expectations let actual_target = match read_symlink_at(&parent_fd, &file_name) { Ok(target) => target, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RemoveSymlinkOutcome::Missing); - } - Err(error) => return Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RemoveSymlinkOutcome::Missing), + _ => return Err(error), + }, }; if actual_target != expected_target { // Mismatched links are user state and remain untouched diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs index b82dbdb43..c38a4e542 100644 --- a/crates/unixnotis-core/src/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -31,18 +31,18 @@ pub fn rename_regular_file_no_replace( ) -> io::Result { let (source_parent, source_name) = match open_parent_existing(source) { Ok(parent) => parent, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RenameRegularFileOutcome::SourceMissing); - } - Err(error) => return Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), + _ => return Err(error), + }, }; // Final-component validation rejects source links, directories, and special files match validate_existing_target(&source_parent, &source_name) { Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RenameRegularFileOutcome::SourceMissing); - } - Err(error) => return Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), + _ => return Err(error), + }, } let (destination_parent, destination_name) = open_parent_existing(destination)?; @@ -55,13 +55,13 @@ pub fn rename_regular_file_no_replace( RenameFlags::NOREPLACE, ) { Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { - return Ok(RenameRegularFileOutcome::DestinationExists); - } - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RenameRegularFileOutcome::SourceMissing); - } - Err(error) => return Err(error.into()), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => { + return Ok(RenameRegularFileOutcome::DestinationExists); + } + io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), + _ => return Err(error.into()), + }, } // Both directory entries must reach durable storage even when parents differ diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs index de1389be2..ab2e64747 100644 --- a/crates/unixnotis-core/src/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -33,11 +33,11 @@ pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result return Ok(CreateSymlinkOutcome::Unchanged), - // A different link is preserved for the caller to report - Ok(existing) => return Ok(CreateSymlinkOutcome::TargetMismatch(existing)), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), + Ok(existing) => return Ok(existing_link_outcome(existing, target)), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => {} + _ => return Err(error), + }, } match symlinkat(target, &parent_fd, &file_name) { @@ -45,14 +45,14 @@ pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result { - // A concurrent creator is accepted only when it published the exact requested link - match read_symlink_at(&parent_fd, &file_name)? { - existing if existing == target => Ok(CreateSymlinkOutcome::Unchanged), - existing => Ok(CreateSymlinkOutcome::TargetMismatch(existing)), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => { + // A concurrent creator is accepted only when it published the requested link + let existing = read_symlink_at(&parent_fd, &file_name)?; + Ok(existing_link_outcome(existing, target)) } - } - Err(error) => Err(error.into()), + _ => Err(error.into()), + }, } } @@ -67,14 +67,19 @@ pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result io::Result { let (parent_fd, file_name) = open_parent(path)?; match read_symlink_at(&parent_fd, &file_name) { - Ok(existing) if existing == target => return Ok(false), - Ok(_existing) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), + Ok(existing) => match existing_link_outcome(existing, target) { + CreateSymlinkOutcome::Unchanged => return Ok(false), + CreateSymlinkOutcome::TargetMismatch(_) => {} + CreateSymlinkOutcome::Created => unreachable!("existing links cannot be newly created"), + }, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => {} + _ => return Err(error), + }, } // The replacement is prepared under an exclusive sibling name - let temp_name = reserve_temp_symlink(&parent_fd, &file_name, target)?; + let temp_name = reserve_temp_symlink(&parent_fd, temp_candidates(&file_name), target)?; // Revalidation prevents known non-link targets from being overwritten if let Err(error) = validate_symlink_or_missing(&parent_fd, &file_name) { let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); @@ -99,13 +104,17 @@ pub fn read_symlink(path: &Path) -> io::Result> { // Inspection never creates a missing parent directory let (parent_fd, file_name) = match open_parent_existing(path) { Ok(parent) => parent, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(None), + _ => return Err(error), + }, }; match read_symlink_at(&parent_fd, &file_name) { Ok(target) => Ok(Some(target)), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(error), + }, } } @@ -116,15 +125,17 @@ pub(super) fn read_symlink_at(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Res fn reserve_temp_symlink( parent_fd: &OwnedFd, - file_name: &OsString, + candidates: impl IntoIterator, target: &Path, ) -> io::Result { // Exclusive candidates make planted temporary names harmless collisions - for temp_name in temp_candidates(file_name) { + for temp_name in candidates { match symlinkat(target, parent_fd, &temp_name) { Ok(()) => return Ok(temp_name), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error.into()), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => continue, + _ => return Err(error.into()), + }, } } Err(io::Error::new( @@ -137,8 +148,18 @@ fn validate_symlink_or_missing(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Re // Regular files, directories, and special objects fail through readlinkat match read_symlink_at(parent_fd, file_name) { Ok(_target) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(error), + }, + } +} + +fn existing_link_outcome(existing: PathBuf, target: &Path) -> CreateSymlinkOutcome { + if existing == target { + CreateSymlinkOutcome::Unchanged + } else { + CreateSymlinkOutcome::TargetMismatch(existing) } } diff --git a/crates/unixnotis-core/src/tests/filesystem/remove.rs b/crates/unixnotis-core/src/tests/filesystem/remove.rs index e079bee58..531ba2cd8 100644 --- a/crates/unixnotis-core/src/tests/filesystem/remove.rs +++ b/crates/unixnotis-core/src/tests/filesystem/remove.rs @@ -110,6 +110,18 @@ fn target_checked_symlink_removal_removes_only_an_exact_match() { let _ = fs::remove_dir_all(root); } +#[test] +fn target_checked_symlink_removal_reports_a_missing_final_entry() { + let root = unique_temp_path("remove-symlink-missing-final"); + fs::create_dir_all(&root).expect("create root"); + + let outcome = remove_symlink_if_target(&root.join("missing"), std::path::Path::new("service")) + .expect("missing final link should be idempotent"); + + assert_eq!(outcome, RemoveSymlinkOutcome::Missing); + let _ = fs::remove_dir_all(root); +} + #[test] fn symlink_operations_reject_regular_files() { let root = unique_temp_path("remove-symlink-regular"); diff --git a/crates/unixnotis-core/src/tests/filesystem/rename.rs b/crates/unixnotis-core/src/tests/filesystem/rename.rs index 9ac918224..04c2e2d71 100644 --- a/crates/unixnotis-core/src/tests/filesystem/rename.rs +++ b/crates/unixnotis-core/src/tests/filesystem/rename.rs @@ -36,6 +36,40 @@ fn regular_file_rename_reports_a_missing_source_without_creating_parents() { assert!(!root.exists()); } +#[test] +fn regular_file_rename_reports_a_missing_final_source() { + let root = unique_temp_path("rename-missing-final-source"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("missing source outcome"); + + assert_eq!(outcome, RenameRegularFileOutcome::SourceMissing); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_propagates_a_non_collision_destination_error() { + let root = unique_temp_path("rename-invalid-destination"); + let source = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + let destination = root.join("x".repeat(300)); + + let error = rename_regular_file_no_replace(&source, &destination) + .expect_err("overlong destination should fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(&source).expect("read source"), + "legacy theme" + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn regular_file_rename_preserves_an_existing_destination() { let root = unique_temp_path("rename-existing-destination"); diff --git a/crates/unixnotis-core/src/tests/filesystem/symlink.rs b/crates/unixnotis-core/src/tests/filesystem/symlink.rs index d0756ab77..8852c32ed 100644 --- a/crates/unixnotis-core/src/tests/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/tests/filesystem/symlink.rs @@ -3,9 +3,12 @@ use std::os::unix::fs::symlink; use std::path::Path; use super::{ - create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, + create_symlink_if_missing, existing_link_outcome, open_parent, read_symlink, + replace_symlink_atomic, reserve_temp_symlink, validate_symlink_or_missing, + CreateSymlinkOutcome, }; use crate::test_support::unique_temp_path; +use std::ffi::OsString; #[test] fn create_symlink_is_idempotent_for_an_exact_target() { @@ -48,6 +51,18 @@ fn create_symlink_preserves_a_different_target() { let _ = fs::remove_dir_all(root); } +#[test] +fn existing_link_classification_distinguishes_exact_and_different_targets() { + assert_eq!( + existing_link_outcome("service".into(), Path::new("service")), + CreateSymlinkOutcome::Unchanged + ); + assert_eq!( + existing_link_outcome("other".into(), Path::new("service")), + CreateSymlinkOutcome::TargetMismatch("other".into()) + ); +} + #[test] fn create_symlink_rejects_an_existing_regular_file() { let root = unique_temp_path("create-symlink-regular"); @@ -115,3 +130,65 @@ fn atomic_symlink_replacement_rejects_a_regular_destination() { ); let _ = fs::remove_dir_all(root); } + +#[test] +fn temporary_symlink_reservation_skips_a_collision_and_uses_the_next_name() { + let root = unique_temp_path("symlink-temp-collision"); + fs::create_dir_all(&root).expect("create root"); + symlink("protected", root.join("first")).expect("plant first candidate"); + let (parent_fd, _) = open_parent(&root.join("link")).expect("open parent"); + + let reserved = reserve_temp_symlink( + &parent_fd, + [OsString::from("first"), OsString::from("second")], + Path::new("service"), + ) + .expect("reserve second candidate"); + + assert_eq!(reserved, OsString::from("second")); + assert_eq!( + fs::read_link(root.join("first")).expect("first link"), + Path::new("protected") + ); + assert_eq!( + fs::read_link(root.join("second")).expect("second link"), + Path::new("service") + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn temporary_symlink_reservation_propagates_non_collision_errors() { + let root = unique_temp_path("symlink-temp-error"); + fs::create_dir_all(&root).expect("create root"); + let (parent_fd, _) = open_parent(&root.join("link")).expect("open parent"); + + let error = reserve_temp_symlink( + &parent_fd, + [OsString::from("x".repeat(300)), OsString::from("unused")], + Path::new("service"), + ) + .expect_err("overlong candidate should fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert!(!root.join("unused").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_revalidation_accepts_links_and_missing_entries_but_rejects_files() { + let root = unique_temp_path("symlink-revalidation"); + fs::create_dir_all(&root).expect("create root"); + let (parent_fd, _) = open_parent(&root.join("target")).expect("open parent"); + + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("missing")) + .expect("missing entry is safe"); + symlink("service", root.join("link")).expect("create link"); + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("link")) + .expect("link entry is safe"); + fs::write(root.join("regular"), "data").expect("write regular file"); + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("regular")) + .expect_err("regular entry should fail"); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs index f34179177..8750adad0 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs @@ -1,4 +1,5 @@ mod restore; mod retention; +mod settings; mod snapshot; mod support; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs new file mode 100644 index 000000000..4157fdb8a --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs @@ -0,0 +1,40 @@ +//! Installer settings file tests + +use std::fs; + +use crate::detect::Detection; + +use super::super::settings::ensure_installer_config; +use super::support::{test_context, test_paths}; + +#[test] +fn installer_config_is_created_once_and_preserves_existing_settings() { + let root = crate::test_support::fs::unique_temp_path("installer-settings"); + let config_dir = root.join("config"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + + let config_path = ensure_installer_config(&mut context, &config_dir) + .expect("installer config should be created"); + + assert_eq!(config_path, config_dir.join("installer.toml")); + assert_eq!( + fs::read_to_string(&config_path).expect("read installer config"), + "# UnixNotis installer settings\n# Backup retention for config/theme resets\n[backups]\nkeep = 3\n" + ); + + fs::write(&config_path, "[backups]\nkeep = 9\n").expect("customize installer config"); + let retained = ensure_installer_config(&mut context, &config_dir) + .expect("existing installer config should be retained"); + + assert_eq!(retained, config_path); + assert_eq!( + fs::read_to_string(&retained).expect("read retained installer config"), + "[backups]\nkeep = 9\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/tests/mod.rs index ba43b8dbc..c11aebaaf 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/mod.rs @@ -1,2 +1,3 @@ mod default_template; +mod provision; mod state_cleanup; diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs new file mode 100644 index 000000000..4e263bf83 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -0,0 +1,135 @@ +//! End-to-end configuration provisioning tests + +use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; +use crate::test_support::env::{test_env_lock, EnvGuard}; +use unixnotis_core::Config; + +use super::super::provision::{ensure_config, reset_config}; + +fn test_paths(root: &std::path::Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user(root.join("service")), + } +} + +fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(64); + ActionContext { + detection, + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +#[test] +fn ensure_config_creates_every_default_and_preserves_the_live_config() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + + ensure_config(&mut context).expect("default config should be provisioned"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + let config_text = fs::read_to_string(&config_path).expect("read generated config"); + toml::from_str::(&config_text).expect("generated config should parse"); + assert!(config_dir.join("installer.toml").is_file()); + for name in [ + "base.css", + "panel.css", + "popup.css", + "widgets.css", + "media.css", + ] { + assert!(config_dir.join(name).is_file(), "missing theme file {name}"); + } + for script in unixnotis_core::DEFAULT_SCRIPTS { + assert!(config_dir.join(script.relative_path).is_file()); + } + + fs::write(&config_path, "custom = true\n").expect("customize live config"); + ensure_config(&mut context).expect("existing config should be preserved"); + assert_eq!( + fs::read_to_string(&config_path).expect("read retained config"), + "custom = true\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_config_backs_up_custom_files_and_restores_every_default() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed default config"); + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::write(&config_path, "custom = true\n").expect("customize config"); + fs::write(config_dir.join("base.css"), "/* custom */\n").expect("customize theme"); + let script_path = config_dir.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path); + fs::write(&script_path, "#!/bin/sh\nexit 9\n").expect("customize script"); + + reset_config(&mut context).expect("config reset should succeed"); + + let config_text = fs::read_to_string(&config_path).expect("read reset config"); + toml::from_str::(&config_text).expect("reset config should parse"); + assert_ne!(config_text, "custom = true\n"); + assert_eq!( + fs::read_to_string(config_dir.join("base.css")).expect("read reset theme"), + unixnotis_core::DEFAULT_BASE_CSS + ); + assert_eq!( + fs::read_to_string(&script_path).expect("read reset script"), + unixnotis_core::DEFAULT_SCRIPTS[0].contents + ); + + let backup_dir = fs::read_dir(&config_dir) + .expect("read config directory") + .filter_map(Result::ok) + .find(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_dir()) + && entry.file_name().to_string_lossy().starts_with("Backup-") + }) + .expect("reset should create a backup") + .path(); + assert_eq!( + fs::read_to_string(backup_dir.join("config.toml")).expect("read config backup"), + "custom = true\n" + ); + assert_eq!( + fs::read_to_string(backup_dir.join("base.css")).expect("read theme backup"), + "/* custom */\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index 6ba73fb4d..05fc1cb2c 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -612,6 +612,24 @@ fn service_symlink_helpers_distinguish_missing_paths_from_filesystem_errors() { let _ = fs::remove_dir_all(&root); } +#[test] +fn service_symlink_removal_rejects_a_regular_file_without_deleting_it() { + let root = test_root("install-service-remove-regular-link"); + let artifact = root.join("service-link"); + fs::create_dir_all(&root).expect("make service root"); + fs::write(&artifact, "user data").expect("write regular artifact"); + + let error = remove_service_symlink(&artifact, std::path::Path::new("service")) + .expect_err("regular artifacts must not be removed as links"); + + assert!(format!("{error:#}").contains("refusing to remove non-symlink")); + assert_eq!( + fs::read_to_string(&artifact).expect("read preserved artifact"), + "user data" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn install_replaces_regular_owned_artifact_but_rejects_unsafe_existing_path() { let root = test_root("install-service-owned-replace"); From 1d311e1a2cf4a358aa4ab0858d253467a149b405 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 03:13:37 -0500 Subject: [PATCH 052/275] test: colocate module coverage and reject orphans Summary: colocate module coverage and reject orphans. Scope: repository. --- .../src/config/widgets/cards.rs | 4 ++ .../src/config/widgets/plugin.rs | 4 ++ .../src/config/widgets/sliders.rs | 4 ++ .../src/config/widgets/tests/plugin.rs | 11 ++-- .../src/config/widgets/tests/toggles.rs | 4 +- .../src/config/widgets/toggles.rs | 4 ++ crates/unixnotis-core/src/css/hooks/mod.rs | 2 +- .../src/css/{ => hooks}/tests/hooks.rs | 2 + .../unixnotis-core/src/filesystem/atomic.rs | 2 +- .../src/filesystem/directory.rs | 23 +++++--- .../unixnotis-core/src/filesystem/install.rs | 2 +- crates/unixnotis-core/src/filesystem/path.rs | 2 +- crates/unixnotis-core/src/filesystem/read.rs | 7 ++- .../unixnotis-core/src/filesystem/remove.rs | 2 +- .../unixnotis-core/src/filesystem/rename.rs | 29 ++++++---- .../unixnotis-core/src/filesystem/symlink.rs | 32 ++++++++--- .../filesystem => filesystem/tests}/atomic.rs | 2 + .../tests}/directory.rs | 20 ++++++- .../tests}/install.rs | 2 + .../filesystem => filesystem/tests}/path.rs | 2 + .../filesystem => filesystem/tests}/read.rs | 2 + .../filesystem => filesystem/tests}/remove.rs | 2 + .../filesystem => filesystem/tests}/rename.rs | 26 ++++++++- .../tests}/symlink.rs | 25 ++++++++- .../notifications/server/capabilities.rs | 2 +- .../src/daemon/notifications/server/flow.rs | 2 +- .../{ => server}/tests/capabilities.rs | 2 + .../notifications/{ => server}/tests/flow.rs | 2 + crates/unixnotis-ui/src/css/loader/merge.rs | 2 +- crates/unixnotis-ui/src/css/loader/mod.rs | 2 +- crates/unixnotis-ui/src/css/loader/model.rs | 2 +- .../{tests/loader => loader/tests}/merge.rs | 2 + .../{tests/loader => loader/tests}/model.rs | 2 + .../{tests/loader => loader/tests}/paths.rs | 2 + .../loader => loader/tests}/provider.rs | 2 + .../{tests/loader => loader/tests}/rebase.rs | 2 + .../{tests/loader => loader/tests}/tokens.rs | 2 + crates/unixnotis-ui/src/css/loader/tokens.rs | 2 +- crates/unixnotis-ui/src/css/loader/urls.rs | 4 +- tests/check-test-placement.sh | 55 +++++++++++++++++++ 40 files changed, 247 insertions(+), 55 deletions(-) rename crates/unixnotis-core/src/css/{ => hooks}/tests/hooks.rs (99%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/atomic.rs (99%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/directory.rs (87%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/install.rs (99%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/path.rs (98%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/read.rs (98%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/remove.rs (99%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/rename.rs (85%) rename crates/unixnotis-core/src/{tests/filesystem => filesystem/tests}/symlink.rs (87%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => server}/tests/capabilities.rs (94%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => server}/tests/flow.rs (99%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/merge.rs (97%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/model.rs (93%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/paths.rs (95%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/provider.rs (98%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/rebase.rs (99%) rename crates/unixnotis-ui/src/css/{tests/loader => loader/tests}/tokens.rs (98%) diff --git a/crates/unixnotis-core/src/config/widgets/cards.rs b/crates/unixnotis-core/src/config/widgets/cards.rs index 0c6c949e5..f8130ac98 100644 --- a/crates/unixnotis-core/src/config/widgets/cards.rs +++ b/crates/unixnotis-core/src/config/widgets/cards.rs @@ -94,3 +94,7 @@ pub enum CardLayout { Banner, ImageRow, } + +#[cfg(test)] +#[path = "tests/cards.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/plugin.rs b/crates/unixnotis-core/src/config/widgets/plugin.rs index 60160818c..fe277c48e 100644 --- a/crates/unixnotis-core/src/config/widgets/plugin.rs +++ b/crates/unixnotis-core/src/config/widgets/plugin.rs @@ -31,3 +31,7 @@ impl Default for WidgetPluginConfig { } } } + +#[cfg(test)] +#[path = "tests/plugin.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/sliders.rs b/crates/unixnotis-core/src/config/widgets/sliders.rs index df43f27cd..548c0af8b 100644 --- a/crates/unixnotis-core/src/config/widgets/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/sliders.rs @@ -134,3 +134,7 @@ pub enum NumericParseMode { /// Interprets values as 0.0-1.0 ratios and scales to percent Ratio, } + +#[cfg(test)] +#[path = "tests/sliders.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/tests/plugin.rs b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs index fc70753ef..ee4de15d1 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/plugin.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs @@ -1,11 +1,11 @@ -use crate::WidgetPluginConfig; +use crate::{CommandSpec, WidgetPluginConfig}; #[test] fn widget_plugin_defaults_keep_contract_limits() { let plugin = WidgetPluginConfig::default(); assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); - assert_eq!(plugin.command, ""); + assert!(plugin.command.is_empty()); assert_eq!(plugin.timeout_ms, 2_000); assert_eq!(plugin.max_output_bytes, 16 * 1024); } @@ -14,13 +14,16 @@ fn widget_plugin_defaults_keep_contract_limits() { fn widget_plugin_partial_toml_uses_default_limits() { let plugin: WidgetPluginConfig = toml::from_str( r#" - command = "scripts/widget" + command = { mode = "direct", program = "scripts/widget" } "#, ) .expect("plugin should parse"); assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); - assert_eq!(plugin.command, "scripts/widget"); + assert_eq!( + plugin.command, + CommandSpec::direct("scripts/widget", std::iter::empty::<&str>()) + ); assert_eq!(plugin.timeout_ms, WidgetPluginConfig::default().timeout_ms); assert_eq!( plugin.max_output_bytes, diff --git a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs index 43ddc7ab6..a28b0c415 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs @@ -67,7 +67,9 @@ fn default_toggles_keep_commands_config_owned() { { // Stock commands should stay relative or PATH based so config files remain portable assert!( - command.program().is_none_or(|program| !program.is_absolute()), + command + .program() + .is_none_or(|program| !program.is_absolute()), "absolute command leaked: {command}" ); } diff --git a/crates/unixnotis-core/src/config/widgets/toggles.rs b/crates/unixnotis-core/src/config/widgets/toggles.rs index c0ff1bf18..6021c1cbd 100644 --- a/crates/unixnotis-core/src/config/widgets/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/toggles.rs @@ -136,3 +136,7 @@ impl Default for ToggleWidgetConfig { } } } + +#[cfg(test)] +#[path = "tests/toggles.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/css/hooks/mod.rs b/crates/unixnotis-core/src/css/hooks/mod.rs index 84960fc60..18850e3fe 100644 --- a/crates/unixnotis-core/src/css/hooks/mod.rs +++ b/crates/unixnotis-core/src/css/hooks/mod.rs @@ -9,5 +9,5 @@ pub use self::classes::{ }; #[cfg(test)] -#[path = "../tests/hooks.rs"] +#[path = "tests/hooks.rs"] mod tests; diff --git a/crates/unixnotis-core/src/css/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs similarity index 99% rename from crates/unixnotis-core/src/css/tests/hooks.rs rename to crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 76e030f41..57ec26cd5 100644 --- a/crates/unixnotis-core/src/css/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -1,3 +1,5 @@ +//! Public CSS hook consistency tests + use std::collections::HashSet; use super::{ diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 39a0514a3..d559b4502 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -276,5 +276,5 @@ const fn file_mode(mode: u32) -> Mode { } #[cfg(test)] -#[path = "../tests/filesystem/atomic.rs"] +#[path = "tests/atomic.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index 956c4837f..7af62d160 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -173,14 +173,8 @@ fn create_directory_component( name: &OsStr, mode: u32, ) -> io::Result<(OwnedFd, bool)> { - let created = match mkdirat(parent_fd, name, file_mode(mode)) { - Ok(()) => true, - // A concurrent creator still has to pass the same no-follow directory open below - Err(error) => match error.kind() { - io::ErrorKind::AlreadyExists => false, - _ => return Err(error.into()), - }, - }; + let create_result = mkdirat(parent_fd, name, file_mode(mode)).map_err(Into::into); + let created = classify_directory_creation(create_result)?; let directory_fd = open_directory_at(parent_fd, name)?; if created { // Apply the exact requested mode because mkdir remains subject to the process umask @@ -191,6 +185,17 @@ fn create_directory_component( Ok((directory_fd, created)) } +fn classify_directory_creation(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(true), + // A concurrent creator still has to pass the same no-follow directory open below + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(false), + _ => Err(error), + }, + } +} + fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { // NOFOLLOW covers the final component while resolve flags cover every nested lookup openat2( @@ -259,5 +264,5 @@ const fn file_mode(mode: u32) -> Mode { } #[cfg(test)] -#[path = "../tests/filesystem/directory.rs"] +#[path = "tests/directory.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/install.rs b/crates/unixnotis-core/src/filesystem/install.rs index 33275af8e..e70a1766f 100644 --- a/crates/unixnotis-core/src/filesystem/install.rs +++ b/crates/unixnotis-core/src/filesystem/install.rs @@ -27,5 +27,5 @@ pub fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<()> { } #[cfg(test)] -#[path = "../tests/filesystem/install.rs"] +#[path = "tests/install.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/path.rs b/crates/unixnotis-core/src/filesystem/path.rs index 2a0a217d8..56fca64ac 100644 --- a/crates/unixnotis-core/src/filesystem/path.rs +++ b/crates/unixnotis-core/src/filesystem/path.rs @@ -125,5 +125,5 @@ impl ContainedPath { } #[cfg(test)] -#[path = "../tests/filesystem/path.rs"] +#[path = "tests/path.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/read.rs b/crates/unixnotis-core/src/filesystem/read.rs index 9a8c0cad4..610ab1239 100644 --- a/crates/unixnotis-core/src/filesystem/read.rs +++ b/crates/unixnotis-core/src/filesystem/read.rs @@ -20,8 +20,9 @@ pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> io::Result io::Error { } #[cfg(test)] -#[path = "../tests/filesystem/read.rs"] +#[path = "tests/read.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index ba6685a0d..e84a81dba 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -109,5 +109,5 @@ fn existing_parent(path: &Path) -> io::Result {} - Err(error) => match error.kind() { - io::ErrorKind::AlreadyExists => { - return Ok(RenameRegularFileOutcome::DestinationExists); - } - io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), - _ => return Err(error.into()), - }, + ) + .map_err(Into::into); + match classify_rename_attempt(rename_result)? { + RenameRegularFileOutcome::Renamed => {} + outcome => return Ok(outcome), } // Both directory entries must reach durable storage even when parents differ @@ -70,6 +66,17 @@ pub fn rename_regular_file_no_replace( Ok(RenameRegularFileOutcome::Renamed) } +fn classify_rename_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(RenameRegularFileOutcome::Renamed), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(RenameRegularFileOutcome::DestinationExists), + io::ErrorKind::NotFound => Ok(RenameRegularFileOutcome::SourceMissing), + _ => Err(error), + }, + } +} + #[cfg(test)] -#[path = "../tests/filesystem/rename.rs"] +#[path = "tests/rename.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs index ab2e64747..2b83adfa0 100644 --- a/crates/unixnotis-core/src/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -22,6 +22,12 @@ pub enum CreateSymlinkOutcome { TargetMismatch(PathBuf), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SymlinkCreateAttempt { + Created, + Collision, +} + /// Create a symbolic link while preserving every existing path /// /// # Errors @@ -40,18 +46,26 @@ pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result { + let create_result = symlinkat(target, &parent_fd, &file_name).map_err(Into::into); + match classify_symlink_creation(create_result)? { + SymlinkCreateAttempt::Created => { sync_directory(&parent_fd)?; Ok(CreateSymlinkOutcome::Created) } + SymlinkCreateAttempt::Collision => { + // A concurrent creator is accepted only when it published the requested link + let existing = read_symlink_at(&parent_fd, &file_name)?; + Ok(existing_link_outcome(existing, target)) + } + } +} + +fn classify_symlink_creation(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(SymlinkCreateAttempt::Created), Err(error) => match error.kind() { - io::ErrorKind::AlreadyExists => { - // A concurrent creator is accepted only when it published the requested link - let existing = read_symlink_at(&parent_fd, &file_name)?; - Ok(existing_link_outcome(existing, target)) - } - _ => Err(error.into()), + io::ErrorKind::AlreadyExists => Ok(SymlinkCreateAttempt::Collision), + _ => Err(error), }, } } @@ -164,5 +178,5 @@ fn existing_link_outcome(existing: PathBuf, target: &Path) -> CreateSymlinkOutco } #[cfg(test)] -#[path = "../tests/filesystem/symlink.rs"] +#[path = "tests/symlink.rs"] mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs similarity index 99% rename from crates/unixnotis-core/src/tests/filesystem/atomic.rs rename to crates/unixnotis-core/src/filesystem/tests/atomic.rs index bb509f5df..dce22a915 100644 --- a/crates/unixnotis-core/src/tests/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -1,3 +1,5 @@ +//! Atomic file operation tests + use super::{ file_mode, make_file_executable, reserve_temp, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, diff --git a/crates/unixnotis-core/src/tests/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/tests/directory.rs similarity index 87% rename from crates/unixnotis-core/src/tests/filesystem/directory.rs rename to crates/unixnotis-core/src/filesystem/tests/directory.rs index 107d5d0ef..1da3c26b7 100644 --- a/crates/unixnotis-core/src/tests/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/tests/directory.rs @@ -1,9 +1,14 @@ +//! Descriptor-relative directory operation tests + use std::fs; use std::os::unix::fs::{symlink, PermissionsExt}; use rustix::fs::{mkfifoat, Mode, CWD}; -use super::{create_directory_all, remove_directory_tree, remove_empty_directory}; +use super::{ + classify_directory_creation, create_directory_all, remove_directory_tree, + remove_empty_directory, +}; use crate::test_support::unique_temp_path; #[test] @@ -41,6 +46,19 @@ fn directory_creation_rejects_a_linked_parent() { let _ = fs::remove_dir_all(root); } +#[test] +fn directory_creation_result_distinguishes_creation_collision_and_failure() { + assert!(classify_directory_creation(Ok(())).expect("successful mkdir should be new")); + assert!( + !classify_directory_creation(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("mkdir collision should be retried as existing") + ); + + let error = classify_directory_creation(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated mkdir failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + #[test] fn empty_directory_removal_is_idempotent() { let root = unique_temp_path("remove-empty-directory"); diff --git a/crates/unixnotis-core/src/tests/filesystem/install.rs b/crates/unixnotis-core/src/filesystem/tests/install.rs similarity index 99% rename from crates/unixnotis-core/src/tests/filesystem/install.rs rename to crates/unixnotis-core/src/filesystem/tests/install.rs index 5ef56b93d..a3155b1a7 100644 --- a/crates/unixnotis-core/src/tests/filesystem/install.rs +++ b/crates/unixnotis-core/src/filesystem/tests/install.rs @@ -1,3 +1,5 @@ +//! Atomic file installation tests + use std::fs; use std::os::unix::fs::{symlink, PermissionsExt}; diff --git a/crates/unixnotis-core/src/tests/filesystem/path.rs b/crates/unixnotis-core/src/filesystem/tests/path.rs similarity index 98% rename from crates/unixnotis-core/src/tests/filesystem/path.rs rename to crates/unixnotis-core/src/filesystem/tests/path.rs index cc07dc5df..178b94136 100644 --- a/crates/unixnotis-core/src/tests/filesystem/path.rs +++ b/crates/unixnotis-core/src/filesystem/tests/path.rs @@ -1,3 +1,5 @@ +//! Lexical and contained path tests + use std::path::{Path, PathBuf}; use proptest::prelude::*; diff --git a/crates/unixnotis-core/src/tests/filesystem/read.rs b/crates/unixnotis-core/src/filesystem/tests/read.rs similarity index 98% rename from crates/unixnotis-core/src/tests/filesystem/read.rs rename to crates/unixnotis-core/src/filesystem/tests/read.rs index 7706a0c7e..fb6d2f72f 100644 --- a/crates/unixnotis-core/src/tests/filesystem/read.rs +++ b/crates/unixnotis-core/src/filesystem/tests/read.rs @@ -1,3 +1,5 @@ +//! Bounded regular-file read tests + use std::fs; use std::os::unix::fs::symlink; diff --git a/crates/unixnotis-core/src/tests/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs similarity index 99% rename from crates/unixnotis-core/src/tests/filesystem/remove.rs rename to crates/unixnotis-core/src/filesystem/tests/remove.rs index 531ba2cd8..426551944 100644 --- a/crates/unixnotis-core/src/tests/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -1,3 +1,5 @@ +//! Descriptor-relative removal tests + use std::fs; use std::os::unix::fs::symlink; diff --git a/crates/unixnotis-core/src/tests/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/tests/rename.rs similarity index 85% rename from crates/unixnotis-core/src/tests/filesystem/rename.rs rename to crates/unixnotis-core/src/filesystem/tests/rename.rs index 04c2e2d71..75d0ff26d 100644 --- a/crates/unixnotis-core/src/tests/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/tests/rename.rs @@ -1,7 +1,9 @@ +//! No-replace regular-file rename tests + use std::fs; use std::os::unix::fs::symlink; -use super::{rename_regular_file_no_replace, RenameRegularFileOutcome}; +use super::{classify_rename_attempt, rename_regular_file_no_replace, RenameRegularFileOutcome}; use crate::test_support::unique_temp_path; #[test] @@ -23,6 +25,28 @@ fn regular_file_rename_moves_source_to_an_unused_destination() { let _ = fs::remove_dir_all(root); } +#[test] +fn rename_attempt_result_distinguishes_every_kernel_outcome() { + assert_eq!( + classify_rename_attempt(Ok(())).expect("successful rename"), + RenameRegularFileOutcome::Renamed + ); + assert_eq!( + classify_rename_attempt(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("destination collision"), + RenameRegularFileOutcome::DestinationExists + ); + assert_eq!( + classify_rename_attempt(Err(std::io::ErrorKind::NotFound.into())) + .expect("source disappeared"), + RenameRegularFileOutcome::SourceMissing + ); + + let error = classify_rename_attempt(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated rename failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + #[test] fn regular_file_rename_reports_a_missing_source_without_creating_parents() { let root = unique_temp_path("rename-missing-source"); diff --git a/crates/unixnotis-core/src/tests/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/tests/symlink.rs similarity index 87% rename from crates/unixnotis-core/src/tests/filesystem/symlink.rs rename to crates/unixnotis-core/src/filesystem/tests/symlink.rs index 8852c32ed..981b33cbb 100644 --- a/crates/unixnotis-core/src/tests/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/tests/symlink.rs @@ -1,11 +1,13 @@ +//! Symbolic-link operation tests + use std::fs; use std::os::unix::fs::symlink; use std::path::Path; use super::{ - create_symlink_if_missing, existing_link_outcome, open_parent, read_symlink, - replace_symlink_atomic, reserve_temp_symlink, validate_symlink_or_missing, - CreateSymlinkOutcome, + classify_symlink_creation, create_symlink_if_missing, existing_link_outcome, open_parent, + read_symlink, replace_symlink_atomic, reserve_temp_symlink, validate_symlink_or_missing, + CreateSymlinkOutcome, SymlinkCreateAttempt, }; use crate::test_support::unique_temp_path; use std::ffi::OsString; @@ -51,6 +53,23 @@ fn create_symlink_preserves_a_different_target() { let _ = fs::remove_dir_all(root); } +#[test] +fn symlink_creation_result_distinguishes_creation_collision_and_failure() { + assert_eq!( + classify_symlink_creation(Ok(())).expect("successful symlink creation"), + SymlinkCreateAttempt::Created + ); + assert_eq!( + classify_symlink_creation(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("symlink collision"), + SymlinkCreateAttempt::Collision + ); + + let error = classify_symlink_creation(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated symlink failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + #[test] fn existing_link_classification_distinguishes_exact_and_different_targets() { assert_eq!( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs index 94a589ca6..d3a633ca5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs @@ -14,5 +14,5 @@ pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { } #[cfg(test)] -#[path = "../tests/capabilities.rs"] +#[path = "tests/capabilities.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 81df34ceb..81506c2d1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -274,5 +274,5 @@ fn sender_app_name_mismatch(app_name: &str, sender_executable: Option<&str>) -> } #[cfg(test)] -#[path = "../tests/flow.rs"] +#[path = "tests/flow.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs similarity index 94% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs rename to crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs index ced0c53ae..696b1a021 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs @@ -1,3 +1,5 @@ +//! Notification server capability tests + use super::notification_capabilities; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs similarity index 99% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs rename to crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 2666d92f0..ba04da591 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -1,3 +1,5 @@ +//! Notification server flow tests + use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; diff --git a/crates/unixnotis-ui/src/css/loader/merge.rs b/crates/unixnotis-ui/src/css/loader/merge.rs index 9fdd59ca2..960ee246a 100644 --- a/crates/unixnotis-ui/src/css/loader/merge.rs +++ b/crates/unixnotis-ui/src/css/loader/merge.rs @@ -14,5 +14,5 @@ pub(super) fn merge_css_with_overrides(contents: &str, fallback: &str, overrides } #[cfg(test)] -#[path = "../tests/loader/merge.rs"] +#[path = "tests/merge.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/loader/mod.rs b/crates/unixnotis-ui/src/css/loader/mod.rs index efbb10b86..6b502d849 100644 --- a/crates/unixnotis-ui/src/css/loader/mod.rs +++ b/crates/unixnotis-ui/src/css/loader/mod.rs @@ -10,5 +10,5 @@ pub(super) use model::{CssFileLoadResult, CssFileLoadSource}; pub(super) use provider::load_provider_with_overrides; #[cfg(test)] -#[path = "../tests/loader/provider.rs"] +#[path = "tests/provider.rs"] mod provider_tests; diff --git a/crates/unixnotis-ui/src/css/loader/model.rs b/crates/unixnotis-ui/src/css/loader/model.rs index 487da7820..3b26536cd 100644 --- a/crates/unixnotis-ui/src/css/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/model.rs @@ -42,5 +42,5 @@ impl CssFileLoadResult { } #[cfg(test)] -#[path = "../tests/loader/model.rs"] +#[path = "tests/model.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/tests/loader/merge.rs b/crates/unixnotis-ui/src/css/loader/tests/merge.rs similarity index 97% rename from crates/unixnotis-ui/src/css/tests/loader/merge.rs rename to crates/unixnotis-ui/src/css/loader/tests/merge.rs index 0e654e0ef..9db8cdb52 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/merge.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/merge.rs @@ -1,3 +1,5 @@ +//! CSS source merge tests + use super::*; #[test] diff --git a/crates/unixnotis-ui/src/css/tests/loader/model.rs b/crates/unixnotis-ui/src/css/loader/tests/model.rs similarity index 93% rename from crates/unixnotis-ui/src/css/tests/loader/model.rs rename to crates/unixnotis-ui/src/css/loader/tests/model.rs index db6b98598..a6273227e 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/model.rs @@ -1,3 +1,5 @@ +//! CSS loader result-model tests + use super::*; #[test] diff --git a/crates/unixnotis-ui/src/css/tests/loader/paths.rs b/crates/unixnotis-ui/src/css/loader/tests/paths.rs similarity index 95% rename from crates/unixnotis-ui/src/css/tests/loader/paths.rs rename to crates/unixnotis-ui/src/css/loader/tests/paths.rs index c4d67591e..c3634b895 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/paths.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/paths.rs @@ -1,3 +1,5 @@ +//! CSS loader path tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/tests/loader/provider.rs b/crates/unixnotis-ui/src/css/loader/tests/provider.rs similarity index 98% rename from crates/unixnotis-ui/src/css/tests/loader/provider.rs rename to crates/unixnotis-ui/src/css/loader/tests/provider.rs index 1470819f6..d2924ecd8 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/provider.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/provider.rs @@ -1,3 +1,5 @@ +//! CSS provider loading tests + use std::cell::RefCell; use std::fs; use std::path::PathBuf; diff --git a/crates/unixnotis-ui/src/css/tests/loader/rebase.rs b/crates/unixnotis-ui/src/css/loader/tests/rebase.rs similarity index 99% rename from crates/unixnotis-ui/src/css/tests/loader/rebase.rs rename to crates/unixnotis-ui/src/css/loader/tests/rebase.rs index 78209485a..c47b9f936 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/rebase.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/rebase.rs @@ -1,3 +1,5 @@ +//! CSS URL rebasing tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/tests/loader/tokens.rs b/crates/unixnotis-ui/src/css/loader/tests/tokens.rs similarity index 98% rename from crates/unixnotis-ui/src/css/tests/loader/tokens.rs rename to crates/unixnotis-ui/src/css/loader/tests/tokens.rs index 20c53f48f..62783917f 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/tokens.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/tokens.rs @@ -1,3 +1,5 @@ +//! CSS tokenization tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/loader/tokens.rs b/crates/unixnotis-ui/src/css/loader/tokens.rs index ef858bbb1..aed888002 100644 --- a/crates/unixnotis-ui/src/css/loader/tokens.rs +++ b/crates/unixnotis-ui/src/css/loader/tokens.rs @@ -25,5 +25,5 @@ pub(super) fn ensure_base_tokens(contents: &str, path: &Path) -> String { } #[cfg(test)] -#[path = "../tests/loader/tokens.rs"] +#[path = "tests/tokens.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/loader/urls.rs b/crates/unixnotis-ui/src/css/loader/urls.rs index be3fe7da8..d9e5e70ce 100644 --- a/crates/unixnotis-ui/src/css/loader/urls.rs +++ b/crates/unixnotis-ui/src/css/loader/urls.rs @@ -94,8 +94,8 @@ fn normalize_lexical_path(path: &Path) -> PathBuf { } #[cfg(test)] -#[path = "../tests/loader/paths.rs"] +#[path = "tests/paths.rs"] mod path_tests; #[cfg(test)] -#[path = "../tests/loader/rebase.rs"] +#[path = "tests/rebase.rs"] mod rebase_tests; diff --git a/tests/check-test-placement.sh b/tests/check-test-placement.sh index c4e9e116b..3836f063d 100755 --- a/tests/check-test-placement.sh +++ b/tests/check-test-placement.sh @@ -53,6 +53,61 @@ while IFS= read -r -d '' file; do fi done < <(find crates -type f -path '*/src/*.rs' ! -path '*/tests/*' -print0) +# Nested modules keep their tests beside their own source directory +# Parent-level test paths make ownership unclear and leave stale folders after moves +while IFS=: read -r file line _match; do + violations+="${file}:${line}: test module must use its source directory's /tests tree"$'\n' +done < <( + rg --line-number --no-heading '#\[path[[:space:]]*=[[:space:]]*"(\.\./)+tests/' \ + crates -g '*.rs' || true +) + +# Every test source below /src needs an incoming Rust module declaration +# This catches test files that look complete but Cargo never compiles +declare -A wired_test_files=() +while IFS= read -r -d '' source_file; do + source_directory="$(dirname -- "$source_file")" + source_name="$(basename -- "$source_file")" + module_directory="$source_directory/${source_name%.rs}" + case "$source_name" in + lib.rs | main.rs | mod.rs) + module_directory="$source_directory" + ;; + esac + + while IFS= read -r module_path; do + wired_test_files["$(realpath -m -- "$source_directory/$module_path")"]=1 + done < <( + sed -nE 's/^[[:space:]]*#\[path[[:space:]]*=[[:space:]]*"([^"]+)"\][[:space:]]*$/\1/p' \ + "$source_file" + ) + + while IFS= read -r module_name; do + for candidate in \ + "$module_directory/$module_name.rs" \ + "$module_directory/$module_name/mod.rs"; do + if [[ -f "$candidate" ]]; then + wired_test_files["$(realpath -m -- "$candidate")"]=1 + fi + done + done < <( + sed -nE \ + 's/^[[:space:]]*(pub(\([^)]*\))?[[:space:]]+)?mod[[:space:]]+(r#)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*;.*$/\4/p' \ + "$source_file" + ) +done < <(find crates -type f -path '*/src/*.rs' -print0) + +while IFS= read -r -d '' test_file; do + canonical_test_file="$(realpath -m -- "$test_file")" + if [[ -z "${wired_test_files[$canonical_test_file]+present}" ]]; then + violations+="${test_file}: test source is not wired into the Rust module graph"$'\n' + fi +done < <( + find crates -type f \ + \( -path '*/src/tests/*.rs' -o -path '*/src/*/tests/*.rs' \) \ + -print0 +) + # A support module is test code too, even when it contains no #[test] function itself while IFS= read -r path; do violations+="${path}: test support must live under a /tests directory"$'\n' From 30967c7da51645171a9d354a94ef8741584a71ed Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 12:38:41 -0500 Subject: [PATCH 053/275] fix(center): reconcile brightness slider writes Summary: reconcile brightness slider writes. Scope: center. --- .../utils/command_slider/actions/signals.rs | 29 ++++++++++++------- .../command_slider/actions/tests/signals.rs | 10 +++---- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs index f56f55333..637a51a88 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs @@ -98,6 +98,11 @@ pub(in super::super) fn attach_scale_action( return; } + // A user change supersedes any read that started before this interaction + // Its completion may otherwise snap the slider back before the write finishes + refresh_meta_for_set + .refresh_gen + .set(refresh_meta_for_set.refresh_gen.get().wrapping_add(1)); let value = scale.value(); // Local label echo keeps dragging responsive before the debounced command finishes label_clone.set_text(&format_display_value(value)); @@ -115,24 +120,26 @@ pub(in super::super) fn attach_scale_action( let refresh_meta = refresh_meta_for_set.clone(); move |failed| { if failed { - // Failed set actions should reconcile quickly instead of waiting for polling + // Failed writes are called out before the shared reconciliation below debug::log(PanelDebugLevel::Warn, || { format!( "slider set action failed; forcing refresh cmd=\"{}\"", request.command() ) }); - // Corrective refresh uses the same parser and backoff path as polling - let Some(refresh) = build_refresh_state_from_weak( - &scale_weak, - &label_weak, - &icon_weak, - &refresh_meta, - ) else { - return; - }; - request_refresh(request.clone(), refresh, Duration::from_secs(1), true); } + + // Read back both successful and failed writes because hardware may clamp values + // This also consumes any refresh queued behind the pre-action stale read + let Some(refresh) = build_refresh_state_from_weak( + &scale_weak, + &label_weak, + &icon_weak, + &refresh_meta, + ) else { + return; + }; + request_refresh(request.clone(), refresh, Duration::from_secs(1), true); } }), ); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs index 7a4e86065..fb5493964 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs @@ -61,7 +61,7 @@ fn scale_action_echoes_the_changed_value_immediately() { } #[gtk::test] -fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { +fn successful_scale_action_invalidates_stale_reads_and_reconciles_backend_value() { let config = SliderWidgetConfig { get_cmd: CommandSpec::direct("printf", ["22"]), set_cmd: CommandSpec::direct("true", [] as [&str; 0]), @@ -81,8 +81,8 @@ fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { widgets.scale.set_value(37.0); iterate_main_context_for(Duration::from_millis(400)); - assert_eq!(refresh_meta.refresh_gen.get(), 0); - assert_eq!(widgets.value_label.text(), "37%"); + assert_eq!(refresh_meta.refresh_gen.get(), 2); + assert_eq!(widgets.value_label.text(), "22%"); } #[gtk::test] @@ -105,13 +105,13 @@ fn failed_scale_action_runs_corrective_refresh() { widgets.scale.set_value(37.0); let deadline = Instant::now() + Duration::from_secs(3); - while (refresh_meta.refresh_gen.get() == 0 || refresh_meta.gate.is_in_flight()) + while (refresh_meta.refresh_gen.get() < 2 || refresh_meta.gate.is_in_flight()) && Instant::now() < deadline { iterate_main_context_for(Duration::from_millis(1)); } - assert_eq!(refresh_meta.refresh_gen.get(), 1); + assert_eq!(refresh_meta.refresh_gen.get(), 2); assert!(!refresh_meta.gate.is_in_flight()); assert_eq!(widgets.value_label.text(), "22%"); } From bb7a375663392e8e28a443ea10c21feaaf253b14 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 12:47:17 -0500 Subject: [PATCH 054/275] feat(filesystem): add guarded ownership transactions Summary: add guarded ownership transactions. Scope: filesystem. --- .../unixnotis-core/src/filesystem/atomic.rs | 95 +++++++++- .../src/filesystem/directory.rs | 178 +++++++++++++++++- crates/unixnotis-core/src/filesystem/mod.rs | 12 +- .../unixnotis-core/src/filesystem/remove.rs | 91 ++++++++- .../src/filesystem/tests/atomic.rs | 57 +++++- .../src/filesystem/tests/directory.rs | 96 +++++++++- .../src/filesystem/tests/remove.rs | 33 +++- .../src/actions/install/service/dirs.rs | 128 +++++-------- .../src/actions/install/service/files.rs | 109 +++-------- .../tests/service/backend_idempotence.rs | 3 +- .../tests/service/flow_failures/runit.rs | 6 +- .../actions/install/tests/service/writes.rs | 9 +- .../src/service_manager/contract/mod.rs | 4 +- .../src/service_manager/mod.rs | 4 +- 14 files changed, 628 insertions(+), 197 deletions(-) diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index d559b4502..6a05815de 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -3,7 +3,7 @@ use rustix::fs::{openat2, renameat, unlinkat, AtFlags, Mode, OFlags}; use std::ffi::OsString; use std::fs; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::os::fd::OwnedFd; use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -17,6 +17,17 @@ use super::directory::{ const TEMP_ATTEMPTS: u8 = 16; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); +/// Result of creating or validating a file whose bytes must match exactly +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureExactFileOutcome { + /// The destination was absent and this operation created it + Created, + /// The existing regular file already contained the required bytes + AlreadyExact, + /// The existing regular file belongs to another owner or configuration + ContentsMismatch, +} + /// Replace a regular file through an exclusive sibling temporary file /// /// # Errors @@ -128,6 +139,23 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res Ok(true) } +/// Create a regular file when absent or validate an exact existing payload +/// +/// A collision is opened once through the retained parent descriptor and is never replaced +/// +/// # Errors +/// +/// Returns an error when the parent path is unsafe, the destination is not a regular file, or +/// creating, reading, applying the mode, or synchronizing the file fails +pub fn ensure_exact_file( + path: &Path, + contents: &[u8], + mode: u32, +) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + ensure_exact_file_at(&parent_fd, &file_name, contents, mode) +} + /// Add executable bits to an existing regular file without following links /// /// # Errors @@ -153,9 +181,16 @@ pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { pub(super) fn open_regular_file(path: &Path) -> io::Result { let (parent_fd, file_name) = open_parent_existing(path)?; + open_regular_file_at(&parent_fd, &file_name) +} + +pub(super) fn open_regular_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result { let fd = openat2( - &parent_fd, - &file_name, + parent_fd, + file_name, OFlags::RDONLY .union(OFlags::NONBLOCK) .union(OFlags::CLOEXEC) @@ -170,6 +205,60 @@ pub(super) fn open_regular_file(path: &Path) -> io::Result { Ok(file) } +pub(super) fn ensure_exact_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: u32, +) -> io::Result { + let fd = match openat2( + parent_fd, + file_name, + OFlags::RDWR + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::CREATE) + .union(OFlags::EXCL), + file_mode(mode), + contained_resolve_flags(), + ) { + Ok(fd) => fd, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let mut file = open_regular_file_at(parent_fd, file_name)?; + if !file_contents_equal(&mut file, contents)? { + return Ok(EnsureExactFileOutcome::ContentsMismatch); + } + // Matching shared state may still need its declared service-manager mode restored + file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; + file.sync_all()?; + return Ok(EnsureExactFileOutcome::AlreadyExact); + } + Err(error) => return Err(error.into()), + }; + + let mut file = fs::File::from(fd); + if let Err(error) = file + .write_all(contents) + .and_then(|()| set_mode_and_sync(&file, mode)) + { + drop(file); + let _ = unlinkat(parent_fd, file_name, AtFlags::empty()); + return Err(error); + } + drop(file); + sync_directory(parent_fd)?; + Ok(EnsureExactFileOutcome::Created) +} + +pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { + let read_limit = u64::try_from(expected.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut actual = Vec::with_capacity(expected.len().saturating_add(1)); + file.take(read_limit).read_to_end(&mut actual)?; + Ok(actual == expected) +} + fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { existing_target_mode(parent_fd, file_name).map(|_mode| ()) } diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index 7af62d160..83b132a20 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -7,21 +7,76 @@ use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path}; use rustix::fs::{ - fchmod, fsync, mkdirat, openat2, statat, unlinkat, AtFlags, Dir, FileType, Mode, OFlags, + fchmod, fstat, fsync, mkdirat, openat2, statat, unlinkat, AtFlags, Dir, FileType, Mode, OFlags, ResolveFlags, CWD, }; +use super::atomic::{ensure_exact_file_at, file_contents_equal, open_regular_file_at}; + +/// Outcome for the final component of recursive directory creation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateDirectoryOutcome { + /// The requested directory itself was created by this operation + TargetCreated, + /// The requested directory already existed when its retained descriptor was opened + TargetAlreadyExisted, +} + /// Create a directory and every missing parent without following links /// -/// Returns `true` when at least the requested directory had to be created +/// Reports whether the final directory was created without conflating parent creation /// /// # Errors /// /// Returns an error when the path traverses upward or through a link, an existing component is not /// a directory, or creation, permission repair, or synchronization fails -pub fn create_directory_all(path: &Path, mode: u32) -> io::Result { - let (_directory_fd, created) = open_directory_path(path, MissingDirectory::Create(mode))?; - Ok(created) +pub fn create_directory_all(path: &Path, mode: u32) -> io::Result { + let (_directory_fd, outcome) = open_directory_path(path, MissingDirectory::Create(mode))?; + Ok(outcome) +} + +/// Create a directory with an ownership marker or validate the retained existing directory +/// +/// Existing directories are never mutated until their marker bytes are proven through the same +/// directory descriptor used for the decision +/// +/// # Errors +/// +/// Returns an error for unsafe paths, invalid marker names, missing or mismatched ownership +/// markers, and directory or marker creation failures +pub fn ensure_marked_directory( + path: &Path, + directory_mode: u32, + marker_name: &OsStr, + marker_contents: &[u8], + marker_mode: u32, +) -> io::Result { + validate_child_name(marker_name)?; + let (directory_fd, outcome) = + open_directory_path(path, MissingDirectory::Create(directory_mode))?; + let marker_name = marker_name.to_os_string(); + + match outcome { + CreateDirectoryOutcome::TargetCreated => { + let marker_outcome = + ensure_exact_file_at(&directory_fd, &marker_name, marker_contents, marker_mode)?; + if matches!( + marker_outcome, + super::atomic::EnsureExactFileOutcome::ContentsMismatch + ) { + return Err(invalid_marker_error()); + } + } + CreateDirectoryOutcome::TargetAlreadyExisted => { + let mut marker = open_regular_file_at(&directory_fd, &marker_name) + .map_err(|_error| invalid_marker_error())?; + if !file_contents_equal(&mut marker, marker_contents)? { + return Err(invalid_marker_error()); + } + } + } + + Ok(outcome) } /// Remove an empty directory without following links @@ -59,6 +114,41 @@ pub fn remove_directory_tree(path: &Path) -> io::Result { Ok(true) } +/// Remove a marked regular-only directory tree through one retained root descriptor +/// +/// The entire tree is checked before any entry is deleted. The ownership marker is read relative +/// to that same descriptor, and the visible root name must still identify it before final removal +/// +/// # Errors +/// +/// Returns an error when the path or marker is unsafe, marker bytes differ, the tree contains a +/// link or special file, an entry changes shape, or durable removal fails +pub fn remove_marked_directory_tree( + path: &Path, + marker_name: &OsStr, + marker_contents: &[u8], +) -> io::Result { + validate_child_name(marker_name)?; + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + let marker_name = marker_name.to_os_string(); + let mut marker = open_regular_file_at(&directory_fd, &marker_name) + .map_err(|_error| invalid_marker_error())?; + if !file_contents_equal(&mut marker, marker_contents)? { + return Err(invalid_marker_error()); + } + + // Preflight is intentionally read-only so one rejected child cannot cause partial deletion + preflight_directory_contents(&directory_fd)?; + remove_directory_contents(&directory_fd)?; + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + pub(super) fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { open_parent_with(path, MissingDirectory::Create(0o755)) } @@ -108,10 +198,10 @@ fn open_parent_with( fn open_directory_path( path: &Path, missing_directory: MissingDirectory, -) -> io::Result<(OwnedFd, bool)> { +) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { // Absolute and relative paths begin from different trusted anchors let mut directory_fd = open_anchor(path)?; - let mut created = false; + let mut target_outcome = CreateDirectoryOutcome::TargetAlreadyExisted; for component in path.components() { match component { @@ -128,12 +218,16 @@ fn open_directory_path( let (next_fd, component_created) = open_directory_component(&directory_fd, name, missing_directory)?; directory_fd = next_fd; - created |= component_created; + target_outcome = if component_created { + CreateDirectoryOutcome::TargetCreated + } else { + CreateDirectoryOutcome::TargetAlreadyExisted + }; } } } - Ok((directory_fd, created)) + Ok((directory_fd, target_outcome)) } fn open_anchor(path: &Path) -> io::Result { @@ -259,6 +353,72 @@ fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { Ok(()) } +fn preflight_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + continue; + } + if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + preflight_directory_contents(&child_fd)?; + continue; + } + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing unsafe entry inside directory tree: {}", + name.to_string_lossy() + ), + )); + } + Ok(()) +} + +fn revalidate_directory_identity( + parent_fd: &OwnedFd, + file_name: &OsStr, + directory_fd: &OwnedFd, +) -> io::Result<()> { + let retained = fstat(directory_fd)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev + && retained.st_ino == visible.st_ino + && FileType::from_raw_mode(visible.st_mode).is_dir() + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "directory changed while guarded removal was in progress", + )) +} + +fn validate_child_name(name: &OsStr) -> io::Result<()> { + let mut components = Path::new(name).components(); + if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ownership marker must be one relative file name", + )) +} + +fn invalid_marker_error() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "directory ownership marker is missing or does not match", + ) +} + const fn file_mode(mode: u32) -> Mode { Mode::from_raw_mode(mode & 0o777) } diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index dce14a52d..f351a8ea8 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -10,15 +10,19 @@ mod rename; mod symlink; pub use atomic::{ - make_file_executable, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, - write_file_if_missing, + ensure_exact_file, make_file_executable, set_file_mode, write_file_atomic, + write_file_atomic_preserving_mode, write_file_if_missing, EnsureExactFileOutcome, +}; +pub use directory::{ + create_directory_all, ensure_marked_directory, remove_directory_tree, remove_empty_directory, + remove_marked_directory_tree, CreateDirectoryOutcome, }; -pub use directory::{create_directory_all, remove_directory_tree, remove_empty_directory}; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; pub use read::read_regular_file_bounded; pub use remove::{ - remove_regular_file, remove_symlink, remove_symlink_if_target, RemoveSymlinkOutcome, + remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, + remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, }; pub use rename::{rename_regular_file_no_replace, RenameRegularFileOutcome}; pub use symlink::{ diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index e84a81dba..c7df392b4 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -2,11 +2,12 @@ use std::ffi::OsString; use std::io; +use std::os::fd::OwnedFd; use std::path::{Path, PathBuf}; -use rustix::fs::{unlinkat, AtFlags}; +use rustix::fs::{fstat, statat, unlinkat, AtFlags}; -use super::atomic::validate_existing_target; +use super::atomic::{file_contents_equal, open_regular_file_at, validate_existing_target}; use super::directory::{open_parent_existing, sync_directory}; use super::symlink::read_symlink_at; @@ -21,6 +22,17 @@ pub enum RemoveSymlinkOutcome { TargetMismatch(PathBuf), } +/// Result of conditionally removing one exact regular file +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoveExactFileOutcome { + /// The requested file or its required marker was absent + Missing, + /// One retained file did not contain the authorized bytes + ContentsMismatch, + /// Every retained file matched and the requested entries were removed + Removed, +} + /// Remove a regular file without following links in its path /// /// # Errors @@ -45,6 +57,65 @@ pub fn remove_regular_file(path: &Path) -> io::Result { Ok(true) } +/// Remove two same-directory regular files only when both retained payloads match +/// +/// This is intended for a shared artifact and its ownership marker. Both files are opened and +/// preflighted through one parent descriptor before either name is unlinked +/// +/// # Errors +/// +/// Returns an error when paths have different parents, path traversal is unsafe, either target is +/// not a regular file, retained identities change, or durable unlinking fails +pub fn remove_regular_file_pair_if_contents( + path: &Path, + expected_contents: &[u8], + marker_path: &Path, + expected_marker_contents: &[u8], +) -> io::Result { + if path.parent() != marker_path.parent() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "guarded files must share one parent directory", + )); + } + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(RemoveExactFileOutcome::Missing); + }; + let marker_name = marker_path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "marker has no file name"))? + .to_os_string(); + + let mut file = match open_regular_file_at(&parent_fd, &file_name) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RemoveExactFileOutcome::Missing) + } + Err(error) => return Err(error), + }; + let mut marker = match open_regular_file_at(&parent_fd, &marker_name) { + Ok(marker) => marker, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(RemoveExactFileOutcome::Missing) + } + Err(error) => return Err(error), + }; + if !file_contents_equal(&mut file, expected_contents)? + || !file_contents_equal(&mut marker, expected_marker_contents)? + { + return Ok(RemoveExactFileOutcome::ContentsMismatch); + } + + // The marker is removed first so a target-name race fails closed with the shared file intact + revalidate_file_identity(&parent_fd, &marker_name, &marker)?; + unlinkat(&parent_fd, &marker_name, AtFlags::empty())?; + revalidate_file_identity(&parent_fd, &file_name, &file)?; + unlinkat(&parent_fd, &file_name, AtFlags::empty())?; + sync_directory(&parent_fd)?; + Ok(RemoveExactFileOutcome::Removed) +} + /// Remove a symbolic link without requiring a specific target /// /// # Errors @@ -108,6 +179,22 @@ fn existing_parent(path: &Path) -> io::Result io::Result<()> { + let retained = fstat(file)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev && retained.st_ino == visible.st_ino { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "regular file changed during guarded removal", + )) +} + #[cfg(test)] #[path = "tests/remove.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/tests/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs index dce22a915..244953ec2 100644 --- a/crates/unixnotis-core/src/filesystem/tests/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -1,8 +1,9 @@ //! Atomic file operation tests use super::{ - file_mode, make_file_executable, reserve_temp, set_file_mode, write_file_atomic, - write_file_atomic_preserving_mode, write_file_if_missing, + ensure_exact_file, file_mode, make_file_executable, reserve_temp, set_file_mode, + write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, + EnsureExactFileOutcome, }; use std::ffi::OsString; use std::fs; @@ -104,6 +105,58 @@ fn create_if_missing_preserves_existing_file_and_mode() { let _ = fs::remove_dir_all(root); } +#[test] +fn exact_file_creation_accepts_only_identical_existing_bytes() { + let root = unique_temp_path("atomic-exact-file"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o644).expect("create exact file"), + EnsureExactFileOutcome::Created + ); + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o600).expect("accept exact file"), + EnsureExactFileOutcome::AlreadyExact + ); + assert_eq!( + ensure_exact_file(&target, b"longrun\n", 0o644).expect("reject mismatched bytes"), + EnsureExactFileOutcome::ContentsMismatch + ); + + assert_eq!( + fs::read_to_string(&target).expect("read exact file"), + "bundle\n" + ); + assert_eq!( + fs::metadata(&target) + .expect("exact file metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_never_follows_a_collision_symlink() { + let root = unique_temp_path("atomic-exact-file-link"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let target = root.join("type"); + fs::write(&outside, "foreign").expect("write outside file"); + symlink(&outside, &target).expect("create exact-file link"); + + ensure_exact_file(&target, b"bundle\n", 0o644).expect_err("link collision should fail"); + + assert_eq!( + fs::read_to_string(outside).expect("read outside file"), + "foreign" + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn create_if_missing_rejects_every_unsafe_existing_target() { let root = unique_temp_path("atomic-if-missing-unsafe"); diff --git a/crates/unixnotis-core/src/filesystem/tests/directory.rs b/crates/unixnotis-core/src/filesystem/tests/directory.rs index 1da3c26b7..5cb8a0f49 100644 --- a/crates/unixnotis-core/src/filesystem/tests/directory.rs +++ b/crates/unixnotis-core/src/filesystem/tests/directory.rs @@ -6,8 +6,9 @@ use std::os::unix::fs::{symlink, PermissionsExt}; use rustix::fs::{mkfifoat, Mode, CWD}; use super::{ - classify_directory_creation, create_directory_all, remove_directory_tree, - remove_empty_directory, + classify_directory_creation, create_directory_all, ensure_marked_directory, + remove_directory_tree, remove_empty_directory, remove_marked_directory_tree, + CreateDirectoryOutcome, }; use crate::test_support::unique_temp_path; @@ -16,8 +17,14 @@ fn directory_creation_builds_missing_components_with_requested_mode() { let root = unique_temp_path("create-directory-tree"); let target = root.join("parent").join("child"); - assert!(create_directory_all(&target, 0o750).expect("create directory tree")); - assert!(!create_directory_all(&target, 0o700).expect("existing directory stays unchanged")); + assert_eq!( + create_directory_all(&target, 0o750).expect("create directory tree"), + CreateDirectoryOutcome::TargetCreated + ); + assert_eq!( + create_directory_all(&target, 0o700).expect("existing directory stays unchanged"), + CreateDirectoryOutcome::TargetAlreadyExisted + ); for directory in [&root, &root.join("parent"), &target] { assert_eq!( @@ -59,6 +66,46 @@ fn directory_creation_result_distinguishes_creation_collision_and_failure() { assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); } +#[test] +fn marked_directory_refuses_to_adopt_an_unmarked_existing_target() { + let root = unique_temp_path("marked-directory-adoption"); + let target = root.join("service"); + fs::create_dir_all(&target).expect("create foreign directory"); + fs::write(target.join("foreign"), "keep").expect("write foreign child"); + + ensure_marked_directory(&target, 0o755, ".owner".as_ref(), b"owned\n", 0o644) + .expect_err("unmarked directory should not be adopted"); + + assert!(!target.join(".owner").exists()); + assert_eq!( + fs::read_to_string(target.join("foreign")).expect("read foreign child"), + "keep" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_directory_creation_and_reopen_share_one_ownership_contract() { + let root = unique_temp_path("marked-directory-create"); + let target = root.join("service"); + + assert_eq!( + ensure_marked_directory(&target, 0o750, ".owner".as_ref(), b"owned\n", 0o640) + .expect("create marked directory"), + CreateDirectoryOutcome::TargetCreated + ); + assert_eq!( + ensure_marked_directory(&target, 0o700, ".owner".as_ref(), b"owned\n", 0o600) + .expect("validate marked directory"), + CreateDirectoryOutcome::TargetAlreadyExisted + ); + assert_eq!( + fs::read_to_string(target.join(".owner")).expect("read marker"), + "owned\n" + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn empty_directory_removal_is_idempotent() { let root = unique_temp_path("remove-empty-directory"); @@ -139,6 +186,47 @@ fn recursive_directory_removal_rejects_a_special_child() { let _ = fs::remove_dir_all(root); } +#[test] +fn marked_tree_preflight_preserves_regular_siblings_when_a_child_is_unsafe() { + let root = unique_temp_path("marked-tree-preflight"); + let target = root.join("managed"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("regular"), "keep until full preflight").expect("write regular child"); + symlink("regular", target.join("unsafe-link")).expect("create unsafe link"); + + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect_err("unsafe child should reject the whole tree"); + + assert_eq!( + fs::read_to_string(target.join("regular")).expect("regular sibling remains"), + "keep until full preflight" + ); + assert!(target.join(".owner").exists()); + assert!(fs::symlink_metadata(target.join("unsafe-link")) + .expect("unsafe link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_removal_validates_marker_and_deletes_a_preflighted_tree() { + let root = unique_temp_path("marked-tree-remove"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested tree"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("nested").join("file"), "owned").expect("write nested file"); + + assert!( + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect("remove marked tree") + ); + assert!(!target.exists()); + + let _ = fs::remove_dir_all(root); +} + #[test] fn directory_removal_rejects_linked_ancestors_without_touching_target() { let root = unique_temp_path("remove-directory-linked-parent"); diff --git a/crates/unixnotis-core/src/filesystem/tests/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs index 426551944..11ee76b3f 100644 --- a/crates/unixnotis-core/src/filesystem/tests/remove.rs +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -3,7 +3,10 @@ use std::fs; use std::os::unix::fs::symlink; -use super::{remove_regular_file, remove_symlink, remove_symlink_if_target, RemoveSymlinkOutcome}; +use super::{ + remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, + remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, +}; use crate::filesystem::symlink::read_symlink; use crate::test_support::unique_temp_path; @@ -60,6 +63,34 @@ fn regular_file_removal_rejects_a_symlinked_parent() { let _ = fs::remove_dir_all(root); } +#[test] +fn exact_pair_removal_requires_both_payloads_before_unlinking_either_file() { + let root = unique_temp_path("remove-exact-pair"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + fs::write(&target, "bundle\n").expect("write shared file"); + fs::write(&marker, "foreign\n").expect("write foreign marker"); + + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("inspect exact pair"), + RemoveExactFileOutcome::ContentsMismatch + ); + assert!(target.exists()); + assert!(marker.exists()); + + fs::write(&marker, "owned\n").expect("repair marker"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("remove exact pair"), + RemoveExactFileOutcome::Removed + ); + assert!(!target.exists()); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(root); +} + #[test] fn symlink_removal_keeps_the_link_target() { let root = unique_temp_path("remove-symlink"); diff --git a/crates/unixnotis-installer/src/actions/install/service/dirs.rs b/crates/unixnotis-installer/src/actions/install/service/dirs.rs index 9a56d224b..4c67739aa 100644 --- a/crates/unixnotis-installer/src/actions/install/service/dirs.rs +++ b/crates/unixnotis-installer/src/actions/install/service/dirs.rs @@ -1,59 +1,48 @@ //! Service artifact directory creation and guarded directory removal +use std::ffi::OsStr; use std::fs; -use std::io::ErrorKind; use std::path::Path; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - create_directory_all, remove_directory_tree, remove_empty_directory, write_file_atomic, + create_directory_all, ensure_marked_directory, remove_empty_directory, + remove_marked_directory_tree, CreateDirectoryOutcome, }; use crate::paths::format_with_home; -use crate::service_manager::{ - managed_directory_marker, managed_directory_marker_is_valid, MANAGED_DIRECTORY_MARKER_CONTENTS, +use crate::service_manager::contract::{ + MANAGED_DIRECTORY_MARKER, MANAGED_DIRECTORY_MARKER_CONTENTS, }; -use super::files::ensure_regular_artifact_file_path; - pub(in crate::actions::install::service) fn write_directory_artifact(path: &Path) -> Result { - // Plain directories are container nodes only, so they must already be real directories - let existed_before = ensure_artifact_directory_path(path)?; - // Parent and final directory creation share the same no-symlink walk - ensure_directory_without_symlink(path) + // The descriptor-backed result reflects the final component even after a create collision + let outcome = create_directory_all(path, 0o755) .with_context(|| format!("failed to create {}", format_with_home(path)))?; - Ok(!existed_before) + Ok(outcome == CreateDirectoryOutcome::TargetCreated) } pub(in crate::actions::install::service) fn write_managed_directory(path: &Path) -> Result { - // Managed directories are the only artifact type allowed to contain nested backend files - let existed_before = ensure_artifact_directory_path(path)?; - // Create the directory before marker validation so first install can seed ownership - ensure_directory_without_symlink(path) - .with_context(|| format!("failed to create {}", format_with_home(path)))?; - - let marker = managed_directory_marker(path); - if existed_before && !managed_directory_marker_is_valid(&marker) { - // Existing service directories need proof of ownership before UnixNotis manages them - return Err(anyhow!( - "refusing to manage unmarked service directory at {}", - format_with_home(path) - )); - } - - ensure_regular_artifact_file_path(&marker)?; - let marker_changed = match fs::read_to_string(&marker) { - // Marker contents stay tiny and exact so foreign files are not treated as ownership - Ok(existing) if existing == MANAGED_DIRECTORY_MARKER_CONTENTS => false, - Ok(_) | Err(_) => { - // The marker itself is written atomically so partial writes do not grant ownership - write_file_atomic(&marker, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) - .with_context(|| format!("failed to write {}", format_with_home(&marker)))?; - true - } - }; - - Ok(!existed_before || marker_changed) + let outcome = ensure_marked_directory( + path, + 0o755, + OsStr::new(MANAGED_DIRECTORY_MARKER), + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + 0o644, + ) + .map_err(|error| match error.kind() { + std::io::ErrorKind::PermissionDenied => anyhow!( + "refusing to manage unmarked service directory at {}: {}", + format_with_home(path), + error + ), + _ => anyhow!( + "refusing unsafe service directory at {}: {}", + format_with_home(path), + error + ), + })?; + Ok(outcome == CreateDirectoryOutcome::TargetCreated) } pub(in crate::actions::install::service) fn ensure_directory_without_symlink( @@ -61,7 +50,7 @@ pub(in crate::actions::install::service) fn ensure_directory_without_symlink( ) -> Result<()> { // Core keeps one descriptor per component so parent swaps cannot redirect creation create_directory_all(path, 0o755) - .map(|_created| ()) + .map(|_outcome| ()) .map_err(|error| { anyhow!( "refusing unsafe service directory path {}: {}", @@ -108,28 +97,24 @@ pub(in crate::actions::install::service) fn remove_empty_service_directory( } pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path) -> Result<()> { - let marker = managed_directory_marker(path); - // Managed directories can contain backend files, so the marker gates recursive removal - if !managed_directory_marker_is_valid(&marker) { - return Err(anyhow!( - "refusing to recursively remove unmarked service directory at {}", - format_with_home(path) - )); - } - - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("failed to inspect {}", format_with_home(path)))?; - // Recheck the root immediately before deletion so a swapped symlink is not removed - if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() { - return Err(anyhow!( - "refusing to recursively remove unsafe service directory at {}", - format_with_home(path) - )); - } - - if remove_directory_tree(path) - .with_context(|| format!("failed to remove {}", format_with_home(path)))? - { + let removed = remove_marked_directory_tree( + path, + OsStr::new(MANAGED_DIRECTORY_MARKER), + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + ) + .map_err(|error| match error.kind() { + std::io::ErrorKind::PermissionDenied => anyhow!( + "refusing to recursively remove unmarked service directory at {}: {}", + format_with_home(path), + error + ), + _ => anyhow!( + "refusing to recursively remove unsafe service directory at {}: {}", + format_with_home(path), + error + ), + })?; + if removed { Ok(()) } else { Err(anyhow!( @@ -138,22 +123,3 @@ pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path )) } } - -fn ensure_artifact_directory_path(path: &Path) -> Result { - // Directory artifacts are container paths, so replacing files or links would be surprising - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!( - "cannot replace symlink service directory at {}", - format_with_home(path) - )), - Ok(metadata) if !metadata.is_dir() => Err(anyhow!( - "cannot replace non-directory service artifact at {}", - format_with_home(path) - )), - Ok(_) => Ok(true), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(path))) - } - } -} diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index b031edd61..5e2d01fae 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -8,8 +8,9 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - remove_empty_directory, remove_regular_file, set_file_mode, write_file_atomic, - write_file_atomic_preserving_mode, + ensure_exact_file, remove_empty_directory, remove_regular_file, + remove_regular_file_pair_if_contents, set_file_mode, write_file_atomic, + write_file_atomic_preserving_mode, EnsureExactFileOutcome, RemoveExactFileOutcome, }; use crate::paths::format_with_home; @@ -72,24 +73,19 @@ pub(in crate::actions::install::service) fn write_shared_service_file( artifact_label: &str, created_marker: Option<&Path>, ) -> Result { - // Shared files are setup anchors, not UnixNotis-owned replacement targets - let existed_before = ensure_regular_artifact_file_path(path)?; - if existed_before { - let existing = fs::read_to_string(path) - .with_context(|| format!("failed to read {}", format_with_home(path)))?; - if existing != contents { + let outcome = ensure_exact_file(path, contents.as_bytes(), mode.unwrap_or(0o644)) + .with_context(|| format!("failed to write {artifact_label}"))?; + match outcome { + EnsureExactFileOutcome::ContentsMismatch => { return Err(anyhow!( "refusing to overwrite shared service artifact at {}", format_with_home(path) )); } - apply_artifact_mode_if_needed(path, mode)?; - return Ok(false); + EnsureExactFileOutcome::AlreadyExact => return Ok(false), + EnsureExactFileOutcome::Created => {} } - // Missing shared files can be seeded because no user contents are being replaced - write_file_atomic(path, contents.as_bytes(), mode.unwrap_or(0o644)) - .with_context(|| format!("failed to write {artifact_label}"))?; if let Some(marker) = created_marker { write_shared_creation_marker(marker)?; } @@ -101,18 +97,20 @@ pub(in crate::actions::install::service) fn remove_shared_service_file( created_marker: &Path, expected_contents: &str, ) -> Result { - if !shared_creation_marker_is_valid(created_marker) { - // No marker means the shared file predated UnixNotis or has unknown ownership - return Ok(false); - } - if !shared_file_contents_match(path, expected_contents)? { - // User edits after install turn the file back into shared user state - return Ok(false); + let outcome = remove_regular_file_pair_if_contents( + path, + expected_contents.as_bytes(), + created_marker, + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + ) + .with_context(|| format!("failed to remove {}", format_with_home(path)))?; + match outcome { + RemoveExactFileOutcome::Missing | RemoveExactFileOutcome::ContentsMismatch => Ok(false), + RemoveExactFileOutcome::Removed => { + remove_empty_shared_layout_dirs(path)?; + Ok(true) + } } - remove_regular_service_file(path)?; - remove_regular_service_file(created_marker)?; - remove_empty_shared_layout_dirs(path)?; - Ok(true) } #[cfg(unix)] @@ -126,65 +124,16 @@ pub(in crate::actions::install) fn current_mode(path: &Path) -> Result) -> Result<()> { - let Some(mode) = mode else { - return Ok(()); - }; - - #[cfg(unix)] - { - if current_mode(path)? != Some(mode) { - // Shared support files still need explicit modes when the backend requests one - set_file_mode(path, mode) - .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; - } - Ok(()) - } - - #[cfg(not(unix))] - { - Err(anyhow!( - "cannot apply executable mode {} on non-Unix platforms", - mode - )) - } -} - fn write_shared_creation_marker(path: &Path) -> Result<()> { - ensure_regular_artifact_file_path(path)?; - write_file_atomic(path, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) - .with_context(|| format!("failed to write {}", format_with_home(path))) -} - -fn shared_creation_marker_is_valid(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - if !metadata.file_type().is_file() { - return false; - } - fs::read_to_string(path).is_ok_and(|contents| contents == MANAGED_DIRECTORY_MARKER_CONTENTS) -} - -fn shared_file_contents_match(path: &Path, expected_contents: &str) -> Result { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), - Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect {}", format_with_home(path))); - } - }; - if !metadata.file_type().is_file() { - // A marker does not make a replaced symlink, socket, or directory removable - return Err(anyhow!( - "refusing to remove non-regular shared service artifact at {}", + match ensure_exact_file(path, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) + .with_context(|| format!("failed to write {}", format_with_home(path)))? + { + EnsureExactFileOutcome::Created | EnsureExactFileOutcome::AlreadyExact => Ok(()), + EnsureExactFileOutcome::ContentsMismatch => Err(anyhow!( + "refusing to replace ownership marker at {}", format_with_home(path) - )); + )), } - fs::read_to_string(path) - .map(|contents| contents == expected_contents) - .with_context(|| format!("failed to read {}", format_with_home(path))) } fn remove_empty_shared_layout_dirs(path: &Path) -> Result<()> { diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs index ba4c17ff3..65fa67879 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs @@ -102,7 +102,8 @@ fn every_backend_wrong_primary_artifact_shape_fails_without_mutation() { assert!( err.to_string().contains("symlink") || err.to_string().contains("unsafe") - || err.to_string().contains("not managed"), + || err.to_string().contains("not managed") + || err.to_string().contains("unmarked"), "{name} error should explain the unsafe shape: {err}" ); assert_eq!( diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs index 5e2fd20d8..c8ce63fff 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs @@ -28,7 +28,11 @@ fn runit_envdir_sync_failure_keeps_down_gate() { let err = run_enable_only(&paths).expect_err("envdir sync should fail"); // The down gate must remain because env sync failed before the service was allowed to start - assert!(format!("{err:#}").contains("cannot replace symlink service directory")); + assert!( + format!("{err:#}").contains("failed to create") + && format!("{err:#}").contains("Not a directory"), + "unexpected envdir safety error: {err:#}" + ); assert!(service_dir.join("down").is_file()); let calls = if log_path.exists() { read_calls(&log_path) diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index 05fc1cb2c..c823025c6 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -451,7 +451,7 @@ fn remove_shared_service_file_handles_missing_and_invalid_paths_safely() { let error = remove_service_artifact(&invalid) .expect_err("filesystem errors must not look like missing shared files"); - assert!(format!("{error:#}").contains("failed to inspect")); + assert!(format!("{error:#}").contains("failed to remove")); assert!(marker.exists()); let directory_artifact = ServiceArtifact { @@ -467,9 +467,10 @@ fn remove_shared_service_file_handles_missing_and_invalid_paths_safely() { let error = remove_service_artifact(&directory_artifact) .expect_err("a directory must never be removed as a shared file"); - assert!(error - .to_string() - .contains("non-regular shared service artifact")); + assert!( + format!("{error:#}").contains("non-regular file target"), + "unexpected shared directory error: {error:#}" + ); assert!(directory_artifact.path.is_dir()); let _ = fs::remove_dir_all(&root); } diff --git a/crates/unixnotis-installer/src/service_manager/contract/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/mod.rs index 608d51080..8f29d76e0 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/mod.rs @@ -18,8 +18,8 @@ mod refresh; mod shell; pub use artifact::{ - managed_directory_marker, managed_directory_marker_is_valid, ServiceArtifact, - ServiceArtifactKind, MANAGED_DIRECTORY_MARKER, MANAGED_DIRECTORY_MARKER_CONTENTS, + ServiceArtifact, ServiceArtifactKind, MANAGED_DIRECTORY_MARKER, + MANAGED_DIRECTORY_MARKER_CONTENTS, }; pub use command::CommandSpec; pub use probe::ServiceProbe; diff --git a/crates/unixnotis-installer/src/service_manager/mod.rs b/crates/unixnotis-installer/src/service_manager/mod.rs index dc607c6b3..7142d7da4 100644 --- a/crates/unixnotis-installer/src/service_manager/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/mod.rs @@ -9,9 +9,7 @@ mod backends; pub mod contract; mod orchestration; -pub use contract::{ - managed_directory_marker, managed_directory_marker_is_valid, MANAGED_DIRECTORY_MARKER_CONTENTS, -}; +pub use contract::MANAGED_DIRECTORY_MARKER_CONTENTS; pub use contract::{ CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, ServiceArtifactRefresh, From 93b40054e64454dfd958c425d89c423915dd7cf4 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 12:51:57 -0500 Subject: [PATCH 055/275] fix(css): harden reference scanning Summary: harden reference scanning. Scope: css. --- .../src/preset/css_asset_refs/rewrite.rs | 7 ++ .../preset/css_asset_refs/tests/rewrite.rs | 27 ++++++ .../src/css/references/import.rs | 10 +-- .../src/css/references/lexer.rs | 83 +++++++++++++------ .../src/css/references/tests/lexer.rs | 20 ++--- .../src/css/references/tests/url.rs | 36 ++++++++ .../unixnotis-core/src/css/references/url.rs | 23 ++--- 7 files changed, 151 insertions(+), 55 deletions(-) diff --git a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs index 62c680dd3..deb5b67d9 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs @@ -50,6 +50,13 @@ fn rewrite_host_specific_refs_in_text( let mut last_index = 0usize; for span in collect_url_spans(css_text)? { + if span.value_start > span.value_end + || span.value_start < last_index + || !css_text.is_char_boundary(span.value_start) + || !css_text.is_char_boundary(span.value_end) + { + anyhow::bail!("CSS scanner returned an invalid UTF-8 rewrite range"); + } // Everything before the current url(...) payload is copied through unchanged rewritten.push_str(&css_text[last_index..span.value_start]); diff --git a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs index 795664fca..77526d2ed 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs @@ -54,3 +54,30 @@ fn rewrite_percent_encodes_decoded_file_url_characters_in_quoted_and_unquoted_fo } } } + +#[test] +fn rewrite_preserves_unicode_whitespace_without_invalid_utf8_ranges() { + let whitespace = [ + '\u{0085}', '\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', + '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', + '\u{2028}', '\u{2029}', '\u{202F}', '\u{205F}', '\u{3000}', + ]; + + for character in whitespace { + for css in [ + format!(".a {{ background: url({character}asset.png); }}"), + format!(".a {{ background: url(asset.png{character}); }}"), + format!(".a {{ background: url(\"{character}asset.png{character}\"); }}"), + ] { + let (rewritten, findings) = rewrite_host_specific_refs_in_text( + Path::new("/config/unixnotis"), + Path::new("/config/unixnotis/base.css"), + &css, + ) + .expect("rewrite Unicode CSS URL safely"); + + assert_eq!(rewritten, css); + assert!(findings.is_empty()); + } + } +} diff --git a/crates/unixnotis-core/src/css/references/import.rs b/crates/unixnotis-core/src/css/references/import.rs index 40f099ea3..24b630f37 100644 --- a/crates/unixnotis-core/src/css/references/import.rs +++ b/crates/unixnotis-core/src/css/references/import.rs @@ -1,7 +1,7 @@ //! Decoded CSS `@import` discovery use super::lexer::{ - consume_escape, consume_identifier, skip_comment, skip_css_whitespace_and_comments, + consume_escape, identifier_matches, skip_comment, skip_css_whitespace_and_comments, skip_quoted_value, starts_comment, utf8_char_len, would_start_identifier, }; use super::url::{parse_url_value, MAX_CSS_REFERENCES_PER_FILE}; @@ -86,8 +86,8 @@ fn collect_import_records(css_text: &str) -> Result, CssRefere } // At-keyword names follow the same escape rules as function identifiers - let (name, name_end) = consume_identifier(css_text, index.saturating_add(1)); - if !name.eq_ignore_ascii_case("import") { + let (is_import, name_end) = identifier_matches(css_text, index.saturating_add(1), "import"); + if !is_import { if name_end <= index { return Err(CssReferenceError::ScannerDidNotAdvance); } @@ -125,8 +125,8 @@ fn parse_import_value( let mut index = skip_css_whitespace_and_comments(bytes, start); if would_start_identifier(bytes, index) { - let (name, name_end) = consume_identifier(input, index); - if name.eq_ignore_ascii_case("url") && bytes.get(name_end) == Some(&b'(') { + let (is_url, name_end) = identifier_matches(input, index, "url"); + if is_url && bytes.get(name_end) == Some(&b'(') { let Some((span, next_index)) = parse_url_value(input, name_end.saturating_add(1)) else { return (Some(CssImportReference::Ambiguous), None, bytes.len()); diff --git a/crates/unixnotis-core/src/css/references/lexer.rs b/crates/unixnotis-core/src/css/references/lexer.rs index cc029c54b..53791159c 100644 --- a/crates/unixnotis-core/src/css/references/lexer.rs +++ b/crates/unixnotis-core/src/css/references/lexer.rs @@ -1,40 +1,41 @@ //! CSS identifier, escape, comment, and whitespace primitives -pub(super) fn consume_identifier(input: &str, start: usize) -> (String, usize) { +pub(super) fn identifier_matches(input: &str, start: usize, expected: &str) -> (bool, usize) { let bytes = input.as_bytes(); - let mut decoded = String::new(); + let expected = expected.as_bytes(); + let mut matched = true; + let mut decoded_len = 0usize; let mut index = start; - // A source byte can be visited at most once during a valid identifier scan + // Security scanners recognize a small fixed vocabulary without allocating every identifier for _ in 0..bytes.len().saturating_add(1) { let Some(&byte) = bytes.get(index) else { break; }; - if is_name_byte(byte) { - if byte.is_ascii() { - decoded.push(char::from(byte)); - index = index.saturating_add(1); + let (decoded, next_index) = if is_name_byte(byte) { + let decoded = if byte.is_ascii() { + char::from(byte) } else { - // Non-ASCII name characters are copied by scalar value - let ch = input[index..].chars().next().unwrap_or('\u{FFFD}'); - decoded.push(ch); - index = index.saturating_add(ch.len_utf8()); - } - continue; - } - if byte == b'\\' && valid_escape(bytes, index) { - let (ch, next_index) = consume_escape(input, index); - if next_index <= index { - break; - } - decoded.push(ch); - index = next_index; - continue; + input[index..].chars().next().unwrap_or('\u{FFFD}') + }; + (decoded, index.saturating_add(decoded.len_utf8())) + } else if byte == b'\\' && valid_escape(bytes, index) { + consume_escape(input, index) + } else { + break; + }; + + if next_index <= index { + break; } - break; + matched &= expected + .get(decoded_len) + .is_some_and(|expected| decoded.eq_ignore_ascii_case(&char::from(*expected))); + decoded_len = decoded_len.saturating_add(1); + index = next_index; } - (decoded, index) + (matched && decoded_len == expected.len(), index) } pub(super) fn consume_escape(input: &str, slash_index: usize) -> (char, usize) { @@ -90,7 +91,10 @@ pub(super) fn skip_css_whitespace_and_comments(bytes: &[u8], mut index: usize) - pub(super) fn skip_css_whitespace(bytes: &[u8], mut index: usize) -> usize { for _ in 0..bytes.len().saturating_add(1) { - if !bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + if !bytes + .get(index) + .is_some_and(|byte| is_css_whitespace(*byte)) + { break; } index = index.saturating_add(1); @@ -98,6 +102,29 @@ pub(super) fn skip_css_whitespace(bytes: &[u8], mut index: usize) -> usize { index } +pub(super) fn trim_css_whitespace_range( + bytes: &[u8], + mut start: usize, + mut end: usize, +) -> (usize, usize) { + while start < end + && bytes + .get(start) + .is_some_and(|byte| is_css_whitespace(*byte)) + { + start = start.saturating_add(1); + } + while end > start + && end + .checked_sub(1) + .and_then(|index| bytes.get(index)) + .is_some_and(|byte| is_css_whitespace(*byte)) + { + end = end.saturating_sub(1); + } + (start, end) +} + pub(super) fn skip_quoted_value(input: &str, start: usize) -> Option { let bytes = input.as_bytes(); let quote = *bytes.get(start)?; @@ -157,6 +184,10 @@ const fn is_name_byte(byte: u8) -> bool { is_name_start_byte(byte) || byte.is_ascii_digit() || byte == b'-' } +const fn is_css_whitespace(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ') +} + pub(super) const fn utf8_char_len(first_byte: u8) -> usize { match first_byte { 0x00..=0x7f => 1, @@ -171,7 +202,7 @@ fn consume_escape_terminator(bytes: &[u8], index: usize) -> usize { Some(b'\r') if bytes.get(index.saturating_add(1)) == Some(&b'\n') => { index.saturating_add(2) } - Some(byte) if byte.is_ascii_whitespace() => index.saturating_add(1), + Some(byte) if is_css_whitespace(*byte) => index.saturating_add(1), _ => index, } } diff --git a/crates/unixnotis-core/src/css/references/tests/lexer.rs b/crates/unixnotis-core/src/css/references/tests/lexer.rs index f730f220c..262c8046e 100644 --- a/crates/unixnotis-core/src/css/references/tests/lexer.rs +++ b/crates/unixnotis-core/src/css/references/tests/lexer.rs @@ -1,5 +1,5 @@ use super::super::lexer::{ - consume_escape, consume_identifier, skip_css_whitespace_and_comments, skip_quoted_value, + consume_escape, identifier_matches, skip_css_whitespace_and_comments, skip_quoted_value, valid_escape, would_start_identifier, }; use super::super::{collect_css_import_values, collect_css_url_values, CssImportReference}; @@ -82,19 +82,11 @@ fn escaped_non_ascii_string_content_does_not_end_string_skipping_early() { } #[test] -fn identifier_consumption_preserves_unicode_digits_hyphens_and_exact_end() { - assert_eq!( - consume_identifier("é-theme2(", 0), - ("é-theme2".to_string(), 9) - ); - assert_eq!(consume_identifier("_theme(", 0), ("_theme".to_string(), 6)); - assert_eq!(consume_identifier("url-2(", 0), ("url-2".to_string(), 5)); -} - -#[test] -fn identifier_consumption_stops_before_invalid_escapes() { - assert_eq!(consume_identifier("url\\\nnext", 0), ("url".to_string(), 3)); - assert_eq!(consume_identifier("url\\", 0), ("url".to_string(), 3)); +fn fixed_identifier_matching_decodes_escapes_without_allocating_names() { + assert_eq!(identifier_matches("u\\72l(", 0, "url"), (true, 5)); + assert_eq!(identifier_matches("im\\70ort ", 0, "import"), (true, 8)); + assert_eq!(identifier_matches("url-extra(", 0, "url"), (false, 9)); + assert_eq!(identifier_matches("éurl(", 0, "url"), (false, 5)); } #[test] diff --git a/crates/unixnotis-core/src/css/references/tests/url.rs b/crates/unixnotis-core/src/css/references/tests/url.rs index 73e230348..31eeddfb4 100644 --- a/crates/unixnotis-core/src/css/references/tests/url.rs +++ b/crates/unixnotis-core/src/css/references/tests/url.rs @@ -88,3 +88,39 @@ fn invalid_unquoted_delimiters_and_controls_are_marked_ambiguous() { assert!(values[0].ambiguous, "{css:?} should be ambiguous"); } } + +#[test] +fn unicode_whitespace_in_unquoted_urls_preserves_utf8_aligned_ranges() { + let unicode_whitespace = [ + '\u{0085}', '\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', + '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', + '\u{2028}', '\u{2029}', '\u{202F}', '\u{205F}', '\u{3000}', + ]; + + for whitespace in unicode_whitespace { + for value in [ + format!("{whitespace}asset.png"), + format!("asset.png{whitespace}"), + format!("{whitespace}asset.png{whitespace}"), + ] { + let css = format!("url({value})"); + let spans = collect_css_url_spans(&css).expect("scan Unicode URL whitespace"); + let span = spans.first().expect("one URL span"); + + assert_eq!(span.value, value); + assert!(css.is_char_boundary(span.value_start)); + assert!(css.is_char_boundary(span.value_end)); + assert_eq!(&css[span.value_start..span.value_end], value); + } + } +} + +#[test] +fn unquoted_url_trims_only_css_whitespace_bytes() { + let css = "url(\t\n\u{000c}\r asset.png \t\n\u{000c}\r) url(\u{000b}asset.png\u{000b})"; + let spans = collect_css_url_spans(css).expect("scan exact CSS whitespace"); + + assert_eq!(spans[0].value, "asset.png"); + assert_eq!(spans[1].value, "\u{000b}asset.png\u{000b}"); + assert!(spans[1].ambiguous); +} diff --git a/crates/unixnotis-core/src/css/references/url.rs b/crates/unixnotis-core/src/css/references/url.rs index 3178d13fa..49d55436c 100644 --- a/crates/unixnotis-core/src/css/references/url.rs +++ b/crates/unixnotis-core/src/css/references/url.rs @@ -1,8 +1,8 @@ //! Decoded CSS `url(...)` discovery and byte-range extraction use super::lexer::{ - consume_escape, consume_identifier, skip_comment, skip_css_whitespace, skip_quoted_value, - starts_comment, utf8_char_len, would_start_identifier, + consume_escape, identifier_matches, skip_comment, skip_css_whitespace, skip_quoted_value, + starts_comment, trim_css_whitespace_range, utf8_char_len, would_start_identifier, }; use super::{CssReference, CssReferenceError, CssUrlSpan}; @@ -47,8 +47,8 @@ pub fn collect_css_url_spans(css_text: &str) -> Result, CssRefer } // CSS escapes are decoded while the source indexes remain byte-exact - let (name, name_end) = consume_identifier(css_text, index); - if name.eq_ignore_ascii_case("url") && bytes.get(name_end) == Some(&b'(') { + let (is_url, name_end) = identifier_matches(css_text, index, "url"); + if is_url && bytes.get(name_end) == Some(&b'(') { let (span, next_index) = parse_url_value(css_text, name_end.saturating_add(1)) .ok_or(CssReferenceError::UnterminatedUrl)?; if next_index <= index { @@ -148,14 +148,17 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS continue; } if byte == b')' { - let raw = &input[raw_start..index]; - let value = raw.trim(); - // Leading whitespace was consumed before raw_start was recorded - let value_start = raw_start; - let value_end = value_start + value.len(); + // CSS defines five ASCII whitespace bytes; Unicode whitespace remains URL data + let (value_start, value_end) = trim_css_whitespace_range(bytes, raw_start, index); + if value_start > value_end + || !input.is_char_boundary(value_start) + || !input.is_char_boundary(value_end) + { + return None; + } return Some(( CssUrlSpan { - value: value.to_string(), + value: input[value_start..value_end].to_string(), value_start, value_end, ambiguous, From 4a1a1069c3d570986f12a14f3d78574219e1d749 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:00:27 -0500 Subject: [PATCH 056/275] fix(daemon): contain notification sound decoding Summary: contain notification sound decoding. Scope: daemon. --- .../src/config/loading/io/tests/load.rs | 21 +++ crates/unixnotis-core/src/config/types.rs | 6 + .../unixnotis-core/src/filesystem/atomic.rs | 8 +- crates/unixnotis-core/src/filesystem/mod.rs | 2 +- .../src/filesystem/tests/read.rs | 26 +++- .../daemon/notifications/server/tests/flow.rs | 2 +- crates/unixnotis-daemon/src/runtime/daemon.rs | 2 +- crates/unixnotis-daemon/src/sound/backend.rs | 8 +- crates/unixnotis-daemon/src/sound/command.rs | 107 ++++++++++++-- crates/unixnotis-daemon/src/sound/mod.rs | 4 +- crates/unixnotis-daemon/src/sound/resolve.rs | 131 ++++++++++++++---- crates/unixnotis-daemon/src/sound/settings.rs | 33 +++-- crates/unixnotis-daemon/src/sound/source.rs | 45 ++++++ .../src/sound/tests/command.rs | 29 +++- .../src/sound/tests/resolve.rs | 97 ++++++++++--- .../src/sound/tests/settings.rs | 12 +- crates/unixnotis-daemon/src/tests/support.rs | 2 +- 17 files changed, 445 insertions(+), 90 deletions(-) create mode 100644 crates/unixnotis-daemon/src/sound/source.rs diff --git a/crates/unixnotis-core/src/config/loading/io/tests/load.rs b/crates/unixnotis-core/src/config/loading/io/tests/load.rs index f41019d31..bbd33990a 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/load.rs @@ -69,6 +69,27 @@ fn parse_returns_the_config_produced_by_the_report_pipeline() { assert_eq!(config.panel.title, "Parsed Title"); } +#[test] +fn sound_file_hints_require_explicit_configuration() { + let defaults = Config::parse("").expect("default config should parse"); + let enabled = Config::parse( + r#" + [sound] + allow_file_hints = true + allowed_file_hint_dirs = ["sounds", "/srv/notification-sounds"] + "#, + ) + .expect("sound hint policy should parse"); + + assert!(!defaults.sound.allow_file_hints); + assert!(defaults.sound.allowed_file_hint_dirs.is_empty()); + assert!(enabled.sound.allow_file_hints); + assert_eq!( + enabled.sound.allowed_file_hint_dirs, + ["sounds", "/srv/notification-sounds"] + ); +} + #[test] fn load_from_path_rejects_oversized_config_before_parsing() { assert_eq!(MAX_CONFIG_BYTES, EXPECTED_MAX_CONFIG_BYTES as u64); diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index a28394f92..5b5a36385 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -110,6 +110,10 @@ impl Default for HistoryConfig { pub struct SoundConfig { /// Enables sound playback when the daemon receives notifications pub enabled: bool, + /// Allows notification senders to request local audio files + pub allow_file_hints: bool, + /// Directories that may contain notification-requested audio files + pub allowed_file_hint_dirs: Vec, /// Default named sound from the freedesktop sound theme pub default_name: Option, /// Default sound file path, resolves relative to the `UnixNotis` config dir @@ -122,6 +126,8 @@ impl Default for SoundConfig { fn default() -> Self { Self { enabled: true, + allow_file_hints: false, + allowed_file_hint_dirs: Vec::new(), default_name: Some("message-new-instant".to_string()), default_file: None, default_dir: None, diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 6a05815de..ae9bb18c2 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -179,7 +179,13 @@ pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) } -pub(super) fn open_regular_file(path: &Path) -> io::Result { +/// Open one regular file through a no-follow descriptor path +/// +/// # Errors +/// +/// Returns an error when any path component is a link, the target is not a regular file, or the +/// descriptor-relative open fails +pub fn open_regular_file(path: &Path) -> io::Result { let (parent_fd, file_name) = open_parent_existing(path)?; open_regular_file_at(&parent_fd, &file_name) } diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index f351a8ea8..d609c8a31 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -10,7 +10,7 @@ mod rename; mod symlink; pub use atomic::{ - ensure_exact_file, make_file_executable, set_file_mode, write_file_atomic, + ensure_exact_file, make_file_executable, open_regular_file, set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, EnsureExactFileOutcome, }; pub use directory::{ diff --git a/crates/unixnotis-core/src/filesystem/tests/read.rs b/crates/unixnotis-core/src/filesystem/tests/read.rs index fb6d2f72f..6a6104fa0 100644 --- a/crates/unixnotis-core/src/filesystem/tests/read.rs +++ b/crates/unixnotis-core/src/filesystem/tests/read.rs @@ -1,9 +1,10 @@ //! Bounded regular-file read tests use std::fs; +use std::io::Read; use std::os::unix::fs::symlink; -use super::read_regular_file_bounded; +use super::{open_regular_file, read_regular_file_bounded}; use crate::test_support::unique_temp_path; #[test] @@ -80,3 +81,26 @@ fn bounded_regular_file_read_rejects_a_directory() { assert!(path.is_dir()); let _ = fs::remove_dir_all(root); } + +#[test] +fn open_regular_file_retains_the_validated_object_after_path_replacement() { + let root = unique_temp_path("open-regular-pinned"); + let path = root.join("sound.ogg"); + let moved = root.join("original.ogg"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"original").expect("write original file"); + let mut file = open_regular_file(&path).expect("open validated file"); + + fs::rename(&path, &moved).expect("move original file"); + fs::write(&path, b"replacement").expect("write replacement file"); + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("read retained descriptor"); + + assert_eq!(contents, "original"); + assert_eq!( + fs::read_to_string(path).expect("read replacement"), + "replacement" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index ba04da591..2c7e3aa8e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -91,7 +91,7 @@ fn notify_header_message() -> Message { async fn daemon_state_with_config(config: Config) -> Arc { let connection = Connection::session().await.expect("session bus"); - let sound = SoundSettings::from_config(&config); + let sound = SoundSettings::from_config(&config, None); let store = NotificationStore::new_with_state_store(config, None); DaemonState::new_with_store(connection, store, sound, false) } diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index ae6770d0a..8b2ed0b57 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -29,7 +29,7 @@ pub(super) async fn run_daemon( notifications_name: zbus::names::BusName<'_>, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work - let sound_settings = SoundSettings::from_config(&config); + let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); let state = DaemonState::new(connection.clone(), config, sound_settings, args.trial); let scheduler = ExpirationScheduler::start(state.clone()); state.set_scheduler(scheduler.clone()); diff --git a/crates/unixnotis-daemon/src/sound/backend.rs b/crates/unixnotis-daemon/src/sound/backend.rs index 2b32eb4d0..63798881e 100644 --- a/crates/unixnotis-daemon/src/sound/backend.rs +++ b/crates/unixnotis-daemon/src/sound/backend.rs @@ -1,4 +1,4 @@ -use unixnotis_core::program_in_path; +use crate::system_tools; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(super) enum SoundBackend { @@ -14,13 +14,13 @@ pub(super) enum SoundBackend { pub(super) fn detect_backend() -> SoundBackend { // Prefer canberra first because it supports both sound names and files - if program_in_path("canberra-gtk-play") { + if system_tools::program_path("canberra-gtk-play").is_ok() { return SoundBackend::Canberra; } - if program_in_path("pw-play") { + if system_tools::program_path("pw-play").is_ok() { return SoundBackend::PwPlay; } - if program_in_path("paplay") { + if system_tools::program_path("paplay").is_ok() { return SoundBackend::PaPlay; } SoundBackend::None diff --git a/crates/unixnotis-daemon/src/sound/command.rs b/crates/unixnotis-daemon/src/sound/command.rs index 42be65711..c8e41bdb1 100644 --- a/crates/unixnotis-daemon/src/sound/command.rs +++ b/crates/unixnotis-daemon/src/sound/command.rs @@ -1,4 +1,5 @@ use std::ffi::OsString; +use std::fs::File; use std::process::Stdio; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -9,6 +10,8 @@ use tokio::time::timeout; use tracing::{debug, warn}; use unixnotis_core::util; +use crate::system_tools; + use super::SoundSource; const SOUND_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); @@ -18,37 +21,63 @@ const SOUND_MAX_CONCURRENT: usize = 2; pub(super) fn play_with_canberra(source: SoundSource) { // canberra supports both symbolic names and direct files let mut args = Vec::new(); + let mut display_args = Vec::new(); + let mut keepalive = None; match source { SoundSource::Name(name) => { args.push(OsString::from("-i")); args.push(OsString::from(name)); + display_args.clone_from(&args); } - SoundSource::File(path) => { + SoundSource::File(file) => { args.push(OsString::from("-f")); - args.push(path.into_os_string()); + args.push(file.playback_path().into_os_string()); + display_args.push(OsString::from("-f")); + display_args.push(file.path().as_os_str().to_os_string()); + keepalive = Some(file.keepalive()); } } - spawn_sound_command("canberra", "canberra-gtk-play", &args); + spawn_sound_command( + "canberra", + "canberra-gtk-play", + &args, + &display_args, + keepalive, + ); } pub(super) fn play_with_pw_play(source: SoundSource) { // pw-play accepts only direct file playback - let SoundSource::File(path) = source else { + let SoundSource::File(file) = source else { warn!("pw-play backend does not support sound-name hints"); return; }; - let args = vec![path.into_os_string()]; - spawn_sound_command("pw-play", "pw-play", &args); + let args = vec![file.playback_path().into_os_string()]; + let display_args = vec![file.path().as_os_str().to_os_string()]; + spawn_sound_command( + "pw-play", + "pw-play", + &args, + &display_args, + Some(file.keepalive()), + ); } pub(super) fn play_with_paplay(source: SoundSource) { // paplay accepts only direct file playback - let SoundSource::File(path) = source else { + let SoundSource::File(file) = source else { warn!("paplay backend does not support sound-name hints"); return; }; - let args = vec![path.into_os_string()]; - spawn_sound_command("paplay", "paplay", &args); + let args = vec![file.playback_path().into_os_string()]; + let display_args = vec![file.path().as_os_str().to_os_string()]; + spawn_sound_command( + "paplay", + "paplay", + &args, + &display_args, + Some(file.keepalive()), + ); } fn sound_semaphore() -> &'static Arc { @@ -57,7 +86,13 @@ fn sound_semaphore() -> &'static Arc { SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(SOUND_MAX_CONCURRENT))) } -fn spawn_sound_command(backend: &'static str, program: &str, args: &[OsString]) { +fn spawn_sound_command( + backend: &'static str, + program: &str, + args: &[OsString], + display_args: &[OsString], + keepalive: Option>, +) { let limiter = sound_semaphore().clone(); // try_acquire keeps this call non-blocking on hot paths let permit = if let Ok(permit) = limiter.try_acquire_owned() { @@ -66,9 +101,20 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[OsString]) debug!(backend, "sound command skipped (concurrency limit reached)"); return; }; - let command_str = sound_command_display(program, args); + let command_str = sound_command_display(program, display_args); let command_snip = util::log_snippet(&command_str); - let mut command = build_sound_command(program, args); + let mut command = match build_sound_command(program, args) { + Ok(command) => command, + Err(err) => { + warn!( + backend, + program, + ?err, + "trusted sound backend is unavailable" + ); + return; + } + }; match command.spawn() { Ok(child) => { let pid = child.id(); @@ -81,6 +127,8 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[OsString]) tokio::spawn(async move { // Keep the permit owned until this child exits or gets killed let _permit = permit; + // Keep descriptor-backed paths valid for the complete decoder lifetime + let _keepalive = keepalive; reap_sound_child(backend, command_snip, pid, child).await; }); } @@ -95,8 +143,8 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[OsString]) } } -fn build_sound_command(program: &str, args: &[OsString]) -> Command { - let mut command = Command::new(program); +fn build_sound_command(program: &str, args: &[OsString]) -> std::io::Result { + let mut command = system_tools::tokio_command(program)?; command // OsString keeps every valid Unix path byte intact .args(args) @@ -105,7 +153,36 @@ fn build_sound_command(program: &str, args: &[OsString]) -> Command { .stderr(Stdio::null()) // Dropped tasks must not leave playback children behind .kill_on_drop(true); - command + apply_sound_environment(&mut command); + Ok(command) +} + +fn apply_sound_environment(command: &mut Command) { + const PASSTHROUGH: [&str; 12] = [ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LC_ALL", + "PIPEWIRE_REMOTE", + "PULSE_SERVER", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + ]; + + // Decoder helpers receive only session routing data and a fixed system search path + command.env_clear().env( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ); + for name in PASSTHROUGH { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } } fn sound_command_display(program: &str, args: &[OsString]) -> String { diff --git a/crates/unixnotis-daemon/src/sound/mod.rs b/crates/unixnotis-daemon/src/sound/mod.rs index 831c46af7..0cf186763 100644 --- a/crates/unixnotis-daemon/src/sound/mod.rs +++ b/crates/unixnotis-daemon/src/sound/mod.rs @@ -4,5 +4,7 @@ mod backend; mod command; mod resolve; mod settings; +mod source; -pub use settings::{SoundSettings, SoundSource}; +pub use settings::SoundSettings; +use source::{SoundFile, SoundSource}; diff --git a/crates/unixnotis-daemon/src/sound/resolve.rs b/crates/unixnotis-daemon/src/sound/resolve.rs index a0bbe0b06..392085348 100644 --- a/crates/unixnotis-daemon/src/sound/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/resolve.rs @@ -1,23 +1,34 @@ use std::collections::HashMap; use std::fs; +use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use tracing::{debug, info}; +use unixnotis_core::filesystem::{open_regular_file, ContainedPath}; use unixnotis_core::{util, Config}; use zbus::zvariant::OwnedValue; -use super::SoundSource; +use super::{SoundFile, SoundSource}; const MAX_SOUND_FILE_BYTES: u64 = 16 * 1024 * 1024; +const SOUND_HEADER_PROBE_BYTES: usize = 4 * 1024; -pub(super) fn resolve_hint_sound(hints: &HashMap) -> Option { - // sound-file has priority because it is the most explicit payload - if let Some(file) = hint_string(hints, "sound-file") { - let path = resolve_sound_file(&file); - if validate_sound_file_path(&path) { - return Some(SoundSource::File(path)); +pub(super) fn resolve_hint_sound( + hints: &HashMap, + allow_file_hints: bool, + allowed_dirs: &[PathBuf], +) -> Option { + // File hints cross into host decoders and stay disabled unless explicitly allowed + if allow_file_hints { + if let Some(file) = hint_string(hints, "sound-file") { + let path = resolve_sound_file(&file); + if path_is_allowed(&path, allowed_dirs) { + if let Some(file) = open_sound_file(&path, true) { + return Some(SoundSource::File(file)); + } + } + debug!(path = %path.display(), "ignoring invalid sound-file hint"); } - debug!(path = %path.display(), "ignoring invalid sound-file hint"); } // Fall back to event name when file path is missing or invalid if let Some(name) = hint_string(hints, "sound-name") { @@ -26,21 +37,44 @@ pub(super) fn resolve_hint_sound(hints: &HashMap) -> Option< None } -pub(super) fn resolve_default_file(config: &Config) -> Option { +pub(super) fn resolve_default_file( + config: &Config, + config_dir: Option<&Path>, +) -> Option { // First choice is an explicit default file if let Some(path) = config.sound.default_file.as_ref() { - let resolved = resolve_config_path(path).or_else(|| Some(PathBuf::from(path))); - return resolved.filter(|path| validate_sound_file_path(path)); + let resolved = resolve_config_path(path, config_dir); + return resolved.and_then(|path| open_sound_file(&path, false)); } // Second choice is scanning a configured directory for the first valid audio file if let Some(dir) = config.sound.default_dir.as_ref() { - if let Some(path) = resolve_config_path(dir).or_else(|| Some(PathBuf::from(dir))) { + if let Some(path) = resolve_config_path(dir, config_dir) { return choose_first_sound_file(&path); } } None } +pub(super) fn resolve_config_dir(config_path: Option<&Path>) -> Option { + // An explicit daemon path owns relative assets even when the environment selects another file + let config_path = config_path + .map(Path::to_path_buf) + .or_else(|| Config::active_config_path().ok())?; + config_path.parent().map(Path::to_path_buf) +} + +pub(super) fn resolve_allowed_file_hint_dirs( + config: &Config, + config_dir: Option<&Path>, +) -> Vec { + config + .sound + .allowed_file_hint_dirs + .iter() + .filter_map(|path| resolve_config_path(path, config_dir)) + .collect() +} + pub(super) fn hint_bool(hints: &HashMap, key: &str) -> Option { // Borrowed conversion avoids cloning large values hints.get(key).and_then(|value| bool::try_from(value).ok()) @@ -97,18 +131,18 @@ fn percent_decode_path(value: &str) -> Option { String::from_utf8(out).ok() } -fn resolve_config_path(value: &str) -> Option { +fn resolve_config_path(value: &str, config_dir: Option<&Path>) -> Option { // Expand "~" so config remains short and portable let path = util::expand_tilde(value); let path = PathBuf::from(path.as_ref()); if path.is_absolute() { return Some(path); } - let base = Config::default_config_dir().ok()?; + let base = config_dir?; Some(base.join(path)) } -fn choose_first_sound_file(dir: &Path) -> Option { +fn choose_first_sound_file(dir: &Path) -> Option { // Missing directory is treated as no default instead of an error path let entries = fs::read_dir(dir).ok()?; let mut candidates = Vec::new(); @@ -121,15 +155,18 @@ fn choose_first_sound_file(dir: &Path) -> Option { } // Deterministic ordering keeps startup behavior stable between runs candidates.sort(); - let selected = candidates.into_iter().next(); - if let Some(path) = selected.as_ref() { + for path in candidates { + let Some(selected) = open_sound_file(&path, false) else { + continue; + }; let name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("sound file"); info!(name, "using default notification sound file"); + return Some(selected); } - selected + None } fn has_audio_extension(path: &Path) -> bool { @@ -143,12 +180,58 @@ fn has_audio_extension(path: &Path) -> bool { ) } -fn validate_sound_file_path(path: &Path) -> bool { - let Ok(meta) = fs::metadata(path) else { - return false; - }; - // Regular files with a bounded size avoid device and FIFO abuse - meta.is_file() && meta.len() <= MAX_SOUND_FILE_BYTES && has_audio_extension(path) +fn open_sound_file(path: &Path, require_safe_hint_format: bool) -> Option { + if !has_audio_extension(path) { + return None; + } + // One descriptor binds all checks and later playback to the same regular file + let file = open_regular_file(path).ok()?; + let metadata = file.metadata().ok()?; + if metadata.len() > MAX_SOUND_FILE_BYTES { + return None; + } + if require_safe_hint_format && !has_safe_hint_format(path, &file) { + return None; + } + Some(SoundFile::new(path.to_path_buf(), file)) +} + +fn path_is_allowed(path: &Path, allowed_dirs: &[PathBuf]) -> bool { + path.is_absolute() + && allowed_dirs + .iter() + .any(|root| ContainedPath::resolve(root, path).is_ok()) +} + +fn has_safe_hint_format(path: &Path, file: &fs::File) -> bool { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + let mut header = [0u8; SOUND_HEADER_PROBE_BYTES]; + let read = file.read_at(&mut header, 0).ok().unwrap_or(0); + let header = &header[..read]; + + if extension.eq_ignore_ascii_case("wav") { + return header.starts_with(b"RIFF") + && header.get(8..12) == Some(b"WAVE") + && wav_audio_format(header) == Some(1); + } + if extension.eq_ignore_ascii_case("ogg") || extension.eq_ignore_ascii_case("oga") { + return header.starts_with(b"OggS") + && header.windows(7).any(|window| window == b"\x01vorbis"); + } + false +} + +fn wav_audio_format(header: &[u8]) -> Option { + let format_offset = header.windows(4).position(|window| window == b"fmt ")?; + let value_start = format_offset.checked_add(8)?; + let bytes: [u8; 2] = header + .get(value_start..value_start.checked_add(2)?)? + .try_into() + .ok()?; + Some(u16::from_le_bytes(bytes)) } fn hint_string(hints: &HashMap, key: &str) -> Option { diff --git a/crates/unixnotis-daemon/src/sound/settings.rs b/crates/unixnotis-daemon/src/sound/settings.rs index 0113a2947..4b2d087c8 100644 --- a/crates/unixnotis-daemon/src/sound/settings.rs +++ b/crates/unixnotis-daemon/src/sound/settings.rs @@ -1,7 +1,7 @@ //! Notification sound playback and backend selection use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -11,7 +11,11 @@ use zbus::zvariant::OwnedValue; use super::backend::{detect_backend, SoundBackend}; use super::command::{play_with_canberra, play_with_paplay, play_with_pw_play}; -use super::resolve::{hint_bool, resolve_default_file, resolve_hint_sound}; +use super::resolve::{ + hint_bool, resolve_allowed_file_hint_dirs, resolve_config_dir, resolve_default_file, + resolve_hint_sound, +}; +use super::SoundSource; const SOUND_MIN_INTERVAL: Duration = Duration::from_millis(150); @@ -21,24 +25,22 @@ pub struct SoundSettings { enabled: bool, // Detected backend that is safe to call on this machine backend: SoundBackend, + // File hints are an explicit compatibility opt-in + allow_file_hints: bool, + // Every accepted file hint must remain beneath one configured directory + allowed_file_hint_dirs: Vec, // Fallback event name used by canberra-style backends default_name: Option, // Fallback audio file path when hint does not supply one - default_file: Option, + default_file: Option, // Last successful play request used for burst throttling last_played: Mutex>, } -#[derive(Debug, Clone)] -pub enum SoundSource { - Name(String), - File(PathBuf), -} - impl SoundSettings { /// Build sound settings from configuration and resolve any custom paths - pub fn from_config(config: &Config) -> Self { - // Backend discovery is done once during startup to avoid repeated PATH scans + pub fn from_config(config: &Config, config_path: Option<&Path>) -> Self { + // Backend discovery is done once during startup to avoid repeated trusted-path scans let backend = detect_backend(); debug!(?backend, "sound backend selected"); if Self::should_warn_missing_backend(config.sound.enabled, backend) { @@ -46,10 +48,14 @@ impl SoundSettings { } // Resolve config paths once so notification hot paths stay cheap - let default_file = resolve_default_file(config); + let config_dir = resolve_config_dir(config_path); + let default_file = resolve_default_file(config, config_dir.as_deref()); + let allowed_file_hint_dirs = resolve_allowed_file_hint_dirs(config, config_dir.as_deref()); Self { enabled: config.sound.enabled, backend, + allow_file_hints: config.sound.allow_file_hints, + allowed_file_hint_dirs, default_name: config.sound.default_name.clone(), default_file, last_played: Mutex::new(None), @@ -77,7 +83,8 @@ impl SoundSettings { } // Hint source wins, then fallback source from config - let source = resolve_hint_sound(hints).or_else(|| self.default_source()); + let source = resolve_hint_sound(hints, self.allow_file_hints, &self.allowed_file_hint_dirs) + .or_else(|| self.default_source()); if let Some(source) = source { return self.play(source); } diff --git a/crates/unixnotis-daemon/src/sound/source.rs b/crates/unixnotis-daemon/src/sound/source.rs new file mode 100644 index 000000000..486180528 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/source.rs @@ -0,0 +1,45 @@ +//! Descriptor-pinned sound inputs + +use std::fs::File; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub(super) struct SoundFile { + // The original path is retained only for diagnostics and policy checks + path: PathBuf, + // The open file pins the validated object until the playback child exits + file: Arc, +} + +impl SoundFile { + pub(super) fn new(path: PathBuf, file: File) -> Self { + Self { + path, + file: Arc::new(file), + } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) fn playback_path(&self) -> PathBuf { + // The child opens the daemon's retained descriptor instead of resolving the source again + PathBuf::from("/proc") + .join(std::process::id().to_string()) + .join("fd") + .join(self.file.as_raw_fd().to_string()) + } + + pub(super) fn keepalive(&self) -> Arc { + self.file.clone() + } +} + +#[derive(Debug, Clone)] +pub(super) enum SoundSource { + Name(String), + File(SoundFile), +} diff --git a/crates/unixnotis-daemon/src/sound/tests/command.rs b/crates/unixnotis-daemon/src/sound/tests/command.rs index 80e0d56fa..717792fa6 100644 --- a/crates/unixnotis-daemon/src/sound/tests/command.rs +++ b/crates/unixnotis-daemon/src/sound/tests/command.rs @@ -1,17 +1,40 @@ use super::*; +use crate::system_tools::routing::use_fake_tool_bin; +use crate::test_support::TempRoot; + +fn fake_sound_tool(name: &str) -> (TempRoot, crate::system_tools::routing::FakeToolBinGuard) { + use std::os::unix::fs::PermissionsExt; + + let root = TempRoot::new("sound-command"); + let path = root.join(name); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake sound tool"); + let mut permissions = std::fs::metadata(&path) + .expect("fake sound tool metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("make fake sound tool executable"); + let guard = use_fake_tool_bin(root.path()); + (root, guard) +} #[cfg(unix)] #[test] fn sound_command_preserves_non_utf8_argument_bytes() { use std::os::unix::ffi::OsStringExt; + let (_root, _tools) = fake_sound_tool("sound-player"); let path = OsString::from_vec(b"/tmp/sound-\xff.ogg".to_vec()); - let command = build_sound_command("true", std::slice::from_ref(&path)); + let command = build_sound_command("sound-player", std::slice::from_ref(&path)) + .expect("build trusted sound command"); let args = command.as_std().get_args().collect::>(); - let display = sound_command_display("true", std::slice::from_ref(&path)); + let display = sound_command_display("sound-player", std::slice::from_ref(&path)); assert_eq!(args, vec![path.as_os_str()]); - assert_eq!(display, "true /tmp/sound-�.ogg"); + assert_eq!(display, "sound-player /tmp/sound-�.ogg"); + assert!(command + .as_std() + .get_envs() + .any(|(name, value)| name == "PATH" && value.is_some())); } #[cfg(target_os = "linux")] diff --git a/crates/unixnotis-daemon/src/sound/tests/resolve.rs b/crates/unixnotis-daemon/src/sound/tests/resolve.rs index 1afcfaeba..a37a53942 100644 --- a/crates/unixnotis-daemon/src/sound/tests/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/tests/resolve.rs @@ -1,5 +1,5 @@ use super::*; -use crate::test_support::{env_lock, EnvVarGuard, TempRoot}; +use crate::test_support::TempRoot; use zbus::zvariant::Value; fn string_value(value: &str) -> OwnedValue { @@ -9,7 +9,13 @@ fn string_value(value: &str) -> OwnedValue { } fn write_sound_file(path: &Path) { - fs::write(path, b"sound").expect("write sound file"); + let contents = match path.extension().and_then(|extension| extension.to_str()) { + Some(extension) if extension.eq_ignore_ascii_case("wav") => { + b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00".as_slice() + } + _ => b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01vorbis".as_slice(), + }; + fs::write(path, contents).expect("write sound file"); } #[test] @@ -37,7 +43,7 @@ fn percent_decode_path_rejects_nul_and_keeps_utf8_valid() { } #[test] -fn resolve_hint_sound_prefers_valid_sound_file_and_falls_back_to_name() { +fn resolve_hint_sound_requires_opt_in_allowed_directory_and_safe_format() { let root = TempRoot::new("sound-hints"); let sound = root.join("alert.ogg"); write_sound_file(&sound); @@ -49,39 +55,57 @@ fn resolve_hint_sound_prefers_valid_sound_file_and_falls_back_to_name() { ); hints.insert("sound-name".to_string(), string_value("message-new")); - match resolve_hint_sound(&hints).expect("sound-file should resolve") { - SoundSource::File(path) => assert_eq!(path, sound), + match resolve_hint_sound(&hints, true, &[root.path().to_path_buf()]) + .expect("sound-file should resolve") + { + SoundSource::File(file) => assert_eq!(file.path(), sound), SoundSource::Name(name) => panic!("sound file should win over name: {name}"), } + match resolve_hint_sound(&hints, false, &[root.path().to_path_buf()]) + .expect("sound-name should remain when file hints are disabled") + { + SoundSource::Name(name) => assert_eq!(name, "message-new"), + SoundSource::File(file) => panic!("disabled sound file was accepted: {:?}", file.path()), + } + + match resolve_hint_sound(&hints, true, &[]) + .expect("sound-name should remain when no directory is allowed") + { + SoundSource::Name(name) => assert_eq!(name, "message-new"), + SoundSource::File(file) => panic!("uncontained sound file was accepted: {:?}", file.path()), + } + hints.insert( "sound-file".to_string(), string_value("/missing/not-a-sound.ogg"), ); - match resolve_hint_sound(&hints).expect("sound-name should remain fallback") { + match resolve_hint_sound(&hints, true, &[root.path().to_path_buf()]) + .expect("sound-name should remain fallback") + { SoundSource::Name(name) => assert_eq!(name, "message-new"), - SoundSource::File(path) => panic!("invalid sound file should not be used: {path:?}"), + SoundSource::File(file) => { + panic!("invalid sound file should not be used: {:?}", file.path()) + } } } #[test] fn resolve_default_file_uses_relative_config_path_and_validates_file() { - let _guard = env_lock(); let root = TempRoot::new("sound-default-file"); - let config_dir = root.join("xdg"); - let unixnotis_dir = config_dir.join("unixnotis"); + let unixnotis_dir = root.join("unixnotis"); fs::create_dir_all(&unixnotis_dir).expect("create config dir"); let sound = unixnotis_dir.join("relative.ogg"); write_sound_file(&sound); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", &config_dir); - let mut config = Config::default(); config.sound.default_file = Some("relative.ogg".to_string()); - assert_eq!(resolve_default_file(&config), Some(sound)); + let selected = resolve_default_file(&config, Some(&unixnotis_dir)) + .expect("relative default should resolve"); + assert_eq!(selected.path(), sound); config.sound.default_file = Some("relative.txt".to_string()); - assert!(resolve_default_file(&config).is_none()); + assert!(resolve_default_file(&config, Some(&unixnotis_dir)).is_none()); } #[test] @@ -94,7 +118,7 @@ fn choose_first_sound_file_filters_extensions_and_sorts_deterministically() { let selected = choose_first_sound_file(root.path()).expect("sound file should be selected"); assert_eq!( - selected.file_name().and_then(|name| name.to_str()), + selected.path().file_name().and_then(|name| name.to_str()), Some("b-first.OGG") ); } @@ -125,7 +149,7 @@ fn has_audio_extension_accepts_supported_audio_extensions_only() { } #[test] -fn validate_sound_file_path_rejects_missing_oversized_and_non_audio_files() { +fn sound_file_open_rejects_missing_oversized_and_non_audio_files() { let root = TempRoot::new("sound-validate"); let valid = root.join("valid.ogg"); let oversized = root.join("oversized.ogg"); @@ -137,14 +161,43 @@ fn validate_sound_file_path_rejects_missing_oversized_and_non_audio_files() { .set_len(MAX_SOUND_FILE_BYTES + 1) .expect("resize oversized sound"); - assert!(validate_sound_file_path(&valid)); - assert!(!validate_sound_file_path(&oversized)); - assert!(!validate_sound_file_path(&wrong_ext)); - assert!(!validate_sound_file_path(&root.join("missing.ogg"))); + assert!(open_sound_file(&valid, false).is_some()); + assert!(open_sound_file(&oversized, false).is_none()); + assert!(open_sound_file(&wrong_ext, false).is_none()); + assert!(open_sound_file(&root.join("missing.ogg"), false).is_none()); +} + +#[test] +fn hint_format_validation_rejects_spoofed_and_complex_audio_formats() { + let root = TempRoot::new("sound-hint-format"); + let spoofed = root.join("spoofed.ogg"); + let mp3 = root.join("sound.mp3"); + let pcm = root.join("sound.wav"); + fs::write(&spoofed, b"not an ogg file").expect("write spoofed Ogg file"); + fs::write(&mp3, b"ID3\x04\x00\x00").expect("write MP3 file"); + write_sound_file(&pcm); + + assert!(open_sound_file(&spoofed, true).is_none()); + assert!(open_sound_file(&mp3, true).is_none()); + assert!(open_sound_file(&pcm, true).is_some()); +} + +#[cfg(unix)] +#[test] +fn sound_file_open_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + + let root = TempRoot::new("sound-symlink"); + let target = root.join("target.ogg"); + let link = root.join("link.ogg"); + write_sound_file(&target); + symlink(&target, &link).expect("create sound symlink"); + + assert!(open_sound_file(&link, false).is_none()); } #[cfg(target_os = "linux")] #[test] -fn validate_sound_file_path_rejects_device_nodes() { - assert!(!validate_sound_file_path(Path::new("/dev/zero"))); +fn sound_file_open_rejects_device_nodes() { + assert!(open_sound_file(Path::new("/dev/zero"), false).is_none()); } diff --git a/crates/unixnotis-daemon/src/sound/tests/settings.rs b/crates/unixnotis-daemon/src/sound/tests/settings.rs index 4e7ad6e2c..d7247007b 100644 --- a/crates/unixnotis-daemon/src/sound/tests/settings.rs +++ b/crates/unixnotis-daemon/src/sound/tests/settings.rs @@ -1,10 +1,14 @@ use super::*; +use crate::sound::SoundFile; +use crate::test_support::TempRoot; use zbus::zvariant::{OwnedValue, Value}; fn settings(enabled: bool, backend: SoundBackend) -> SoundSettings { SoundSettings { enabled, backend, + allow_file_hints: false, + allowed_file_hint_dirs: Vec::new(), default_name: Some("message-new-instant".to_string()), default_file: None, last_played: Mutex::new(None), @@ -55,11 +59,15 @@ fn missing_backend_warning_policy_requires_enabled_sound_without_backend() { #[test] fn default_source_prefers_file_before_event_name() { + let root = TempRoot::new("sound-settings-default"); + let path = root.join("default.ogg"); + std::fs::write(&path, b"sound").expect("write default sound"); + let file = std::fs::File::open(&path).expect("open default sound"); let mut sound = settings(true, SoundBackend::Canberra); - sound.default_file = Some(PathBuf::from("/tmp/unixnotis-test.ogg")); + sound.default_file = Some(SoundFile::new(path.clone(), file)); match sound.default_source().expect("default source") { - SoundSource::File(path) => assert_eq!(path, PathBuf::from("/tmp/unixnotis-test.ogg")), + SoundSource::File(file) => assert_eq!(file.path(), path), SoundSource::Name(name) => panic!("file fallback should win over event name: {name}"), } diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index 049d21b70..ae3291c28 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -27,7 +27,7 @@ pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { .await .expect("session bus should be available for daemon signal tests"); let config = Config::default(); - let sound = SoundSettings::from_config(&config); + let sound = SoundSettings::from_config(&config, None); let store = NotificationStore::new_with_state_store(config, None); DaemonState::new_with_store(connection, store, sound, trial_mode) } From dcc72ea18cf4538d6a4d18e0289238c3fee904dc Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:01:28 -0500 Subject: [PATCH 057/275] perf(center): skip duplicate marquee measurement Summary: skip duplicate marquee measurement. Scope: center. --- crates/unixnotis-center/src/ui/media/marquee.rs | 13 ++++++++----- .../unixnotis-center/src/ui/media/tests/marquee.rs | 10 +++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/unixnotis-center/src/ui/media/marquee.rs b/crates/unixnotis-center/src/ui/media/marquee.rs index 16c4063a5..49e823f68 100644 --- a/crates/unixnotis-center/src/ui/media/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/marquee.rs @@ -134,14 +134,13 @@ impl MarqueeLabel { } fn set_text_inner(&self, text: &str, force: bool) { + // Identical media snapshots avoid both allocation and Pango layout measurement + if !marquee_text_needs_update(&self.state.borrow().full_text, text, force) { + return; + } // Pango measures rendered pixels so short wide-glyph titles still activate scrolling let text_width = self.label.create_pango_layout(Some(text)).pixel_size().0; let mut state = self.state.borrow_mut(); - // Avoid resetting the marquee when the full text is identical - // This prevents unnecessary redraws and keeps CPU usage stable - if !force && state.full_text == text { - return; - } let char_limit = state.char_limit; state.overflows = marquee_should_tick( char_limit, @@ -268,6 +267,10 @@ fn marquee_should_tick( char_limit > 0 && (char_count > char_limit || text_width > max_width.max(0)) } +fn marquee_text_needs_update(current: &str, next: &str, force: bool) -> bool { + force || current != next +} + const fn marquee_can_start(state: &MarqueeState) -> bool { !state.is_ticking && state.tick_source.is_none() diff --git a/crates/unixnotis-center/src/ui/media/tests/marquee.rs b/crates/unixnotis-center/src/ui/media/tests/marquee.rs index cfada4add..8cea2ec7f 100644 --- a/crates/unixnotis-center/src/ui/media/tests/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/tests/marquee.rs @@ -1,5 +1,6 @@ use super::{ - marquee_can_start, marquee_should_stop, marquee_should_tick, MarqueeLabel, MarqueeState, + marquee_can_start, marquee_should_stop, marquee_should_tick, marquee_text_needs_update, + MarqueeLabel, MarqueeState, }; fn ready_marquee_state() -> MarqueeState { @@ -65,6 +66,13 @@ fn marquee_stays_idle_when_text_fits_both_limits() { assert!(!marquee_should_tick(32, 17, 81, 81)); } +#[test] +fn marquee_text_fast_path_skips_identical_updates_unless_forced() { + assert!(!marquee_text_needs_update("Track", "Track", false)); + assert!(marquee_text_needs_update("Track", "Track", true)); + assert!(marquee_text_needs_update("Track", "Next track", false)); +} + #[test] fn disabled_marquee_never_starts_for_overflowing_text() { assert!(!marquee_should_tick(0, 40, 300, 81)); From 9c3fa0bf495655f4d09e85c786c167a204037bad Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:07:04 -0500 Subject: [PATCH 058/275] fix(daemon): bound notification ingress work Summary: bound notification ingress work. Scope: daemon. --- .../src/daemon/control/tests/reply.rs | 19 +++ .../src/daemon/control/watch.rs | 2 + .../src/daemon/notifications/mod.rs | 2 + .../src/daemon/notifications/quota.rs | 139 ++++++++++++++++++ .../src/daemon/notifications/sender.rs | 16 +- .../src/daemon/notifications/sender_cache.rs | 86 +++++++++++ .../src/daemon/notifications/server/close.rs | 7 +- .../src/daemon/notifications/server/flow.rs | 7 +- .../daemon/notifications/server/interface.rs | 29 +++- .../src/daemon/notifications/tests/quota.rs | 45 ++++++ .../notifications/tests/sender_cache.rs | 45 ++++++ .../src/daemon/state/model.rs | 4 + crates/unixnotis-daemon/src/runtime/runner.rs | 9 +- 13 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/quota.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 3430550b7..f27ff6341 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -4,6 +4,7 @@ use std::time::Duration; use chrono::Utc; use futures_util::TryStreamExt; use unixnotis_core::{InlineReply, Notification, NotificationImage, Urgency}; +use zbus::fdo::DBusProxy; use zbus::message::Type; use zbus::zvariant::OwnedValue; use zbus::{Connection, MatchRule, MessageStream}; @@ -215,7 +216,25 @@ async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { .notification .id }; + let sender_name = sender.unique_name().expect("sender unique name").clone(); sender.close().await.expect("close sender connection"); + let proxy = DBusProxy::new(state.connection()) + .await + .expect("create bus proxy"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let has_owner = proxy + .name_has_owner(sender_name.clone().into()) + .await + .expect("query sender ownership"); + if !has_owner { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("bus should release the closed sender name"); let error = ControlServer::new(state.clone()) .submit_inline_reply(id, "Anyone there?") diff --git a/crates/unixnotis-daemon/src/daemon/control/watch.rs b/crates/unixnotis-daemon/src/daemon/control/watch.rs index df8365b97..eba16fb1f 100644 --- a/crates/unixnotis-daemon/src/daemon/control/watch.rs +++ b/crates/unixnotis-daemon/src/daemon/control/watch.rs @@ -32,6 +32,8 @@ pub async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Resul continue; } let owner = args.name().to_string(); + // Unique-name metadata can be dropped as soon as the bus reports owner loss + state.sender_metadata_cache.remove(&owner); // Remove inhibitors owned by the disconnected bus name let (changed, active, count) = { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index b67ddddf9..5875c2d64 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -2,7 +2,9 @@ mod limits; mod payload; +mod quota; mod sender; +pub(in crate::daemon) mod sender_cache; mod server; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/quota.rs new file mode 100644 index 000000000..2d37dc4d7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/quota.rs @@ -0,0 +1,139 @@ +//! Bounded notification ingress policy + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; + +const GLOBAL_BURST: f64 = 120.0; +const GLOBAL_REFILL_PER_SECOND: f64 = 60.0; +const SENDER_BURST: f64 = 40.0; +const SENDER_REFILL_PER_SECOND: f64 = 20.0; +const MAX_TRACKED_SENDERS: usize = 256; +const SENDER_IDLE_TTL_SECONDS: u64 = 60; +const UNKNOWN_SENDER: &str = ""; + +pub(super) struct NotificationQuota { + state: Mutex, +} + +struct QuotaState { + global: TokenBucket, + senders: HashMap, +} + +struct SenderBucket { + bucket: TokenBucket, + last_seen: Instant, +} + +struct TokenBucket { + tokens: f64, + capacity: f64, + refill_per_second: f64, + last_refill: Instant, +} + +impl NotificationQuota { + pub(super) fn new() -> Self { + Self::new_at(Instant::now()) + } + + fn new_at(now: Instant) -> Self { + Self { + state: Mutex::new(QuotaState { + global: TokenBucket::new(GLOBAL_BURST, GLOBAL_REFILL_PER_SECOND, now), + senders: HashMap::new(), + }), + } + } + + pub(super) fn admit(&self, sender: Option<&str>, now: Instant) -> bool { + let Ok(mut state) = self.state.lock() else { + // A poisoned limiter fails closed instead of disabling ingress control + return false; + }; + state.global.refill(now); + if !state.global.has_token() { + return false; + } + + let sender = sender.unwrap_or(UNKNOWN_SENDER); + state.prune_sender_buckets(now); + state.ensure_sender_capacity(sender); + let sender_bucket = + state + .senders + .entry(sender.to_string()) + .or_insert_with(|| SenderBucket { + bucket: TokenBucket::new(SENDER_BURST, SENDER_REFILL_PER_SECOND, now), + last_seen: now, + }); + sender_bucket.last_seen = now; + sender_bucket.bucket.refill(now); + if !sender_bucket.bucket.take_token() { + return false; + } + + // The global token is consumed only after the sender also passes + state.global.take_token() + } +} + +impl QuotaState { + fn prune_sender_buckets(&mut self, now: Instant) { + self.senders.retain(|_sender, bucket| { + now.saturating_duration_since(bucket.last_seen).as_secs() < SENDER_IDLE_TTL_SECONDS + }); + } + + fn ensure_sender_capacity(&mut self, sender: &str) { + if self.senders.contains_key(sender) || self.senders.len() < MAX_TRACKED_SENDERS { + return; + } + // A bounded linear scan is cheaper than unbounded attacker-controlled state + if let Some(oldest) = self + .senders + .iter() + .min_by_key(|(_sender, bucket)| bucket.last_seen) + .map(|(sender, _bucket)| sender.clone()) + { + self.senders.remove(&oldest); + } + } +} + +impl TokenBucket { + const fn new(capacity: f64, refill_per_second: f64, now: Instant) -> Self { + Self { + tokens: capacity, + capacity, + refill_per_second, + last_refill: now, + } + } + + fn refill(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.last_refill); + self.tokens = elapsed + .as_secs_f64() + .mul_add(self.refill_per_second, self.tokens) + .min(self.capacity); + self.last_refill = now; + } + + fn has_token(&self) -> bool { + self.tokens >= 1.0 + } + + fn take_token(&mut self) -> bool { + if !self.has_token() { + return false; + } + self.tokens -= 1.0; + true + } +} + +#[cfg(test)] +#[path = "tests/quota.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs index 5a88d9eaa..b3602c2aa 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs @@ -9,6 +9,8 @@ use zbus::fdo::DBusProxy; use zbus::message::Header; use zbus::Connection; +use super::sender_cache::SenderMetadataCache; + #[derive(Debug, Clone, Default)] pub(super) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks @@ -22,6 +24,7 @@ pub(super) struct SenderMetadata { } pub(super) async fn resolve_sender_metadata( + cache: &SenderMetadataCache, connection: &Connection, header: &Header<'_>, ) -> SenderMetadata { @@ -36,6 +39,12 @@ pub(super) async fn resolve_sender_metadata( }; }; + // Unique names are stable for one bus connection and safe cache identities + if let Some(metadata) = cache.get(sender_name_str) { + return metadata; + } + let cache_key = sender_name_str.to_string(); + let Ok(bus_name) = zbus::names::BusName::try_from(sender_name_str) else { return SenderMetadata { sender_name, @@ -64,12 +73,17 @@ pub(super) async fn resolve_sender_metadata( None => None, }; - SenderMetadata { + let metadata = SenderMetadata { sender_name, sender_pid, sender_start_time, sender_executable, + }; + // Failed lookups remain retryable instead of becoming persistent unknown identities + if metadata.sender_pid.is_some() { + cache.insert(cache_key, metadata.clone()); } + metadata } pub(super) fn app_name_matches_sender(app_name: &str, sender_executable: &str) -> bool { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs new file mode 100644 index 000000000..45a591fa1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs @@ -0,0 +1,86 @@ +//! Bounded sender identity cache keyed by unique D-Bus names + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::sender::SenderMetadata; + +const MAX_CACHED_SENDERS: usize = 256; + +pub(in crate::daemon) struct SenderMetadataCache { + state: Mutex, +} + +struct CacheState { + entries: HashMap, + sequence: u64, +} + +struct CacheEntry { + metadata: SenderMetadata, + last_used: u64, +} + +impl SenderMetadataCache { + pub(in crate::daemon) fn new() -> Self { + Self { + state: Mutex::new(CacheState { + entries: HashMap::new(), + sequence: 0, + }), + } + } + + pub(super) fn get(&self, sender: &str) -> Option { + let mut state = self.state.lock().ok()?; + let sequence = state.next_sequence(); + let entry = state.entries.get_mut(sender)?; + entry.last_used = sequence; + Some(entry.metadata.clone()) + } + + pub(super) fn insert(&self, sender: String, metadata: SenderMetadata) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let sequence = state.next_sequence(); + if !state.entries.contains_key(&sender) && state.entries.len() >= MAX_CACHED_SENDERS { + state.evict_oldest(); + } + state.entries.insert( + sender, + CacheEntry { + metadata, + last_used: sequence, + }, + ); + } + + pub(in crate::daemon) fn remove(&self, sender: &str) { + if let Ok(mut state) = self.state.lock() { + state.entries.remove(sender); + } + } +} + +impl CacheState { + const fn next_sequence(&mut self) -> u64 { + self.sequence = self.sequence.wrapping_add(1); + self.sequence + } + + fn evict_oldest(&mut self) { + let oldest = self + .entries + .iter() + .min_by_key(|(_sender, entry)| entry.last_used) + .map(|(sender, _entry)| sender.clone()); + if let Some(sender) = oldest { + self.entries.remove(&sender); + } + } +} + +#[cfg(test)] +#[path = "tests/sender_cache.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs index 10cc0bbb2..67675c889 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -16,7 +16,12 @@ impl NotificationServer { debug!(id, "close notification requested"); // Close requests are ownership checked and become no-op when unauthorized - let sender = resolve_sender_metadata(self.state.connection(), header).await; + let sender = resolve_sender_metadata( + &self.state.sender_metadata_cache, + self.state.connection(), + header, + ) + .await; let Some(sender_name) = sender.sender_name.as_deref() else { return Ok(()); }; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 81506c2d1..2f4d02a76 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -110,7 +110,12 @@ impl NotificationServer { header: &Header<'_>, ) -> Notification { // Sender metadata helps with ownership checks and diagnostics - let sender = resolve_sender_metadata(self.state.connection(), header).await; + let sender = resolve_sender_metadata( + &self.state.sender_metadata_cache, + self.state.connection(), + header, + ) + .await; if sender_app_name_mismatch(&input.app_name, sender.sender_executable.as_deref()) { debug!( app_name = %input.app_name, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index 87fc05fab..98c3ea56a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; +use tokio::sync::Semaphore; use zbus::message::Header; use zbus::zvariant::OwnedValue; use zbus::{interface, SignalContext}; @@ -10,20 +12,32 @@ use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; +use crate::daemon::notifications::quota::NotificationQuota; use crate::daemon::DaemonState; +const MAX_CONCURRENT_NOTIFY_HANDLERS: usize = 8; + /// D-Bus server for org.freedesktop.Notifications pub struct NotificationServer { // Shared daemon state for store access, sounds, and signal emission pub(super) state: Arc, // Scheduler handles expiration deadlines without blocking D-Bus handlers pub(super) scheduler: ExpirationScheduler, + // Shared token buckets reject sustained sender and process-wide floods + quota: NotificationQuota, + // Expensive sender and payload work has a fixed concurrency ceiling + notify_slots: Semaphore, } impl NotificationServer { - pub const fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { + pub fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { // Keep constructor minimal and explicit - Self { state, scheduler } + Self { + state, + scheduler, + quota: NotificationQuota::new(), + notify_slots: Semaphore::const_new(MAX_CONCURRENT_NOTIFY_HANDLERS), + } } } @@ -50,6 +64,17 @@ impl NotificationServer { #[zbus(header)] header: Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { + let sender = header.sender().map(zbus::names::UniqueName::as_str); + if !self.quota.admit(sender, Instant::now()) { + return Err(zbus::fdo::Error::LimitsExceeded( + "notification ingress quota exceeded".to_string(), + )); + } + let _slot = self.notify_slots.try_acquire().map_err(|_error| { + zbus::fdo::Error::LimitsExceeded( + "too many concurrent notification requests".to_string(), + ) + })?; // The interface adapter forwards the authenticated header with the exact wire payload self.ingest_notify( app_name, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs new file mode 100644 index 000000000..31d5a1f11 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs @@ -0,0 +1,45 @@ +use std::time::{Duration, Instant}; + +use super::{NotificationQuota, GLOBAL_BURST, MAX_TRACKED_SENDERS, SENDER_BURST}; + +#[test] +fn sender_bucket_rejects_a_burst_and_refills_over_time() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + + for _ in 0..SENDER_BURST as usize { + assert!(quota.admit(Some(":1.10"), now)); + } + assert!(!quota.admit(Some(":1.10"), now)); + assert!(quota.admit(Some(":1.10"), now + Duration::from_millis(50))); +} + +#[test] +fn global_bucket_limits_many_independent_senders() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + + for index in 0..GLOBAL_BURST as usize { + assert!(quota.admit(Some(&format!(":1.{index}")), now)); + } + assert!(!quota.admit(Some(":1.blocked"), now)); + assert!(quota.admit(Some(":1.allowed"), now + Duration::from_millis(17))); +} + +#[test] +fn sender_tracking_stays_bounded_and_unknown_callers_share_one_bucket() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + + for index in 0..MAX_TRACKED_SENDERS + 20 { + let at = now + Duration::from_secs(index as u64); + assert!(quota.admit(Some(&format!(":1.{index}")), at)); + } + assert!(quota.state.lock().expect("quota state").senders.len() <= MAX_TRACKED_SENDERS); + + let later = now + Duration::from_secs((MAX_TRACKED_SENDERS + 21) as u64); + for _ in 0..SENDER_BURST as usize { + assert!(quota.admit(None, later)); + } + assert!(!quota.admit(None, later)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs new file mode 100644 index 000000000..fa19f81dc --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs @@ -0,0 +1,45 @@ +use super::{SenderMetadataCache, MAX_CACHED_SENDERS}; +use crate::daemon::notifications::sender::SenderMetadata; + +fn metadata(sender: &str, pid: u32) -> SenderMetadata { + SenderMetadata { + sender_name: Some(sender.to_string()), + sender_pid: Some(pid), + sender_start_time: Some(u64::from(pid)), + sender_executable: Some(format!("/usr/bin/app-{pid}")), + } +} + +#[test] +fn sender_cache_reuses_exact_unique_name_and_removes_disconnected_owner() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.42".to_string(), metadata(":1.42", 42)); + + assert_eq!( + cache.get(":1.42").and_then(|value| value.sender_pid), + Some(42) + ); + assert!(cache.get(":1.43").is_none()); + + cache.remove(":1.42"); + assert!(cache.get(":1.42").is_none()); +} + +#[test] +fn sender_cache_evicts_least_recently_used_entry_at_capacity() { + let cache = SenderMetadataCache::new(); + for index in 0..MAX_CACHED_SENDERS { + let sender = format!(":1.{index}"); + cache.insert(sender.clone(), metadata(&sender, index as u32 + 1)); + } + assert!(cache.get(":1.0").is_some()); + + cache.insert( + ":1.replacement".to_string(), + metadata(":1.replacement", 999), + ); + + assert!(cache.get(":1.1").is_none()); + assert!(cache.get(":1.0").is_some()); + assert!(cache.get(":1.replacement").is_some()); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 81e4ca690..fe337b517 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -10,6 +10,7 @@ use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; use crate::store::NotificationStore; +use crate::daemon::notifications::sender_cache::SenderMetadataCache; use crate::daemon::signal_burst::NotificationBurstState; /// Shared daemon state guarded behind an async mutex @@ -39,6 +40,8 @@ pub struct DaemonState { // instead of forcing a storm of full add/update fanout pub(in crate::daemon::state) notification_signal_bursts: StdMutex>, + // Unique sender identities avoid repeated bus and procfs lookups during bursts + pub(in crate::daemon) sender_metadata_cache: SenderMetadataCache, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, } @@ -75,6 +78,7 @@ impl DaemonState { last_emitted_state: StdMutex::new(None), last_emitted_popup_gate: StdMutex::new(None), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), + sender_metadata_cache: SenderMetadataCache::new(), trial_mode, }) } diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 42e99c517..af9ef63ca 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -1,8 +1,8 @@ //! Daemon runtime and trial cleanup coordination use anyhow::{Context, Result}; +use zbus::connection::Builder; use zbus::fdo::DBusProxy; -use zbus::Connection; use crate::cli::Args; use crate::trial_mode::{prepare_trial, TrialState}; @@ -10,8 +10,13 @@ use unixnotis_core::{Config, NOTIFICATIONS_BUS_NAME}; use super::{daemon, trial_cleanup}; +const DAEMON_DBUS_QUEUE_CAPACITY: usize = 16; + pub async fn run(args: &Args, config: Config) -> Result<()> { - let connection = Connection::session() + let connection = Builder::session() + .context("create session bus connection")? + .max_queued(DAEMON_DBUS_QUEUE_CAPACITY) + .build() .await .context("connect to session bus")?; let dbus_proxy = DBusProxy::new(&connection).await?; From 30a0f59a10503d18fc3460a3f3696ee937cd37d7 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:10:46 -0500 Subject: [PATCH 059/275] fix(core): bound text after markup removal Summary: bound text after markup removal. Scope: core. --- Cargo.lock | 2 +- crates/unixnotis-core/Cargo.toml | 1 + .../unixnotis-core/src/model/notification.rs | 14 +++- .../src/model/tests/notification.rs | 20 +++++ crates/unixnotis-core/src/util/display.rs | 61 +++++++++++++++ crates/unixnotis-core/src/util/mod.rs | 4 +- .../unixnotis-core/src/util/tests/display.rs | 58 ++++++++++++++ crates/unixnotis-daemon/Cargo.toml | 1 - .../src/daemon/notifications/payload.rs | 76 +------------------ .../src/daemon/notifications/tests/payload.rs | 75 +----------------- 10 files changed, 160 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cd170e35..4388d9ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3563,6 +3563,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "unicode-width", "zbus", ] @@ -3581,7 +3582,6 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "unicode-width", "unixnotis-core", "zbus", ] diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index 59cb239d6..6ec795560 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -16,6 +16,7 @@ shell-words.workspace = true toml.workspace = true thiserror.workspace = true tracing.workspace = true +unicode-width.workspace = true zbus.workspace = true [dev-dependencies] diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index d7d17fe86..06a84ebad 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -9,6 +9,7 @@ use zbus::zvariant::{OwnedValue, Type}; use super::image::NotificationImage; use super::reply::InlineReply; use super::types::{Action, Urgency}; +use crate::util::{fold_text_for_layout, MAX_DISPLAY_TOKEN_WIDTH}; /// Full notification record stored by the daemon #[derive(Debug)] @@ -55,8 +56,8 @@ impl Notification { NotificationView { id: self.id, app_name: self.app_name.clone(), - summary: notification_plain_text(&self.summary), - body: notification_plain_text(&self.body), + summary: notification_display_text(&self.summary), + body: notification_display_text(&self.body), actions: self.actions.clone(), inline_reply: self.inline_reply.clone(), urgency: self.urgency.as_u8(), @@ -74,8 +75,8 @@ impl Notification { NotificationView { id: self.id, app_name: self.app_name.clone(), - summary: notification_plain_text(&self.summary), - body: notification_plain_text(&self.body), + summary: notification_display_text(&self.summary), + body: notification_display_text(&self.body), actions: self.actions.clone(), inline_reply: self.inline_reply.clone(), urgency: self.urgency.as_u8(), @@ -158,6 +159,11 @@ fn notification_plain_text(input: &str) -> String { collapse_notification_whitespace(&output) } +fn notification_display_text(input: &str) -> String { + // Markup removal can join text that was separated by tags in the stored payload + fold_text_for_layout(¬ification_plain_text(input), MAX_DISPLAY_TOKEN_WIDTH) +} + fn push_tag_spacing(output: &mut String, tag: &str) { const BLOCK_TAGS: [&str; 5] = ["br", "p", "div", "li", "tr"]; diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index af698c8f4..6c3983fb3 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -142,6 +142,26 @@ fn notification_view_preserves_inline_markup_adjacency() { assert_eq!(view.body, "foobar and baz"); } +#[test] +fn notification_view_refolds_tokens_joined_by_markup_removal() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.summary = format!( + "markup-{}", + "link".repeat(180) + ); + + let view = notification.to_view(); + let longest = view + .summary + .split_whitespace() + .map(|token| token.chars().count()) + .max() + .unwrap_or_default(); + + assert!(view.summary.contains('…')); + assert!(longest <= crate::util::MAX_DISPLAY_TOKEN_WIDTH); +} + #[test] fn notification_view_collapses_inline_spaces_without_leaking_after_blocks() { let mut notification = notification_with_image(image_with_raw_bytes()); diff --git a/crates/unixnotis-core/src/util/display.rs b/crates/unixnotis-core/src/util/display.rs index 942cfba81..a69818df2 100644 --- a/crates/unixnotis-core/src/util/display.rs +++ b/crates/unixnotis-core/src/util/display.rs @@ -52,6 +52,53 @@ pub fn sanitize_inline_display_text(value: &str) -> String { sanitize_display_text_with(value, false) } +/// Fold an unbroken display token to a bounded column width +#[must_use] +pub fn fold_text_for_layout(value: &str, max_contiguous: usize) -> String { + if value.is_empty() || max_contiguous == 0 { + return value.to_string(); + } + + let mut output = String::with_capacity(value.len()); + let mut run_width = 0usize; + let mut folded_run = false; + + for character in value.chars() { + if character.is_whitespace() { + // Whitespace begins a fresh independently bounded token + run_width = 0; + folded_run = false; + output.push(character); + continue; + } + + let width = display_width(character); + if run_width.saturating_add(width) <= max_contiguous { + output.push(character); + run_width = run_width.saturating_add(width); + continue; + } + + if !folded_run { + let ellipsis_width = display_width('…'); + // Reclaim only the columns needed for one visible truncation marker + while run_width.saturating_add(ellipsis_width) > max_contiguous { + let Some(last) = output.pop() else { + break; + }; + run_width = run_width.saturating_sub(display_width(last)); + } + if run_width.saturating_add(ellipsis_width) <= max_contiguous { + output.push('…'); + run_width = run_width.saturating_add(ellipsis_width); + } + folded_run = true; + } + } + + output +} + fn sanitize_display_text_with(value: &str, keep_newlines: bool) -> String { sanitize_display_text_with_limit(value, keep_newlines, usize::MAX) } @@ -107,6 +154,20 @@ const fn is_bidi_control(ch: char) -> bool { ) } +fn display_width(character: char) -> usize { + // Joiners and selectors count as one slot because UI estimators can expose them separately + if matches!( + character, + '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FE0E}' | '\u{FE0F}' + ) { + return 1; + } + UnicodeWidthChar::width_cjk(character).unwrap_or(0) +} + #[cfg(test)] #[path = "tests/display.rs"] mod tests; +use unicode_width::UnicodeWidthChar; + +pub const MAX_DISPLAY_TOKEN_WIDTH: usize = 96; diff --git a/crates/unixnotis-core/src/util/mod.rs b/crates/unixnotis-core/src/util/mod.rs index 8510b4bf8..2f819c07e 100644 --- a/crates/unixnotis-core/src/util/mod.rs +++ b/crates/unixnotis-core/src/util/mod.rs @@ -9,8 +9,8 @@ pub use diagnostics::{ default_log_limit, diagnostic_log_limit, diagnostic_mode, log_limit, log_snippet, }; pub use display::{ - sanitize_display_text, sanitize_display_text_bounded, sanitize_inline_display_text, - sanitize_log_value, + fold_text_for_layout, sanitize_display_text, sanitize_display_text_bounded, + sanitize_inline_display_text, sanitize_log_value, MAX_DISPLAY_TOKEN_WIDTH, }; pub use paths::{expand_tilde, resolve_state_dir, resolve_state_dir_from_env, CONFIG_PATH_ENV}; pub use programs::{program_in_path, trusted_system_program_path, TRUSTED_SYSTEM_TOOL_DIRS}; diff --git a/crates/unixnotis-core/src/util/tests/display.rs b/crates/unixnotis-core/src/util/tests/display.rs index 30451fa45..855896a76 100644 --- a/crates/unixnotis-core/src/util/tests/display.rs +++ b/crates/unixnotis-core/src/util/tests/display.rs @@ -63,3 +63,61 @@ fn bounded_display_text_handles_zero_and_exact_limits() { assert_eq!(sanitize_display_text_bounded("value", 5), "value..."); assert_eq!(sanitize_display_text_bounded("ok", 5), "ok"); } + +#[test] +fn layout_folding_bounds_long_unbroken_tokens() { + let input = "x".repeat(200); + let folded = fold_text_for_layout(&input, MAX_DISPLAY_TOKEN_WIDTH); + let longest = folded + .split_whitespace() + .map(|part| part.chars().filter(char::is_ascii_alphanumeric).count()) + .max() + .unwrap_or(0); + + assert!(folded.contains('…')); + assert!(longest <= MAX_DISPLAY_TOKEN_WIDTH); +} + +#[test] +fn layout_folding_handles_zero_exact_and_separate_token_limits() { + assert_eq!(fold_text_for_layout("unchanged", 0), "unchanged"); + assert_eq!( + fold_text_for_layout( + &"x".repeat(MAX_DISPLAY_TOKEN_WIDTH), + MAX_DISPLAY_TOKEN_WIDTH + ), + "x".repeat(MAX_DISPLAY_TOKEN_WIDTH) + ); + let separate = format!( + "{} {}", + "x".repeat(MAX_DISPLAY_TOKEN_WIDTH), + "y".repeat(MAX_DISPLAY_TOKEN_WIDTH) + ); + assert_eq!( + fold_text_for_layout(&separate, MAX_DISPLAY_TOKEN_WIDTH), + separate + ); +} + +#[test] +fn layout_folding_reserves_only_the_required_ellipsis_width() { + assert_eq!(fold_text_for_layout("xxxx", 3), "x…"); + let folded = fold_text_for_layout(&"x".repeat(200), MAX_DISPLAY_TOKEN_WIDTH); + + // The CJK-width ellipsis uses two columns beside 94 ASCII characters + assert_eq!(folded.chars().count(), 95); +} + +#[test] +fn layout_folding_counts_wide_glyphs_joiners_and_selectors() { + let wide = fold_text_for_layout(&"界".repeat(120), MAX_DISPLAY_TOKEN_WIDTH); + let emoji = fold_text_for_layout( + &"👨\u{200D}👩\u{200D}👧\u{200D}👦".repeat(80), + MAX_DISPLAY_TOKEN_WIDTH, + ); + + assert!(wide.chars().map(display_width).sum::() <= MAX_DISPLAY_TOKEN_WIDTH); + assert!(emoji.chars().map(display_width).sum::() <= MAX_DISPLAY_TOKEN_WIDTH); + assert!(display_width('界') > 1); + assert_eq!(display_width('\u{200D}'), 1); +} diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index 07245cfe8..1e62c3256 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -18,4 +18,3 @@ zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } indexmap.workspace = true rustix.workspace = true -unicode-width.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 4c6bef79d..55e17f57e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -6,7 +6,6 @@ use std::cmp::Ordering; use std::collections::HashMap; use std::time::{Duration, Instant}; -use unicode_width::UnicodeWidthChar; use unixnotis_core::{util, Action, Config, InlineReply, Notification, NotificationImage, Urgency}; use zbus::zvariant::{OwnedValue, Value}; @@ -17,9 +16,6 @@ use super::limits::{ }; use super::sender::SenderMetadata; -// Unbroken tokens longer than this are folded with an ellipsis to avoid UI overflow spikes -const MAX_CONTIGUOUS_TOKEN_CHARS: usize = 96; - pub(super) struct NotificationInput { pub(super) app_name: String, pub(super) app_icon: String, @@ -82,15 +78,15 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { app_icon: truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid // Fold very long unbroken runs so renderer width remains bounded - summary: normalize_text_for_layout( + summary: util::fold_text_for_layout( &truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), - MAX_CONTIGUOUS_TOKEN_CHARS, + util::MAX_DISPLAY_TOKEN_WIDTH, ), // Apply the same order for body so renderer sees consistent text constraints // Body can be much larger, so apply the same run-folding protection here - body: normalize_text_for_layout( + body: util::fold_text_for_layout( &truncate_utf8_bytes(&body, MAX_BODY_BYTES), - MAX_CONTIGUOUS_TOKEN_CHARS, + util::MAX_DISPLAY_TOKEN_WIDTH, ), actions, inline_reply, @@ -270,70 +266,6 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { value[..end].to_string() } -fn normalize_text_for_layout(value: &str, max_contiguous: usize) -> String { - if value.is_empty() || max_contiguous == 0 { - return value.to_string(); - } - - // Reserve original length to keep this pass allocation-stable on long input - let mut out = String::with_capacity(value.len()); - let mut run_width = 0usize; - let mut folded_run = false; - - // Walk characters so non-ASCII content remains valid after normalization - // Width is tracked in display columns instead of char count to handle wide glyphs - for ch in value.chars() { - if ch.is_whitespace() { - // Whitespace resets contiguous-run accounting - run_width = 0; - folded_run = false; - out.push(ch); - continue; - } - - let width = display_width(ch); - if run_width.saturating_add(width) <= max_contiguous { - // Short runs stay as they are - out.push(ch); - run_width = run_width.saturating_add(width); - continue; - } - - // Add one ellipsis when a contiguous token crosses the safety threshold - if !folded_run { - let ellipsis_width = display_width('…'); - // Keep final run width bounded by trimming the current run tail first - while run_width.saturating_add(ellipsis_width) > max_contiguous { - // Pop one char at a time - let Some(last) = out.pop() else { - break; - }; - run_width = run_width.saturating_sub(display_width(last)); - } - if run_width.saturating_add(ellipsis_width) <= max_contiguous { - out.push('…'); - run_width = run_width.saturating_add(ellipsis_width); - } - folded_run = true; - } - // Remaining chars in this run are dropped until whitespace appears again - } - - out -} - -fn display_width(ch: char) -> usize { - // Width estimators in downstream UI surfaces often treat joiners/selectors as visible slots - // Counting them here keeps folded output safely within those stricter layouts - if matches!( - ch, - '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FE0E}' | '\u{FE0F}' - ) { - return 1; - } - UnicodeWidthChar::width_cjk(ch).unwrap_or(0) -} - #[cfg(test)] #[path = "tests/payload.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index afc0f6d95..882ca4211 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -4,10 +4,9 @@ use std::time::{Duration, Instant}; use zbus::zvariant::OwnedValue; use super::{ - build_notification, display_width, normalize_text_for_layout, owned_to_string, parse_actions, - parse_urgency_hint, resolve_expiration, sanitize_hints_for_storage, string_to_owned_value, - truncate_utf8_bytes, NotificationInput, SenderMetadata, MAX_ACTIONS, MAX_BODY_BYTES, - MAX_SUMMARY_BYTES, + build_notification, owned_to_string, parse_actions, parse_urgency_hint, resolve_expiration, + sanitize_hints_for_storage, string_to_owned_value, truncate_utf8_bytes, NotificationInput, + SenderMetadata, MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES, }; use unixnotis_core::{Config, NotificationImage, Urgency}; @@ -310,71 +309,3 @@ fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_ notification.expire_timeout = -1; assert!(resolve_expiration(&config, ¬ification).is_none()); } - -#[test] -fn normalize_text_for_layout_folds_long_unbroken_tokens() { - let input = "x".repeat(200); - let normalized = normalize_text_for_layout(&input, 96); - assert!(normalized.contains('…')); - let longest = normalized - .split_whitespace() - .map(|part| part.chars().filter(char::is_ascii_alphanumeric).count()) - .max() - .unwrap_or(0); - assert!(longest <= 96); -} - -#[test] -fn normalize_text_for_layout_returns_input_when_limit_is_zero() { - assert_eq!(normalize_text_for_layout("unchanged", 0), "unchanged"); -} - -#[test] -fn normalize_text_for_layout_keeps_exact_width_token_without_ellipsis() { - let input = "x".repeat(96); - let normalized = normalize_text_for_layout(&input, 96); - assert_eq!(normalized, input); -} - -#[test] -fn normalize_text_for_layout_resets_run_after_whitespace() { - let input = format!("{} {}", "x".repeat(96), "y".repeat(96)); - let normalized = normalize_text_for_layout(&input, 96); - assert_eq!(normalized, input); -} - -#[test] -fn normalize_text_for_layout_keeps_char_count_bound_with_ellipsis() { - let input = "x".repeat(200); - let normalized = normalize_text_for_layout(&input, 96); - assert!(normalized.contains('…')); - // Ellipsis is width 2 in CJK width mode, so the text keeps 94 ASCII chars plus ellipsis - assert_eq!(normalized.chars().count(), 95); -} - -#[test] -fn normalize_text_for_layout_trims_only_as_much_as_needed_for_ellipsis() { - assert_eq!(normalize_text_for_layout("xxxx", 3), "x…"); -} - -#[test] -fn normalize_text_for_layout_limits_wide_glyph_runs() { - let input = "界".repeat(120); - let normalized = normalize_text_for_layout(&input, 96); - let width: usize = normalized.chars().map(display_width).sum(); - assert!(width <= 96); -} - -#[test] -fn normalize_text_for_layout_limits_emoji_joiner_runs() { - let input = "👨\u{200D}👩\u{200D}👧\u{200D}👦".repeat(80); - let normalized = normalize_text_for_layout(&input, 96); - let width: usize = normalized.chars().map(display_width).sum(); - assert!(width <= 96); -} - -#[test] -fn display_width_counts_wide_and_joiner_characters_for_layout_safety() { - assert!(display_width('界') > 1); - assert_eq!(display_width('\u{200D}'), 1); -} From 26d4df6a98d3b19c8e9a5505f3fe8642a1e81019 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:15:24 -0500 Subject: [PATCH 060/275] fix(preset): reject ambiguous tar extensions Summary: reject ambiguous tar extensions. Scope: preset. --- .../src/preset/archive/preflight.rs | 13 +++++++ .../src/preset/archive/tests/limits.rs | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/noticenterctl/src/preset/archive/preflight.rs b/crates/noticenterctl/src/preset/archive/preflight.rs index 754caae9f..c1be456b6 100644 --- a/crates/noticenterctl/src/preset/archive/preflight.rs +++ b/crates/noticenterctl/src/preset/archive/preflight.rs @@ -43,6 +43,12 @@ fn scan_headers(input: &mut impl Read) -> Result<()> { let entry_type = header.entry_type(); let recognized = header.as_gnu().is_some() || header.as_ustar().is_some(); + if entry_type.is_pax_global_extensions() { + // Global records change later entries and would give both parsers different state + return Err(anyhow!( + "preset bundle contains unsupported global PAX metadata" + )); + } let is_hidden_extension = recognized && (entry_type.is_gnu_longname() || entry_type.is_gnu_longlink() @@ -146,6 +152,13 @@ fn validate_visible_header(header: &Header, effective_size: u64) -> Result<()> { } return Ok(()); } + if !header.entry_type().is_file() { + // Preset archives model only regular files, directories, and handled local extensions + return Err(anyhow!( + "preset bundle contains an unsupported archive entry type: {}", + archive_path.display() + )); + } if archive_path == Path::new(MANIFEST_ARCHIVE_PATH) { return validate_entry_size( "manifest", diff --git a/crates/noticenterctl/src/preset/archive/tests/limits.rs b/crates/noticenterctl/src/preset/archive/tests/limits.rs index 5efcef454..36fb3fac9 100644 --- a/crates/noticenterctl/src/preset/archive/tests/limits.rs +++ b/crates/noticenterctl/src/preset/archive/tests/limits.rs @@ -72,6 +72,45 @@ fn read_bundle_uses_effective_pax_size_for_payload_limits() { assert!(error.to_string().contains("payload entry is too large")); } +#[test] +fn read_bundle_rejects_global_pax_metadata_before_second_pass() { + let root = TempDirGuard::new("global-pax-metadata"); + let bundle_path = root.path.join("demo.unixnotis"); + let pax = pax_record("path", "manifest.toml"); + + write_raw_gzip_tar(&bundle_path, |encoder| { + append_extension_entry(encoder, tar::EntryType::XGlobalHeader, &pax); + append_raw_tar_file(encoder, Path::new("ignored-name"), b"", 0o644); + }); + + let error = read_bundle(&bundle_path).expect_err("global PAX state must be rejected"); + + assert!(error.to_string().contains("global PAX metadata")); +} + +#[test] +fn read_bundle_rejects_unmodeled_archive_entry_types_during_preflight() { + let root = TempDirGuard::new("unsupported-archive-entry"); + let bundle_path = root.path.join("demo.unixnotis"); + + write_raw_gzip_tar(&bundle_path, |encoder| { + let mut header = tar::Header::new_gnu(); + header.set_path("foreign-link").expect("set link path"); + header.set_entry_type(tar::EntryType::Symlink); + header.set_link_name("target").expect("set link target"); + header.set_mode(0o777); + header.set_size(0); + header.set_cksum(); + encoder + .write_all(header.as_bytes()) + .expect("write unsupported entry"); + }); + + let error = read_bundle(&bundle_path).expect_err("unmodeled entry type must be rejected"); + + assert!(error.to_string().contains("unsupported archive entry type")); +} + #[test] fn read_bundle_accepts_a_bounded_pax_size_override() { let root = TempDirGuard::new("bounded-pax-size-override"); From d8906eb807c6d525979181f37bd7fb06a38b34c3 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:17:27 -0500 Subject: [PATCH 061/275] fix(daemon): validate control action targets Summary: validate control action targets. Scope: daemon. --- .../src/daemon/control/action.rs | 83 ++++++++++++ .../src/daemon/control/mod.rs | 1 + .../src/daemon/control/server.rs | 11 +- .../src/daemon/control/tests/action.rs | 118 ++++++++++++++++++ .../src/daemon/control/tests/mod.rs | 1 + crates/unixnotis-daemon/src/store/core.rs | 17 +++ .../unixnotis-daemon/src/store/tests/reply.rs | 26 ++++ 7 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/control/action.rs create mode 100644 crates/unixnotis-daemon/src/daemon/control/tests/action.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs new file mode 100644 index 000000000..cd60e5cd5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -0,0 +1,83 @@ +//! Validation for application action signals requested by trusted control clients + +use std::future::Future; + +use zbus::fdo::DBusProxy; +use zbus::SignalContext; + +use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; + +use super::ControlServer; + +impl ControlServer { + pub(super) async fn invoke_validated_action( + &self, + id: u32, + action_key: &str, + ) -> zbus::fdo::Result<()> { + self.invoke_validated_action_with_pre_emit(id, action_key, || std::future::ready(())) + .await + } + + pub(super) async fn invoke_validated_action_with_pre_emit( + &self, + id: u32, + action_key: &str, + pre_emit: F, + ) -> zbus::fdo::Result<()> + where + F: FnOnce() -> Fut, + Fut: Future, + { + let target = { + // Capture one concrete generation while validating the stored action identity + let store = self.state.store.lock().await; + store.active_action_target(id, action_key).ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification is not live or does not advertise this action".to_string(), + ) + })? + }; + let sender = target + .sender_name + .as_deref() + .ok_or_else(application_unavailable_error)?; + let bus_name = zbus::names::BusName::try_from(sender) + .map_err(|_error| application_unavailable_error())?; + let proxy = DBusProxy::new(self.state.connection()) + .await + .map_err(to_fdo_error)?; + if !proxy + .name_has_owner(bus_name) + .await + .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))? + { + return Err(application_unavailable_error()); + } + + // The test seam models replacement after the external liveness query + pre_emit().await; + let is_current = self + .state + .store + .lock() + .await + .is_active_notification_generation(id, &target); + if !is_current { + return Err(zbus::fdo::Error::InvalidArgs( + "notification changed before its action could be invoked".to_string(), + )); + } + + // Reuse the freedesktop signal path only after identity and liveness checks pass + let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) + .map_err(to_fdo_error)?; + NotificationServer::action_invoked(&context, id, action_key) + .await + .map_err(to_fdo_error) + } +} + +fn application_unavailable_error() -> zbus::fdo::Error { + zbus::fdo::Error::Failed("The application is no longer available".to_string()) +} diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 0227dcb76..51c8fd3d5 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -1,5 +1,6 @@ //! D-Bus server for com.unixnotis.Control +mod action; mod clear; mod dnd; mod inhibit; diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 99ba1db19..3551b53b8 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -9,9 +9,7 @@ use unixnotis_core::{ use zbus::message::Header; use zbus::{interface, SignalContext}; -use crate::daemon::{ - auth, to_fdo_error, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, -}; +use crate::daemon::{auth, to_fdo_error, DaemonState}; use super::clear; @@ -189,12 +187,7 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "InvokeAction").await?; - // Reuse the freedesktop action signal path for compatibility with listeners - let ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; - NotificationServer::action_invoked(&ctx, id, action_key) - .await - .map_err(to_fdo_error) + self.invoke_validated_action(id, action_key).await } pub(super) async fn reply_notification( diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs new file mode 100644 index 000000000..bda049304 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; + +use chrono::Utc; +use futures_util::TryStreamExt; +use unixnotis_core::{Action, Notification, NotificationImage, Urgency}; +use zbus::message::Type; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +use super::super::ControlServer; +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn validated_action_emits_only_an_advertised_live_action() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = action_signal_stream(&state).await; + let id = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&sender, "open"), 0) + .notification + .id + }; + + ControlServer::new(state) + .invoke_validated_action(id, "open") + .await + .expect("invoke advertised action"); + + assert_eq!( + next_action_signal(&mut stream).await, + (id, "open".to_string()) + ); +} + +#[tokio::test] +async fn validated_action_rejects_missing_and_stale_action_generations() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let id = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&sender, "open"), 0) + .notification + .id + }; + let server = ControlServer::new(state.clone()); + + server + .invoke_validated_action(id, "missing") + .await + .expect_err("unadvertised action must fail"); + let replacement_state = state.clone(); + let replacement_sender = sender.clone(); + server + .invoke_validated_action_with_pre_emit(id, "open", move || async move { + let replacement = action_notification(&replacement_sender, "different"); + let outcome = replacement_state.store.lock().await.insert(replacement, id); + assert!(outcome.replaced); + }) + .await + .expect_err("stale action generation must fail"); +} + +fn action_notification(sender: &Connection, key: &str) -> Notification { + Notification { + id: 0, + app_name: "ActionApp".to_string(), + app_icon: String::new(), + summary: "Action".to_string(), + body: String::new(), + actions: vec![Action { + key: key.to_string(), + label: "Run".to_string(), + }], + inline_reply: unixnotis_core::InlineReply::default(), + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: sender.unique_name().map(ToString::to_string), + sender_pid: None, + sender_start_time: None, + sender_executable: None, + } +} + +async fn action_signal_stream(state: &crate::daemon::DaemonState) -> MessageStream { + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("ActionInvoked") + .expect("action member") + .path(NOTIFICATIONS_OBJECT_PATH) + .expect("notification path") + .build(); + MessageStream::for_match_rule(rule, state.connection(), Some(8)) + .await + .expect("action signal stream") +} + +async fn next_action_signal(stream: &mut MessageStream) -> (u32, String) { + let message = tokio::time::timeout(std::time::Duration::from_secs(1), stream.try_next()) + .await + .expect("action signal timeout") + .expect("read action signal") + .expect("action signal stream ended"); + message.body().deserialize().expect("action signal body") +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index 9ae383e17..7a2136f4d 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,3 +1,4 @@ +mod action; mod clear; mod sanitize; mod server; diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs index 892c8efa8..760d39ba8 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -118,6 +118,23 @@ impl NotificationStore { (notification.inline_reply.available && has_reply_action).then(|| Arc::clone(notification)) } + pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { + let notification = self.active.get(&id)?; + // Exact matching prevents a trusted control caller from inventing application actions + notification + .actions + .iter() + .any(|action| action.key == action_key) + .then(|| Arc::clone(notification)) + } + + pub fn is_active_notification_generation(&self, id: u32, expected: &Arc) -> bool { + // Arc identity distinguishes a same-ID replacement from the row that was clicked + self.active + .get(&id) + .is_some_and(|active| Arc::ptr_eq(active, expected)) + } + pub fn history_len(&self) -> usize { // Exposed for diagnostics and test assertions self.history.len() diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/reply.rs index 0acf358a2..b5c76cb1b 100644 --- a/crates/unixnotis-daemon/src/store/tests/reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/reply.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use unixnotis_core::{Action, CloseReason, InlineReply}; use super::{make_notification, make_store_with_limits}; @@ -29,6 +31,30 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { assert!(!target.is_resident); } +#[test] +fn active_action_target_requires_an_exact_action_on_the_live_generation() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("action"); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + let original = store.insert(notification, 0).notification; + let id = original.id; + + let target = store + .active_action_target(id, "open") + .expect("stored action should resolve"); + assert!(Arc::ptr_eq(&target, &original)); + assert!(store.active_action_target(id, "missing").is_none()); + assert!(store.is_active_notification_generation(id, &original)); + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + assert!(!store.is_active_notification_generation(id, &original)); + assert!(store.active_action_target(id, "open").is_none()); +} + #[test] fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { let mut store = make_store_with_limits(12, 20); From 180c7e248099ff28f66bbaaa7cd063b8ba3bcc70 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 13:18:29 -0500 Subject: [PATCH 062/275] fix(center): validate SVG scaling geometry Summary: validate SVG scaling geometry. Scope: center. --- .../src/ui/icons/decode/svg.rs | 48 +++++++++++++++++-- .../src/ui/icons/decode/tests/svg.rs | 22 ++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index 839093562..7c21344d9 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -49,14 +49,22 @@ pub(super) fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result Result Result<(u32, u32, f32), String> { + if !source_width.is_finite() + || !source_height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + || target == 0 + || target > MAX_ICON_DIMENSION + { + return Err("SVG scaling inputs must be finite and bounded".to_string()); + } + + let target = target as f32; + let scale = (target / source_width).min(target / source_height); + let scaled_width = (source_width * scale).round().max(1.0); + let scaled_height = (source_height * scale).round().max(1.0); + if !scale.is_finite() || scale <= 0.0 || !scaled_width.is_finite() || !scaled_height.is_finite() + { + return Err("SVG scaling result must be finite and positive".to_string()); + } + + let width = scaled_width as u32; + let height = scaled_height as u32; + validate_svg_dimensions(width, height)?; + Ok((width, height, scale)) +} + pub(super) fn decompress_svgz_with_limit(bytes: &[u8], max_bytes: u64) -> Result, String> { let mut decoder = GzDecoder::new(bytes); let mut document = Vec::new(); diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index fde9ce5c0..f9ee86978 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -5,7 +5,8 @@ use flate2::Compression; use super::super::pipeline::MAX_ICON_DIMENSION; use super::super::svg::{ - decode_svg_bytes, decompress_svgz_with_limit, is_gzip_payload, validate_svg_dimensions, + decode_svg_bytes, decompress_svgz_with_limit, fitted_svg_dimensions, is_gzip_payload, + validate_svg_dimensions, }; #[test] @@ -93,3 +94,22 @@ fn svg_source_limits_cover_zero_exact_and_oversized_boundaries() { assert!(validate_svg_dimensions(MAX_ICON_DIMENSION + 1, 1).is_err()); assert!(validate_svg_dimensions(1, MAX_ICON_DIMENSION + 1).is_err()); } + +#[test] +fn svg_scaling_rejects_non_finite_zero_and_oversized_inputs() { + assert!(fitted_svg_dimensions(f32::NAN, 10.0, 16).is_err()); + assert!(fitted_svg_dimensions(10.0, f32::INFINITY, 16).is_err()); + assert!(fitted_svg_dimensions(0.0, 10.0, 16).is_err()); + assert!(fitted_svg_dimensions(10.0, 10.0, 0).is_err()); + assert!(fitted_svg_dimensions(10.0, 10.0, MAX_ICON_DIMENSION + 1).is_err()); +} + +#[test] +fn svg_scaling_returns_finite_bounded_geometry() { + let (width, height, scale) = + fitted_svg_dimensions(20.0, 10.0, 16).expect("fit finite geometry"); + + assert_eq!((width, height), (16, 8)); + assert!(scale.is_finite()); + assert!(scale > 0.0); +} From 74d12c334b6024a727b7eafe7c0bbda2bc1f0700 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 14:23:11 -0500 Subject: [PATCH 063/275] test: strengthen filesystem, CSS, and SVG boundaries Summary: strengthen filesystem, CSS, and SVG boundaries. Scope: repository. --- .../src/preset/css_asset_refs/rewrite.rs | 25 +++-- .../preset/css_asset_refs/tests/rewrite.rs | 21 +++- .../src/ui/icons/decode/svg.rs | 21 ++-- .../src/ui/icons/decode/tests/svg.rs | 36 ++++++- .../src/css/references/lexer.rs | 2 +- .../src/css/references/tests/lexer.rs | 11 ++- .../src/css/references/tests/url.rs | 13 ++- .../unixnotis-core/src/css/references/url.rs | 12 ++- .../unixnotis-core/src/filesystem/atomic.rs | 9 +- .../unixnotis-core/src/filesystem/remove.rs | 13 +-- .../src/filesystem/tests/atomic.rs | 43 +++++++- .../src/filesystem/tests/directory.rs | 55 ++++++++++- .../src/filesystem/tests/remove.rs | 97 ++++++++++++++++++- 13 files changed, 310 insertions(+), 48 deletions(-) diff --git a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs index deb5b67d9..ffd6e204b 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs @@ -50,13 +50,7 @@ fn rewrite_host_specific_refs_in_text( let mut last_index = 0usize; for span in collect_url_spans(css_text)? { - if span.value_start > span.value_end - || span.value_start < last_index - || !css_text.is_char_boundary(span.value_start) - || !css_text.is_char_boundary(span.value_end) - { - anyhow::bail!("CSS scanner returned an invalid UTF-8 rewrite range"); - } + validate_rewrite_range(css_text, last_index, span.value_start, span.value_end)?; // Everything before the current url(...) payload is copied through unchanged rewritten.push_str(&css_text[last_index..span.value_start]); @@ -82,6 +76,23 @@ fn rewrite_host_specific_refs_in_text( Ok((rewritten, rewrites)) } +pub(super) fn validate_rewrite_range( + css_text: &str, + last_index: usize, + value_start: usize, + value_end: usize, +) -> Result<()> { + // Every slice must move forward and land on complete UTF-8 characters + if value_start > value_end + || value_start < last_index + || !css_text.is_char_boundary(value_start) + || !css_text.is_char_boundary(value_end) + { + anyhow::bail!("CSS scanner returned an invalid UTF-8 rewrite range"); + } + Ok(()) +} + fn rewrite_host_specific_asset_ref( config_dir: &Path, css_path: &Path, diff --git a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs index 77526d2ed..16d535667 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs @@ -1,6 +1,25 @@ use std::path::Path; -use super::rewrite_host_specific_refs_in_text; +use super::{rewrite_host_specific_refs_in_text, validate_rewrite_range}; + +#[test] +fn rewrite_range_validation_accepts_ordered_character_boundaries() { + let css = "éx"; + + validate_rewrite_range(css, 0, 0, 0).expect("empty range at current offset"); + validate_rewrite_range(css, 0, 0, css.len()).expect("complete UTF-8 range"); + validate_rewrite_range(css, 2, 2, css.len()).expect("range at prior end"); +} + +#[test] +fn rewrite_range_validation_rejects_each_invalid_offset_shape() { + let css = "éx"; + + assert!(validate_rewrite_range(css, 0, 2, 0).is_err()); + assert!(validate_rewrite_range(css, 2, 0, 2).is_err()); + assert!(validate_rewrite_range(css, 0, 1, 2).is_err()); + assert!(validate_rewrite_range(css, 0, 0, 1).is_err()); +} #[test] fn rewrite_keeps_ambiguous_escaped_url_unchanged() { diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index 7c21344d9..f24108105 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -51,20 +51,13 @@ pub(super) fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result 0.0); + + let (width, height, _scale) = + fitted_svg_dimensions(1.0, 1.0, MAX_ICON_DIMENSION).expect("fit exact target limit"); + assert_eq!((width, height), (MAX_ICON_DIMENSION, MAX_ICON_DIMENSION)); } diff --git a/crates/unixnotis-core/src/css/references/lexer.rs b/crates/unixnotis-core/src/css/references/lexer.rs index 53791159c..2dc3bed1b 100644 --- a/crates/unixnotis-core/src/css/references/lexer.rs +++ b/crates/unixnotis-core/src/css/references/lexer.rs @@ -19,7 +19,7 @@ pub(super) fn identifier_matches(input: &str, start: usize, expected: &str) -> ( input[index..].chars().next().unwrap_or('\u{FFFD}') }; (decoded, index.saturating_add(decoded.len_utf8())) - } else if byte == b'\\' && valid_escape(bytes, index) { + } else if valid_escape(bytes, index) { consume_escape(input, index) } else { break; diff --git a/crates/unixnotis-core/src/css/references/tests/lexer.rs b/crates/unixnotis-core/src/css/references/tests/lexer.rs index 262c8046e..1879f4d01 100644 --- a/crates/unixnotis-core/src/css/references/tests/lexer.rs +++ b/crates/unixnotis-core/src/css/references/tests/lexer.rs @@ -1,6 +1,6 @@ use super::super::lexer::{ consume_escape, identifier_matches, skip_css_whitespace_and_comments, skip_quoted_value, - valid_escape, would_start_identifier, + trim_css_whitespace_range, valid_escape, would_start_identifier, }; use super::super::{collect_css_import_values, collect_css_url_values, CssImportReference}; @@ -87,6 +87,15 @@ fn fixed_identifier_matching_decodes_escapes_without_allocating_names() { assert_eq!(identifier_matches("im\\70ort ", 0, "import"), (true, 8)); assert_eq!(identifier_matches("url-extra(", 0, "url"), (false, 9)); assert_eq!(identifier_matches("éurl(", 0, "url"), (false, 5)); + assert_eq!(identifier_matches("xrl(", 0, "url"), (false, 3)); + assert_eq!(identifier_matches("urx(", 0, "url"), (false, 3)); +} + +#[test] +fn css_whitespace_range_trimming_handles_empty_and_nonempty_boundaries() { + assert_eq!(trim_css_whitespace_range(b" value ", 0, 7), (1, 6)); + assert_eq!(trim_css_whitespace_range(b" ", 0, 1), (1, 1)); + assert_eq!(trim_css_whitespace_range(b" x", 1, 1), (1, 1)); } #[test] diff --git a/crates/unixnotis-core/src/css/references/tests/url.rs b/crates/unixnotis-core/src/css/references/tests/url.rs index 31eeddfb4..de40bca4d 100644 --- a/crates/unixnotis-core/src/css/references/tests/url.rs +++ b/crates/unixnotis-core/src/css/references/tests/url.rs @@ -1,4 +1,4 @@ -use super::super::url::parse_url_value; +use super::super::url::{parse_url_value, valid_url_value_range}; use super::super::{collect_css_url_spans, collect_css_url_values}; #[test] @@ -124,3 +124,14 @@ fn unquoted_url_trims_only_css_whitespace_bytes() { assert_eq!(spans[1].value, "\u{000b}asset.png\u{000b}"); assert!(spans[1].ambiguous); } + +#[test] +fn url_value_ranges_require_ordered_utf8_boundaries() { + let value = "aéz"; + + assert!(valid_url_value_range(value, 0, value.len())); + assert!(valid_url_value_range(value, 1, 3)); + assert!(!valid_url_value_range(value, 3, 1)); + assert!(!valid_url_value_range(value, 2, 3)); + assert!(!valid_url_value_range(value, 1, 2)); +} diff --git a/crates/unixnotis-core/src/css/references/url.rs b/crates/unixnotis-core/src/css/references/url.rs index 49d55436c..570b4966f 100644 --- a/crates/unixnotis-core/src/css/references/url.rs +++ b/crates/unixnotis-core/src/css/references/url.rs @@ -150,10 +150,7 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS if byte == b')' { // CSS defines five ASCII whitespace bytes; Unicode whitespace remains URL data let (value_start, value_end) = trim_css_whitespace_range(bytes, raw_start, index); - if value_start > value_end - || !input.is_char_boundary(value_start) - || !input.is_char_boundary(value_end) - { + if !valid_url_value_range(input, value_start, value_end) { return None; } return Some(( @@ -180,3 +177,10 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS } None } + +pub(super) fn valid_url_value_range(input: &str, value_start: usize, value_end: usize) -> bool { + // Scanner offsets are accepted only when direct string slicing is safe + value_start <= value_end + && input.is_char_boundary(value_start) + && input.is_char_boundary(value_end) +} diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index ae9bb18c2..f00bf5c45 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -118,7 +118,7 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res contained_resolve_flags(), ) { Ok(fd) => fd, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(error) if exclusive_create_collided(error) => { // A collision is safe only when the existing destination is a regular file validate_existing_target(&parent_fd, &file_name)?; return Ok(false); @@ -229,7 +229,7 @@ pub(super) fn ensure_exact_file_at( contained_resolve_flags(), ) { Ok(fd) => fd, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(error) if exclusive_create_collided(error) => { let mut file = open_regular_file_at(parent_fd, file_name)?; if !file_contents_equal(&mut file, contents)? { return Ok(EnsureExactFileOutcome::ContentsMismatch); @@ -256,6 +256,11 @@ pub(super) fn ensure_exact_file_at( Ok(EnsureExactFileOutcome::Created) } +fn exclusive_create_collided(error: rustix::io::Errno) -> bool { + // Only an existing target may enter the create-or-compare collision path + error == rustix::io::Errno::EXIST +} + pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { let read_limit = u64::try_from(expected.len()) .unwrap_or(u64::MAX) diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index c7df392b4..6906f564b 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -89,16 +89,12 @@ pub fn remove_regular_file_pair_if_contents( let mut file = match open_regular_file_at(&parent_fd, &file_name) { Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RemoveExactFileOutcome::Missing) - } + Err(error) if file_lookup_is_missing(&error) => return Ok(RemoveExactFileOutcome::Missing), Err(error) => return Err(error), }; let mut marker = match open_regular_file_at(&parent_fd, &marker_name) { Ok(marker) => marker, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Ok(RemoveExactFileOutcome::Missing) - } + Err(error) if file_lookup_is_missing(&error) => return Ok(RemoveExactFileOutcome::Missing), Err(error) => return Err(error), }; if !file_contents_equal(&mut file, expected_contents)? @@ -195,6 +191,11 @@ fn revalidate_file_identity( )) } +fn file_lookup_is_missing(error: &io::Error) -> bool { + // Missing exact-pair members are idempotent while every other error fails closed + error.kind() == io::ErrorKind::NotFound +} + #[cfg(test)] #[path = "tests/remove.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/tests/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs index 244953ec2..6e160ac59 100644 --- a/crates/unixnotis-core/src/filesystem/tests/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -1,8 +1,8 @@ //! Atomic file operation tests use super::{ - ensure_exact_file, file_mode, make_file_executable, reserve_temp, set_file_mode, - write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, + ensure_exact_file, exclusive_create_collided, file_mode, make_file_executable, reserve_temp, + set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, EnsureExactFileOutcome, }; use std::ffi::OsString; @@ -19,6 +19,13 @@ use crate::filesystem::directory::{ }; use crate::test_support::unique_temp_path; +#[test] +fn exclusive_create_collision_classification_accepts_only_existing_targets() { + assert!(exclusive_create_collided(rustix::io::Errno::EXIST)); + assert!(!exclusive_create_collided(rustix::io::Errno::ACCESS)); + assert!(!exclusive_create_collided(rustix::io::Errno::INVAL)); +} + #[test] fn atomic_write_rejects_target_symlink_without_changing_outside_file() { let root = unique_temp_path("atomic-target-symlink"); @@ -265,6 +272,38 @@ fn create_if_missing_propagates_non_collision_open_error() { let _ = fs::remove_dir_all(root); } +#[test] +fn create_if_missing_preserves_permission_denied_errors() { + let root = unique_temp_path("atomic-if-missing-permission"); + fs::create_dir_all(&root).expect("create test root"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o500)) + .expect("make test root read only"); + + let error = write_file_if_missing(&root.join("state"), b"data", 0o600) + .expect_err("read-only parent must reject creation"); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .expect("restore test root permissions"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_preserves_permission_denied_errors() { + let root = unique_temp_path("atomic-exact-permission"); + fs::create_dir_all(&root).expect("create test root"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o500)) + .expect("make test root read only"); + + let error = ensure_exact_file(&root.join("state"), b"data", 0o600) + .expect_err("read-only parent must reject exact creation"); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .expect("restore test root permissions"); + let _ = fs::remove_dir_all(root); +} + #[test] fn temp_reservation_propagates_non_collision_error_without_using_later_candidate() { let root = unique_temp_path("atomic-temp-error"); diff --git a/crates/unixnotis-core/src/filesystem/tests/directory.rs b/crates/unixnotis-core/src/filesystem/tests/directory.rs index 5cb8a0f49..a7f63fab1 100644 --- a/crates/unixnotis-core/src/filesystem/tests/directory.rs +++ b/crates/unixnotis-core/src/filesystem/tests/directory.rs @@ -7,8 +7,9 @@ use rustix::fs::{mkfifoat, Mode, CWD}; use super::{ classify_directory_creation, create_directory_all, ensure_marked_directory, - remove_directory_tree, remove_empty_directory, remove_marked_directory_tree, - CreateDirectoryOutcome, + open_target_directory, preflight_directory_contents, remove_directory_tree, + remove_empty_directory, remove_marked_directory_tree, revalidate_directory_identity, + validate_child_name, CreateDirectoryOutcome, }; use crate::test_support::unique_temp_path; @@ -39,6 +40,15 @@ fn directory_creation_builds_missing_components_with_requested_mode() { let _ = fs::remove_dir_all(root); } +#[test] +fn ownership_marker_name_accepts_one_normal_component_only() { + validate_child_name(".owner".as_ref()).expect("plain marker name"); + + for invalid in ["", ".", "..", "nested/.owner", "/.owner"] { + validate_child_name(invalid.as_ref()).expect_err("invalid marker name must fail"); + } +} + #[test] fn directory_creation_rejects_a_linked_parent() { let root = unique_temp_path("create-directory-linked-parent"); @@ -210,6 +220,47 @@ fn marked_tree_preflight_preserves_regular_siblings_when_a_child_is_unsafe() { let _ = fs::remove_dir_all(root); } +#[test] +fn marked_tree_preflight_directly_rejects_unsafe_descendants() { + let root = unique_temp_path("marked-tree-direct-preflight"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("nested").join("regular"), "keep").expect("write regular child"); + symlink("regular", target.join("nested").join("unsafe-link")).expect("create unsafe link"); + let (_parent_fd, _name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + preflight_directory_contents(&directory_fd).expect_err("unsafe descendant must fail preflight"); + + assert_eq!( + fs::read_to_string(target.join("nested").join("regular")).expect("regular child remains"), + "keep" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_identity_revalidation_rejects_a_same_device_replacement() { + let root = unique_temp_path("directory-identity-replacement"); + let target = root.join("managed"); + let moved = root.join("original"); + fs::create_dir_all(&target).expect("create original directory"); + let (parent_fd, file_name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect("unchanged identity should pass"); + fs::rename(&target, &moved).expect("move retained directory"); + fs::create_dir(&target).expect("create same-device replacement"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + #[test] fn marked_tree_removal_validates_marker_and_deletes_a_preflighted_tree() { let root = unique_temp_path("marked-tree-remove"); diff --git a/crates/unixnotis-core/src/filesystem/tests/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs index 11ee76b3f..efcbbc48d 100644 --- a/crates/unixnotis-core/src/filesystem/tests/remove.rs +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -3,13 +3,51 @@ use std::fs; use std::os::unix::fs::symlink; +use rustix::fs::{mkfifoat, Mode, CWD}; + use super::{ - remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, - remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, + existing_parent, file_lookup_is_missing, remove_regular_file, + remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, + revalidate_file_identity, RemoveExactFileOutcome, RemoveSymlinkOutcome, }; +use crate::filesystem::atomic::open_regular_file_at; use crate::filesystem::symlink::read_symlink; use crate::test_support::unique_temp_path; +#[test] +fn optional_file_lookup_classifies_only_missing_errors() { + assert!(file_lookup_is_missing(&std::io::ErrorKind::NotFound.into())); + assert!(!file_lookup_is_missing( + &std::io::ErrorKind::PermissionDenied.into() + )); + assert!(!file_lookup_is_missing( + &std::io::ErrorKind::InvalidInput.into() + )); +} + +#[test] +fn retained_file_identity_rejects_a_same_directory_replacement() { + let root = unique_temp_path("remove-file-identity"); + fs::create_dir_all(&root).expect("create root"); + let target = root.join("shared"); + let moved = root.join("original"); + fs::write(&target, "original").expect("write original"); + let (parent_fd, file_name) = existing_parent(&target) + .expect("open parent") + .expect("parent exists"); + let retained = open_regular_file_at(&parent_fd, &file_name).expect("open retained file"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect("unchanged file should pass"); + fs::rename(&target, &moved).expect("move original"); + fs::write(&target, "replacement").expect("write replacement"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + #[test] fn regular_file_removal_is_idempotent() { let root = unique_temp_path("remove-regular-file"); @@ -91,6 +129,61 @@ fn exact_pair_removal_requires_both_payloads_before_unlinking_either_file() { let _ = fs::remove_dir_all(root); } +#[test] +fn exact_pair_removal_reports_each_missing_member() { + let root = unique_temp_path("remove-exact-missing-member"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("both missing is idempotent"), + RemoveExactFileOutcome::Missing + ); + fs::write(&target, "bundle\n").expect("write shared file"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("missing marker is idempotent"), + RemoveExactFileOutcome::Missing + ); + fs::remove_file(&target).expect("remove shared file"); + fs::write(&marker, "owned\n").expect("write marker"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("missing shared file is idempotent"), + RemoveExactFileOutcome::Missing + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_removal_rejects_special_objects_for_each_member() { + let root = unique_temp_path("remove-exact-special-member"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + + fs::create_dir(&target).expect("create target directory"); + fs::write(&marker, "owned\n").expect("write marker"); + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect_err("target directory must be rejected"); + fs::remove_dir(&target).expect("remove target directory"); + + fs::write(&target, "bundle\n").expect("write target"); + fs::remove_file(&marker).expect("remove marker"); + mkfifoat(CWD, &marker, Mode::from_raw_mode(0o600)).expect("create marker FIFO"); + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect_err("marker FIFO must be rejected"); + + assert_eq!( + fs::read_to_string(&target).expect("shared file remains"), + "bundle\n" + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn symlink_removal_keeps_the_link_target() { let root = unique_temp_path("remove-symlink"); From 963d0fb000db03d94a41c7f540c57953dc89a261 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 14:44:19 -0500 Subject: [PATCH 064/275] test(daemon): close bounded runtime and resource gaps Summary: close bounded runtime and resource gaps. Scope: daemon. --- .../unixnotis-core/src/css/references/url.rs | 6 +- .../src/filesystem/tests/atomic.rs | 32 ++++---- .../src/daemon/control/tests/watch.rs | 37 +++++++++ .../src/daemon/control/watch.rs | 4 + .../src/daemon/notifications/tests/quota.rs | 82 ++++++++++++++++++- crates/unixnotis-daemon/src/runtime/daemon.rs | 14 ++++ .../src/runtime/tests/daemon.rs | 8 ++ crates/unixnotis-daemon/src/sound/source.rs | 4 + .../src/sound/tests/command.rs | 56 ++++++++++++- .../src/sound/tests/resolve.rs | 73 +++++++++++++++++ .../src/sound/tests/source.rs | 21 +++++ .../src/system_tools/tests/lookup.rs | 8 ++ .../src/system_tools/tests/mod.rs | 1 + .../src/system_tools/tests/routing.rs | 15 +--- crates/unixnotis-daemon/tests/cli.rs | 56 ++++++++++++- 15 files changed, 382 insertions(+), 35 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/control/tests/watch.rs create mode 100644 crates/unixnotis-daemon/src/runtime/tests/daemon.rs create mode 100644 crates/unixnotis-daemon/src/sound/tests/source.rs create mode 100644 crates/unixnotis-daemon/src/system_tools/tests/lookup.rs diff --git a/crates/unixnotis-core/src/css/references/url.rs b/crates/unixnotis-core/src/css/references/url.rs index 570b4966f..fbc950425 100644 --- a/crates/unixnotis-core/src/css/references/url.rs +++ b/crates/unixnotis-core/src/css/references/url.rs @@ -178,7 +178,11 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS None } -pub(super) fn valid_url_value_range(input: &str, value_start: usize, value_end: usize) -> bool { +pub(super) const fn valid_url_value_range( + input: &str, + value_start: usize, + value_end: usize, +) -> bool { // Scanner offsets are accepted only when direct string slicing is safe value_start <= value_end && input.is_char_boundary(value_start) diff --git a/crates/unixnotis-core/src/filesystem/tests/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs index 6e160ac59..762d56f9d 100644 --- a/crates/unixnotis-core/src/filesystem/tests/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -273,34 +273,30 @@ fn create_if_missing_propagates_non_collision_open_error() { } #[test] -fn create_if_missing_preserves_permission_denied_errors() { - let root = unique_temp_path("atomic-if-missing-permission"); +fn create_if_missing_preserves_non_directory_parent_errors() { + let root = unique_temp_path("atomic-if-missing-parent-file"); fs::create_dir_all(&root).expect("create test root"); - fs::set_permissions(&root, fs::Permissions::from_mode(0o500)) - .expect("make test root read only"); + let parent_file = root.join("parent-file"); + fs::write(&parent_file, "not a directory").expect("write parent file"); - let error = write_file_if_missing(&root.join("state"), b"data", 0o600) - .expect_err("read-only parent must reject creation"); + let error = write_file_if_missing(&parent_file.join("state"), b"data", 0o600) + .expect_err("regular-file parent must reject creation"); - assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) - .expect("restore test root permissions"); + assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); let _ = fs::remove_dir_all(root); } #[test] -fn exact_file_creation_preserves_permission_denied_errors() { - let root = unique_temp_path("atomic-exact-permission"); +fn exact_file_creation_preserves_non_directory_parent_errors() { + let root = unique_temp_path("atomic-exact-parent-file"); fs::create_dir_all(&root).expect("create test root"); - fs::set_permissions(&root, fs::Permissions::from_mode(0o500)) - .expect("make test root read only"); + let parent_file = root.join("parent-file"); + fs::write(&parent_file, "not a directory").expect("write parent file"); - let error = ensure_exact_file(&root.join("state"), b"data", 0o600) - .expect_err("read-only parent must reject exact creation"); + let error = ensure_exact_file(&parent_file.join("state"), b"data", 0o600) + .expect_err("regular-file parent must reject exact creation"); - assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) - .expect("restore test root permissions"); + assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); let _ = fs::remove_dir_all(root); } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/watch.rs b/crates/unixnotis-daemon/src/daemon/control/tests/watch.rs new file mode 100644 index 000000000..36a5d6e86 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/watch.rs @@ -0,0 +1,37 @@ +use std::time::Duration; + +use super::spawn_inhibitor_owner_watch; +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn owner_watch_removes_inhibitors_when_the_client_disconnects() { + let state = daemon_state_for_test(false).await; + let client = zbus::Connection::session() + .await + .expect("connect inhibitor owner to session bus"); + let owner = client + .unique_name() + .expect("client should have a unique bus name") + .to_string(); + { + let mut store = state.store.lock().await; + store.add_inhibitor(owner, "test owner lifetime".to_string(), 0); + assert_eq!(store.inhibitor_count(), 1); + } + + spawn_inhibitor_owner_watch(state.clone()) + .await + .expect("start inhibitor owner watch"); + client.close().await.expect("disconnect inhibitor owner"); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if state.store.lock().await.inhibitor_count() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owner watch should remove disconnected inhibitors"); +} diff --git a/crates/unixnotis-daemon/src/daemon/control/watch.rs b/crates/unixnotis-daemon/src/daemon/control/watch.rs index eba16fb1f..d32538934 100644 --- a/crates/unixnotis-daemon/src/daemon/control/watch.rs +++ b/crates/unixnotis-daemon/src/daemon/control/watch.rs @@ -71,3 +71,7 @@ pub async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Resul Ok(()) } + +#[cfg(test)] +#[path = "tests/watch.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs index 31d5a1f11..8cb86fc70 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs @@ -1,6 +1,25 @@ use std::time::{Duration, Instant}; -use super::{NotificationQuota, GLOBAL_BURST, MAX_TRACKED_SENDERS, SENDER_BURST}; +use std::collections::HashMap; + +use super::{ + NotificationQuota, QuotaState, SenderBucket, TokenBucket, GLOBAL_BURST, MAX_TRACKED_SENDERS, + SENDER_BURST, SENDER_IDLE_TTL_SECONDS, +}; + +fn sender_bucket(now: Instant) -> SenderBucket { + SenderBucket { + bucket: TokenBucket::new(SENDER_BURST, 1.0, now), + last_seen: now, + } +} + +fn quota_state(now: Instant) -> QuotaState { + QuotaState { + global: TokenBucket::new(GLOBAL_BURST, 1.0, now), + senders: HashMap::new(), + } +} #[test] fn sender_bucket_rejects_a_burst_and_refills_over_time() { @@ -43,3 +62,64 @@ fn sender_tracking_stays_bounded_and_unknown_callers_share_one_bucket() { } assert!(!quota.admit(None, later)); } + +#[test] +fn sender_pruning_removes_entries_at_the_idle_boundary_only() { + let now = Instant::now(); + let mut state = quota_state(now); + state + .senders + .insert("stale".to_string(), sender_bucket(now)); + state.senders.insert( + "recent".to_string(), + sender_bucket(now + Duration::from_secs(1)), + ); + + state.prune_sender_buckets(now + Duration::from_secs(SENDER_IDLE_TTL_SECONDS)); + + assert!(!state.senders.contains_key("stale")); + assert!(state.senders.contains_key("recent")); +} + +#[test] +fn sender_capacity_preserves_existing_and_below_limit_sets() { + let now = Instant::now(); + let mut state = quota_state(now); + state + .senders + .insert("existing".to_string(), sender_bucket(now)); + + state.ensure_sender_capacity("new"); + assert_eq!(state.senders.len(), 1); + assert!(state.senders.contains_key("existing")); + + for index in 1..MAX_TRACKED_SENDERS { + state.senders.insert( + format!("sender-{index}"), + sender_bucket(now + Duration::from_secs(index as u64)), + ); + } + let before = state.senders.len(); + state.ensure_sender_capacity("existing"); + + assert_eq!(state.senders.len(), before); + assert!(state.senders.contains_key("existing")); +} + +#[test] +fn sender_capacity_evicts_the_oldest_entry_at_the_exact_limit() { + let now = Instant::now(); + let mut state = quota_state(now); + for index in 0..MAX_TRACKED_SENDERS { + state.senders.insert( + format!("sender-{index}"), + sender_bucket(now + Duration::from_secs(index as u64)), + ); + } + + state.ensure_sender_capacity("new"); + + assert_eq!(state.senders.len(), MAX_TRACKED_SENDERS - 1); + assert!(!state.senders.contains_key("sender-0")); + assert!(state.senders.contains_key("sender-1")); +} diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 8b2ed0b57..1ec72d987 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -90,6 +90,12 @@ pub(super) async fn run_daemon( )); } + // A zero-duration run verifies service registration without launching UI processes + if skip_ui_for_zero_duration(args.run_seconds) { + info!("zero-duration daemon startup completed"); + return Ok(()); + } + if let Err(err) = spawn_inhibitor_owner_watch(state.clone()).await { warn!(?err, "failed to start inhibitor owner watcher"); } @@ -122,3 +128,11 @@ pub(super) async fn run_daemon( } Ok(()) } + +const fn skip_ui_for_zero_duration(run_seconds: Option) -> bool { + matches!(run_seconds, Some(0)) +} + +#[cfg(test)] +#[path = "tests/daemon.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/runtime/tests/daemon.rs b/crates/unixnotis-daemon/src/runtime/tests/daemon.rs new file mode 100644 index 000000000..b438e6d51 --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/daemon.rs @@ -0,0 +1,8 @@ +use super::skip_ui_for_zero_duration; + +#[test] +fn only_zero_duration_runs_skip_ui_startup() { + assert!(skip_ui_for_zero_duration(Some(0))); + assert!(!skip_ui_for_zero_duration(Some(1))); + assert!(!skip_ui_for_zero_duration(None)); +} diff --git a/crates/unixnotis-daemon/src/sound/source.rs b/crates/unixnotis-daemon/src/sound/source.rs index 486180528..1d0ec2b55 100644 --- a/crates/unixnotis-daemon/src/sound/source.rs +++ b/crates/unixnotis-daemon/src/sound/source.rs @@ -43,3 +43,7 @@ pub(super) enum SoundSource { Name(String), File(SoundFile), } + +#[cfg(test)] +#[path = "tests/source.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/sound/tests/command.rs b/crates/unixnotis-daemon/src/sound/tests/command.rs index 717792fa6..8b16fdf63 100644 --- a/crates/unixnotis-daemon/src/sound/tests/command.rs +++ b/crates/unixnotis-daemon/src/sound/tests/command.rs @@ -2,21 +2,40 @@ use super::*; use crate::system_tools::routing::use_fake_tool_bin; use crate::test_support::TempRoot; -fn fake_sound_tool(name: &str) -> (TempRoot, crate::system_tools::routing::FakeToolBinGuard) { +fn install_fake_sound_tool(root: &TempRoot, name: &str) -> std::path::PathBuf { use std::os::unix::fs::PermissionsExt; - let root = TempRoot::new("sound-command"); let path = root.join(name); - std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake sound tool"); + std::fs::write(&path, "#!/bin/sh\n: > \"$0.called\"\n").expect("write fake sound tool"); let mut permissions = std::fs::metadata(&path) .expect("fake sound tool metadata") .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&path, permissions).expect("make fake sound tool executable"); + path +} + +fn fake_sound_tool(name: &str) -> (TempRoot, crate::system_tools::routing::FakeToolBinGuard) { + let root = TempRoot::new("sound-command"); + install_fake_sound_tool(&root, name); let guard = use_fake_tool_bin(root.path()); (root, guard) } +async fn launch_until_marker(path: &std::path::Path, mut launch: impl FnMut()) { + tokio::time::timeout(Duration::from_secs(5), async { + while !path.exists() { + // Other sound tests can briefly occupy the process-wide playback permits + launch(); + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("sound backend should create its marker"); + // The marker is written immediately before exit, so allow the reaper to release its permit + tokio::time::sleep(Duration::from_millis(20)).await; +} + #[cfg(unix)] #[test] fn sound_command_preserves_non_utf8_argument_bytes() { @@ -49,3 +68,34 @@ async fn reaps_short_lived_command() { let child = command.spawn().expect("spawn true"); reap_sound_child("test", "true".to_string(), child.id(), child).await; } + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "current_thread")] +async fn every_sound_backend_launches_its_trusted_tool() { + let root = TempRoot::new("sound-backends"); + let canberra = install_fake_sound_tool(&root, "canberra-gtk-play"); + let pw_play = install_fake_sound_tool(&root, "pw-play"); + let paplay = install_fake_sound_tool(&root, "paplay"); + let _tools = use_fake_tool_bin(root.path()); + let sound_path = root.join("sound.wav"); + std::fs::write(&sound_path, b"sound fixture").expect("write sound fixture"); + + launch_until_marker(&canberra.with_extension("called"), || { + play_with_canberra(SoundSource::Name("message-new".to_string())); + }) + .await; + + let file = std::fs::File::open(&sound_path).expect("open sound fixture for pw-play"); + let pw_source = crate::sound::SoundFile::new(sound_path.clone(), file); + launch_until_marker(&pw_play.with_extension("called"), || { + play_with_pw_play(SoundSource::File(pw_source.clone())); + }) + .await; + + let file = std::fs::File::open(&sound_path).expect("open sound fixture for paplay"); + let paplay_source = crate::sound::SoundFile::new(sound_path, file); + launch_until_marker(&paplay.with_extension("called"), || { + play_with_paplay(SoundSource::File(paplay_source.clone())); + }) + .await; +} diff --git a/crates/unixnotis-daemon/src/sound/tests/resolve.rs b/crates/unixnotis-daemon/src/sound/tests/resolve.rs index a37a53942..7f071791e 100644 --- a/crates/unixnotis-daemon/src/sound/tests/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/tests/resolve.rs @@ -108,6 +108,32 @@ fn resolve_default_file_uses_relative_config_path_and_validates_file() { assert!(resolve_default_file(&config, Some(&unixnotis_dir)).is_none()); } +#[test] +fn config_directory_and_allowed_hint_paths_follow_the_active_config() { + let root = TempRoot::new("sound-config-paths"); + let config_path = root.join("profile/config.toml"); + let config_dir = config_path.parent().expect("config path has parent"); + let absolute = root.join("shared-sounds"); + let mut config = Config::default(); + config.sound.allowed_file_hint_dirs = vec![ + "relative-sounds".to_string(), + absolute.to_string_lossy().into_owned(), + ]; + + assert_eq!( + resolve_config_dir(Some(&config_path)).as_deref(), + Some(config_dir) + ); + assert_eq!( + resolve_allowed_file_hint_dirs(&config, Some(config_dir)), + vec![config_dir.join("relative-sounds"), absolute.clone()] + ); + assert_eq!( + resolve_allowed_file_hint_dirs(&config, None), + vec![absolute] + ); +} + #[test] fn choose_first_sound_file_filters_extensions_and_sorts_deterministically() { let root = TempRoot::new("sound-default-dir"); @@ -138,6 +164,7 @@ fn hint_bool_reads_only_boolean_hints() { fn max_sound_file_size_stays_at_sixteen_mib() { // This cap keeps notification-provided audio files from becoming large IO spikes assert_eq!(MAX_SOUND_FILE_BYTES, 16 * 1024 * 1024); + assert_eq!(SOUND_HEADER_PROBE_BYTES, 4 * 1024); } #[test] @@ -152,6 +179,7 @@ fn has_audio_extension_accepts_supported_audio_extensions_only() { fn sound_file_open_rejects_missing_oversized_and_non_audio_files() { let root = TempRoot::new("sound-validate"); let valid = root.join("valid.ogg"); + let exact_limit = root.join("exact-limit.ogg"); let oversized = root.join("oversized.ogg"); let wrong_ext = root.join("valid.txt"); write_sound_file(&valid); @@ -160,8 +188,13 @@ fn sound_file_open_rejects_missing_oversized_and_non_audio_files() { .expect("create oversized sound") .set_len(MAX_SOUND_FILE_BYTES + 1) .expect("resize oversized sound"); + fs::File::create(&exact_limit) + .expect("create exact-limit sound") + .set_len(MAX_SOUND_FILE_BYTES) + .expect("resize exact-limit sound"); assert!(open_sound_file(&valid, false).is_some()); + assert!(open_sound_file(&exact_limit, false).is_some()); assert!(open_sound_file(&oversized, false).is_none()); assert!(open_sound_file(&wrong_ext, false).is_none()); assert!(open_sound_file(&root.join("missing.ogg"), false).is_none()); @@ -182,6 +215,46 @@ fn hint_format_validation_rejects_spoofed_and_complex_audio_formats() { assert!(open_sound_file(&pcm, true).is_some()); } +#[test] +fn safe_hint_format_requires_every_wav_and_vorbis_signature_field() { + let root = TempRoot::new("sound-signatures"); + let valid_wav = root.join("valid.wav"); + let wrong_riff = root.join("wrong-riff.wav"); + let wrong_wave = root.join("wrong-wave.wav"); + let compressed_wav = root.join("compressed.wav"); + let valid_ogg = root.join("valid.ogg"); + let wrong_ogg_magic = root.join("wrong-magic.ogg"); + let missing_vorbis = root.join("missing-vorbis.ogg"); + + let pcm = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00"; + fs::write(&valid_wav, pcm).expect("write PCM fixture"); + let mut bytes = pcm.to_vec(); + bytes[0..4].copy_from_slice(b"RIFX"); + fs::write(&wrong_riff, &bytes).expect("write wrong RIFF fixture"); + bytes = pcm.to_vec(); + bytes[8..12].copy_from_slice(b"WAWE"); + fs::write(&wrong_wave, &bytes).expect("write wrong WAVE fixture"); + bytes = pcm.to_vec(); + bytes[20..22].copy_from_slice(&3u16.to_le_bytes()); + fs::write(&compressed_wav, &bytes).expect("write compressed WAV fixture"); + + fs::write(&valid_ogg, b"OggS\0\x01vorbis").expect("write Vorbis fixture"); + fs::write(&wrong_ogg_magic, b"Bad!\0\x01vorbis").expect("write wrong Ogg fixture"); + fs::write(&missing_vorbis, b"OggS\0\x01vorbix").expect("write non-Vorbis fixture"); + + assert!(open_sound_file(&valid_wav, true).is_some()); + assert!(open_sound_file(&wrong_riff, true).is_none()); + assert!(open_sound_file(&wrong_wave, true).is_none()); + assert!(open_sound_file(&compressed_wav, true).is_none()); + assert!(open_sound_file(&valid_ogg, true).is_some()); + assert!(open_sound_file(&wrong_ogg_magic, true).is_none()); + assert!(open_sound_file(&missing_vorbis, true).is_none()); + + assert_eq!(wav_audio_format(pcm), Some(1)); + assert_eq!(wav_audio_format(b""), None); + assert_eq!(wav_audio_format(b"fmt \0\0"), None); +} + #[cfg(unix)] #[test] fn sound_file_open_rejects_symbolic_links() { diff --git a/crates/unixnotis-daemon/src/sound/tests/source.rs b/crates/unixnotis-daemon/src/sound/tests/source.rs new file mode 100644 index 000000000..47e9fdf77 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/tests/source.rs @@ -0,0 +1,21 @@ +use std::fs; + +use super::SoundFile; +use crate::test_support::TempRoot; + +#[test] +fn playback_path_uses_the_retained_descriptor() { + let root = TempRoot::new("sound-source"); + let path = root.join("alert.wav"); + fs::write(&path, b"descriptor-backed sound").expect("write sound fixture"); + let file = fs::File::open(&path).expect("open sound fixture"); + let sound = SoundFile::new(path, file); + + let playback_path = sound.playback_path(); + + assert!(playback_path.starts_with(format!("/proc/{}/fd", std::process::id()))); + assert_eq!( + fs::read(playback_path).expect("read retained descriptor path"), + b"descriptor-backed sound" + ); +} diff --git a/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs b/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs new file mode 100644 index 000000000..6a362e4a8 --- /dev/null +++ b/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs @@ -0,0 +1,8 @@ +use super::super::lookup::trusted_program_path; + +#[test] +fn production_lookup_rejects_empty_and_path_shaped_programs_before_scanning() { + assert!(trusted_program_path("").is_none()); + assert!(trusted_program_path("relative/tool").is_none()); + assert!(trusted_program_path("/absolute/tool").is_none()); +} diff --git a/crates/unixnotis-daemon/src/system_tools/tests/mod.rs b/crates/unixnotis-daemon/src/system_tools/tests/mod.rs index 264b05ea0..beec7dcd4 100644 --- a/crates/unixnotis-daemon/src/system_tools/tests/mod.rs +++ b/crates/unixnotis-daemon/src/system_tools/tests/mod.rs @@ -1 +1,2 @@ mod command; +mod lookup; diff --git a/crates/unixnotis-daemon/src/system_tools/tests/routing.rs b/crates/unixnotis-daemon/src/system_tools/tests/routing.rs index 253172b37..83236d7ca 100644 --- a/crates/unixnotis-daemon/src/system_tools/tests/routing.rs +++ b/crates/unixnotis-daemon/src/system_tools/tests/routing.rs @@ -5,10 +5,8 @@ pub(super) fn trusted_program_path(program: &str) -> Option { if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { return None; } - if fake_tool_bin_is_set() { - return fake_program_path(program); - } - super::lookup::trusted_program_path(program) + // Unit tests never resolve or launch tools installed on the host + fake_program_path(program) } fn executable_file(path: &Path) -> bool { @@ -30,13 +28,6 @@ fn executable_mode(_metadata: &std::fs::Metadata) -> bool { true } -fn fake_tool_bin_is_set() -> bool { - fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .is_some() -} - fn fake_program_path(program: &str) -> Option { let fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); let candidate = fake_bin.as_ref()?.join(program); @@ -60,6 +51,7 @@ pub fn use_fake_tool_bin(path: &Path) -> FakeToolBinGuard { .expect("fake tool bin test lock"); let mut fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); let previous = fake_bin.replace(path.to_path_buf()); + drop(fake_bin); FakeToolBinGuard { _lock: lock, previous, @@ -72,6 +64,7 @@ fn fake_tool_bin() -> &'static Mutex> { } fn fake_tool_bin_test_lock() -> &'static Mutex<()> { + // Async command tests may resume on another worker, so fixtures are process-global and serial static FAKE_TOOL_BIN_TEST_LOCK: OnceLock> = OnceLock::new(); FAKE_TOOL_BIN_TEST_LOCK.get_or_init(|| Mutex::new(())) } diff --git a/crates/unixnotis-daemon/tests/cli.rs b/crates/unixnotis-daemon/tests/cli.rs index 762cd0071..295eea49f 100644 --- a/crates/unixnotis-daemon/tests/cli.rs +++ b/crates/unixnotis-daemon/tests/cli.rs @@ -1,9 +1,13 @@ #[cfg(test)] mod tests { use std::error::Error; + use std::fs; + use std::os::unix::net::UnixListener; + use std::path::{Path, PathBuf}; use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; - type TestResult = Result<(), Box>; + type TestResult = Result>; #[test] fn daemon_help_prints_usage_from_entrypoint() -> TestResult { @@ -18,4 +22,54 @@ mod tests { assert!(stdout.contains("--trial")); Ok(()) } + + struct TempRoot(PathBuf); + + impl TempRoot { + fn new() -> TestResult { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-daemon-cli-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&path)?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn daemon_runtime_reports_an_unreachable_session_bus() -> TestResult { + let root = TempRoot::new()?; + let config = root.path().join("config.toml"); + fs::write(&config, "config_version = 3\n")?; + let display = "wayland-unixnotis-test"; + let _wayland = UnixListener::bind(root.path().join(display))?; + + let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-daemon")) + .args(["--config", config.to_str().ok_or("non-UTF-8 config path")?]) + .args(["--run-seconds", "0"]) + .env("XDG_RUNTIME_DIR", root.path()) + .env("WAYLAND_DISPLAY", display) + .env("XDG_SESSION_TYPE", "wayland") + .env( + "DBUS_SESSION_BUS_ADDRESS", + "unix:path=/nonexistent/unixnotis-test-session-bus", + ) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.contains("session bus"), "unexpected error: {stderr}"); + Ok(()) + } } From 0fd46ecf97963ce3ed6b8ef1acc5a5771fcd6043 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 15:51:25 -0500 Subject: [PATCH 065/275] fix(daemon): structurally validate WAV file hints Summary: structurally validate WAV file hints. Scope: daemon. --- crates/unixnotis-daemon/src/sound/mod.rs | 1 + crates/unixnotis-daemon/src/sound/resolve.rs | 33 +--- .../src/sound/tests/resolve.rs | 43 ++---- .../unixnotis-daemon/src/sound/tests/wav.rs | 142 ++++++++++++++++++ crates/unixnotis-daemon/src/sound/wav.rs | 142 ++++++++++++++++++ 5 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 crates/unixnotis-daemon/src/sound/tests/wav.rs create mode 100644 crates/unixnotis-daemon/src/sound/wav.rs diff --git a/crates/unixnotis-daemon/src/sound/mod.rs b/crates/unixnotis-daemon/src/sound/mod.rs index 0cf186763..035a7f33d 100644 --- a/crates/unixnotis-daemon/src/sound/mod.rs +++ b/crates/unixnotis-daemon/src/sound/mod.rs @@ -5,6 +5,7 @@ mod command; mod resolve; mod settings; mod source; +mod wav; pub use settings::SoundSettings; use source::{SoundFile, SoundSource}; diff --git a/crates/unixnotis-daemon/src/sound/resolve.rs b/crates/unixnotis-daemon/src/sound/resolve.rs index 392085348..4ee26fcde 100644 --- a/crates/unixnotis-daemon/src/sound/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/resolve.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::fs; -use std::os::unix::fs::FileExt; use std::path::{Path, PathBuf}; use tracing::{debug, info}; @@ -8,10 +7,9 @@ use unixnotis_core::filesystem::{open_regular_file, ContainedPath}; use unixnotis_core::{util, Config}; use zbus::zvariant::OwnedValue; -use super::{SoundFile, SoundSource}; +use super::{wav::is_safe_pcm_wav, SoundFile, SoundSource}; const MAX_SOUND_FILE_BYTES: u64 = 16 * 1024 * 1024; -const SOUND_HEADER_PROBE_BYTES: usize = 4 * 1024; pub(super) fn resolve_hint_sound( hints: &HashMap, @@ -190,7 +188,7 @@ fn open_sound_file(path: &Path, require_safe_hint_format: bool) -> Option MAX_SOUND_FILE_BYTES { return None; } - if require_safe_hint_format && !has_safe_hint_format(path, &file) { + if require_safe_hint_format && !has_safe_hint_format(path, &file, metadata.len()) { return None; } Some(SoundFile::new(path.to_path_buf(), file)) @@ -203,35 +201,12 @@ fn path_is_allowed(path: &Path, allowed_dirs: &[PathBuf]) -> bool { .any(|root| ContainedPath::resolve(root, path).is_ok()) } -fn has_safe_hint_format(path: &Path, file: &fs::File) -> bool { +fn has_safe_hint_format(path: &Path, file: &fs::File, file_len: u64) -> bool { let extension = path .extension() .and_then(|extension| extension.to_str()) .unwrap_or_default(); - let mut header = [0u8; SOUND_HEADER_PROBE_BYTES]; - let read = file.read_at(&mut header, 0).ok().unwrap_or(0); - let header = &header[..read]; - - if extension.eq_ignore_ascii_case("wav") { - return header.starts_with(b"RIFF") - && header.get(8..12) == Some(b"WAVE") - && wav_audio_format(header) == Some(1); - } - if extension.eq_ignore_ascii_case("ogg") || extension.eq_ignore_ascii_case("oga") { - return header.starts_with(b"OggS") - && header.windows(7).any(|window| window == b"\x01vorbis"); - } - false -} - -fn wav_audio_format(header: &[u8]) -> Option { - let format_offset = header.windows(4).position(|window| window == b"fmt ")?; - let value_start = format_offset.checked_add(8)?; - let bytes: [u8; 2] = header - .get(value_start..value_start.checked_add(2)?)? - .try_into() - .ok()?; - Some(u16::from_le_bytes(bytes)) + extension.eq_ignore_ascii_case("wav") && is_safe_pcm_wav(file, file_len) } fn hint_string(hints: &HashMap, key: &str) -> Option { diff --git a/crates/unixnotis-daemon/src/sound/tests/resolve.rs b/crates/unixnotis-daemon/src/sound/tests/resolve.rs index 7f071791e..6f009ea27 100644 --- a/crates/unixnotis-daemon/src/sound/tests/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/tests/resolve.rs @@ -11,7 +11,7 @@ fn string_value(value: &str) -> OwnedValue { fn write_sound_file(path: &Path) { let contents = match path.extension().and_then(|extension| extension.to_str()) { Some(extension) if extension.eq_ignore_ascii_case("wav") => { - b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00".as_slice() + b"RIFF\x26\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x02\x00\x00\x00\x00\x00".as_slice() } _ => b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01vorbis".as_slice(), }; @@ -45,7 +45,7 @@ fn percent_decode_path_rejects_nul_and_keeps_utf8_valid() { #[test] fn resolve_hint_sound_requires_opt_in_allowed_directory_and_safe_format() { let root = TempRoot::new("sound-hints"); - let sound = root.join("alert.ogg"); + let sound = root.join("alert.wav"); write_sound_file(&sound); let mut hints = HashMap::new(); @@ -164,7 +164,6 @@ fn hint_bool_reads_only_boolean_hints() { fn max_sound_file_size_stays_at_sixteen_mib() { // This cap keeps notification-provided audio files from becoming large IO spikes assert_eq!(MAX_SOUND_FILE_BYTES, 16 * 1024 * 1024); - assert_eq!(SOUND_HEADER_PROBE_BYTES, 4 * 1024); } #[test] @@ -216,43 +215,21 @@ fn hint_format_validation_rejects_spoofed_and_complex_audio_formats() { } #[test] -fn safe_hint_format_requires_every_wav_and_vorbis_signature_field() { - let root = TempRoot::new("sound-signatures"); +fn safe_hint_format_accepts_only_structurally_valid_pcm_wave() { + let root = TempRoot::new("sound-safe-format"); let valid_wav = root.join("valid.wav"); - let wrong_riff = root.join("wrong-riff.wav"); - let wrong_wave = root.join("wrong-wave.wav"); let compressed_wav = root.join("compressed.wav"); - let valid_ogg = root.join("valid.ogg"); - let wrong_ogg_magic = root.join("wrong-magic.ogg"); - let missing_vorbis = root.join("missing-vorbis.ogg"); - - let pcm = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00"; - fs::write(&valid_wav, pcm).expect("write PCM fixture"); - let mut bytes = pcm.to_vec(); - bytes[0..4].copy_from_slice(b"RIFX"); - fs::write(&wrong_riff, &bytes).expect("write wrong RIFF fixture"); - bytes = pcm.to_vec(); - bytes[8..12].copy_from_slice(b"WAWE"); - fs::write(&wrong_wave, &bytes).expect("write wrong WAVE fixture"); - bytes = pcm.to_vec(); + let ogg = root.join("valid.ogg"); + + write_sound_file(&valid_wav); + let mut bytes = fs::read(&valid_wav).expect("read PCM fixture"); bytes[20..22].copy_from_slice(&3u16.to_le_bytes()); fs::write(&compressed_wav, &bytes).expect("write compressed WAV fixture"); - - fs::write(&valid_ogg, b"OggS\0\x01vorbis").expect("write Vorbis fixture"); - fs::write(&wrong_ogg_magic, b"Bad!\0\x01vorbis").expect("write wrong Ogg fixture"); - fs::write(&missing_vorbis, b"OggS\0\x01vorbix").expect("write non-Vorbis fixture"); + write_sound_file(&ogg); assert!(open_sound_file(&valid_wav, true).is_some()); - assert!(open_sound_file(&wrong_riff, true).is_none()); - assert!(open_sound_file(&wrong_wave, true).is_none()); assert!(open_sound_file(&compressed_wav, true).is_none()); - assert!(open_sound_file(&valid_ogg, true).is_some()); - assert!(open_sound_file(&wrong_ogg_magic, true).is_none()); - assert!(open_sound_file(&missing_vorbis, true).is_none()); - - assert_eq!(wav_audio_format(pcm), Some(1)); - assert_eq!(wav_audio_format(b""), None); - assert_eq!(wav_audio_format(b"fmt \0\0"), None); + assert!(open_sound_file(&ogg, true).is_none()); } #[cfg(unix)] diff --git a/crates/unixnotis-daemon/src/sound/tests/wav.rs b/crates/unixnotis-daemon/src/sound/tests/wav.rs new file mode 100644 index 000000000..76a34a66e --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/tests/wav.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::test_support::TempRoot; +use std::io::Write; + +fn chunk(name: &[u8; 4], contents: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(8 + contents.len() + (contents.len() & 1)); + bytes.extend_from_slice(name); + bytes.extend_from_slice( + &u32::try_from(contents.len()) + .expect("chunk size") + .to_le_bytes(), + ); + bytes.extend_from_slice(contents); + if contents.len() & 1 == 1 { + bytes.push(0); + } + bytes +} + +fn pcm_format(channels: u16, sample_rate: u32, bits_per_sample: u16) -> Vec { + let block_align = channels * (bits_per_sample / 8); + let byte_rate = sample_rate * u32::from(block_align); + let mut format = Vec::with_capacity(16); + format.extend_from_slice(&1u16.to_le_bytes()); + format.extend_from_slice(&channels.to_le_bytes()); + format.extend_from_slice(&sample_rate.to_le_bytes()); + format.extend_from_slice(&byte_rate.to_le_bytes()); + format.extend_from_slice(&block_align.to_le_bytes()); + format.extend_from_slice(&bits_per_sample.to_le_bytes()); + format +} + +fn wave(chunks: &[Vec]) -> Vec { + let payload_len = 4usize + chunks.iter().map(Vec::len).sum::(); + let mut bytes = Vec::with_capacity(payload_len + 8); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice( + &u32::try_from(payload_len) + .expect("RIFF payload size") + .to_le_bytes(), + ); + bytes.extend_from_slice(b"WAVE"); + for item in chunks { + bytes.extend_from_slice(item); + } + bytes +} + +fn validate(bytes: &[u8]) -> bool { + let root = TempRoot::new("sound-wav-parser"); + let path = root.join("sound.wav"); + let mut file = fs::File::create(path).expect("create WAVE fixture"); + file.write_all(bytes).expect("write WAVE fixture"); + drop(file); + let file = fs::File::open(root.join("sound.wav")).expect("open WAVE fixture"); + is_safe_pcm_wav(&file, bytes.len() as u64) +} + +#[test] +fn canonical_pcm_wave_requires_format_then_nonempty_aligned_data() { + let format = chunk(b"fmt ", &pcm_format(2, 48_000, 16)); + let data = chunk(b"data", &[0; 8]); + + assert!(validate(&wave(&[format.clone(), data.clone()]))); + assert!(!validate(&wave(&[data, format.clone()]))); + assert!(!validate(&wave(&[format.clone(), chunk(b"data", &[])]))); + assert!(!validate(&wave(&[format, chunk(b"data", &[0; 3])]))); +} + +#[test] +fn chunk_boundaries_prevent_fake_format_and_data_markers() { + let fake_format = chunk( + b"JUNK", + b"fmt \x10\0\0\0\x01\0\x01\0\x44\xac\0\0\x88\x58\x01\0\x02\0\x10\0data\x02\0\0\0\0\0", + ); + let compressed = { + let mut value = pcm_format(1, 44_100, 16); + value[0..2].copy_from_slice(&3u16.to_le_bytes()); + chunk(b"fmt ", &value) + }; + + assert!(!validate(&wave(std::slice::from_ref(&fake_format)))); + assert!(!validate(&wave(&[ + fake_format, + compressed, + chunk(b"data", &[0; 2]), + ]))); +} + +#[test] +fn odd_unknown_chunks_use_declared_padding_without_hiding_following_chunks() { + let junk = chunk(b"JUNK", b"x"); + let format = chunk(b"fmt ", &pcm_format(1, 44_100, 16)); + let data = chunk(b"data", &[0; 2]); + + assert!(validate(&wave(&[junk, format, data]))); +} + +#[test] +fn pcm_format_bounds_and_derived_rates_must_be_consistent() { + for invalid in [ + pcm_format(0, 44_100, 16), + pcm_format(3, 44_100, 16), + pcm_format(1, 7_999, 16), + pcm_format(1, 192_001, 16), + pcm_format(1, 44_100, 12), + ] { + assert!(!validate(&wave(&[ + chunk(b"fmt ", &invalid), + chunk(b"data", &[0; 4]), + ]))); + } + + let mut wrong_align = pcm_format(1, 44_100, 16); + wrong_align[12..14].copy_from_slice(&4u16.to_le_bytes()); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &wrong_align), + chunk(b"data", &[0; 4]), + ]))); + + let mut wrong_rate = pcm_format(1, 44_100, 16); + wrong_rate[8..12].copy_from_slice(&1u32.to_le_bytes()); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &wrong_rate), + chunk(b"data", &[0; 4]), + ]))); +} + +#[test] +fn riff_length_truncation_duplicate_format_and_extended_format_fail_closed() { + let format = chunk(b"fmt ", &pcm_format(1, 44_100, 16)); + let data = chunk(b"data", &[0; 2]); + let mut wrong_length = wave(&[format.clone(), data.clone()]); + wrong_length[4..8].copy_from_slice(&0u32.to_le_bytes()); + + assert!(!validate(&wrong_length)); + assert!(!validate(&wave(&[format.clone(), format, data]))); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &[0; 18]), + chunk(b"data", &[0; 2]), + ]))); +} diff --git a/crates/unixnotis-daemon/src/sound/wav.rs b/crates/unixnotis-daemon/src/sound/wav.rs new file mode 100644 index 000000000..57668a037 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/wav.rs @@ -0,0 +1,142 @@ +//! Structural validation for notification-supplied PCM WAVE files + +use std::fs; +use std::os::unix::fs::FileExt; + +const RIFF_HEADER_BYTES: u64 = 12; +const CHUNK_HEADER_BYTES: u64 = 8; +const PCM_FORMAT_BYTES: u32 = 16; +const MAX_WAV_CHUNKS: usize = 1_024; +const MIN_SAMPLE_RATE: u32 = 8_000; +const MAX_SAMPLE_RATE: u32 = 192_000; +const MAX_CHANNELS: u16 = 2; + +#[derive(Clone, Copy)] +struct PcmFormat { + block_align: u16, +} + +pub(super) fn is_safe_pcm_wav(file: &fs::File, file_len: u64) -> bool { + let mut riff_header = [0u8; RIFF_HEADER_BYTES as usize]; + if file.read_exact_at(&mut riff_header, 0).is_err() + || &riff_header[..4] != b"RIFF" + || &riff_header[8..] != b"WAVE" + { + return false; + } + + // RIFF size excludes the leading identifier and size field + let Some(declared_len) = read_u32(&riff_header[4..8]).map(|size| u64::from(size) + 8) else { + return false; + }; + if declared_len != file_len { + return false; + } + + let mut cursor = RIFF_HEADER_BYTES; + let mut pcm_format = None; + let mut found_data = false; + let mut chunk_count = 0usize; + + while cursor < file_len { + chunk_count += 1; + if chunk_count > MAX_WAV_CHUNKS { + return false; + } + + let mut chunk_header = [0u8; CHUNK_HEADER_BYTES as usize]; + if file.read_exact_at(&mut chunk_header, cursor).is_err() { + return false; + } + let Some(chunk_size) = read_u32(&chunk_header[4..]) else { + return false; + }; + let data_start = match cursor.checked_add(CHUNK_HEADER_BYTES) { + Some(offset) => offset, + None => return false, + }; + let data_end = match data_start.checked_add(u64::from(chunk_size)) { + Some(offset) if offset <= file_len => offset, + _ => return false, + }; + // RIFF chunks use one padding byte after odd-sized payloads + let padded_end = match data_end.checked_add(u64::from(chunk_size & 1)) { + Some(offset) if offset <= file_len => offset, + _ => return false, + }; + + match &chunk_header[..4] { + b"fmt " => { + // Multiple format declarations create decoder-dependent interpretation + if pcm_format.is_some() { + return false; + } + pcm_format = read_pcm_format(file, data_start, chunk_size); + if pcm_format.is_none() { + return false; + } + } + b"data" => { + // The format must be known before audio bytes are accepted + let Some(format) = pcm_format else { + return false; + }; + if found_data || chunk_size == 0 || chunk_size % u32::from(format.block_align) != 0 + { + return false; + } + found_data = true; + } + _ => {} + } + + cursor = padded_end; + } + + cursor == file_len && pcm_format.is_some() && found_data +} + +fn read_pcm_format(file: &fs::File, offset: u64, chunk_size: u32) -> Option { + // Restrict file hints to the fixed-size canonical PCM format block + if chunk_size != PCM_FORMAT_BYTES { + return None; + } + let mut format = [0u8; PCM_FORMAT_BYTES as usize]; + file.read_exact_at(&mut format, offset).ok()?; + + let audio_format = read_u16(&format[0..2])?; + let channels = read_u16(&format[2..4])?; + let sample_rate = read_u32(&format[4..8])?; + let byte_rate = read_u32(&format[8..12])?; + let block_align = read_u16(&format[12..14])?; + let bits_per_sample = read_u16(&format[14..16])?; + + if audio_format != 1 + || !(1..=MAX_CHANNELS).contains(&channels) + || !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) + || !matches!(bits_per_sample, 8 | 16 | 24 | 32) + { + return None; + } + + let bytes_per_sample = bits_per_sample.checked_div(8)?; + let expected_align = channels.checked_mul(bytes_per_sample)?; + let expected_rate = sample_rate.checked_mul(u32::from(expected_align))?; + if block_align != expected_align || byte_rate != expected_rate { + return None; + } + + Some(PcmFormat { block_align }) +} + +fn read_u16(bytes: &[u8]) -> Option { + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_u32(bytes: &[u8]) -> Option { + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +#[cfg(test)] +#[path = "tests/wav.rs"] +mod tests; From 268254fa13a71897cd5fc99f336f9c02d8fb54cb Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:00:05 -0500 Subject: [PATCH 066/275] feat(filesystem): add exact file-pair transactions Summary: add exact file-pair transactions. Scope: filesystem. --- .../unixnotis-core/src/filesystem/atomic.rs | 198 +---------- .../src/filesystem/directory.rs | 8 +- crates/unixnotis-core/src/filesystem/exact.rs | 313 ++++++++++++++++++ .../unixnotis-core/src/filesystem/install.rs | 3 +- crates/unixnotis-core/src/filesystem/mod.rs | 13 +- crates/unixnotis-core/src/filesystem/read.rs | 2 +- .../unixnotis-core/src/filesystem/regular.rs | 165 +++++++++ .../unixnotis-core/src/filesystem/remove.rs | 2 +- .../unixnotis-core/src/filesystem/rename.rs | 2 +- .../src/filesystem/tests/atomic.rs | 146 +------- .../src/filesystem/tests/exact.rs | 221 +++++++++++++ .../src/filesystem/tests/regular.rs | 125 +++++++ .../src/filesystem/tests/remove.rs | 2 +- .../src/actions/install/service/files.rs | 61 ++-- .../actions/install/tests/service/writes.rs | 98 +++++- 15 files changed, 983 insertions(+), 376 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/exact.rs create mode 100644 crates/unixnotis-core/src/filesystem/regular.rs create mode 100644 crates/unixnotis-core/src/filesystem/tests/exact.rs create mode 100644 crates/unixnotis-core/src/filesystem/tests/regular.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index f00bf5c45..254a522b8 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -3,31 +3,20 @@ use rustix::fs::{openat2, renameat, unlinkat, AtFlags, Mode, OFlags}; use std::ffi::OsString; use std::fs; -use std::io::{self, Read, Write}; +use std::io::{self, Write}; use std::os::fd::OwnedFd; use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use super::directory::{ - contained_resolve_flags, open_parent, open_parent_existing, sync_directory, -}; +use super::directory::{contained_resolve_flags, open_parent, sync_directory}; +use super::exact::exclusive_create_collided; +use super::regular::{existing_target_mode, validate_existing_target}; const TEMP_ATTEMPTS: u8 = 16; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); -/// Result of creating or validating a file whose bytes must match exactly -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EnsureExactFileOutcome { - /// The destination was absent and this operation created it - Created, - /// The existing regular file already contained the required bytes - AlreadyExact, - /// The existing regular file belongs to another owner or configuration - ContentsMismatch, -} - /// Replace a regular file through an exclusive sibling temporary file /// /// # Errors @@ -139,189 +128,10 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res Ok(true) } -/// Create a regular file when absent or validate an exact existing payload -/// -/// A collision is opened once through the retained parent descriptor and is never replaced -/// -/// # Errors -/// -/// Returns an error when the parent path is unsafe, the destination is not a regular file, or -/// creating, reading, applying the mode, or synchronizing the file fails -pub fn ensure_exact_file( - path: &Path, - contents: &[u8], - mode: u32, -) -> io::Result { - let (parent_fd, file_name) = open_parent(path)?; - ensure_exact_file_at(&parent_fd, &file_name, contents, mode) -} - -/// Add executable bits to an existing regular file without following links -/// -/// # Errors -/// -/// Returns an error when the path escapes through a link, is not a regular file, or cannot be -/// opened and updated through its stable descriptor -pub fn make_file_executable(path: &Path) -> io::Result<()> { - let file = open_regular_file(path)?; - let mode = file.metadata()?.permissions().mode() | 0o111; - file.set_permissions(fs::Permissions::from_mode(mode)) -} - -/// Set permission bits on an existing regular file without following links -/// -/// # Errors -/// -/// Returns an error when the path escapes through a link, is not a regular file, or cannot be -/// opened and updated through its stable descriptor -pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { - let file = open_regular_file(path)?; - file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) -} - -/// Open one regular file through a no-follow descriptor path -/// -/// # Errors -/// -/// Returns an error when any path component is a link, the target is not a regular file, or the -/// descriptor-relative open fails -pub fn open_regular_file(path: &Path) -> io::Result { - let (parent_fd, file_name) = open_parent_existing(path)?; - open_regular_file_at(&parent_fd, &file_name) -} - -pub(super) fn open_regular_file_at( - parent_fd: &OwnedFd, - file_name: &OsString, -) -> io::Result { - let fd = openat2( - parent_fd, - file_name, - OFlags::RDONLY - .union(OFlags::NONBLOCK) - .union(OFlags::CLOEXEC) - .union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - )?; - let file = fs::File::from(fd); - if !file.metadata()?.is_file() { - return Err(unsafe_target_error()); - } - Ok(file) -} - -pub(super) fn ensure_exact_file_at( - parent_fd: &OwnedFd, - file_name: &OsString, - contents: &[u8], - mode: u32, -) -> io::Result { - let fd = match openat2( - parent_fd, - file_name, - OFlags::RDWR - .union(OFlags::NONBLOCK) - .union(OFlags::CLOEXEC) - .union(OFlags::CREATE) - .union(OFlags::EXCL), - file_mode(mode), - contained_resolve_flags(), - ) { - Ok(fd) => fd, - Err(error) if exclusive_create_collided(error) => { - let mut file = open_regular_file_at(parent_fd, file_name)?; - if !file_contents_equal(&mut file, contents)? { - return Ok(EnsureExactFileOutcome::ContentsMismatch); - } - // Matching shared state may still need its declared service-manager mode restored - file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; - file.sync_all()?; - return Ok(EnsureExactFileOutcome::AlreadyExact); - } - Err(error) => return Err(error.into()), - }; - - let mut file = fs::File::from(fd); - if let Err(error) = file - .write_all(contents) - .and_then(|()| set_mode_and_sync(&file, mode)) - { - drop(file); - let _ = unlinkat(parent_fd, file_name, AtFlags::empty()); - return Err(error); - } - drop(file); - sync_directory(parent_fd)?; - Ok(EnsureExactFileOutcome::Created) -} - -fn exclusive_create_collided(error: rustix::io::Errno) -> bool { - // Only an existing target may enter the create-or-compare collision path - error == rustix::io::Errno::EXIST -} - -pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { - let read_limit = u64::try_from(expected.len()) - .unwrap_or(u64::MAX) - .saturating_add(1); - let mut actual = Vec::with_capacity(expected.len().saturating_add(1)); - file.take(read_limit).read_to_end(&mut actual)?; - Ok(actual == expected) -} - fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { existing_target_mode(parent_fd, file_name).map(|_mode| ()) } -fn existing_target_mode(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result> { - match openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - ) { - Ok(fd) => { - let metadata = fs::File::from(fd).metadata()?; - if metadata.is_file() { - Ok(Some(metadata.permissions().mode() & 0o777)) - } else { - Err(unsafe_target_error()) - } - } - Err(error) => match error.kind() { - io::ErrorKind::NotFound => Ok(None), - _ => Err(error.into()), - }, - } -} - -pub(super) fn validate_existing_target( - parent_fd: &OwnedFd, - file_name: &OsString, -) -> io::Result<()> { - let fd = openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - )?; - if fs::File::from(fd).metadata()?.is_file() { - Ok(()) - } else { - Err(unsafe_target_error()) - } -} - -fn unsafe_target_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidInput, - "refusing to operate on a non-regular file target", - ) -} - pub(super) fn temp_candidates(file_name: &OsString) -> impl Iterator + '_ { (0..TEMP_ATTEMPTS).map(move |attempt| { let nanos = SystemTime::now() diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index 83b132a20..21b82d130 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -11,7 +11,8 @@ use rustix::fs::{ ResolveFlags, CWD, }; -use super::atomic::{ensure_exact_file_at, file_contents_equal, open_regular_file_at}; +use super::exact::{ensure_exact_file_at, EnsureExactFileOutcome}; +use super::regular::{file_contents_equal, open_regular_file_at}; /// Outcome for the final component of recursive directory creation #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -60,10 +61,7 @@ pub fn ensure_marked_directory( CreateDirectoryOutcome::TargetCreated => { let marker_outcome = ensure_exact_file_at(&directory_fd, &marker_name, marker_contents, marker_mode)?; - if matches!( - marker_outcome, - super::atomic::EnsureExactFileOutcome::ContentsMismatch - ) { + if matches!(marker_outcome, EnsureExactFileOutcome::ContentsMismatch) { return Err(invalid_marker_error()); } } diff --git a/crates/unixnotis-core/src/filesystem/exact.rs b/crates/unixnotis-core/src/filesystem/exact.rs new file mode 100644 index 000000000..30d6146ef --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/exact.rs @@ -0,0 +1,313 @@ +//! Create-or-validate transactions for exact regular-file state + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Write}; +use std::os::fd::OwnedFd; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use rustix::fs::{fstat, openat2, statat, unlinkat, AtFlags, Mode, OFlags}; + +use super::directory::{contained_resolve_flags, open_parent, sync_directory}; +use super::regular::{file_contents_equal, open_regular_file_at}; + +/// Result of creating or validating a file whose bytes must match exactly +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureExactFileOutcome { + /// The destination was absent and this operation created it + Created, + /// The existing regular file already contained the required bytes + AlreadyExact, + /// The existing regular file belongs to another owner or configuration + ContentsMismatch, +} + +/// Result of creating or validating an exact file-and-marker pair +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureExactFilePairOutcome { + /// At least one missing member was created and the complete pair is exact + Created, + /// Both existing regular files already contained the required bytes + AlreadyExact, + /// The primary file was already exact but no ownership marker existed + AlreadyExactUnowned, + /// At least one existing member contained different bytes + ContentsMismatch, +} + +struct ExactMember { + file: fs::File, + created: bool, +} + +enum ExactMemberResult { + Exact(ExactMember), + ContentsMismatch, +} + +/// Create a regular file when absent or validate an exact existing payload +/// +/// A collision is opened once through the retained parent descriptor and is never replaced +/// +/// # Errors +/// +/// Returns an error when the parent path is unsafe, the destination is not a regular file, or +/// creating, reading, applying the mode, or synchronizing the file fails +pub fn ensure_exact_file( + path: &Path, + contents: &[u8], + mode: u32, +) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + ensure_exact_file_at(&parent_fd, &file_name, contents, mode) +} + +/// Create or validate a same-directory regular file and ownership marker as one transaction +/// +/// Existing members are never replaced. When either member conflicts, files created by this +/// operation are removed through retained descriptors before returning +/// +/// # Errors +/// +/// Returns an error when the paths do not share one parent, a path is unsafe, either target is not +/// a regular file, or creation, rollback, permission repair, or synchronization fails +pub fn ensure_exact_file_pair( + path: &Path, + contents: &[u8], + mode: u32, + marker_path: &Path, + marker_contents: &[u8], + marker_mode: u32, +) -> io::Result { + if path.parent() != marker_path.parent() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "exact file pair must share one parent directory", + )); + } + + let (parent_fd, file_name) = open_parent(path)?; + let marker_name = marker_path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "marker has no file name"))? + .to_os_string(); + if file_name == marker_name { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "exact file pair must use two distinct names", + )); + } + + let file = match create_or_validate_member(&parent_fd, &file_name, contents, mode)? { + ExactMemberResult::Exact(member) => member, + ExactMemberResult::ContentsMismatch => { + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + }; + let marker = if file.created { + match create_or_validate_member(&parent_fd, &marker_name, marker_contents, marker_mode) { + Ok(ExactMemberResult::Exact(member)) => member, + Ok(ExactMemberResult::ContentsMismatch) => { + rollback_created_member(&parent_fd, &file_name, &file)?; + sync_directory(&parent_fd)?; + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + Err(error) => { + return Err(rollback_after_error(&parent_fd, &file_name, &file, error)); + } + } + } else { + // An existing unmarked file may be compatible user state, so never claim it retroactively + match open_regular_file_at(&parent_fd, &marker_name) { + Ok(mut marker_file) => { + if !file_contents_equal(&mut marker_file, marker_contents)? { + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + ExactMember { + file: marker_file, + created: false, + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(EnsureExactFilePairOutcome::AlreadyExactUnowned); + } + Err(error) => return Err(error), + } + }; + + // Modes are repaired only after both retained payloads prove the complete pair is owned + if let Err(error) = set_mode_and_sync(&file.file, mode) + .and_then(|()| set_mode_and_sync(&marker.file, marker_mode)) + .and_then(|()| sync_directory(&parent_fd)) + { + return Err(rollback_pair_after_error( + &parent_fd, + (&file_name, &file), + (&marker_name, &marker), + error, + )); + } + + if file.created || marker.created { + Ok(EnsureExactFilePairOutcome::Created) + } else { + Ok(EnsureExactFilePairOutcome::AlreadyExact) + } +} + +pub(super) fn ensure_exact_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: u32, +) -> io::Result { + let member = match create_or_validate_member(parent_fd, file_name, contents, mode)? { + ExactMemberResult::Exact(member) => member, + ExactMemberResult::ContentsMismatch => { + return Ok(EnsureExactFileOutcome::ContentsMismatch); + } + }; + + if let Err(error) = set_mode_and_sync(&member.file, mode) { + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + if let Err(error) = sync_directory(parent_fd) { + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + + if member.created { + Ok(EnsureExactFileOutcome::Created) + } else { + Ok(EnsureExactFileOutcome::AlreadyExact) + } +} + +fn create_or_validate_member( + parent_fd: &OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: u32, +) -> io::Result { + let fd = match openat2( + parent_fd, + file_name, + OFlags::RDWR + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::CREATE) + .union(OFlags::EXCL), + file_mode(mode), + contained_resolve_flags(), + ) { + Ok(fd) => fd, + Err(error) if exclusive_create_collided(error) => { + let mut file = open_regular_file_at(parent_fd, file_name)?; + if !file_contents_equal(&mut file, contents)? { + return Ok(ExactMemberResult::ContentsMismatch); + } + return Ok(ExactMemberResult::Exact(ExactMember { + file, + created: false, + })); + } + Err(error) => return Err(error.into()), + }; + + let mut file = fs::File::from(fd); + if let Err(error) = file + .write_all(contents) + .and_then(|()| set_mode_and_sync(&file, mode)) + { + let member = ExactMember { + file, + created: true, + }; + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + Ok(ExactMemberResult::Exact(ExactMember { + file, + created: true, + })) +} + +pub(super) fn exclusive_create_collided(error: rustix::io::Errno) -> bool { + // Only an existing target may enter the create-or-compare collision path + error == rustix::io::Errno::EXIST +} + +fn rollback_pair_after_error( + parent_fd: &OwnedFd, + file: (&OsString, &ExactMember), + marker: (&OsString, &ExactMember), + error: io::Error, +) -> io::Error { + let marker_rollback = rollback_created_member(parent_fd, marker.0, marker.1); + let file_rollback = rollback_created_member(parent_fd, file.0, file.1); + let directory_sync = sync_directory(parent_fd); + combine_rollback_error( + error, + marker_rollback.and(file_rollback).and(directory_sync), + ) +} + +fn rollback_after_error( + parent_fd: &OwnedFd, + file_name: &OsString, + member: &ExactMember, + error: io::Error, +) -> io::Error { + let rollback = rollback_created_member(parent_fd, file_name, member) + .and_then(|()| sync_directory(parent_fd)); + combine_rollback_error(error, rollback) +} + +fn combine_rollback_error(error: io::Error, rollback: io::Result<()>) -> io::Error { + match rollback { + Ok(()) => error, + Err(rollback_error) => io::Error::new( + rollback_error.kind(), + format!("{error}; exact-file rollback also failed: {rollback_error}"), + ), + } +} + +fn rollback_created_member( + parent_fd: &OwnedFd, + file_name: &OsString, + member: &ExactMember, +) -> io::Result<()> { + if !member.created { + return Ok(()); + } + + // Identity revalidation prevents rollback from removing a replacement object + let retained = fstat(&member.file)?; + let visible = match statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW) { + Ok(visible) => visible, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if retained.st_dev != visible.st_dev || retained.st_ino != visible.st_ino { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "created exact file changed before rollback", + )); + } + unlinkat(parent_fd, file_name, AtFlags::empty())?; + Ok(()) +} + +fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { + file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; + file.sync_all() +} + +const fn file_mode(mode: u32) -> Mode { + Mode::from_raw_mode(mode & 0o777) +} + +#[cfg(test)] +#[path = "tests/exact.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/install.rs b/crates/unixnotis-core/src/filesystem/install.rs index e70a1766f..e56b3659a 100644 --- a/crates/unixnotis-core/src/filesystem/install.rs +++ b/crates/unixnotis-core/src/filesystem/install.rs @@ -4,7 +4,8 @@ use std::io; use std::os::unix::fs::PermissionsExt; use std::path::Path; -use super::atomic::{open_regular_file, publish_file_atomic}; +use super::atomic::publish_file_atomic; +use super::regular::open_regular_file; /// Copy one regular file into an atomically published destination /// diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index d609c8a31..c09b4e155 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -2,24 +2,29 @@ mod atomic; mod directory; +mod exact; mod install; mod path; mod read; +mod regular; mod remove; mod rename; mod symlink; -pub use atomic::{ - ensure_exact_file, make_file_executable, open_regular_file, set_file_mode, write_file_atomic, - write_file_atomic_preserving_mode, write_file_if_missing, EnsureExactFileOutcome, -}; +pub use atomic::{write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing}; pub use directory::{ create_directory_all, ensure_marked_directory, remove_directory_tree, remove_empty_directory, remove_marked_directory_tree, CreateDirectoryOutcome, }; +pub use exact::{ + ensure_exact_file, ensure_exact_file_pair, EnsureExactFileOutcome, EnsureExactFilePairOutcome, +}; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; pub use read::read_regular_file_bounded; +pub use regular::{ + make_file_executable, open_regular_file, regular_file_contents_equal, set_file_mode, +}; pub use remove::{ remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, diff --git a/crates/unixnotis-core/src/filesystem/read.rs b/crates/unixnotis-core/src/filesystem/read.rs index 610ab1239..73d354960 100644 --- a/crates/unixnotis-core/src/filesystem/read.rs +++ b/crates/unixnotis-core/src/filesystem/read.rs @@ -3,7 +3,7 @@ use std::io::{self, Read}; use std::path::Path; -use super::atomic::open_regular_file; +use super::regular::open_regular_file; /// Read a regular file without following links and enforce a byte limit /// diff --git a/crates/unixnotis-core/src/filesystem/regular.rs b/crates/unixnotis-core/src/filesystem/regular.rs new file mode 100644 index 000000000..1de19ba83 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/regular.rs @@ -0,0 +1,165 @@ +//! Stable-descriptor operations for regular files + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read}; +use std::os::fd::OwnedFd; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use rustix::fs::{openat2, Mode, OFlags}; + +use super::directory::{contained_resolve_flags, open_parent_existing}; + +/// Open one regular file through a no-follow descriptor path +/// +/// # Errors +/// +/// Returns an error when any path component is a link, the target is not a regular file, or the +/// descriptor-relative open fails +pub fn open_regular_file(path: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent_existing(path)?; + open_regular_file_at(&parent_fd, &file_name) +} + +/// Compare one regular file with expected bytes through a single retained descriptor +/// +/// Files larger than `maximum_size` are reported as non-matching without being read +/// +/// # Errors +/// +/// Returns an error when the expected bytes exceed the declared limit, the path is unsafe, the +/// target is not a regular file, or the bounded comparison cannot complete +pub fn regular_file_contents_equal( + path: &Path, + expected: &[u8], + maximum_size: u64, +) -> io::Result { + let expected_size = u64::try_from(expected.len()).map_err(|_error| { + io::Error::new( + io::ErrorKind::InvalidInput, + "expected regular-file contents do not fit the size limit", + ) + })?; + if expected_size > maximum_size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected regular-file contents exceed the size limit", + )); + } + + // One open pins the object used by both the size check and byte comparison + let mut file = open_regular_file(path)?; + if file.metadata()?.len() > maximum_size { + return Ok(false); + } + file_contents_equal(&mut file, expected) +} + +/// Add executable bits to an existing regular file without following links +/// +/// # Errors +/// +/// Returns an error when the path escapes through a link, is not a regular file, or cannot be +/// opened and updated through its stable descriptor +pub fn make_file_executable(path: &Path) -> io::Result<()> { + let file = open_regular_file(path)?; + let mode = file.metadata()?.permissions().mode() | 0o111; + file.set_permissions(fs::Permissions::from_mode(mode)) +} + +/// Set permission bits on an existing regular file without following links +/// +/// # Errors +/// +/// Returns an error when the path escapes through a link, is not a regular file, or cannot be +/// opened and updated through its stable descriptor +pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { + let file = open_regular_file(path)?; + file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) +} + +pub(super) fn open_regular_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result { + let fd = openat2( + parent_fd, + file_name, + OFlags::RDONLY + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + let file = fs::File::from(fd); + if !file.metadata()?.is_file() { + return Err(unsafe_target_error()); + } + Ok(file) +} + +pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { + let read_limit = u64::try_from(expected.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut actual = Vec::with_capacity(expected.len().saturating_add(1)); + file.take(read_limit).read_to_end(&mut actual)?; + Ok(actual == expected) +} + +pub(super) fn existing_target_mode( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result> { + match openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + ) { + Ok(fd) => { + let metadata = fs::File::from(fd).metadata()?; + if metadata.is_file() { + Ok(Some(metadata.permissions().mode() & 0o777)) + } else { + Err(unsafe_target_error()) + } + } + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(error.into()), + }, + } +} + +pub(super) fn validate_existing_target( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result<()> { + let fd = openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + if fs::File::from(fd).metadata()?.is_file() { + Ok(()) + } else { + Err(unsafe_target_error()) + } +} + +pub(super) fn unsafe_target_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to operate on a non-regular file target", + ) +} + +#[cfg(test)] +#[path = "tests/regular.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index 6906f564b..07132f4b6 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use rustix::fs::{fstat, statat, unlinkat, AtFlags}; -use super::atomic::{file_contents_equal, open_regular_file_at, validate_existing_target}; use super::directory::{open_parent_existing, sync_directory}; +use super::regular::{file_contents_equal, open_regular_file_at, validate_existing_target}; use super::symlink::read_symlink_at; /// Result of removing a symbolic link with an expected target diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs index ba5098f9c..db4f23a12 100644 --- a/crates/unixnotis-core/src/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -5,8 +5,8 @@ use std::path::Path; use rustix::fs::{renameat_with, RenameFlags}; -use super::atomic::validate_existing_target; use super::directory::{open_parent_existing, sync_directory}; +use super::regular::validate_existing_target; /// Result of moving a regular file without replacing another filesystem entry #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/unixnotis-core/src/filesystem/tests/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs index 762d56f9d..2943f8194 100644 --- a/crates/unixnotis-core/src/filesystem/tests/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -1,9 +1,8 @@ //! Atomic file operation tests use super::{ - ensure_exact_file, exclusive_create_collided, file_mode, make_file_executable, reserve_temp, - set_file_mode, write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing, - EnsureExactFileOutcome, + file_mode, reserve_temp, write_file_atomic, write_file_atomic_preserving_mode, + write_file_if_missing, }; use std::ffi::OsString; use std::fs; @@ -19,13 +18,6 @@ use crate::filesystem::directory::{ }; use crate::test_support::unique_temp_path; -#[test] -fn exclusive_create_collision_classification_accepts_only_existing_targets() { - assert!(exclusive_create_collided(rustix::io::Errno::EXIST)); - assert!(!exclusive_create_collided(rustix::io::Errno::ACCESS)); - assert!(!exclusive_create_collided(rustix::io::Errno::INVAL)); -} - #[test] fn atomic_write_rejects_target_symlink_without_changing_outside_file() { let root = unique_temp_path("atomic-target-symlink"); @@ -112,58 +104,6 @@ fn create_if_missing_preserves_existing_file_and_mode() { let _ = fs::remove_dir_all(root); } -#[test] -fn exact_file_creation_accepts_only_identical_existing_bytes() { - let root = unique_temp_path("atomic-exact-file"); - fs::create_dir_all(&root).expect("create test root"); - let target = root.join("type"); - - assert_eq!( - ensure_exact_file(&target, b"bundle\n", 0o644).expect("create exact file"), - EnsureExactFileOutcome::Created - ); - assert_eq!( - ensure_exact_file(&target, b"bundle\n", 0o600).expect("accept exact file"), - EnsureExactFileOutcome::AlreadyExact - ); - assert_eq!( - ensure_exact_file(&target, b"longrun\n", 0o644).expect("reject mismatched bytes"), - EnsureExactFileOutcome::ContentsMismatch - ); - - assert_eq!( - fs::read_to_string(&target).expect("read exact file"), - "bundle\n" - ); - assert_eq!( - fs::metadata(&target) - .expect("exact file metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn exact_file_creation_never_follows_a_collision_symlink() { - let root = unique_temp_path("atomic-exact-file-link"); - fs::create_dir_all(&root).expect("create test root"); - let outside = root.join("outside"); - let target = root.join("type"); - fs::write(&outside, "foreign").expect("write outside file"); - symlink(&outside, &target).expect("create exact-file link"); - - ensure_exact_file(&target, b"bundle\n", 0o644).expect_err("link collision should fail"); - - assert_eq!( - fs::read_to_string(outside).expect("read outside file"), - "foreign" - ); - let _ = fs::remove_dir_all(root); -} - #[test] fn create_if_missing_rejects_every_unsafe_existing_target() { let root = unique_temp_path("atomic-if-missing-unsafe"); @@ -191,74 +131,6 @@ fn create_if_missing_rejects_every_unsafe_existing_target() { let _ = fs::remove_dir_all(root); } -#[test] -fn executable_update_rejects_symlink_without_touching_its_target() { - let root = unique_temp_path("atomic-executable-symlink"); - fs::create_dir_all(&root).expect("create test root"); - let outside = root.join("outside.sh"); - let link = root.join("script.sh"); - fs::write(&outside, "safe").expect("write outside script"); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); - symlink(&outside, &link).expect("create script link"); - - make_file_executable(&link).expect_err("script link should fail"); - - assert_eq!(fs::read_to_string(&outside).expect("read outside"), "safe"); - assert_eq!( - fs::metadata(&outside) - .expect("outside metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn mode_update_applies_exact_permissions_to_a_regular_file() { - let root = unique_temp_path("atomic-mode-update"); - fs::create_dir_all(&root).expect("create test root"); - let target = root.join("run"); - fs::write(&target, "service").expect("write service file"); - fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set original mode"); - - set_file_mode(&target, 0o755).expect("set service mode"); - - assert_eq!( - fs::metadata(&target) - .expect("service metadata") - .permissions() - .mode() - & 0o777, - 0o755 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn mode_update_rejects_a_symlink_without_touching_its_target() { - let root = unique_temp_path("atomic-mode-symlink"); - fs::create_dir_all(&root).expect("create test root"); - let outside = root.join("outside"); - let link = root.join("run"); - fs::write(&outside, "service").expect("write outside file"); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); - symlink(&outside, &link).expect("create service link"); - - set_file_mode(&link, 0o755).expect_err("service link should fail"); - - assert_eq!( - fs::metadata(&outside) - .expect("outside metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - #[test] fn create_if_missing_propagates_non_collision_open_error() { let root = unique_temp_path("atomic-if-missing-error"); @@ -286,20 +158,6 @@ fn create_if_missing_preserves_non_directory_parent_errors() { let _ = fs::remove_dir_all(root); } -#[test] -fn exact_file_creation_preserves_non_directory_parent_errors() { - let root = unique_temp_path("atomic-exact-parent-file"); - fs::create_dir_all(&root).expect("create test root"); - let parent_file = root.join("parent-file"); - fs::write(&parent_file, "not a directory").expect("write parent file"); - - let error = ensure_exact_file(&parent_file.join("state"), b"data", 0o600) - .expect_err("regular-file parent must reject exact creation"); - - assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); - let _ = fs::remove_dir_all(root); -} - #[test] fn temp_reservation_propagates_non_collision_error_without_using_later_candidate() { let root = unique_temp_path("atomic-temp-error"); diff --git a/crates/unixnotis-core/src/filesystem/tests/exact.rs b/crates/unixnotis-core/src/filesystem/tests/exact.rs new file mode 100644 index 000000000..76ce7edea --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/exact.rs @@ -0,0 +1,221 @@ +//! Exact regular-file transaction tests + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use super::{ + ensure_exact_file, ensure_exact_file_pair, exclusive_create_collided, EnsureExactFileOutcome, + EnsureExactFilePairOutcome, +}; +use crate::test_support::unique_temp_path; + +#[test] +fn exclusive_create_collision_classification_accepts_only_existing_targets() { + assert!(exclusive_create_collided(rustix::io::Errno::EXIST)); + assert!(!exclusive_create_collided(rustix::io::Errno::ACCESS)); + assert!(!exclusive_create_collided(rustix::io::Errno::INVAL)); +} + +#[test] +fn exact_file_creation_accepts_only_identical_existing_bytes() { + let root = unique_temp_path("exact-file"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o644).expect("create exact file"), + EnsureExactFileOutcome::Created + ); + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o600).expect("accept exact file"), + EnsureExactFileOutcome::AlreadyExact + ); + assert_eq!( + ensure_exact_file(&target, b"longrun\n", 0o644).expect("reject mismatched bytes"), + EnsureExactFileOutcome::ContentsMismatch + ); + + assert_eq!( + fs::read_to_string(&target).expect("read exact file"), + "bundle\n" + ); + assert_eq!( + fs::metadata(&target) + .expect("exact file metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_never_follows_a_collision_symlink() { + let root = unique_temp_path("exact-file-link"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let target = root.join("type"); + fs::write(&outside, "foreign").expect("write outside file"); + symlink(&outside, &target).expect("create exact-file link"); + + ensure_exact_file(&target, b"bundle\n", 0o644).expect_err("link collision should fail"); + + assert_eq!( + fs::read_to_string(outside).expect("read outside file"), + "foreign" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_preserves_non_directory_parent_errors() { + let root = unique_temp_path("exact-file-blocked-parent"); + fs::create_dir_all(&root).expect("create test root"); + let parent_file = root.join("parent-file"); + fs::write(&parent_file, "not a directory").expect("write blocking parent"); + + let error = ensure_exact_file(&parent_file.join("state"), b"data", 0o600) + .expect_err("non-directory parent should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_creates_and_validates_both_members() { + let root = unique_temp_path("exact-pair-create"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + + assert_eq!( + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o600,) + .expect("create exact pair"), + EnsureExactFilePairOutcome::Created + ); + assert_eq!( + ensure_exact_file_pair(&target, b"bundle\n", 0o640, &marker, b"unixnotis\n", 0o644,) + .expect("validate exact pair"), + EnsureExactFilePairOutcome::AlreadyExact + ); + assert_eq!( + fs::metadata(&target) + .expect("target metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert_eq!( + fs::metadata(&marker) + .expect("marker metadata") + .permissions() + .mode() + & 0o777, + 0o644 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_preserves_an_unmarked_exact_existing_file() { + let root = unique_temp_path("exact-pair-unmarked-file"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("preserve exact unmarked file"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::AlreadyExactUnowned); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rejects_an_invalid_marker_shape_for_an_existing_file() { + let root = unique_temp_path("exact-pair-invalid-marker"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + fs::create_dir(&marker).expect("create invalid marker directory"); + + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect_err("an invalid marker shape must not be treated as missing"); + + assert!(target.is_file()); + assert!(marker.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rolls_back_a_new_file_when_the_marker_conflicts() { + let root = unique_temp_path("exact-pair-marker-conflict"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&marker, b"foreign\n").expect("write foreign marker"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("report marker conflict"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::ContentsMismatch); + assert!(!target.exists()); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_preserves_an_existing_file_when_the_marker_conflicts() { + let root = unique_temp_path("exact-pair-existing-file-conflict"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + fs::write(&marker, b"foreign\n").expect("write foreign marker"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("report marker conflict"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::ContentsMismatch); + assert_eq!( + fs::read_to_string(target).expect("read existing file"), + "bundle\n" + ); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rejects_different_parents_and_reused_names() { + let root = unique_temp_path("exact-pair-path-validation"); + fs::create_dir_all(root.join("other")).expect("create test roots"); + let target = root.join("type"); + + ensure_exact_file_pair( + &target, + b"bundle\n", + 0o644, + &root.join("other").join("marker"), + b"unixnotis\n", + 0o644, + ) + .expect_err("different parents should fail"); + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &target, b"unixnotis\n", 0o644) + .expect_err("reused names should fail"); + + assert!(!target.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/regular.rs b/crates/unixnotis-core/src/filesystem/tests/regular.rs new file mode 100644 index 000000000..cf2ba96cb --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/regular.rs @@ -0,0 +1,125 @@ +//! Stable regular-file operation tests + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{make_file_executable, regular_file_contents_equal, set_file_mode}; +use crate::test_support::unique_temp_path; + +#[test] +fn bounded_comparison_accepts_exact_bytes_and_rejects_larger_files() { + let root = unique_temp_path("regular-bounded-comparison"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("service"); + fs::write(&target, b"bundle\n").expect("write exact file"); + + assert!( + regular_file_contents_equal(&target, b"bundle\n", 7).expect("compare exact regular file") + ); + assert!( + !regular_file_contents_equal(&target, b"bundle", 6).expect("reject oversized regular file") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_comparison_rejects_a_limit_smaller_than_expected_bytes() { + let root = unique_temp_path("regular-invalid-comparison-limit"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("service"); + fs::write(&target, b"bundle\n").expect("write exact file"); + + let error = regular_file_contents_equal(&target, b"bundle\n", 6) + .expect_err("invalid comparison limit should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_comparison_rejects_links_and_special_files_without_blocking() { + let root = unique_temp_path("regular-unsafe-comparison"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let link = root.join("link"); + let fifo = root.join("fifo"); + fs::write(&outside, b"bundle\n").expect("write outside file"); + symlink(&outside, &link).expect("create comparison link"); + mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create comparison fifo"); + + regular_file_contents_equal(&link, b"bundle\n", 7).expect_err("comparison link should fail"); + regular_file_contents_equal(&fifo, b"bundle\n", 7).expect_err("comparison fifo should fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn executable_update_rejects_symlink_without_touching_its_target() { + let root = unique_temp_path("regular-executable-symlink"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside.sh"); + let link = root.join("script.sh"); + fs::write(&outside, "safe").expect("write outside script"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); + symlink(&outside, &link).expect("create script link"); + + make_file_executable(&link).expect_err("script link should fail"); + + assert_eq!(fs::read_to_string(&outside).expect("read outside"), "safe"); + assert_eq!( + fs::metadata(&outside) + .expect("outside metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mode_update_applies_exact_permissions_to_a_regular_file() { + let root = unique_temp_path("regular-mode-update"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("run"); + fs::write(&target, "service").expect("write service file"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set original mode"); + + set_file_mode(&target, 0o755).expect("set service mode"); + + assert_eq!( + fs::metadata(&target) + .expect("service metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mode_update_rejects_a_symlink_without_touching_its_target() { + let root = unique_temp_path("regular-mode-symlink"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let link = root.join("run"); + fs::write(&outside, "service").expect("write outside file"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); + symlink(&outside, &link).expect("create mode link"); + + set_file_mode(&link, 0o755).expect_err("service link should fail"); + + assert_eq!( + fs::metadata(&outside) + .expect("outside metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs index efcbbc48d..9d8e9f655 100644 --- a/crates/unixnotis-core/src/filesystem/tests/remove.rs +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -10,7 +10,7 @@ use super::{ remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, revalidate_file_identity, RemoveExactFileOutcome, RemoveSymlinkOutcome, }; -use crate::filesystem::atomic::open_regular_file_at; +use crate::filesystem::regular::open_regular_file_at; use crate::filesystem::symlink::read_symlink; use crate::test_support::unique_temp_path; diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 5e2d01fae..722f27e5e 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -8,9 +8,10 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - ensure_exact_file, remove_empty_directory, remove_regular_file, - remove_regular_file_pair_if_contents, set_file_mode, write_file_atomic, - write_file_atomic_preserving_mode, EnsureExactFileOutcome, RemoveExactFileOutcome, + ensure_exact_file, ensure_exact_file_pair, regular_file_contents_equal, remove_empty_directory, + remove_regular_file, remove_regular_file_pair_if_contents, set_file_mode, write_file_atomic, + write_file_atomic_preserving_mode, EnsureExactFileOutcome, EnsureExactFilePairOutcome, + RemoveExactFileOutcome, }; use crate::paths::format_with_home; @@ -23,7 +24,7 @@ pub(in crate::actions::install::service) fn write_regular_service_file( artifact_label: &str, ) -> Result { // Refuse unsafe existing paths before looking at file contents - ensure_regular_artifact_file_path(path)?; + let path_exists = ensure_regular_artifact_file_path(path)?; let mode_changed = match mode { Some(mode) => { #[cfg(unix)] @@ -41,10 +42,18 @@ pub(in crate::actions::install::service) fn write_regular_service_file( } None => false, }; - let contents_changed = match fs::read_to_string(path) { - // Stable contents keep reinstall quiet and avoid unnecessary manager reloads - Ok(existing) if existing == contents => false, - Ok(_) | Err(_) => true, + let contents_changed = if path_exists { + let maximum_size = u64::try_from(contents.len()).unwrap_or(u64::MAX); + // One no-follow descriptor owns both the size gate and bounded byte comparison + match regular_file_contents_equal(path, contents.as_bytes(), maximum_size) { + Ok(equal) => !equal, + Err(error) if error.kind() == ErrorKind::NotFound => true, + Err(error) => { + return Err(error).with_context(|| format!("failed to compare {artifact_label}")); + } + } + } else { + true }; if contents_changed { @@ -73,6 +82,27 @@ pub(in crate::actions::install::service) fn write_shared_service_file( artifact_label: &str, created_marker: Option<&Path>, ) -> Result { + if let Some(marker) = created_marker { + let outcome = ensure_exact_file_pair( + path, + contents.as_bytes(), + mode.unwrap_or(0o644), + marker, + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + 0o644, + ) + .with_context(|| format!("failed to write {artifact_label} and its ownership marker"))?; + return match outcome { + EnsureExactFilePairOutcome::Created => Ok(true), + EnsureExactFilePairOutcome::AlreadyExact + | EnsureExactFilePairOutcome::AlreadyExactUnowned => Ok(false), + EnsureExactFilePairOutcome::ContentsMismatch => Err(anyhow!( + "refusing to overwrite shared service artifact at {}", + format_with_home(path) + )), + }; + } + let outcome = ensure_exact_file(path, contents.as_bytes(), mode.unwrap_or(0o644)) .with_context(|| format!("failed to write {artifact_label}"))?; match outcome { @@ -86,9 +116,6 @@ pub(in crate::actions::install::service) fn write_shared_service_file( EnsureExactFileOutcome::Created => {} } - if let Some(marker) = created_marker { - write_shared_creation_marker(marker)?; - } Ok(true) } @@ -124,18 +151,6 @@ pub(in crate::actions::install) fn current_mode(path: &Path) -> Result Result<()> { - match ensure_exact_file(path, MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), 0o644) - .with_context(|| format!("failed to write {}", format_with_home(path)))? - { - EnsureExactFileOutcome::Created | EnsureExactFileOutcome::AlreadyExact => Ok(()), - EnsureExactFileOutcome::ContentsMismatch => Err(anyhow!( - "refusing to replace ownership marker at {}", - format_with_home(path) - )), - } -} - fn remove_empty_shared_layout_dirs(path: &Path) -> Result<()> { let Some(parent) = path.parent() else { return Ok(()); diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index c823025c6..0121422a8 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -340,6 +340,68 @@ fn write_shared_service_file_refuses_to_overwrite_user_content() { let _ = fs::remove_dir_all(&root); } +#[test] +fn write_shared_service_file_preserves_an_exact_unowned_file() { + let root = test_root("install-service-shared-unowned-file"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let marker = root.join("default").join(".unixnotis-created-type"); + let artifact = ServiceArtifact { + path: root.join("default").join("type"), + kind: ServiceArtifactKind::SharedFile { + created_marker: Some(marker.clone()), + }, + contents: Some("bundle\n".to_string()), + mode: Some(0o644), + }; + fs::create_dir_all(artifact.path.parent().expect("shared file parent")) + .expect("create shared file parent"); + fs::write(&artifact.path, "bundle\n").expect("seed exact unmarked shared file"); + + let unchanged = write_service_artifact(&ctx, &artifact).expect("accept exact unowned file"); + + assert!(!unchanged); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn write_shared_service_file_rolls_back_when_the_marker_conflicts() { + let root = test_root("install-service-shared-marker-conflict"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let marker = root.join("default").join(".unixnotis-created-type"); + let artifact = ServiceArtifact { + path: root.join("default").join("type"), + kind: ServiceArtifactKind::SharedFile { + created_marker: Some(marker.clone()), + }, + contents: Some("bundle\n".to_string()), + mode: Some(0o644), + }; + fs::create_dir_all(marker.parent().expect("marker parent")).expect("create shared file parent"); + fs::write(&marker, "foreign\n").expect("seed foreign marker"); + + let error = write_service_artifact(&ctx, &artifact) + .expect_err("conflicting marker should reject the pair"); + + assert!(error.to_string().contains("refusing to overwrite")); + assert!(!artifact.path.exists()); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn remove_shared_service_file_only_removes_marker_owned_file() { let root = test_root("install-service-shared-file-remove"); @@ -705,6 +767,40 @@ fn install_replaces_regular_owned_artifact_but_rejects_unsafe_existing_path() { let _ = fs::remove_dir_all(&root); } +#[test] +fn install_replaces_an_oversized_sparse_service_file_without_reading_it() { + let root = test_root("install-service-oversized-regular-file"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + fs::create_dir_all(&root).expect("create service root"); + let path = root.join("service-file"); + let oversized = fs::File::create(&path).expect("create sparse service file"); + oversized + .set_len(1_073_741_824) + .expect("extend sparse service file"); + drop(oversized); + let artifact = ServiceArtifact { + path: path.clone(), + kind: ServiceArtifactKind::File, + contents: Some("service\n".to_string()), + mode: None, + }; + + let changed = + write_service_artifact(&ctx, &artifact).expect("replace oversized regular service file"); + + assert!(changed); + assert_eq!( + fs::read_to_string(path).expect("read replaced service file"), + "service\n" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn write_service_artifact_rejects_socket_artifact_path() { let root = test_root("install-service-special-file-reject"); @@ -727,7 +823,7 @@ fn write_service_artifact_rejects_socket_artifact_path() { let err = write_service_artifact(&ctx, &artifact).expect_err("socket path is unsafe"); - // The socket remains untouched and the writer fails before read_to_string can block on it + // The socket remains untouched and the writer fails before descriptor comparison can block assert!(err .to_string() .contains("cannot replace non-regular service artifact")); From 3c8b8577b8516f5a3034cd6fb4abc84e5d2d3a34 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:03:06 -0500 Subject: [PATCH 067/275] fix(daemon): rate limit notification close requests Summary: rate limit notification close requests. Scope: daemon. --- .../src/daemon/notifications/metrics.rs | 97 +++++++++++++++++++ .../src/daemon/notifications/mod.rs | 1 + .../src/daemon/notifications/quota.rs | 52 +++++++++- .../daemon/notifications/server/interface.rs | 37 ++++++- .../src/daemon/notifications/tests/metrics.rs | 38 ++++++++ .../src/daemon/notifications/tests/quota.rs | 21 +++- 6 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/metrics.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs new file mode 100644 index 000000000..21ca3fdab --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs @@ -0,0 +1,97 @@ +//! Allocation-free counters for notification ingress pressure + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RejectedRequest { + NotifyQuota, + NotifyConcurrency, + CloseQuota, +} + +pub(super) struct IngressMetrics { + notify_quota_rejections: AtomicU64, + notify_concurrency_rejections: AtomicU64, + close_quota_rejections: AtomicU64, + active_handlers: AtomicUsize, + peak_active_handlers: AtomicUsize, +} + +pub(super) struct ActiveHandler<'a> { + metrics: &'a IngressMetrics, +} + +impl IngressMetrics { + pub(super) const fn new() -> Self { + Self { + notify_quota_rejections: AtomicU64::new(0), + notify_concurrency_rejections: AtomicU64::new(0), + close_quota_rejections: AtomicU64::new(0), + active_handlers: AtomicUsize::new(0), + peak_active_handlers: AtomicUsize::new(0), + } + } + + pub(super) fn record_rejection(&self, rejected: RejectedRequest) -> u64 { + let counter = match rejected { + RejectedRequest::NotifyQuota => &self.notify_quota_rejections, + RejectedRequest::NotifyConcurrency => &self.notify_concurrency_rejections, + RejectedRequest::CloseQuota => &self.close_quota_rejections, + }; + counter.fetch_add(1, Ordering::Relaxed).saturating_add(1) + } + + pub(super) fn enter_handler(&self) -> ActiveHandler<'_> { + let active = self + .active_handlers + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + // A compare loop works on every supported Rust release and never lowers the peak + let mut peak = self.peak_active_handlers.load(Ordering::Relaxed); + while active > peak { + match self.peak_active_handlers.compare_exchange_weak( + peak, + active, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => peak = observed, + } + } + ActiveHandler { metrics: self } + } + + #[cfg(test)] + pub(super) fn snapshot(&self) -> IngressMetricsSnapshot { + IngressMetricsSnapshot { + notify_quota_rejections: self.notify_quota_rejections.load(Ordering::Relaxed), + notify_concurrency_rejections: self + .notify_concurrency_rejections + .load(Ordering::Relaxed), + close_quota_rejections: self.close_quota_rejections.load(Ordering::Relaxed), + active_handlers: self.active_handlers.load(Ordering::Relaxed), + peak_active_handlers: self.peak_active_handlers.load(Ordering::Relaxed), + } + } +} + +impl Drop for ActiveHandler<'_> { + fn drop(&mut self) { + self.metrics.active_handlers.fetch_sub(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct IngressMetricsSnapshot { + pub(super) notify_quota_rejections: u64, + pub(super) notify_concurrency_rejections: u64, + pub(super) close_quota_rejections: u64, + pub(super) active_handlers: usize, + pub(super) peak_active_handlers: usize, +} + +#[cfg(test)] +#[path = "tests/metrics.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 5875c2d64..ba2044a04 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -1,6 +1,7 @@ //! D-Bus server for org.freedesktop.Notifications mod limits; +mod metrics; mod payload; mod quota; mod sender; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/quota.rs index 2d37dc4d7..aa91718c8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/quota.rs @@ -8,12 +8,25 @@ const GLOBAL_BURST: f64 = 120.0; const GLOBAL_REFILL_PER_SECOND: f64 = 60.0; const SENDER_BURST: f64 = 40.0; const SENDER_REFILL_PER_SECOND: f64 = 20.0; +const CLOSE_GLOBAL_BURST: f64 = 480.0; +const CLOSE_GLOBAL_REFILL_PER_SECOND: f64 = 240.0; +const CLOSE_SENDER_BURST: f64 = 160.0; +const CLOSE_SENDER_REFILL_PER_SECOND: f64 = 80.0; const MAX_TRACKED_SENDERS: usize = 256; const SENDER_IDLE_TTL_SECONDS: u64 = 60; const UNKNOWN_SENDER: &str = ""; pub(super) struct NotificationQuota { state: Mutex, + policy: QuotaPolicy, +} + +#[derive(Clone, Copy)] +struct QuotaPolicy { + global_burst: f64, + global_refill_per_second: f64, + sender_burst: f64, + sender_refill_per_second: f64, } struct QuotaState { @@ -34,16 +47,45 @@ struct TokenBucket { } impl NotificationQuota { - pub(super) fn new() -> Self { + pub(super) fn new_notify() -> Self { Self::new_at(Instant::now()) } + pub(super) fn new_close() -> Self { + Self::new_close_at(Instant::now()) + } + fn new_at(now: Instant) -> Self { + Self::with_policy( + now, + QuotaPolicy { + global_burst: GLOBAL_BURST, + global_refill_per_second: GLOBAL_REFILL_PER_SECOND, + sender_burst: SENDER_BURST, + sender_refill_per_second: SENDER_REFILL_PER_SECOND, + }, + ) + } + + fn new_close_at(now: Instant) -> Self { + Self::with_policy( + now, + QuotaPolicy { + global_burst: CLOSE_GLOBAL_BURST, + global_refill_per_second: CLOSE_GLOBAL_REFILL_PER_SECOND, + sender_burst: CLOSE_SENDER_BURST, + sender_refill_per_second: CLOSE_SENDER_REFILL_PER_SECOND, + }, + ) + } + + fn with_policy(now: Instant, policy: QuotaPolicy) -> Self { Self { state: Mutex::new(QuotaState { - global: TokenBucket::new(GLOBAL_BURST, GLOBAL_REFILL_PER_SECOND, now), + global: TokenBucket::new(policy.global_burst, policy.global_refill_per_second, now), senders: HashMap::new(), }), + policy, } } @@ -65,7 +107,11 @@ impl NotificationQuota { .senders .entry(sender.to_string()) .or_insert_with(|| SenderBucket { - bucket: TokenBucket::new(SENDER_BURST, SENDER_REFILL_PER_SECOND, now), + bucket: TokenBucket::new( + self.policy.sender_burst, + self.policy.sender_refill_per_second, + now, + ), last_seen: now, }); sender_bucket.last_seen = now; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index 98c3ea56a..b70d391e2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Instant; use tokio::sync::Semaphore; +use tracing::debug; use zbus::message::Header; use zbus::zvariant::OwnedValue; use zbus::{interface, SignalContext}; @@ -12,6 +13,7 @@ use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; +use crate::daemon::notifications::metrics::{IngressMetrics, RejectedRequest}; use crate::daemon::notifications::quota::NotificationQuota; use crate::daemon::DaemonState; @@ -24,9 +26,13 @@ pub struct NotificationServer { // Scheduler handles expiration deadlines without blocking D-Bus handlers pub(super) scheduler: ExpirationScheduler, // Shared token buckets reject sustained sender and process-wide floods - quota: NotificationQuota, + notify_quota: NotificationQuota, + // Close requests are cheaper but still trigger sender identity and store work + close_quota: NotificationQuota, // Expensive sender and payload work has a fixed concurrency ceiling notify_slots: Semaphore, + // Counters expose pressure without retaining attacker-controlled labels + ingress_metrics: IngressMetrics, } impl NotificationServer { @@ -35,8 +41,10 @@ impl NotificationServer { Self { state, scheduler, - quota: NotificationQuota::new(), + notify_quota: NotificationQuota::new_notify(), + close_quota: NotificationQuota::new_close(), notify_slots: Semaphore::const_new(MAX_CONCURRENT_NOTIFY_HANDLERS), + ingress_metrics: IngressMetrics::new(), } } } @@ -65,16 +73,28 @@ impl NotificationServer { expire_timeout: i32, ) -> zbus::fdo::Result { let sender = header.sender().map(zbus::names::UniqueName::as_str); - if !self.quota.admit(sender, Instant::now()) { + if !self.notify_quota.admit(sender, Instant::now()) { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyQuota); + debug!(rejected, "notification request rejected by ingress quota"); return Err(zbus::fdo::Error::LimitsExceeded( "notification ingress quota exceeded".to_string(), )); } let _slot = self.notify_slots.try_acquire().map_err(|_error| { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyConcurrency); + debug!( + rejected, + "notification request rejected by concurrency limit" + ); zbus::fdo::Error::LimitsExceeded( "too many concurrent notification requests".to_string(), ) })?; + let _activity = self.ingress_metrics.enter_handler(); // The interface adapter forwards the authenticated header with the exact wire payload self.ingest_notify( app_name, @@ -95,6 +115,17 @@ impl NotificationServer { id: u32, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { + let sender = header.sender().map(zbus::names::UniqueName::as_str); + if !self.close_quota.admit(sender, Instant::now()) { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::CloseQuota); + debug!(rejected, "close request rejected by ingress quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification close quota exceeded".to_string(), + )); + } + let _activity = self.ingress_metrics.enter_handler(); // Ownership checks remain in the shared close path used by all D-Bus callers self.close_notification_if_owned(id, &header).await } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs new file mode 100644 index 000000000..550ea6809 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs @@ -0,0 +1,38 @@ +//! Notification ingress metric tests + +use super::{IngressMetrics, RejectedRequest}; + +#[test] +fn rejection_counters_are_kept_separate_by_request_path() { + let metrics = IngressMetrics::new(); + + assert_eq!(metrics.record_rejection(RejectedRequest::NotifyQuota), 1); + assert_eq!(metrics.record_rejection(RejectedRequest::NotifyQuota), 2); + assert_eq!( + metrics.record_rejection(RejectedRequest::NotifyConcurrency), + 1 + ); + assert_eq!(metrics.record_rejection(RejectedRequest::CloseQuota), 1); + + let snapshot = metrics.snapshot(); + assert_eq!(snapshot.notify_quota_rejections, 2); + assert_eq!(snapshot.notify_concurrency_rejections, 1); + assert_eq!(snapshot.close_quota_rejections, 1); +} + +#[test] +fn handler_guard_tracks_current_and_peak_concurrency_without_leaking_activity() { + let metrics = IngressMetrics::new(); + + let first = metrics.enter_handler(); + let second = metrics.enter_handler(); + assert_eq!(metrics.snapshot().active_handlers, 2); + assert_eq!(metrics.snapshot().peak_active_handlers, 2); + drop(second); + assert_eq!(metrics.snapshot().active_handlers, 1); + drop(first); + + let snapshot = metrics.snapshot(); + assert_eq!(snapshot.active_handlers, 0); + assert_eq!(snapshot.peak_active_handlers, 2); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs index 8cb86fc70..cf1694ecf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs @@ -3,8 +3,8 @@ use std::time::{Duration, Instant}; use std::collections::HashMap; use super::{ - NotificationQuota, QuotaState, SenderBucket, TokenBucket, GLOBAL_BURST, MAX_TRACKED_SENDERS, - SENDER_BURST, SENDER_IDLE_TTL_SECONDS, + NotificationQuota, QuotaState, SenderBucket, TokenBucket, CLOSE_SENDER_BURST, GLOBAL_BURST, + MAX_TRACKED_SENDERS, SENDER_BURST, SENDER_IDLE_TTL_SECONDS, }; fn sender_bucket(now: Instant) -> SenderBucket { @@ -45,6 +45,23 @@ fn global_bucket_limits_many_independent_senders() { assert!(quota.admit(Some(":1.allowed"), now + Duration::from_millis(17))); } +#[test] +fn close_requests_use_a_separate_higher_budget() { + let now = Instant::now(); + let notify = NotificationQuota::new_at(now); + let close = NotificationQuota::new_close_at(now); + + for _ in 0..SENDER_BURST as usize { + assert!(notify.admit(Some(":1.10"), now)); + assert!(close.admit(Some(":1.10"), now)); + } + assert!(!notify.admit(Some(":1.10"), now)); + for _ in SENDER_BURST as usize..CLOSE_SENDER_BURST as usize { + assert!(close.admit(Some(":1.10"), now)); + } + assert!(!close.admit(Some(":1.10"), now)); +} + #[test] fn sender_tracking_stays_bounded_and_unknown_callers_share_one_bucket() { let now = Instant::now(); From 1a4b142516621125a6e645d1625128f0a0f035b8 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:15:52 -0500 Subject: [PATCH 068/275] refactor(daemon): centralize event and client ownership Summary: centralize event and client ownership. Scope: daemon. --- .../src/daemon/auth/authorization.rs | 6 +- .../{ => executable_trust}/fingerprint.rs | 4 +- .../auth/{ => executable_trust}/metadata.rs | 0 .../src/daemon/auth/executable_trust/mod.rs | 13 + .../auth/{ => executable_trust}/paths.rs | 8 +- .../auth/{ => executable_trust}/snapshots.rs | 6 +- .../{ => executable_trust}/tests/cache.rs | 12 +- .../{ => executable_trust}/tests/metadata.rs | 6 +- .../daemon/auth/executable_trust/tests/mod.rs | 5 + .../{ => executable_trust}/tests/paths.rs | 10 +- .../tests/snapshots.rs} | 9 +- .../{ => executable_trust}/tests/strict.rs | 10 +- .../src/daemon/auth/filesystem.rs | 8 - .../unixnotis-daemon/src/daemon/auth/mod.rs | 27 +- .../auth/{process.rs => process_identity.rs} | 0 .../src/daemon/auth/tests/authorization.rs | 2 +- .../src/daemon/auth/tests/credentials.rs | 2 +- .../tests/{procfs.rs => process_identity.rs} | 4 +- .../src/daemon/bus/clients.rs | 23 ++ crates/unixnotis-daemon/src/daemon/bus/mod.rs | 11 + .../src/daemon/{bus_names.rs => bus/names.rs} | 0 .../bus/ownership.rs} | 34 ++- .../tests/watch.rs => bus/tests/clients.rs} | 4 +- .../src/daemon/bus/tests/mod.rs | 2 + .../bus/tests/ownership.rs} | 6 +- .../src/daemon/control/clear.rs | 104 -------- .../src/daemon/control/dnd.rs | 2 +- .../src/daemon/control/inhibit.rs | 43 +--- .../src/daemon/control/mod.rs | 3 - .../src/daemon/control/query.rs | 9 +- .../src/daemon/control/server.rs | 8 +- .../src/daemon/control/tests/mod.rs | 1 - .../src/daemon/control/watch.rs | 77 ------ .../src/daemon/events/inhibitors.rs | 38 +++ .../unixnotis-daemon/src/daemon/events/mod.rs | 11 + .../src/daemon/events/notifications.rs | 234 ++++++++++++++++++ .../src/daemon/events/publisher.rs | 39 +++ .../src/daemon/events/state.rs | 82 ++++++ .../daemon/{state => events}/tests/cache.rs | 28 +-- .../src/daemon/events/tests/mod.rs | 3 + .../tests/notifications.rs} | 28 ++- .../signals.rs => events/tests/state.rs} | 58 +++-- crates/unixnotis-daemon/src/daemon/mod.rs | 12 +- .../flow_control.rs} | 6 +- .../src/daemon/notifications/mod.rs | 4 + .../src/daemon/notifications/server/flow.rs | 85 ++----- .../tests/flow_control.rs} | 0 .../src/daemon/state/cache.rs | 22 -- .../unixnotis-daemon/src/daemon/state/dnd.rs | 39 --- .../unixnotis-daemon/src/daemon/state/mod.rs | 9 +- .../src/daemon/state/model.rs | 16 +- ...fications.rs => notification_lifecycle.rs} | 12 +- .../state/{scheduler.rs => schedulers.rs} | 34 ++- .../src/daemon/state/signals.rs | 200 --------------- .../daemon/state/{runtime.rs => status.rs} | 2 +- .../src/daemon/state/tests/mod.rs | 6 +- ...fications.rs => notification_lifecycle.rs} | 0 .../state/tests/{runtime.rs => status.rs} | 0 crates/unixnotis-daemon/src/main.rs | 1 - crates/unixnotis-daemon/src/runtime/daemon.rs | 10 +- .../src/runtime/trial_cleanup.rs | 2 +- crates/unixnotis-daemon/src/store/core.rs | 13 +- .../unixnotis-daemon/src/trial_mode/state.rs | 2 +- 63 files changed, 720 insertions(+), 735 deletions(-) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/fingerprint.rs (99%) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/metadata.rs (100%) create mode 100644 crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/paths.rs (92%) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/snapshots.rs (97%) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/tests/cache.rs (94%) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/tests/metadata.rs (90%) create mode 100644 crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/tests/paths.rs (94%) rename crates/unixnotis-daemon/src/daemon/auth/{tests/snapshot.rs => executable_trust/tests/snapshots.rs} (93%) rename crates/unixnotis-daemon/src/daemon/auth/{ => executable_trust}/tests/strict.rs (82%) delete mode 100644 crates/unixnotis-daemon/src/daemon/auth/filesystem.rs rename crates/unixnotis-daemon/src/daemon/auth/{process.rs => process_identity.rs} (100%) rename crates/unixnotis-daemon/src/daemon/auth/tests/{procfs.rs => process_identity.rs} (97%) create mode 100644 crates/unixnotis-daemon/src/daemon/bus/clients.rs create mode 100644 crates/unixnotis-daemon/src/daemon/bus/mod.rs rename crates/unixnotis-daemon/src/daemon/{bus_names.rs => bus/names.rs} (100%) rename crates/unixnotis-daemon/src/{dbus_owner.rs => daemon/bus/ownership.rs} (72%) rename crates/unixnotis-daemon/src/daemon/{control/tests/watch.rs => bus/tests/clients.rs} (91%) create mode 100644 crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs rename crates/unixnotis-daemon/src/{tests/dbus_owner.rs => daemon/bus/tests/ownership.rs} (87%) delete mode 100644 crates/unixnotis-daemon/src/daemon/control/clear.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/control/watch.rs create mode 100644 crates/unixnotis-daemon/src/daemon/events/inhibitors.rs create mode 100644 crates/unixnotis-daemon/src/daemon/events/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/events/notifications.rs create mode 100644 crates/unixnotis-daemon/src/daemon/events/publisher.rs create mode 100644 crates/unixnotis-daemon/src/daemon/events/state.rs rename crates/unixnotis-daemon/src/daemon/{state => events}/tests/cache.rs (72%) create mode 100644 crates/unixnotis-daemon/src/daemon/events/tests/mod.rs rename crates/unixnotis-daemon/src/daemon/{control/tests/clear.rs => events/tests/notifications.rs} (67%) rename crates/unixnotis-daemon/src/daemon/{state/tests/signals.rs => events/tests/state.rs} (77%) rename crates/unixnotis-daemon/src/daemon/{signal_burst.rs => notifications/flow_control.rs} (94%) rename crates/unixnotis-daemon/src/daemon/{tests/signal_burst.rs => notifications/tests/flow_control.rs} (100%) delete mode 100644 crates/unixnotis-daemon/src/daemon/state/cache.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/state/dnd.rs rename crates/unixnotis-daemon/src/daemon/state/{notifications.rs => notification_lifecycle.rs} (86%) rename crates/unixnotis-daemon/src/daemon/state/{scheduler.rs => schedulers.rs} (55%) delete mode 100644 crates/unixnotis-daemon/src/daemon/state/signals.rs rename crates/unixnotis-daemon/src/daemon/state/{runtime.rs => status.rs} (90%) rename crates/unixnotis-daemon/src/daemon/state/tests/{notifications.rs => notification_lifecycle.rs} (100%) rename crates/unixnotis-daemon/src/daemon/state/tests/{runtime.rs => status.rs} (100%) diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index aecec1580..ae89ac927 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -10,12 +10,12 @@ use zbus::message::Header; use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; -use super::paths::is_trusted_control_executable_path; +use super::executable_trust::is_trusted_control_executable_path; use super::policy::{TRUSTED_CONTROL_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES}; #[cfg(not(target_os = "linux"))] -use super::process::read_process_executable_path; +use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] -use super::process::read_process_executable_path_from_pidfd; +use super::process_identity::read_process_executable_path_from_pidfd; pub(in crate::daemon) async fn authorize_control_call( state: &Arc, diff --git a/crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs similarity index 99% rename from crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs index a99644b14..6f28e701d 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs @@ -3,10 +3,10 @@ use std::path::Path; use std::sync::{Mutex, OnceLock}; -use super::metadata::trusted_control_file_metadata_is_safe; -use super::policy::{ +use super::super::policy::{ FileFingerprint, FileFingerprintSignature, FingerprintCacheEntry, FINGERPRINT_CACHE_CAPACITY, }; +use super::metadata::trusted_control_file_metadata_is_safe; pub(in crate::daemon) fn file_fingerprint(path: &Path) -> Option { let metadata = std::fs::metadata(path).ok()?; diff --git a/crates/unixnotis-daemon/src/daemon/auth/metadata.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/auth/metadata.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs new file mode 100644 index 000000000..81df92458 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -0,0 +1,13 @@ +//! Trusted executable path, metadata, fingerprint, and startup snapshot policy + +mod fingerprint; +mod metadata; +mod paths; +mod snapshots; + +#[cfg(test)] +pub(super) use paths::canonicalize_best_effort; +pub(super) use paths::is_trusted_control_executable_path; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/auth/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs similarity index 92% rename from crates/unixnotis-daemon/src/daemon/auth/paths.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 72cdee9f7..892e5a39d 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -2,12 +2,16 @@ use std::path::{Path, PathBuf}; -use super::filesystem::canonicalize_best_effort; +use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; use super::fingerprint::file_fingerprint; use super::metadata::trusted_control_file_metadata_is_safe; -use super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; use super::snapshots::trusted_control_snapshot; +pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { + // Missing paths remain raw so later trust comparisons fail as ordinary mismatches + std::fs::canonicalize(path).unwrap_or_else(|_error| path.to_path_buf()) +} + pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed: bool) -> bool { // Trust only known sibling binaries from the daemon install/build directory let Some(trusted_dir) = trusted_control_directory() else { diff --git a/crates/unixnotis-daemon/src/daemon/auth/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs similarity index 97% rename from crates/unixnotis-daemon/src/daemon/auth/snapshots.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs index 575fc891c..9be014ff7 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/snapshots.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs @@ -4,12 +4,12 @@ use std::collections::HashMap; use std::path::Path; use std::sync::{Mutex, OnceLock}; -use super::filesystem::canonicalize_best_effort; -use super::fingerprint::file_fingerprint; -use super::policy::{ +use super::super::policy::{ TrustedExecutableSnapshot, TrustedSnapshotCacheEntry, TRUSTED_CONTROL_EXECUTABLES, TRUSTED_SNAPSHOT_CACHE_CAPACITY, }; +use super::fingerprint::file_fingerprint; +use super::paths::canonicalize_best_effort; pub(in crate::daemon) fn trusted_control_snapshot( trusted_dir: &Path, diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs similarity index 94% rename from crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs index b56d355c1..a149216db 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs @@ -1,13 +1,15 @@ use std::collections::HashMap; -use super::fingerprint::{fingerprint_cache, load_cached_fingerprint, store_cached_fingerprint}; -use super::policy::{ - TrustedExecutableSnapshot, FINGERPRINT_CACHE_CAPACITY, TRUSTED_SNAPSHOT_CACHE_CAPACITY, +use super::super::fingerprint::{ + fingerprint_cache, load_cached_fingerprint, store_cached_fingerprint, }; -use super::snapshots::{ +use super::super::snapshots::{ load_cached_trusted_snapshot, store_cached_trusted_snapshots, trusted_snapshot_cache, }; -use super::support::{test_fingerprint, test_signature}; +use crate::daemon::auth::policy::{ + TrustedExecutableSnapshot, FINGERPRINT_CACHE_CAPACITY, TRUSTED_SNAPSHOT_CACHE_CAPACITY, +}; +use crate::daemon::auth::support::{test_fingerprint, test_signature}; use crate::test_support::{env_lock, TempRoot}; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs similarity index 90% rename from crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs index 5008b072e..385a44400 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs @@ -1,8 +1,8 @@ -use super::authorization::control_owner_uid_is_allowed; -use super::metadata::{ +use super::super::metadata::{ trusted_control_file_metadata_is_safe, trusted_control_owner_uid_is_allowed, }; -use super::support::write_executable; +use crate::daemon::auth::authorization::control_owner_uid_is_allowed; +use crate::daemon::auth::support::write_executable; use crate::test_support::TempRoot; #[cfg(unix)] diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs new file mode 100644 index 000000000..c42e38cf7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs @@ -0,0 +1,5 @@ +mod cache; +mod metadata; +mod paths; +mod snapshots; +mod strict; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs similarity index 94% rename from crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs index 6182b4ba8..987b9c0c9 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs @@ -1,9 +1,9 @@ -use super::filesystem::canonicalize_best_effort; -use super::paths::{ - is_trusted_control_executable_path_relaxed_in_dir, trusted_local_bin_matches_executable, - trusted_path_matches_executable, trusted_profile_sibling_matches_executable, +use super::super::paths::{ + canonicalize_best_effort, is_trusted_control_executable_path_relaxed_in_dir, + trusted_local_bin_matches_executable, trusted_path_matches_executable, + trusted_profile_sibling_matches_executable, }; -use super::support::write_executable; +use crate::daemon::auth::support::write_executable; use crate::test_support::{env_lock, EnvVarGuard, TempRoot}; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs similarity index 93% rename from crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs index 2410ce12b..04f6c7fb9 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs @@ -1,8 +1,7 @@ -use super::filesystem::canonicalize_best_effort; -use super::paths::trusted_snapshot_matches_observed; -use super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; -use super::snapshots::build_trusted_control_snapshots; -use super::support::write_executable; +use super::super::paths::{canonicalize_best_effort, trusted_snapshot_matches_observed}; +use super::super::snapshots::build_trusted_control_snapshots; +use crate::daemon::auth::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; +use crate::daemon::auth::support::write_executable; use crate::test_support::TempRoot; use std::collections::HashMap; use std::path::Path; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs similarity index 82% rename from crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs index 4131a32cb..417166e81 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -1,8 +1,8 @@ -use super::authorization::control_executable_is_allowed; -use super::fingerprint::fingerprint_cache; -use super::paths::is_trusted_control_executable_path; -use super::snapshots::trusted_snapshot_cache; -use super::support::write_executable; +use super::super::fingerprint::fingerprint_cache; +use super::super::paths::is_trusted_control_executable_path; +use super::super::snapshots::trusted_snapshot_cache; +use crate::daemon::auth::authorization::control_executable_is_allowed; +use crate::daemon::auth::support::write_executable; use crate::test_support::{env_lock, TempRoot}; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs b/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs deleted file mode 100644 index b27f11e92..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Small filesystem helpers shared by authorization modules - -use std::path::{Path, PathBuf}; - -pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { - // Fall back to the raw path so missing paths fail later as normal trust mismatches - std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/mod.rs index faeefa07c..bd6aaeb5e 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/mod.rs @@ -14,13 +14,9 @@ mod authorization; mod credentials; -mod filesystem; -mod fingerprint; -mod metadata; -mod paths; +mod executable_trust; mod policy; -mod process; -mod snapshots; +mod process_identity; pub(super) use authorization::{authorize_control_call, authorize_panel_readiness_call}; @@ -28,26 +24,11 @@ pub(super) use authorization::{authorize_control_call, authorize_panel_readiness #[path = "tests/authorization.rs"] mod authorization_tests; #[cfg(test)] -#[path = "tests/cache.rs"] -mod cache_tests; -#[cfg(test)] #[path = "tests/credentials.rs"] mod credentials_tests; #[cfg(test)] -#[path = "tests/metadata.rs"] -mod metadata_tests; -#[cfg(test)] -#[path = "tests/paths.rs"] -mod paths_tests; -#[cfg(test)] -#[path = "tests/procfs.rs"] -mod procfs_tests; -#[cfg(test)] -#[path = "tests/snapshot.rs"] -mod snapshot_tests; -#[cfg(test)] -#[path = "tests/strict.rs"] -mod strict_tests; +#[path = "tests/process_identity.rs"] +mod process_identity_tests; #[cfg(test)] #[path = "tests/support.rs"] mod support; diff --git a/crates/unixnotis-daemon/src/daemon/auth/process.rs b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/auth/process.rs rename to crates/unixnotis-daemon/src/daemon/auth/process_identity.rs diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 0ff434458..a45ecd8a1 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -8,7 +8,7 @@ use super::authorization::{ }; #[cfg(target_os = "linux")] use super::credentials::CallerCredentials; -use super::filesystem::canonicalize_best_effort; +use super::executable_trust::canonicalize_best_effort; use super::support::write_executable; use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs index 4b34da0f6..81bfc66ac 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs @@ -4,7 +4,7 @@ use zbus::Connection; use super::credentials::{connection_credentials, CallerCredentials}; #[cfg(target_os = "linux")] -use super::process::read_pidfd_process_id; +use super::process_identity::read_pidfd_process_id; #[tokio::test] async fn connection_credentials_match_the_current_bus_process() { diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs similarity index 97% rename from crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs rename to crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs index cb2161e27..00c906700 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs @@ -1,6 +1,6 @@ -use super::process::read_process_executable_path; +use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] -use super::process::{ +use super::process_identity::{ parse_pidfd_process_id, pidfd_is_live, pidfd_matches_live_process, read_pidfd_info_bytes, read_pidfd_process_id, read_process_executable_path_from_pidfd, }; diff --git a/crates/unixnotis-daemon/src/daemon/bus/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/clients.rs new file mode 100644 index 000000000..be144ccb1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/clients.rs @@ -0,0 +1,23 @@ +//! Domain cleanup when a unique D-Bus client disconnects + +use crate::daemon::DaemonState; + +impl DaemonState { + pub(in crate::daemon::bus) async fn remove_disconnected_client(&self, owner: &str) { + // Sender metadata is keyed by unique names and cannot survive owner loss + self.sender_metadata_cache.remove(owner); + + let inhibitor_change = { + let mut store = self.store.lock().await; + if store.remove_inhibitors_by_owner(owner) { + Some((store.inhibited(), store.inhibitor_count())) + } else { + None + } + }; + if let Some((active, count)) = inhibitor_change { + self.publish_inhibitors_changed(active, count, "owner-disconnected") + .await; + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/mod.rs b/crates/unixnotis-daemon/src/daemon/bus/mod.rs new file mode 100644 index 000000000..44bf4a155 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/mod.rs @@ -0,0 +1,11 @@ +//! Bus-name acquisition and client ownership lifecycle + +mod clients; +mod names; +mod ownership; + +pub use names::{log_name_reply, request_control_name, request_well_known_name}; +pub use ownership::{log_current_owner, spawn_client_owner_watch, wait_for_owner_state}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/bus_names.rs b/crates/unixnotis-daemon/src/daemon/bus/names.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/bus_names.rs rename to crates/unixnotis-daemon/src/daemon/bus/names.rs diff --git a/crates/unixnotis-daemon/src/dbus_owner.rs b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs similarity index 72% rename from crates/unixnotis-daemon/src/dbus_owner.rs rename to crates/unixnotis-daemon/src/daemon/bus/ownership.rs index ab8b47667..53169a78f 100644 --- a/crates/unixnotis-daemon/src/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs @@ -2,6 +2,7 @@ //! //! Provides reusable helpers for name ownership checks during startup and trial mode +use std::sync::Arc; use std::time::Duration; use anyhow::Result; @@ -10,6 +11,8 @@ use tracing::{info, warn}; use zbus::fdo::DBusProxy; use zbus::Connection; +use crate::daemon::DaemonState; + pub async fn wait_for_owner_state( dbus_proxy: &DBusProxy<'_>, name: zbus::names::BusName<'_>, @@ -78,17 +81,38 @@ pub async fn log_current_owner( Ok(is_self) } -fn owner_state_matches(new_owner: Option<&str>, expect_owner: bool) -> bool { +pub(super) fn owner_state_matches(new_owner: Option<&str>, expect_owner: bool) -> bool { // D-Bus signals encode release as an empty owner name, not as a missing signal let has_owner = new_owner.is_some_and(|name| !name.is_empty()); has_owner == expect_owner } -fn owner_name_is_self(unique_name: Option<&str>, owner: &str) -> bool { +pub(super) fn owner_name_is_self(unique_name: Option<&str>, owner: &str) -> bool { // Unique names come from the live connection and must match the queried owner exactly unique_name == Some(owner) } -#[cfg(test)] -#[path = "tests/dbus_owner.rs"] -mod tests; +pub async fn spawn_client_owner_watch(state: Arc) -> zbus::Result<()> { + // One owner-loss stream serves sender metadata and every client-owned domain resource + let proxy = DBusProxy::new(state.connection()).await?; + let mut stream = proxy.receive_name_owner_changed().await?; + + tokio::spawn(async move { + while let Some(signal) = stream.next().await { + let args = match signal.args() { + Ok(args) => args, + Err(error) => { + warn!(?error, "failed to decode NameOwnerChanged arguments"); + continue; + } + }; + if args.new_owner().is_some() { + continue; + } + + state.remove_disconnected_client(args.name().as_str()).await; + } + }); + + Ok(()) +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/watch.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs similarity index 91% rename from crates/unixnotis-daemon/src/daemon/control/tests/watch.rs rename to crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs index 36a5d6e86..bdcbe1b13 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/watch.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use super::spawn_inhibitor_owner_watch; +use super::super::ownership::spawn_client_owner_watch; use crate::test_support::daemon_state_for_test; #[tokio::test] @@ -19,7 +19,7 @@ async fn owner_watch_removes_inhibitors_when_the_client_disconnects() { assert_eq!(store.inhibitor_count(), 1); } - spawn_inhibitor_owner_watch(state.clone()) + spawn_client_owner_watch(state.clone()) .await .expect("start inhibitor owner watch"); client.close().await.expect("disconnect inhibitor owner"); diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs new file mode 100644 index 000000000..c079c8955 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs @@ -0,0 +1,2 @@ +mod clients; +mod ownership; diff --git a/crates/unixnotis-daemon/src/tests/dbus_owner.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs similarity index 87% rename from crates/unixnotis-daemon/src/tests/dbus_owner.rs rename to crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs index 116ba5951..d017ae2ed 100644 --- a/crates/unixnotis-daemon/src/tests/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs @@ -1,4 +1,4 @@ -use super::{owner_name_is_self, owner_state_matches}; +use super::super::ownership::{owner_name_is_self, owner_state_matches, wait_for_owner_state}; use std::time::Duration; use zbus::fdo::DBusProxy; @@ -34,7 +34,7 @@ async fn wait_for_owner_state_returns_true_when_expected_owner_is_already_presen .to_string(); let bus_name = zbus::names::BusName::try_from(unique_name.as_str()).expect("bus name"); - let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + let matched = wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) .await .expect("wait for owned name"); @@ -48,7 +48,7 @@ async fn wait_for_owner_state_returns_false_when_expected_owner_never_appears() let missing_name = format!("com.unixnotis.TestMissing{}", std::process::id()); let bus_name = zbus::names::BusName::try_from(missing_name.as_str()).expect("bus name"); - let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + let matched = wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) .await .expect("wait for missing name"); diff --git a/crates/unixnotis-daemon/src/daemon/control/clear.rs b/crates/unixnotis-daemon/src/daemon/control/clear.rs deleted file mode 100644 index 66b595031..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/clear.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::sync::Arc; - -use futures_util::stream::{self, StreamExt}; -use tracing::warn; -use unixnotis_core::{CloseReason, CONTROL_OBJECT_PATH}; -use zbus::SignalContext; - -use super::super::{DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; -use super::ControlServer; - -// Keep clear-all signal fanout bounded to avoid a burst of tiny tasks -const CLEAR_ALL_CONCURRENCY: usize = 64; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct ClearAllSignalPlan { - pub(super) emit_close_signals: bool, - pub(super) emit_snapshot_invalidated: bool, - pub(super) emit_state_changed: bool, -} - -pub(super) const fn clear_all_signal_plan(ids: &[u32]) -> ClearAllSignalPlan { - ClearAllSignalPlan { - // Only active rows need close fanout - emit_close_signals: !ids.is_empty(), - // Even an empty clear can be the only thing that fixes a stale client list - emit_snapshot_invalidated: true, - // Counters still need a refresh chance after the clear path - emit_state_changed: true, - } -} - -pub(super) async fn emit_clear_all_signals(state: &Arc, ids: Vec) { - let signal_plan = clear_all_signal_plan(&ids); - - if signal_plan.emit_close_signals { - let notif_ctx = SignalContext::new(state.connection(), NOTIFICATIONS_OBJECT_PATH).ok(); - let control_ctx = SignalContext::new(state.connection(), CONTROL_OBJECT_PATH).ok(); - if notif_ctx.is_none() { - // The clear already happened - warn!("failed to build notification signal context for clear_all; continuing with local state"); - } - if control_ctx.is_none() { - // The clear already happened - warn!( - "failed to build control signal context for clear_all; continuing with local state" - ); - } - - // Emit close signals with a bounded concurrency limit to avoid task spikes - stream::iter(ids) - .for_each_concurrent(CLEAR_ALL_CONCURRENCY, move |id| { - let notif_ctx = notif_ctx.clone(); - let control_ctx = control_ctx.clone(); - async move { - if let Some(notif_ctx) = notif_ctx.as_ref() { - if let Err(err) = NotificationServer::notification_closed( - notif_ctx, - id, - CloseReason::DismissedByUser as u32, - ) - .await - { - warn!( - ?err, - id, "failed to emit notification_closed during clear_all" - ); - } - } - if let Some(control_ctx) = control_ctx.as_ref() { - if let Err(err) = ControlServer::notification_closed( - control_ctx, - id, - CloseReason::DismissedByUser, - ) - .await - { - warn!( - ?err, - id, "failed to emit control notification_closed during clear_all" - ); - } - } - } - }) - .await; - } - - emit_post_clear_refresh(state, signal_plan).await; -} - -async fn emit_post_clear_refresh(state: &Arc, signal_plan: ClearAllSignalPlan) { - if signal_plan.emit_snapshot_invalidated { - if let Err(err) = state.emit_snapshot_invalidated().await { - // Clients can still fall back to later reconnect seeding if this broadcast is missed - warn!(?err, "failed to emit snapshot_invalidated after clear_all"); - } - } - if signal_plan.emit_state_changed { - if let Err(err) = state.emit_state_changed().await { - // State was updated locally even if listeners missed this broadcast - warn!(?err, "failed to emit state_changed after clear_all"); - } - } -} diff --git a/crates/unixnotis-daemon/src/daemon/control/dnd.rs b/crates/unixnotis-daemon/src/daemon/control/dnd.rs index 7c60937fb..12a235c2a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/dnd.rs +++ b/crates/unixnotis-daemon/src/daemon/control/dnd.rs @@ -87,7 +87,7 @@ impl ControlServer { // Scheduling follows durable commit so failed writes keep the previous timer self.state.schedule_dnd_expiration(write.current_expires_at); // Mutation is already committed; signal fanout is best-effort - if let Err(err) = self.state.emit_state_changed().await { + if let Err(err) = self.state.publish_state_changed().await { warn!( ?err, "do-not-disturb state changed but post-commit signal fanout failed" diff --git a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs index f2b279edc..ee074de88 100644 --- a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs +++ b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs @@ -2,11 +2,7 @@ //! //! Keeps inhibit/uninhibit flow and best-effort post-commit fanout isolated -use tracing::warn; use zbus::message::Header; -use zbus::SignalContext; - -use unixnotis_core::CONTROL_OBJECT_PATH; use super::{sanitize, ControlServer, MAX_ACTIVE_INHIBITORS}; @@ -38,7 +34,9 @@ impl ControlServer { let count = store.inhibitor_count(); (id, active, count) }; - self.emit_inhibitor_updates(active, count, "added").await; + self.state + .publish_inhibitors_changed(active, count, "added") + .await; Ok(id) } @@ -70,38 +68,9 @@ impl ControlServer { // Unknown IDs are treated as a no-op to keep clients resilient return Ok(()); } - self.emit_inhibitor_updates(active, count, "removed").await; + self.state + .publish_inhibitors_changed(active, count, "removed") + .await; Ok(()) } - - async fn emit_inhibitor_updates(&self, active: bool, count: u32, action: &'static str) { - match SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) { - Ok(ctx) => { - // Broadcast inhibitor updates so UI clients can refresh badges - if let Err(err) = Self::inhibitors_changed(&ctx, active, count).await { - warn!( - ?err, - inhibitor_count = count, - action, - "inhibitor state changed but inhibitors_changed signal fanout failed" - ); - } - } - Err(err) => { - warn!( - ?err, - action, - "inhibitor state changed but failed to build signal context for inhibitors_changed" - ); - } - } - // Mutation is already committed; signal fanout is best-effort - if let Err(err) = self.state.emit_state_changed().await { - warn!( - ?err, - action, - "inhibitor state changed but post-commit state_changed signal fanout failed" - ); - } - } } diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 51c8fd3d5..de3df19be 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -1,7 +1,6 @@ //! D-Bus server for com.unixnotis.Control mod action; -mod clear; mod dnd; mod inhibit; mod panel; @@ -9,10 +8,8 @@ mod query; mod reply; mod sanitize; mod server; -mod watch; pub use server::ControlServer; -pub use watch::spawn_inhibitor_owner_watch; // Cap inhibitor count so memory use stays bounded even under abusive clients const MAX_ACTIVE_INHIBITORS: u32 = 128; diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 9c82de8f5..b32e581b7 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -13,14 +13,7 @@ impl ControlServer { self.authorize_control_call(header, "GetState").await?; // Single lock read keeps state snapshot internally consistent let store = self.state.store.lock().await; - // Cheap state snapshot - Ok(ControlState { - dnd_enabled: store.dnd_enabled(), - dnd_expires_at: store.dnd_expires_at().unwrap_or(0), - history_count: store.history_len() as u32, - inhibited: store.inhibited(), - inhibitor_count: store.inhibitor_count(), - }) + Ok(store.control_state()) } pub(super) async fn query_active( diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 3551b53b8..0238a93e0 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -11,8 +11,6 @@ use zbus::{interface, SignalContext}; use crate::daemon::{auth, to_fdo_error, DaemonState}; -use super::clear; - /// D-Bus server for com.unixnotis.Control pub struct ControlServer { // Shared daemon state used by all control methods @@ -208,7 +206,7 @@ impl ControlServer { self.authorize_control_call(&header, "ClearAll").await?; let ids = self.drain_active_notifications().await; self.clear_saved_history().await; - clear::emit_clear_all_signals(&self.state, ids).await; + self.state.publish_notifications_cleared(ids).await; Ok(()) } @@ -218,7 +216,7 @@ impl ControlServer { ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearActive").await?; let ids = self.drain_active_notifications().await; - clear::emit_clear_all_signals(&self.state, ids).await; + self.state.publish_notifications_cleared(ids).await; Ok(()) } @@ -228,7 +226,7 @@ impl ControlServer { ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearHistory").await?; self.clear_saved_history().await; - clear::emit_clear_all_signals(&self.state, Vec::new()).await; + self.state.publish_notifications_cleared(Vec::new()).await; Ok(()) } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index 7a2136f4d..211d83bc3 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,4 +1,3 @@ mod action; -mod clear; mod sanitize; mod server; diff --git a/crates/unixnotis-daemon/src/daemon/control/watch.rs b/crates/unixnotis-daemon/src/daemon/control/watch.rs deleted file mode 100644 index d32538934..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/watch.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Name-owner watch for automatic inhibitor cleanup -//! -//! When a controlling client exits, its inhibitors should not remain forever - -use std::sync::Arc; - -use futures_util::StreamExt; -use tracing::warn; -use unixnotis_core::CONTROL_OBJECT_PATH; -use zbus::fdo::DBusProxy; -use zbus::SignalContext; - -use crate::daemon::{ControlServer, DaemonState}; - -pub async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Result<()> { - // Subscribe once and process updates in the background - let proxy = DBusProxy::new(state.connection()).await?; - let mut stream = proxy.receive_name_owner_changed().await?; - - tokio::spawn(async move { - while let Some(signal) = stream.next().await { - let args = match signal.args() { - Ok(args) => args, - Err(err) => { - warn!(?err, "failed to decode NameOwnerChanged args"); - continue; - } - }; - - // Ignore owner-acquired events and only process owner-lost events - if args.new_owner().is_some() { - continue; - } - let owner = args.name().to_string(); - // Unique-name metadata can be dropped as soon as the bus reports owner loss - state.sender_metadata_cache.remove(&owner); - - // Remove inhibitors owned by the disconnected bus name - let (changed, active, count) = { - let mut store = state.store.lock().await; - let changed = store.remove_inhibitors_by_owner(&owner); - let active = store.inhibited(); - let count = store.inhibitor_count(); - (changed, active, count) - }; - if !changed { - continue; - } - - // Build signal context each time so failure never blocks store cleanup - let ctx = match SignalContext::new(state.connection(), CONTROL_OBJECT_PATH) { - Ok(ctx) => ctx, - Err(err) => { - warn!(?err, "failed to build signal context for inhibitor cleanup"); - continue; - } - }; - - // Notify listeners so UI can refresh inhibition badges immediately - if let Err(err) = ControlServer::inhibitors_changed(&ctx, active, count).await { - warn!( - ?err, - "failed to emit inhibitors_changed after owner disconnect" - ); - } - if let Err(err) = state.emit_state_changed().await { - warn!(?err, "failed to emit state_changed after owner disconnect"); - } - } - }); - - Ok(()) -} - -#[cfg(test)] -#[path = "tests/watch.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs new file mode 100644 index 000000000..1bb3b286e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs @@ -0,0 +1,38 @@ +//! Inhibitor event fanout after store updates and client disconnects + +use tracing::warn; + +use crate::daemon::{ControlServer, DaemonState}; + +use super::publisher::DaemonEventPublisher; + +impl DaemonState { + pub(in crate::daemon) async fn publish_inhibitors_changed( + &self, + active: bool, + count: u32, + action: &'static str, + ) { + if let Err(error) = self.events.inhibitors_changed(active, count).await { + warn!( + ?error, + inhibitor_count = count, + action, + "inhibitor mutation committed but inhibitor fanout failed" + ); + } + if let Err(error) = self.publish_state_changed().await { + warn!( + ?error, + action, "inhibitor mutation committed but state fanout failed" + ); + } + } +} + +impl DaemonEventPublisher { + async fn inhibitors_changed(&self, active: bool, count: u32) -> zbus::Result<()> { + let context = self.control_context()?; + ControlServer::inhibitors_changed(&context, active, count).await + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/mod.rs b/crates/unixnotis-daemon/src/daemon/events/mod.rs new file mode 100644 index 000000000..dec949ca8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/mod.rs @@ -0,0 +1,11 @@ +//! D-Bus event publication after committed daemon mutations + +mod inhibitors; +mod notifications; +mod publisher; +mod state; + +pub(in crate::daemon) use publisher::DaemonEventPublisher; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/events/notifications.rs b/crates/unixnotis-daemon/src/daemon/events/notifications.rs new file mode 100644 index 000000000..96195f981 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/notifications.rs @@ -0,0 +1,234 @@ +//! Notification add, update, close, eviction, and bulk-clear fanout + +use futures_util::stream::{self, StreamExt}; +use tracing::warn; +use unixnotis_core::CloseReason; + +use crate::daemon::{ControlServer, DaemonState, NotificationServer, NotificationSignalMode}; + +use super::publisher::{record_first_error, DaemonEventPublisher}; + +const CLEAR_ALL_CONCURRENCY: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClearAllSignalPlan { + pub(super) publish_close_signals: bool, + pub(super) publish_snapshot_invalidated: bool, + pub(super) publish_state_changed: bool, +} + +pub(super) const fn clear_all_signal_plan(ids: &[u32]) -> ClearAllSignalPlan { + ClearAllSignalPlan { + publish_close_signals: !ids.is_empty(), + // Empty clears remain a recovery path for stale materialized client views + publish_snapshot_invalidated: true, + publish_state_changed: true, + } +} + +impl DaemonState { + pub(in crate::daemon) async fn publish_notification_closed( + &self, + id: u32, + reason: CloseReason, + ) -> zbus::Result<()> { + let mut first_error = self + .events + .notification_closed(id, reason, true) + .await + .err(); + if let Err(error) = self.publish_state_changed().await { + record_first_error(&mut first_error, error); + } + first_error.map_or(Ok(()), Err) + } + + pub(in crate::daemon) async fn publish_notification_dismissed( + &self, + id: u32, + removed_active: bool, + ) -> zbus::Result<()> { + let mut first_error = self + .events + .notification_closed(id, CloseReason::DismissedByUser, removed_active) + .await + .err(); + if let Err(error) = self.publish_state_changed().await { + record_first_error(&mut first_error, error); + } + first_error.map_or(Ok(()), Err) + } + + pub(in crate::daemon) async fn publish_notification_change( + &self, + mode: NotificationSignalMode, + id: u32, + replaced: bool, + show_popup: bool, + ) -> zbus::Result<()> { + self.events + .notification_change(mode, id, replaced, show_popup) + .await + } + + pub(in crate::daemon) async fn publish_evicted_notifications( + &self, + ids: &[u32], + ) -> zbus::Result<()> { + self.events.evicted_notifications(ids).await + } + + pub(in crate::daemon) async fn publish_notifications_cleared(&self, ids: Vec) { + let plan = clear_all_signal_plan(&ids); + if plan.publish_close_signals { + if let Err(error) = self.events.cleared_notifications(ids).await { + warn!( + ?error, + "notification clear committed but close fanout failed" + ); + } + } + if plan.publish_snapshot_invalidated { + if let Err(error) = self.publish_snapshot_invalidated().await { + warn!( + ?error, + "notification clear committed but snapshot invalidation failed" + ); + } + } + if plan.publish_state_changed { + if let Err(error) = self.publish_state_changed().await { + warn!( + ?error, + "notification clear committed but state fanout failed" + ); + } + } + } +} + +impl DaemonEventPublisher { + async fn notification_closed( + &self, + id: u32, + reason: CloseReason, + publish_freedesktop: bool, + ) -> zbus::Result<()> { + let mut first_error = None; + if publish_freedesktop { + match self.notification_context() { + Ok(context) => { + if let Err(error) = + NotificationServer::notification_closed(&context, id, reason as u32).await + { + record_first_error(&mut first_error, error); + } + } + Err(error) => record_first_error(&mut first_error, error), + } + } + match self.control_context() { + Ok(context) => { + if let Err(error) = ControlServer::notification_closed(&context, id, reason).await { + record_first_error(&mut first_error, error); + } + } + Err(error) => record_first_error(&mut first_error, error), + } + first_error.map_or(Ok(()), Err) + } + + async fn notification_change( + &self, + mode: NotificationSignalMode, + id: u32, + replaced: bool, + show_popup: bool, + ) -> zbus::Result<()> { + match mode { + NotificationSignalMode::Direct => { + let context = self.control_context()?; + if replaced { + ControlServer::notification_updated(&context, id, show_popup).await + } else { + ControlServer::notification_added(&context, id, show_popup).await + } + } + NotificationSignalMode::SnapshotOnly => self.snapshot_invalidated().await, + NotificationSignalMode::Suppress => Ok(()), + } + } + + async fn evicted_notifications(&self, ids: &[u32]) -> zbus::Result<()> { + if ids.is_empty() { + return Ok(()); + } + let notification_context = self.notification_context()?; + let control_context = self.control_context()?; + let mut first_error = None; + for &id in ids { + if let Err(error) = NotificationServer::notification_closed( + ¬ification_context, + id, + CloseReason::Undefined as u32, + ) + .await + { + record_first_error(&mut first_error, error); + } + if let Err(error) = + ControlServer::notification_closed(&control_context, id, CloseReason::Undefined) + .await + { + record_first_error(&mut first_error, error); + } + } + first_error.map_or(Ok(()), Err) + } + + async fn cleared_notifications(&self, ids: Vec) -> zbus::Result<()> { + let notification_context = self.notification_context()?; + let control_context = self.control_context()?; + let first_error = std::sync::Mutex::new(None); + + // Contexts are reused and concurrency remains bounded for large configured stores + stream::iter(ids) + .for_each_concurrent(CLEAR_ALL_CONCURRENCY, |id| { + let notification_context = notification_context.clone(); + let control_context = control_context.clone(); + let first_error = &first_error; + async move { + if let Err(error) = NotificationServer::notification_closed( + ¬ification_context, + id, + CloseReason::DismissedByUser as u32, + ) + .await + { + let mut first = first_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + record_first_error(&mut first, error); + } + if let Err(error) = ControlServer::notification_closed( + &control_context, + id, + CloseReason::DismissedByUser, + ) + .await + { + let mut first = first_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + record_first_error(&mut first, error); + } + } + }) + .await; + + first_error + .into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .map_or(Ok(()), Err) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/publisher.rs b/crates/unixnotis-daemon/src/daemon/events/publisher.rs new file mode 100644 index 000000000..87ecd7757 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/publisher.rs @@ -0,0 +1,39 @@ +//! Shared connection state and error policy for daemon event publication + +use std::sync::Mutex; + +use unixnotis_core::{ControlState, PopupGateState, CONTROL_OBJECT_PATH}; +use zbus::{Connection, SignalContext}; + +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; + +pub(in crate::daemon) struct DaemonEventPublisher { + connection: Connection, + // State snapshots are cached here because publication owns duplicate suppression + pub(super) last_state: Mutex>, + pub(super) last_popup_gate: Mutex>, +} + +impl DaemonEventPublisher { + pub(in crate::daemon) const fn new(connection: Connection) -> Self { + Self { + connection, + last_state: Mutex::new(None), + last_popup_gate: Mutex::new(None), + } + } + + pub(super) fn control_context(&self) -> zbus::Result> { + SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) + } + + pub(super) fn notification_context(&self) -> zbus::Result> { + SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH) + } +} + +pub(super) fn record_first_error(first_error: &mut Option, error: zbus::Error) { + if first_error.is_none() { + *first_error = Some(error); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/state.rs b/crates/unixnotis-daemon/src/daemon/events/state.rs new file mode 100644 index 000000000..9a703dda8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/state.rs @@ -0,0 +1,82 @@ +//! Control-state snapshots, popup gates, and cache invalidation signals + +use unixnotis_core::{ControlState, PopupGateState}; + +use crate::daemon::{ControlServer, DaemonState}; + +use super::publisher::{record_first_error, DaemonEventPublisher}; + +impl DaemonState { + pub(in crate::daemon) async fn publish_state_changed(&self) -> zbus::Result<()> { + let state = { + // One store lock captures every public counter and gate from one revision + let store = self.store.lock().await; + store.control_state() + }; + self.events.state_changed(state).await + } + + pub(in crate::daemon) async fn publish_snapshot_invalidated(&self) -> zbus::Result<()> { + self.events.snapshot_invalidated().await + } +} + +impl DaemonEventPublisher { + pub(super) async fn state_changed(&self, state: ControlState) -> zbus::Result<()> { + let popup_gate = popup_gate_from_state(&state); + let publish_state = should_publish_cached(&self.last_state, &state); + let publish_popup_gate = should_publish_cached(&self.last_popup_gate, &popup_gate); + if !should_publish_any_state_signal(publish_state, publish_popup_gate) { + return Ok(()); + } + + // One context serves both related signals from the same captured state + let context = self.control_context()?; + let mut first_error = None; + if publish_state { + if let Err(error) = ControlServer::state_changed(&context, state).await { + record_first_error(&mut first_error, error); + } + } + if publish_popup_gate { + if let Err(error) = ControlServer::popup_gate_changed(&context, popup_gate).await { + record_first_error(&mut first_error, error); + } + } + first_error.map_or(Ok(()), Err) + } + + pub(super) async fn snapshot_invalidated(&self) -> zbus::Result<()> { + let context = self.control_context()?; + ControlServer::snapshot_invalidated(&context).await + } +} + +pub(super) fn should_publish_cached( + cache: &std::sync::Mutex>, + next: &T, +) -> bool { + // Poison recovery preserves availability after a prior panicking task + let mut cached = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if cached.as_ref() == Some(next) { + return false; + } + cached.clone_from(&Some(next.clone())); + true +} + +pub(super) const fn popup_gate_from_state(state: &ControlState) -> PopupGateState { + PopupGateState { + dnd_enabled: state.dnd_enabled, + inhibited: state.inhibited, + } +} + +pub(super) const fn should_publish_any_state_signal( + publish_state: bool, + publish_popup_gate: bool, +) -> bool { + publish_state || publish_popup_gate +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs similarity index 72% rename from crates/unixnotis-daemon/src/daemon/state/tests/cache.rs rename to crates/unixnotis-daemon/src/daemon/events/tests/cache.rs index 582e7b572..a802d5feb 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use unixnotis_core::{ControlState, PopupGateState}; -use super::super::cache::should_emit_cached; +use super::super::state::should_publish_cached; #[test] fn cached_state_emits_first_value_then_suppresses_duplicates() { @@ -16,9 +16,9 @@ fn cached_state_emits_first_value_then_suppresses_duplicates() { }; // First value must be emitted because clients have no previous state - assert!(should_emit_cached(&cache, &state)); + assert!(should_publish_cached(&cache, &state)); // Identical values should not wake D-Bus subscribers again - assert!(!should_emit_cached(&cache, &state)); + assert!(!should_publish_cached(&cache, &state)); } #[test] @@ -33,10 +33,10 @@ fn cached_state_emits_when_any_gate_field_changes() { inhibited: false, }; - assert!(should_emit_cached(&cache, &open)); + assert!(should_publish_cached(&cache, &open)); // A changed popup gate affects visibility policy, so it must emit - assert!(should_emit_cached(&cache, &dnd)); - assert!(!should_emit_cached(&cache, &dnd)); + assert!(should_publish_cached(&cache, &dnd)); + assert!(!should_publish_cached(&cache, &dnd)); } #[test] @@ -54,9 +54,9 @@ fn cached_state_emits_after_counter_change() { ..first }; - assert!(should_emit_cached(&cache, &first)); - assert!(should_emit_cached(&cache, &changed)); - assert!(!should_emit_cached(&cache, &changed)); + assert!(should_publish_cached(&cache, &first)); + assert!(should_publish_cached(&cache, &changed)); + assert!(!should_publish_cached(&cache, &changed)); } #[test] @@ -69,15 +69,15 @@ fn cached_state_emits_when_only_the_dnd_deadline_changes() { inhibited: false, inhibitor_count: 0, }; - assert!(should_emit_cached(&cache, &indefinite)); + assert!(should_publish_cached(&cache, &indefinite)); let timed = ControlState { dnd_expires_at: 500, ..indefinite }; - assert!(should_emit_cached(&cache, &timed)); - assert!(!should_emit_cached(&cache, &timed)); + assert!(should_publish_cached(&cache, &timed)); + assert!(!should_publish_cached(&cache, &timed)); } #[test] @@ -93,6 +93,6 @@ fn cached_state_recovers_from_poisoned_mutex() { inhibited: true, }; - assert!(should_emit_cached(&cache, &state)); - assert!(!should_emit_cached(&cache, &state)); + assert!(should_publish_cached(&cache, &state)); + assert!(!should_publish_cached(&cache, &state)); } diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs new file mode 100644 index 000000000..ef17870e7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs @@ -0,0 +1,3 @@ +mod cache; +mod notifications; +mod state; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs similarity index 67% rename from crates/unixnotis-daemon/src/daemon/control/tests/clear.rs rename to crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs index 838119d74..a3fcb52fe 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs @@ -1,4 +1,4 @@ -use super::super::clear::{clear_all_signal_plan, emit_clear_all_signals}; +use super::super::notifications::clear_all_signal_plan; use crate::test_support::daemon_state_for_test; #[test] @@ -6,11 +6,11 @@ fn clear_all_with_no_active_rows_still_invalidates_snapshot() { let plan = clear_all_signal_plan(&[]); // No live rows means there is nothing to close - assert!(!plan.emit_close_signals); + assert!(!plan.publish_close_signals); // Empty clear is still the escape hatch for stale client rows - assert!(plan.emit_snapshot_invalidated); + assert!(plan.publish_snapshot_invalidated); // State refresh still needs a chance to run - assert!(plan.emit_state_changed); + assert!(plan.publish_state_changed); } #[test] @@ -18,10 +18,10 @@ fn clear_all_with_active_rows_keeps_close_fanout_and_refresh() { let plan = clear_all_signal_plan(&[11, 12]); // Active rows still need the normal close signals - assert!(plan.emit_close_signals); + assert!(plan.publish_close_signals); // Clients still need a full refresh after the clear - assert!(plan.emit_snapshot_invalidated); - assert!(plan.emit_state_changed); + assert!(plan.publish_snapshot_invalidated); + assert!(plan.publish_state_changed); } #[test] @@ -29,25 +29,27 @@ fn clear_all_signal_plan_treats_any_non_empty_id_set_as_close_fanout() { let plan = clear_all_signal_plan(&[99]); // A single active row still needs both freedesktop and control close fanout - assert!(plan.emit_close_signals); - assert!(plan.emit_snapshot_invalidated); - assert!(plan.emit_state_changed); + assert!(plan.publish_close_signals); + assert!(plan.publish_snapshot_invalidated); + assert!(plan.publish_state_changed); } #[tokio::test] async fn clear_all_without_ids_still_refreshes_cached_control_state() { let state = daemon_state_for_test(false).await; - emit_clear_all_signals(&state, Vec::new()).await; + state.publish_notifications_cleared(Vec::new()).await; // A no-row clear still refreshes state caches so clients can recover stale views assert!(state - .last_emitted_state + .events + .last_state .lock() .expect("state cache lock") .is_some()); assert!(state - .last_emitted_popup_gate + .events + .last_popup_gate .lock() .expect("popup gate cache lock") .is_some()); diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs similarity index 77% rename from crates/unixnotis-daemon/src/daemon/state/tests/signals.rs rename to crates/unixnotis-daemon/src/daemon/events/tests/state.rs index 3bd03f0dd..104bc12fc 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs @@ -9,13 +9,11 @@ use crate::daemon::NOTIFICATIONS_OBJECT_PATH; use crate::store::NotificationStore; use crate::test_support::daemon_state_for_test; -use super::super::signals::{ - control_state_from_store, popup_gate_from_state, record_signal_error, - should_emit_any_state_signal, -}; +use super::super::publisher::record_first_error; +use super::super::state::{popup_gate_from_state, should_publish_any_state_signal}; async fn signal_stream( - state: &super::super::DaemonState, + state: &crate::daemon::DaemonState, path: &str, interface: &str, member: &str, @@ -42,12 +40,12 @@ async fn signal_stream( .expect("signal stream") } -async fn control_signal_stream(state: &super::super::DaemonState, member: &str) -> MessageStream { +async fn control_signal_stream(state: &crate::daemon::DaemonState, member: &str) -> MessageStream { signal_stream(state, CONTROL_OBJECT_PATH, "com.unixnotis.Control", member).await } async fn notifications_signal_stream( - state: &super::super::DaemonState, + state: &crate::daemon::DaemonState, member: &str, ) -> MessageStream { signal_stream( @@ -93,13 +91,13 @@ fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { } #[test] -fn control_state_from_store_reads_dnd_history_and_inhibitors() { +fn notification_store_control_state_reads_dnd_history_and_inhibitors() { let mut store = NotificationStore::new(Config::default()); store.set_dnd_until(500); store.add_inhibitor(":1.test".to_string(), "focus".to_string(), 0); - let state = control_state_from_store(&store); + let state = store.control_state(); assert!(state.dnd_enabled); assert_eq!(state.dnd_expires_at, 500); @@ -109,51 +107,51 @@ fn control_state_from_store_reads_dnd_history_and_inhibitors() { } #[test] -fn should_emit_any_state_signal_is_false_when_both_cached_values_match() { - assert!(!should_emit_any_state_signal(false, false)); +fn should_publish_any_state_signal_is_false_when_both_cached_values_match() { + assert!(!should_publish_any_state_signal(false, false)); } #[test] -fn should_emit_any_state_signal_is_true_when_control_state_changed() { - assert!(should_emit_any_state_signal(true, false)); +fn should_publish_any_state_signal_is_true_when_control_state_changed() { + assert!(should_publish_any_state_signal(true, false)); } #[test] -fn should_emit_any_state_signal_is_true_when_popup_gate_changed() { - assert!(should_emit_any_state_signal(false, true)); +fn should_publish_any_state_signal_is_true_when_popup_gate_changed() { + assert!(should_publish_any_state_signal(false, true)); } #[test] -fn should_emit_any_state_signal_is_true_when_both_values_changed() { - assert!(should_emit_any_state_signal(true, true)); +fn should_publish_any_state_signal_is_true_when_both_values_changed() { + assert!(should_publish_any_state_signal(true, true)); } #[test] -fn record_signal_error_stores_first_error() { +fn record_first_error_stores_first_error() { let mut first_error = None; - record_signal_error(&mut first_error, zbus::Error::Failure("first".to_string())); + record_first_error(&mut first_error, zbus::Error::Failure("first".to_string())); assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); } #[test] -fn record_signal_error_keeps_existing_error() { +fn record_first_error_keeps_existing_error() { let mut first_error = Some(zbus::Error::Failure("first".to_string())); - record_signal_error(&mut first_error, zbus::Error::Failure("second".to_string())); + record_first_error(&mut first_error, zbus::Error::Failure("second".to_string())); assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); } #[tokio::test] -async fn emit_close_fanout_sends_freedesktop_and_control_close_signals() { +async fn publish_notification_closed_sends_freedesktop_and_control_close_signals() { let state = daemon_state_for_test(false).await; let mut freedesktop_stream = notifications_signal_stream(&state, "NotificationClosed").await; let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; state - .emit_close_fanout(7, CloseReason::ClosedByCall) + .publish_notification_closed(7, CloseReason::ClosedByCall) .await .expect("close fanout should emit"); @@ -175,12 +173,12 @@ async fn emit_close_fanout_sends_freedesktop_and_control_close_signals() { } #[tokio::test] -async fn emit_dismiss_fanout_sends_control_close_signal() { +async fn publish_notification_dismissed_sends_control_close_signal() { let state = daemon_state_for_test(false).await; let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; state - .emit_dismiss_fanout(8, false) + .publish_notification_dismissed(8, false) .await .expect("dismiss fanout should emit"); @@ -194,13 +192,13 @@ async fn emit_dismiss_fanout_sends_control_close_signal() { } #[tokio::test] -async fn emit_state_changed_sends_initial_state_and_suppresses_duplicate() { +async fn publish_state_changed_sends_initial_state_and_suppresses_duplicate() { let state = daemon_state_for_test(false).await; let mut state_stream = control_signal_stream(&state, "StateChanged").await; let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; state - .emit_state_changed() + .publish_state_changed() .await .expect("state changed should emit"); @@ -223,7 +221,7 @@ async fn emit_state_changed_sends_initial_state_and_suppresses_duplicate() { assert!(!emitted_gate.inhibited); state - .emit_state_changed() + .publish_state_changed() .await .expect("duplicate state should not fail"); assert_no_signal(&mut state_stream).await; @@ -231,12 +229,12 @@ async fn emit_state_changed_sends_initial_state_and_suppresses_duplicate() { } #[tokio::test] -async fn emit_snapshot_invalidated_sends_snapshot_signal() { +async fn publish_snapshot_invalidated_sends_snapshot_signal() { let state = daemon_state_for_test(false).await; let mut stream = control_signal_stream(&state, "SnapshotInvalidated").await; state - .emit_snapshot_invalidated() + .publish_snapshot_invalidated() .await .expect("snapshot invalidation should emit"); diff --git a/crates/unixnotis-daemon/src/daemon/mod.rs b/crates/unixnotis-daemon/src/daemon/mod.rs index 4a66ec8c0..c0360350e 100644 --- a/crates/unixnotis-daemon/src/daemon/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/mod.rs @@ -1,19 +1,21 @@ //! D-Bus server implementation and daemon state coordination mod auth; -mod bus_names; +mod bus; mod control; mod errors; +mod events; mod notifications; -mod signal_burst; mod state; -pub use bus_names::{log_name_reply, request_control_name, request_well_known_name}; -pub use control::spawn_inhibitor_owner_watch; +pub use bus::{ + log_current_owner, log_name_reply, request_control_name, request_well_known_name, + spawn_client_owner_watch, wait_for_owner_state, +}; pub use control::ControlServer; pub use errors::to_fdo_error; pub use notifications::NotificationServer; -pub(in crate::daemon) use signal_burst::NotificationSignalMode; +pub(in crate::daemon) use notifications::NotificationSignalMode; pub use state::DaemonState; pub const NOTIFICATIONS_OBJECT_PATH: &str = "/org/freedesktop/Notifications"; diff --git a/crates/unixnotis-daemon/src/daemon/signal_burst.rs b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs similarity index 94% rename from crates/unixnotis-daemon/src/daemon/signal_burst.rs rename to crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs index 8af47f8c5..a58be8992 100644 --- a/crates/unixnotis-daemon/src/daemon/signal_burst.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs @@ -15,7 +15,7 @@ pub enum NotificationSignalMode { } #[derive(Clone, Debug)] -pub(super) struct NotificationBurstState { +pub(in crate::daemon) struct NotificationBurstState { window_started: Instant, last_seen: Instant, count: u16, @@ -28,7 +28,7 @@ const NOTIFICATION_DIRECT_SIGNAL_LIMIT: u16 = 8; // Cap tracked senders so hostile unique names cannot grow memory without bound const NOTIFICATION_SIGNAL_TRACK_LIMIT: usize = 128; -pub(super) fn notification_signal_mode_for_sender( +pub(in crate::daemon) fn notification_signal_mode_for_sender( cache: &StdMutex>, sender: &str, ) -> NotificationSignalMode { @@ -76,5 +76,5 @@ pub(super) fn notification_signal_mode_for_sender( } #[cfg(test)] -#[path = "tests/signal_burst.rs"] +#[path = "tests/flow_control.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index ba2044a04..9dedd3702 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -1,5 +1,6 @@ //! D-Bus server for org.freedesktop.Notifications +mod flow_control; mod limits; mod metrics; mod payload; @@ -8,4 +9,7 @@ mod sender; pub(in crate::daemon) mod sender_cache; mod server; +pub(in crate::daemon) use flow_control::{ + notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, +}; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 2f4d02a76..25ccdf40a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -2,18 +2,15 @@ use std::collections::HashMap; use std::time::Instant; use tracing::debug; -use unixnotis_core::{CloseReason, Notification, CONTROL_OBJECT_PATH}; +use unixnotis_core::Notification; use zbus::message::Header; use zbus::zvariant::OwnedValue; -use zbus::SignalContext; use crate::daemon::notifications::payload::{ build_notification, resolve_expiration, NotificationInput, }; use crate::daemon::notifications::sender::{app_name_matches_sender, resolve_sender_metadata}; -use crate::daemon::{ - to_fdo_error, ControlServer, NotificationSignalMode, NOTIFICATIONS_OBJECT_PATH, -}; +use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; use super::NotificationServer; @@ -184,48 +181,25 @@ impl NotificationServer { } async fn emit_notification_change(&self, outcome: &InsertOutcome) -> zbus::fdo::Result<()> { - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; - match self + let mode = self .state - .notification_signal_mode(outcome.notification.sender_name.as_deref()) - { - NotificationSignalMode::Direct => { - if outcome.replaced { - // Only the id crosses the broadcast signal - // Trusted UIs fetch the live payload through the authorized control API - ControlServer::notification_updated( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } else { - // New notification broadcasts only the id for the same confidentiality reason - ControlServer::notification_added( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } - } - NotificationSignalMode::SnapshotOnly => { - debug!( - id = outcome.notification.id, - sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), - "notification burst detected; using snapshot invalidation instead of per-row signal" - ); - self.state - .emit_snapshot_invalidated() - .await - .map_err(to_fdo_error)?; - } - NotificationSignalMode::Suppress => {} + .notification_signal_mode(outcome.notification.sender_name.as_deref()); + if mode == NotificationSignalMode::SnapshotOnly { + debug!( + id = outcome.notification.id, + sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), + "notification burst detected; using snapshot invalidation instead of per-row signal" + ); } - Ok(()) + self.state + .publish_notification_change( + mode, + outcome.notification.id, + outcome.replaced, + outcome.show_popup, + ) + .await + .map_err(to_fdo_error) } async fn finish_notification_change( @@ -242,7 +216,7 @@ impl NotificationServer { // Evicted items are announced so UIs can remove stale rows self.handle_evicted(outcome.evicted).await?; self.state - .emit_state_changed() + .publish_state_changed() .await .map_err(to_fdo_error)?; @@ -256,21 +230,10 @@ impl NotificationServer { } self.state.cancel_expirations(&evicted); - let notif_ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; - - for id in evicted { - // Emit both freedesktop and control close signals for consistent subscribers - Self::notification_closed(¬if_ctx, id, CloseReason::Undefined as u32) - .await - .map_err(to_fdo_error)?; - ControlServer::notification_closed(&control_ctx, id, CloseReason::Undefined) - .await - .map_err(to_fdo_error)?; - } - Ok(()) + self.state + .publish_evicted_notifications(&evicted) + .await + .map_err(to_fdo_error) } } diff --git a/crates/unixnotis-daemon/src/daemon/tests/signal_burst.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/tests/signal_burst.rs rename to crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs diff --git a/crates/unixnotis-daemon/src/daemon/state/cache.rs b/crates/unixnotis-daemon/src/daemon/state/cache.rs deleted file mode 100644 index 08f108c88..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/cache.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Mutex as StdMutex; - -pub(in crate::daemon::state) fn should_emit_cached( - cache: &StdMutex>, - value: &T, -) -> bool { - // Sync mutex is enough here because this cache is tiny and never held across await points - let mut last_value = match cache.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - if last_value - .as_ref() - .is_some_and(|previous| previous == value) - { - // Identical state would only burn CPU in zbus and the listeners - return false; - } - // Clone once on change so later comparisons stay allocation-free for equal values - *last_value = Some(value.clone()); - true -} diff --git a/crates/unixnotis-daemon/src/daemon/state/dnd.rs b/crates/unixnotis-daemon/src/daemon/state/dnd.rs deleted file mode 100644 index c42f0959c..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/dnd.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Timed DND scheduler ownership for shared daemon state - -use std::sync::atomic::Ordering; - -use tokio::sync::MutexGuard; -use tracing::warn; - -use crate::dnd_expiration::DndExpirationScheduler; - -use super::DaemonState; - -impl DaemonState { - pub(in crate::daemon) async fn lock_dnd_write(&self) -> MutexGuard<'_, ()> { - // One writer keeps disk state and the scheduled deadline in the same order - self.dnd_write_lock.lock().await - } - - pub fn set_dnd_scheduler(&self, scheduler: DndExpirationScheduler) { - if self.dnd_scheduler.set(scheduler).is_err() { - warn!("DND scheduler was already installed; ignoring duplicate initialization"); - return; - } - self.dnd_scheduler_missing_warned - .store(false, Ordering::SeqCst); - } - - pub(crate) fn schedule_dnd_expiration(&self, expires_at: Option) { - let Some(scheduler) = self.dnd_scheduler.get() else { - if !self - .dnd_scheduler_missing_warned - .swap(true, Ordering::SeqCst) - { - warn!("DND scheduler is unavailable during live daemon operation"); - } - return; - }; - scheduler.schedule(expires_at); - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/mod.rs b/crates/unixnotis-daemon/src/daemon/state/mod.rs index 6952f5791..c7a8cf469 100644 --- a/crates/unixnotis-daemon/src/daemon/state/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/mod.rs @@ -1,12 +1,9 @@ //! Shared daemon state and signal fanout coordination -mod cache; -mod dnd; mod model; -mod notifications; -mod runtime; -mod scheduler; -mod signals; +mod notification_lifecycle; +mod schedulers; +mod status; pub use model::DaemonState; diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index fe337b517..6a428dabf 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -2,7 +2,7 @@ use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use tokio::sync::Mutex; -use unixnotis_core::{Config, ControlState, PopupGateState}; +use unixnotis_core::Config; use zbus::Connection; use crate::dnd_expiration::DndExpirationScheduler; @@ -10,8 +10,9 @@ use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; use crate::store::NotificationStore; +use crate::daemon::events::DaemonEventPublisher; use crate::daemon::notifications::sender_cache::SenderMetadataCache; -use crate::daemon::signal_burst::NotificationBurstState; +use crate::daemon::notifications::NotificationBurstState; /// Shared daemon state guarded behind an async mutex pub struct DaemonState { @@ -32,10 +33,8 @@ pub struct DaemonState { pub(in crate::daemon::state) dnd_scheduler_missing_warned: AtomicBool, // DND persistence and timer replacement must commit in mutation order pub(in crate::daemon::state) dnd_write_lock: Mutex<()>, - // Cache the last control-state snapshot so no-op signals can be skipped - pub(in crate::daemon) last_emitted_state: StdMutex>, - // Popup UIs only care about the gate, not panel history counters - pub(in crate::daemon) last_emitted_popup_gate: StdMutex>, + // Connection-facing signal policy stays outside mutable domain state + pub(in crate::daemon) events: DaemonEventPublisher, // Burst tracking lets one noisy sender fall back to snapshot invalidation // instead of forcing a storm of full add/update fanout pub(in crate::daemon::state) notification_signal_bursts: @@ -67,7 +66,7 @@ impl DaemonState { Arc::new(Self { store: Mutex::new(store), sound, - connection, + connection: connection.clone(), panel_ready: AtomicBool::new(false), popups_running: AtomicBool::new(false), scheduler: OnceLock::new(), @@ -75,8 +74,7 @@ impl DaemonState { dnd_scheduler: OnceLock::new(), dnd_scheduler_missing_warned: AtomicBool::new(false), dnd_write_lock: Mutex::new(()), - last_emitted_state: StdMutex::new(None), - last_emitted_popup_gate: StdMutex::new(None), + events: DaemonEventPublisher::new(connection), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), sender_metadata_cache: SenderMetadataCache::new(), trial_mode, diff --git a/crates/unixnotis-daemon/src/daemon/state/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs similarity index 86% rename from crates/unixnotis-daemon/src/daemon/state/notifications.rs rename to crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index b07c6c8b1..410ae7f3e 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -17,7 +17,7 @@ impl DaemonState { // Timer cancel happens before signal fanout so stale wakeups stop right away self.cancel_expiration(id); - if let Err(err) = self.emit_close_fanout(id, reason).await { + if let Err(err) = self.publish_notification_closed(id, reason).await { warn!( ?err, id, @@ -42,7 +42,10 @@ impl DaemonState { // Panel dismiss removes the active entry, so its timer must go too self.cancel_expiration(id); } - if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { + if let Err(err) = self + .publish_notification_dismissed(id, outcome.removed_active) + .await + { warn!( ?err, id, "panel dismiss committed but one or more D-Bus signals failed" @@ -69,7 +72,10 @@ impl DaemonState { // Only the matching active generation owns this expiration timer self.cancel_expiration(id); } - if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { + if let Err(err) = self + .publish_notification_dismissed(id, outcome.removed_active) + .await + { warn!( ?err, id, "generation-safe dismiss committed but one or more D-Bus signals failed" diff --git a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs similarity index 55% rename from crates/unixnotis-daemon/src/daemon/state/scheduler.rs rename to crates/unixnotis-daemon/src/daemon/state/schedulers.rs index 0f39d7574..c2a8d883d 100644 --- a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs +++ b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs @@ -1,12 +1,43 @@ +//! Expiration and timed-DND scheduler ownership for shared daemon state + use std::sync::atomic::Ordering; +use tokio::sync::MutexGuard; use tracing::warn; +use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use super::DaemonState; impl DaemonState { + pub(in crate::daemon) async fn lock_dnd_write(&self) -> MutexGuard<'_, ()> { + // One writer keeps disk state and the scheduled deadline in the same order + self.dnd_write_lock.lock().await + } + + pub fn set_dnd_scheduler(&self, scheduler: DndExpirationScheduler) { + if self.dnd_scheduler.set(scheduler).is_err() { + warn!("DND scheduler was already installed; ignoring duplicate initialization"); + return; + } + self.dnd_scheduler_missing_warned + .store(false, Ordering::SeqCst); + } + + pub(crate) fn schedule_dnd_expiration(&self, expires_at: Option) { + let Some(scheduler) = self.dnd_scheduler.get() else { + if !self + .dnd_scheduler_missing_warned + .swap(true, Ordering::SeqCst) + { + warn!("DND scheduler is unavailable during live daemon operation"); + } + return; + }; + scheduler.schedule(expires_at); + } + pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { // Scheduler is wired once during daemon startup if self.scheduler.set(scheduler).is_err() { @@ -38,8 +69,7 @@ impl DaemonState { } pub fn cancel_expirations(&self, ids: &[u32]) { - // Cancel timers for every removed active id so stale wakeups do not build up - // Per-id cancel keeps the existing lazy heap design simple and predictable + // Per-id cancel keeps the lazy expiration heap bounded without rebuilding it here let Some(scheduler) = self.scheduler() else { return; }; diff --git a/crates/unixnotis-daemon/src/daemon/state/signals.rs b/crates/unixnotis-daemon/src/daemon/state/signals.rs deleted file mode 100644 index 183677289..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/signals.rs +++ /dev/null @@ -1,200 +0,0 @@ -use unixnotis_core::{CloseReason, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; -use zbus::SignalContext; - -use crate::daemon::{ControlServer, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; -use crate::store::NotificationStore; - -use super::cache::should_emit_cached; -use super::DaemonState; - -impl DaemonState { - // Sends all the "this notification closed" messages that different listeners expect - pub(in crate::daemon) async fn emit_close_fanout( - &self, - id: u32, - reason: CloseReason, - ) -> zbus::Result<()> { - // Keep the first thing that goes wrong, but still try to send every signal - let mut first_error = None; - - // Tell the standard notification interface that this notification closed - self.emit_freedesktop_close(id, reason as u32, &mut first_error) - .await; - - // Tell this daemon's control interface that the same notification closed - self.emit_control_close(id, reason, &mut first_error).await; - - // After a close, the stored state may look different, so tell clients about that too - if let Err(err) = self.emit_state_changed().await { - record_signal_error(&mut first_error, err); - } - - // Return success only if every attempted signal avoided errors - first_error.map_or(Ok(()), Err) - } - - // Sends the signals needed when a notification is dismissed by the user - pub(in crate::daemon) async fn emit_dismiss_fanout( - &self, - id: u32, - removed_active: bool, - ) -> zbus::Result<()> { - // Save the first error, while still giving the other signals a chance to run - let mut first_error = None; - - // Only the active notification needs the freedesktop close signal here - if removed_active { - self.emit_freedesktop_close(id, CloseReason::DismissedByUser as u32, &mut first_error) - .await; - } - - // The control side is always told that the notification was dismissed - self.emit_control_close(id, CloseReason::DismissedByUser, &mut first_error) - .await; - - // Let clients know the visible daemon state may have changed after dismissal - if let Err(err) = self.emit_state_changed().await { - record_signal_error(&mut first_error, err); - } - - // Give back the first error if any signal failed - first_error.map_or(Ok(()), Err) - } - - // Sends the close signal on the standard desktop notifications interface - async fn emit_freedesktop_close( - &self, - id: u32, - reason: u32, - first_error: &mut Option, - ) { - // Build the D-Bus signal context for the normal notifications object path - match SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH) { - Ok(notif_ctx) => { - // Send the actual "notification closed" signal to desktop clients - if let Err(err) = - NotificationServer::notification_closed(¬if_ctx, id, reason).await - { - // Remember this error only if no earlier signal already failed - record_signal_error(first_error, err); - } - } - // If the signal context cannot be made, remember that as the signal error - Err(err) => record_signal_error(first_error, err), - } - } - - // Sends the close signal on this daemon's control interface - async fn emit_control_close( - &self, - id: u32, - reason: CloseReason, - first_error: &mut Option, - ) { - // Build the D-Bus signal context for the control object path - match SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) { - Ok(control_ctx) => { - // Send the control-layer close event with the richer CloseReason enum - if let Err(err) = ControlServer::notification_closed(&control_ctx, id, reason).await - { - // Store the first failure so callers can still hear about a problem - record_signal_error(first_error, err); - } - } - // If the control signal context fails, treat it like any other signal failure - Err(err) => record_signal_error(first_error, err), - } - } - - // Rebuilds the current public state and tells clients only if something changed - pub(in crate::daemon) async fn emit_state_changed(&self) -> zbus::Result<()> { - // Lock the store briefly so we can take a clean snapshot of the current state - let state = { - let store = self.store.lock().await; - control_state_from_store(&store) - }; - - // Work out whether popups should currently be allowed from that state - let popup_gate = popup_gate_from_state(&state); - - // Duplicate broadcasts add D-Bus churn without changing UI behavior - let should_emit_state = should_emit_cached(&self.last_emitted_state, &state); - - // Avoid sending the popup gate signal if clients already know this value - let should_emit_popup_gate = should_emit_cached(&self.last_emitted_popup_gate, &popup_gate); - - // If neither value changed, there is nothing useful to send - if !should_emit_any_state_signal(should_emit_state, should_emit_popup_gate) { - return Ok(()); - } - - // Create one control context and reuse it for whichever state signals are needed - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - - // Keep the first send error while still trying the other state signal - let mut first_error = None; - - // Send the full state update only when the cached state says it is new - if should_emit_state { - if let Err(err) = ControlServer::state_changed(&control_ctx, state).await { - record_signal_error(&mut first_error, err); - } - } - - // Send the popup gate update only when that specific value changed - if should_emit_popup_gate { - if let Err(err) = ControlServer::popup_gate_changed(&control_ctx, popup_gate).await { - record_signal_error(&mut first_error, err); - } - } - - // Report the first signal error, or success if both needed signals worked - first_error.map_or(Ok(()), Err) - } - - // Tells clients to throw away their cached snapshot and fetch a fresh one - pub async fn emit_snapshot_invalidated(&self) -> zbus::Result<()> { - // This signal tells clients their local materialized view may be stale - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - ControlServer::snapshot_invalidated(&control_ctx).await - } -} - -pub(in crate::daemon::state) fn control_state_from_store( - store: &NotificationStore, -) -> ControlState { - // Panel consumers still need history and inhibitor counters in one snapshot - ControlState { - dnd_enabled: store.dnd_enabled(), - dnd_expires_at: store.dnd_expires_at().unwrap_or(0), - history_count: store.history_len() as u32, - inhibited: store.inhibited(), - inhibitor_count: store.inhibitor_count(), - } -} - -pub(in crate::daemon::state) const fn popup_gate_from_state( - state: &ControlState, -) -> PopupGateState { - // Popup policy only depends on the gate, so history churn should not wake it up - PopupGateState { - dnd_enabled: state.dnd_enabled, - inhibited: state.inhibited, - } -} - -pub(in crate::daemon::state) const fn should_emit_any_state_signal( - should_emit_state: bool, - should_emit_popup_gate: bool, -) -> bool { - should_emit_state || should_emit_popup_gate -} - -pub(in crate::daemon::state) fn record_signal_error( - first_error: &mut Option, - err: zbus::Error, -) { - if first_error.is_none() { - *first_error = Some(err); - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs similarity index 90% rename from crates/unixnotis-daemon/src/daemon/state/runtime.rs rename to crates/unixnotis-daemon/src/daemon/state/status.rs index a67e7ccce..86122d46c 100644 --- a/crates/unixnotis-daemon/src/daemon/state/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -1,6 +1,6 @@ use std::sync::atomic::Ordering; -use crate::daemon::signal_burst::{notification_signal_mode_for_sender, NotificationSignalMode}; +use crate::daemon::notifications::{notification_signal_mode_for_sender, NotificationSignalMode}; use super::DaemonState; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs index a526c7c32..f1b8567db 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -1,5 +1,3 @@ -mod cache; -mod notifications; -mod runtime; +mod notification_lifecycle; mod scheduler; -mod signals; +mod status; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs rename to crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs rename to crates/unixnotis-daemon/src/daemon/state/tests/status.rs diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index 4c2f40d9d..2e871cad6 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -27,7 +27,6 @@ use tracing::info; mod child_process; mod cli; mod daemon; -mod dbus_owner; mod dnd_expiration; mod expire; mod runtime; diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 1ec72d987..cc1454636 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -12,10 +12,10 @@ use super::shutdown::shutdown_signal; use crate::child_process::{spawn_center_supervisor, spawn_popups_supervisor}; use crate::cli::Args; use crate::daemon::{ - log_name_reply, request_control_name, request_well_known_name, spawn_inhibitor_owner_watch, - ControlServer, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, + log_current_owner, log_name_reply, request_control_name, request_well_known_name, + spawn_client_owner_watch, ControlServer, DaemonState, NotificationServer, + NOTIFICATIONS_OBJECT_PATH, }; -use crate::dbus_owner::log_current_owner; use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; @@ -96,8 +96,8 @@ pub(super) async fn run_daemon( return Ok(()); } - if let Err(err) = spawn_inhibitor_owner_watch(state.clone()).await { - warn!(?err, "failed to start inhibitor owner watcher"); + if let Err(err) = spawn_client_owner_watch(state.clone()).await { + warn!(?err, "failed to start client owner watcher"); } // Both UI processes share one shutdown flag and reap their current child before exit diff --git a/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs b/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs index 78af24863..5332f1872 100644 --- a/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs +++ b/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs @@ -7,7 +7,7 @@ use zbus::fdo::DBusProxy; use zbus::Connection; use crate::cli::Args; -use crate::dbus_owner::wait_for_owner_state; +use crate::daemon::wait_for_owner_state; use crate::trial_mode::{self, restore_previous, TrialState}; use unixnotis_core::NOTIFICATIONS_BUS_NAME; diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs index 760d39ba8..2c9444c85 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use indexmap::IndexMap; use tracing::{debug, warn}; -use unixnotis_core::{Config, Notification, NotificationView}; +use unixnotis_core::{Config, ControlState, Notification, NotificationView}; use super::{DndStateStore, HistoryStore, NotificationStore, DND_STATE_VERSION}; @@ -86,6 +86,17 @@ impl NotificationStore { self.inhibitor_count } + pub fn control_state(&self) -> ControlState { + // One canonical snapshot prevents query and event paths from drifting apart + ControlState { + dnd_enabled: self.dnd_enabled(), + dnd_expires_at: self.dnd_expires_at().unwrap_or(0), + history_count: self.history_len() as u32, + inhibited: self.inhibited(), + inhibitor_count: self.inhibitor_count(), + } + } + pub fn list_active(&self) -> Vec { // Reverse iteration returns newest entries first for panel rendering self.active diff --git a/crates/unixnotis-daemon/src/trial_mode/state.rs b/crates/unixnotis-daemon/src/trial_mode/state.rs index baa46f3a8..4c8dd6db6 100644 --- a/crates/unixnotis-daemon/src/trial_mode/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/state.rs @@ -9,7 +9,7 @@ use tracing::debug; use zbus::fdo::DBusProxy; use crate::cli::Args; -use crate::dbus_owner::wait_for_owner_state; +use crate::daemon::wait_for_owner_state; use super::{control, owner, prompt}; From 6d608dca456377b1c884a9d3b9bb0d7e1d3080b3 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:22:34 -0500 Subject: [PATCH 069/275] refactor(filesystem): separate descriptor and tree operations Summary: separate descriptor and tree operations. Scope: filesystem. --- .../unixnotis-core/src/filesystem/atomic.rs | 2 +- .../src/filesystem/descriptor.rs | 199 ++++++++++ .../src/filesystem/directory.rs | 342 +----------------- crates/unixnotis-core/src/filesystem/exact.rs | 2 +- crates/unixnotis-core/src/filesystem/mod.rs | 14 +- crates/unixnotis-core/src/filesystem/read.rs | 45 --- .../unixnotis-core/src/filesystem/regular.rs | 36 +- .../unixnotis-core/src/filesystem/remove.rs | 2 +- .../unixnotis-core/src/filesystem/rename.rs | 2 +- .../unixnotis-core/src/filesystem/symlink.rs | 2 +- .../src/filesystem/tests/atomic.rs | 24 +- .../src/filesystem/tests/descriptor.rs | 23 ++ .../src/filesystem/tests/directory.rs | 150 +------- .../src/filesystem/tests/read.rs | 106 ------ .../src/filesystem/tests/regular.rs | 104 +++++- .../src/filesystem/tests/tree.rs | 169 +++++++++ crates/unixnotis-core/src/filesystem/tree.rs | 149 ++++++++ 17 files changed, 709 insertions(+), 662 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/descriptor.rs delete mode 100644 crates/unixnotis-core/src/filesystem/read.rs create mode 100644 crates/unixnotis-core/src/filesystem/tests/descriptor.rs delete mode 100644 crates/unixnotis-core/src/filesystem/tests/read.rs create mode 100644 crates/unixnotis-core/src/filesystem/tests/tree.rs create mode 100644 crates/unixnotis-core/src/filesystem/tree.rs diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 254a522b8..00b94d9cd 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -10,7 +10,7 @@ use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use super::directory::{contained_resolve_flags, open_parent, sync_directory}; +use super::descriptor::{contained_resolve_flags, open_parent, sync_directory}; use super::exact::exclusive_create_collided; use super::regular::{existing_target_mode, validate_existing_target}; diff --git a/crates/unixnotis-core/src/filesystem/descriptor.rs b/crates/unixnotis-core/src/filesystem/descriptor.rs new file mode 100644 index 000000000..9cec8f337 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/descriptor.rs @@ -0,0 +1,199 @@ +//! Stable directory anchors and descriptor-relative path traversal + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::path::{Component, Path}; + +use rustix::fs::{fchmod, fsync, mkdirat, openat2, Mode, OFlags, ResolveFlags, CWD}; + +/// Outcome for the final component of recursive directory creation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateDirectoryOutcome { + /// The requested directory itself was created by this operation + TargetCreated, + /// The requested directory already existed when its retained descriptor was opened + TargetAlreadyExisted, +} + +pub(super) fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Create(0o755)) +} + +pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Reject) +} + +pub(super) fn open_directory_for_creation( + path: &Path, + mode: u32, +) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { + open_directory_path(path, MissingDirectory::Create(mode)) +} + +pub(super) fn open_target_directory( + path: &Path, +) -> io::Result> { + // Removal never creates missing parents as a side effect + let (parent_fd, file_name) = match open_parent_existing(path) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + match open_directory_at(&parent_fd, &file_name) { + Ok(directory_fd) => Ok(Some((parent_fd, file_name, directory_fd))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub(super) fn sync_directory(directory_fd: &OwnedFd) -> io::Result<()> { + Ok(fsync(directory_fd)?) +} + +pub(super) const fn contained_resolve_flags() -> ResolveFlags { + ResolveFlags::BENEATH + .union(ResolveFlags::NO_SYMLINKS) + .union(ResolveFlags::NO_MAGICLINKS) +} + +pub(super) const fn anchor_resolve_flags() -> ResolveFlags { + ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) +} + +#[derive(Clone, Copy)] +enum MissingDirectory { + Create(u32), + Reject, +} + +fn open_parent_with( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, OsString)> { + // Keeping the final name separate makes every later operation descriptor-relative + let file_name = path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? + .to_os_string(); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let (parent_fd, _created) = open_directory_path(parent, missing_directory)?; + Ok((parent_fd, file_name)) +} + +fn open_directory_path( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { + // Absolute and relative paths begin from different trusted anchors + let mut directory_fd = open_anchor(path)?; + let mut target_outcome = CreateDirectoryOutcome::TargetAlreadyExisted; + + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem path cannot contain parent traversal", + )); + } + Component::Normal(name) => { + let (next_fd, component_created) = + open_directory_component(&directory_fd, name, missing_directory)?; + directory_fd = next_fd; + target_outcome = if component_created { + CreateDirectoryOutcome::TargetCreated + } else { + CreateDirectoryOutcome::TargetAlreadyExisted + }; + } + } + } + + Ok((directory_fd, target_outcome)) +} + +fn open_anchor(path: &Path) -> io::Result { + openat2( + CWD, + if path.is_absolute() { "/" } else { "." }, + OFlags::DIRECTORY.union(OFlags::CLOEXEC), + Mode::empty(), + anchor_resolve_flags(), + ) + .map_err(Into::into) +} + +fn open_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, bool)> { + match open_directory_at(parent_fd, name) { + Ok(fd) => Ok((fd, false)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && matches!(missing_directory, MissingDirectory::Create(_)) => + { + let MissingDirectory::Create(mode) = missing_directory else { + unreachable!("guard requires directory creation mode"); + }; + create_directory_component(parent_fd, name, mode) + } + Err(error) => Err(error), + } +} + +fn create_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + mode: u32, +) -> io::Result<(OwnedFd, bool)> { + let create_result = mkdirat(parent_fd, name, file_mode(mode)).map_err(Into::into); + let created = classify_directory_creation(create_result)?; + let directory_fd = open_directory_at(parent_fd, name)?; + if created { + // Exact permissions are restored because mkdir remains subject to the process umask + fchmod(&directory_fd, file_mode(mode))?; + fsync(&directory_fd)?; + fsync(parent_fd)?; + } + Ok((directory_fd, created)) +} + +pub(super) fn classify_directory_creation(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(true), + Err(error) => match error.kind() { + // A concurrent creator still passes the same no-follow open before use + io::ErrorKind::AlreadyExists => Ok(false), + _ => Err(error), + }, + } +} + +pub(super) fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { + openat2( + parent_fd, + name, + OFlags::DIRECTORY + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + ) + .map_err(Into::into) +} + +const fn file_mode(mode: u32) -> Mode { + Mode::from_raw_mode(mode & 0o777) +} + +#[cfg(test)] +#[path = "tests/descriptor.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs index 21b82d130..a66e08639 100644 --- a/crates/unixnotis-core/src/filesystem/directory.rs +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -1,28 +1,17 @@ -//! Directory traversal, creation, and removal through stable descriptors +//! Directory creation, ownership markers, and empty removal -use std::ffi::{OsStr, OsString}; +use std::ffi::OsStr; use std::io; -use std::os::fd::OwnedFd; -use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path}; -use rustix::fs::{ - fchmod, fstat, fsync, mkdirat, openat2, statat, unlinkat, AtFlags, Dir, FileType, Mode, OFlags, - ResolveFlags, CWD, -}; +use rustix::fs::{unlinkat, AtFlags}; +use super::descriptor::{ + open_directory_for_creation, open_target_directory, sync_directory, CreateDirectoryOutcome, +}; use super::exact::{ensure_exact_file_at, EnsureExactFileOutcome}; use super::regular::{file_contents_equal, open_regular_file_at}; -/// Outcome for the final component of recursive directory creation -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CreateDirectoryOutcome { - /// The requested directory itself was created by this operation - TargetCreated, - /// The requested directory already existed when its retained descriptor was opened - TargetAlreadyExisted, -} - /// Create a directory and every missing parent without following links /// /// Reports whether the final directory was created without conflating parent creation @@ -32,7 +21,7 @@ pub enum CreateDirectoryOutcome { /// Returns an error when the path traverses upward or through a link, an existing component is not /// a directory, or creation, permission repair, or synchronization fails pub fn create_directory_all(path: &Path, mode: u32) -> io::Result { - let (_directory_fd, outcome) = open_directory_path(path, MissingDirectory::Create(mode))?; + let (_directory_fd, outcome) = open_directory_for_creation(path, mode)?; Ok(outcome) } @@ -53,8 +42,7 @@ pub fn ensure_marked_directory( marker_mode: u32, ) -> io::Result { validate_child_name(marker_name)?; - let (directory_fd, outcome) = - open_directory_path(path, MissingDirectory::Create(directory_mode))?; + let (directory_fd, outcome) = open_directory_for_creation(path, directory_mode)?; let marker_name = marker_name.to_os_string(); match outcome { @@ -93,313 +81,7 @@ pub fn remove_empty_directory(path: &Path) -> io::Result { Ok(true) } -/// Recursively remove a directory containing only regular files and directories -/// -/// Symbolic links and special files are rejected and left in place -/// -/// # Errors -/// -/// Returns an error when a path component or child has an unsafe shape, an entry changes during -/// traversal, or removal and synchronization cannot complete -pub fn remove_directory_tree(path: &Path) -> io::Result { - let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { - return Ok(false); - }; - remove_directory_contents(&directory_fd)?; - drop(directory_fd); - unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; - sync_directory(&parent_fd)?; - Ok(true) -} - -/// Remove a marked regular-only directory tree through one retained root descriptor -/// -/// The entire tree is checked before any entry is deleted. The ownership marker is read relative -/// to that same descriptor, and the visible root name must still identify it before final removal -/// -/// # Errors -/// -/// Returns an error when the path or marker is unsafe, marker bytes differ, the tree contains a -/// link or special file, an entry changes shape, or durable removal fails -pub fn remove_marked_directory_tree( - path: &Path, - marker_name: &OsStr, - marker_contents: &[u8], -) -> io::Result { - validate_child_name(marker_name)?; - let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { - return Ok(false); - }; - let marker_name = marker_name.to_os_string(); - let mut marker = open_regular_file_at(&directory_fd, &marker_name) - .map_err(|_error| invalid_marker_error())?; - if !file_contents_equal(&mut marker, marker_contents)? { - return Err(invalid_marker_error()); - } - - // Preflight is intentionally read-only so one rejected child cannot cause partial deletion - preflight_directory_contents(&directory_fd)?; - remove_directory_contents(&directory_fd)?; - revalidate_directory_identity(&parent_fd, &file_name, &directory_fd)?; - drop(directory_fd); - unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; - sync_directory(&parent_fd)?; - Ok(true) -} - -pub(super) fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { - open_parent_with(path, MissingDirectory::Create(0o755)) -} - -pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { - open_parent_with(path, MissingDirectory::Reject) -} - -pub(super) fn sync_directory(directory_fd: &OwnedFd) -> io::Result<()> { - Ok(fsync(directory_fd)?) -} - -pub(super) const fn contained_resolve_flags() -> ResolveFlags { - ResolveFlags::BENEATH - .union(ResolveFlags::NO_SYMLINKS) - .union(ResolveFlags::NO_MAGICLINKS) -} - -pub(super) const fn anchor_resolve_flags() -> ResolveFlags { - ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) -} - -#[derive(Clone, Copy)] -enum MissingDirectory { - Create(u32), - Reject, -} - -fn open_parent_with( - path: &Path, - missing_directory: MissingDirectory, -) -> io::Result<(OwnedFd, OsString)> { - // Keeping the final name separate makes every later operation descriptor-relative - let file_name = path - .file_name() - .filter(|name| !name.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? - .to_os_string(); - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let (parent_fd, _created) = open_directory_path(parent, missing_directory)?; - Ok((parent_fd, file_name)) -} - -fn open_directory_path( - path: &Path, - missing_directory: MissingDirectory, -) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { - // Absolute and relative paths begin from different trusted anchors - let mut directory_fd = open_anchor(path)?; - let mut target_outcome = CreateDirectoryOutcome::TargetAlreadyExisted; - - for component in path.components() { - match component { - // Anchors already account for root and current-directory components - Component::Prefix(_) | Component::RootDir | Component::CurDir => {} - Component::ParentDir => { - // Upward traversal would break the beneath policy of the current descriptor - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "filesystem path cannot contain parent traversal", - )); - } - Component::Normal(name) => { - let (next_fd, component_created) = - open_directory_component(&directory_fd, name, missing_directory)?; - directory_fd = next_fd; - target_outcome = if component_created { - CreateDirectoryOutcome::TargetCreated - } else { - CreateDirectoryOutcome::TargetAlreadyExisted - }; - } - } - } - - Ok((directory_fd, target_outcome)) -} - -fn open_anchor(path: &Path) -> io::Result { - openat2( - CWD, - if path.is_absolute() { "/" } else { "." }, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - anchor_resolve_flags(), - ) - .map_err(Into::into) -} - -fn open_directory_component( - parent_fd: &OwnedFd, - name: &OsStr, - missing_directory: MissingDirectory, -) -> io::Result<(OwnedFd, bool)> { - match open_directory_at(parent_fd, name) { - Ok(fd) => Ok((fd, false)), - Err(error) - if error.kind() == io::ErrorKind::NotFound - && matches!(missing_directory, MissingDirectory::Create(_)) => - { - // Creation is attempted only after a no-follow open proves the component absent - let MissingDirectory::Create(mode) = missing_directory else { - unreachable!("guard requires directory creation mode"); - }; - create_directory_component(parent_fd, name, mode) - } - Err(error) => Err(error), - } -} - -fn create_directory_component( - parent_fd: &OwnedFd, - name: &OsStr, - mode: u32, -) -> io::Result<(OwnedFd, bool)> { - let create_result = mkdirat(parent_fd, name, file_mode(mode)).map_err(Into::into); - let created = classify_directory_creation(create_result)?; - let directory_fd = open_directory_at(parent_fd, name)?; - if created { - // Apply the exact requested mode because mkdir remains subject to the process umask - fchmod(&directory_fd, file_mode(mode))?; - fsync(&directory_fd)?; - fsync(parent_fd)?; - } - Ok((directory_fd, created)) -} - -fn classify_directory_creation(result: io::Result<()>) -> io::Result { - match result { - Ok(()) => Ok(true), - // A concurrent creator still has to pass the same no-follow directory open below - Err(error) => match error.kind() { - io::ErrorKind::AlreadyExists => Ok(false), - _ => Err(error), - }, - } -} - -fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { - // NOFOLLOW covers the final component while resolve flags cover every nested lookup - openat2( - parent_fd, - name, - OFlags::DIRECTORY - .union(OFlags::CLOEXEC) - .union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - ) - .map_err(Into::into) -} - -fn open_target_directory(path: &Path) -> io::Result> { - // Removal never creates missing parents as a side effect - let (parent_fd, file_name) = match open_parent_existing(path) { - Ok(parent) => parent, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error), - }; - match open_directory_at(&parent_fd, &file_name) { - Ok(directory_fd) => Ok(Some((parent_fd, file_name, directory_fd))), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error), - } -} - -fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { - // Dir reads from the retained descriptor even if the visible pathname changes later - let mut entries = Dir::read_from(directory_fd)?; - while let Some(entry) = entries.read() { - let entry = entry?; - let name = entry.file_name(); - if matches!(name.to_bytes(), b"." | b"..") { - continue; - } - let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; - let file_type = FileType::from_raw_mode(stat.st_mode); - if file_type.is_file() { - // Regular children can be unlinked without opening their contents - unlinkat(directory_fd, name, AtFlags::empty())?; - fsync(directory_fd)?; - } else if file_type.is_dir() { - // Child recursion receives another no-follow descriptor before deleting anything - let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; - remove_directory_contents(&child_fd)?; - drop(child_fd); - unlinkat(directory_fd, name, AtFlags::REMOVEDIR)?; - fsync(directory_fd)?; - } else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "refusing unsafe entry inside directory tree: {}", - name.to_string_lossy() - ), - )); - } - } - Ok(()) -} - -fn preflight_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { - let mut entries = Dir::read_from(directory_fd)?; - while let Some(entry) = entries.read() { - let entry = entry?; - let name = entry.file_name(); - if matches!(name.to_bytes(), b"." | b"..") { - continue; - } - let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; - let file_type = FileType::from_raw_mode(stat.st_mode); - if file_type.is_file() { - continue; - } - if file_type.is_dir() { - let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; - preflight_directory_contents(&child_fd)?; - continue; - } - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "refusing unsafe entry inside directory tree: {}", - name.to_string_lossy() - ), - )); - } - Ok(()) -} - -fn revalidate_directory_identity( - parent_fd: &OwnedFd, - file_name: &OsStr, - directory_fd: &OwnedFd, -) -> io::Result<()> { - let retained = fstat(directory_fd)?; - let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; - if retained.st_dev == visible.st_dev - && retained.st_ino == visible.st_ino - && FileType::from_raw_mode(visible.st_mode).is_dir() - { - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "directory changed while guarded removal was in progress", - )) -} - -fn validate_child_name(name: &OsStr) -> io::Result<()> { +pub(super) fn validate_child_name(name: &OsStr) -> io::Result<()> { let mut components = Path::new(name).components(); if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() { return Ok(()); @@ -410,17 +92,13 @@ fn validate_child_name(name: &OsStr) -> io::Result<()> { )) } -fn invalid_marker_error() -> io::Error { +pub(super) fn invalid_marker_error() -> io::Error { io::Error::new( io::ErrorKind::PermissionDenied, "directory ownership marker is missing or does not match", ) } -const fn file_mode(mode: u32) -> Mode { - Mode::from_raw_mode(mode & 0o777) -} - #[cfg(test)] #[path = "tests/directory.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/exact.rs b/crates/unixnotis-core/src/filesystem/exact.rs index 30d6146ef..147572aa1 100644 --- a/crates/unixnotis-core/src/filesystem/exact.rs +++ b/crates/unixnotis-core/src/filesystem/exact.rs @@ -9,7 +9,7 @@ use std::path::Path; use rustix::fs::{fstat, openat2, statat, unlinkat, AtFlags, Mode, OFlags}; -use super::directory::{contained_resolve_flags, open_parent, sync_directory}; +use super::descriptor::{contained_resolve_flags, open_parent, sync_directory}; use super::regular::{file_contents_equal, open_regular_file_at}; /// Result of creating or validating a file whose bytes must match exactly diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index c09b4e155..58444e632 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -1,29 +1,28 @@ //! Shared filesystem operations with stable directory anchors mod atomic; +mod descriptor; mod directory; mod exact; mod install; mod path; -mod read; mod regular; mod remove; mod rename; mod symlink; +mod tree; pub use atomic::{write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing}; -pub use directory::{ - create_directory_all, ensure_marked_directory, remove_directory_tree, remove_empty_directory, - remove_marked_directory_tree, CreateDirectoryOutcome, -}; +pub use descriptor::CreateDirectoryOutcome; +pub use directory::{create_directory_all, ensure_marked_directory, remove_empty_directory}; pub use exact::{ ensure_exact_file, ensure_exact_file_pair, EnsureExactFileOutcome, EnsureExactFilePairOutcome, }; pub use install::copy_file_atomic; pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; -pub use read::read_regular_file_bounded; pub use regular::{ - make_file_executable, open_regular_file, regular_file_contents_equal, set_file_mode, + make_file_executable, open_regular_file, read_regular_file_bounded, + regular_file_contents_equal, set_file_mode, }; pub use remove::{ remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, @@ -33,3 +32,4 @@ pub use rename::{rename_regular_file_no_replace, RenameRegularFileOutcome}; pub use symlink::{ create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, }; +pub use tree::{remove_directory_tree, remove_marked_directory_tree}; diff --git a/crates/unixnotis-core/src/filesystem/read.rs b/crates/unixnotis-core/src/filesystem/read.rs deleted file mode 100644 index 73d354960..000000000 --- a/crates/unixnotis-core/src/filesystem/read.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Bounded regular-file reads through stable descriptors - -use std::io::{self, Read}; -use std::path::Path; - -use super::regular::open_regular_file; - -/// Read a regular file without following links and enforce a byte limit -/// -/// # Errors -/// -/// Returns an error when the path crosses a link, the target is not a regular file, the file is -/// larger than `max_bytes`, or the bounded read cannot complete -pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> io::Result> { - // Opening once keeps the size check and payload read tied to one filesystem object - let mut file = open_regular_file(path)?; - let initial_size = file.metadata()?.len(); - if initial_size > max_bytes { - return Err(limit_error(max_bytes)); - } - - // Reserve only the size already observed and keep the extra-byte growth check bounded - let capacity = usize::try_from(initial_size).map_err(|_size_error| { - io::Error::new(io::ErrorKind::InvalidData, "file size does not fit memory") - })?; - let mut contents = Vec::with_capacity(capacity); - file.by_ref() - .take(max_bytes.saturating_add(1)) - .read_to_end(&mut contents)?; - if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { - return Err(limit_error(max_bytes)); - } - Ok(contents) -} - -fn limit_error(max_bytes: u64) -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - format!("regular file exceeds the {max_bytes}-byte limit"), - ) -} - -#[cfg(test)] -#[path = "tests/read.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/filesystem/regular.rs b/crates/unixnotis-core/src/filesystem/regular.rs index 1de19ba83..be00d2358 100644 --- a/crates/unixnotis-core/src/filesystem/regular.rs +++ b/crates/unixnotis-core/src/filesystem/regular.rs @@ -9,7 +9,7 @@ use std::path::Path; use rustix::fs::{openat2, Mode, OFlags}; -use super::directory::{contained_resolve_flags, open_parent_existing}; +use super::descriptor::{contained_resolve_flags, open_parent_existing}; /// Open one regular file through a no-follow descriptor path /// @@ -56,6 +56,33 @@ pub fn regular_file_contents_equal( file_contents_equal(&mut file, expected) } +/// Read a regular file without following links and enforce a byte limit +/// +/// # Errors +/// +/// Returns an error when the path crosses a link, the target is not a regular file, the file is +/// larger than `max_bytes`, or the bounded read cannot complete +pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> io::Result> { + // Opening once keeps the size check and payload read tied to one filesystem object + let mut file = open_regular_file(path)?; + let initial_size = file.metadata()?.len(); + if initial_size > max_bytes { + return Err(limit_error(max_bytes)); + } + + let capacity = usize::try_from(initial_size).map_err(|_size_error| { + io::Error::new(io::ErrorKind::InvalidData, "file size does not fit memory") + })?; + let mut contents = Vec::with_capacity(capacity); + file.by_ref() + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents)?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(limit_error(max_bytes)); + } + Ok(contents) +} + /// Add executable bits to an existing regular file without following links /// /// # Errors @@ -160,6 +187,13 @@ pub(super) fn unsafe_target_error() -> io::Error { ) } +fn limit_error(max_bytes: u64) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("regular file exceeds the {max_bytes}-byte limit"), + ) +} + #[cfg(test)] #[path = "tests/regular.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index 07132f4b6..4ea470ae1 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use rustix::fs::{fstat, statat, unlinkat, AtFlags}; -use super::directory::{open_parent_existing, sync_directory}; +use super::descriptor::{open_parent_existing, sync_directory}; use super::regular::{file_contents_equal, open_regular_file_at, validate_existing_target}; use super::symlink::read_symlink_at; diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs index db4f23a12..9872af619 100644 --- a/crates/unixnotis-core/src/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -5,7 +5,7 @@ use std::path::Path; use rustix::fs::{renameat_with, RenameFlags}; -use super::directory::{open_parent_existing, sync_directory}; +use super::descriptor::{open_parent_existing, sync_directory}; use super::regular::validate_existing_target; /// Result of moving a regular file without replacing another filesystem entry diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs index 2b83adfa0..f09187dba 100644 --- a/crates/unixnotis-core/src/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use rustix::fs::{readlinkat, renameat, symlinkat, unlinkat, AtFlags}; use super::atomic::temp_candidates; -use super::directory::{open_parent, open_parent_existing, sync_directory}; +use super::descriptor::{open_parent, open_parent_existing, sync_directory}; /// Result of creating a symbolic link without replacing an existing path #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/unixnotis-core/src/filesystem/tests/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs index 2943f8194..09fa3da4d 100644 --- a/crates/unixnotis-core/src/filesystem/tests/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -11,11 +11,9 @@ use std::os::fd::OwnedFd; use std::os::unix::fs::{symlink, PermissionsExt}; use std::os::unix::net::UnixStream; -use rustix::fs::{mkfifoat, Mode, ResolveFlags, CWD}; +use rustix::fs::{mkfifoat, Mode, CWD}; -use crate::filesystem::directory::{ - anchor_resolve_flags, contained_resolve_flags, open_parent, sync_directory, -}; +use crate::filesystem::descriptor::{open_parent, sync_directory}; use crate::test_support::unique_temp_path; #[test] @@ -188,24 +186,6 @@ fn file_mode_masks_special_and_non_permission_bits() { assert_eq!(file_mode(0o17640), Mode::from_raw_mode(0o640)); } -#[test] -fn contained_resolution_policy_keeps_every_escape_barrier() { - let flags = contained_resolve_flags(); - - assert!(flags.contains(ResolveFlags::BENEATH)); - assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); - assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); -} - -#[test] -fn anchor_resolution_policy_rejects_link_detours() { - let flags = anchor_resolve_flags(); - - assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); - assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); - assert!(!flags.contains(ResolveFlags::BENEATH)); -} - #[test] fn atomic_write_replaces_regular_file_and_applies_requested_mode() { let root = unique_temp_path("atomic-replace"); diff --git a/crates/unixnotis-core/src/filesystem/tests/descriptor.rs b/crates/unixnotis-core/src/filesystem/tests/descriptor.rs new file mode 100644 index 000000000..ba40d36f8 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/descriptor.rs @@ -0,0 +1,23 @@ +//! Descriptor traversal policy tests + +use rustix::fs::ResolveFlags; + +use super::{anchor_resolve_flags, contained_resolve_flags}; + +#[test] +fn contained_resolution_policy_keeps_every_escape_barrier() { + let flags = contained_resolve_flags(); + + assert!(flags.contains(ResolveFlags::BENEATH)); + assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); + assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); +} + +#[test] +fn anchor_resolution_policy_rejects_link_detours() { + let flags = anchor_resolve_flags(); + + assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); + assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); + assert!(!flags.contains(ResolveFlags::BENEATH)); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/directory.rs b/crates/unixnotis-core/src/filesystem/tests/directory.rs index a7f63fab1..cdd42253a 100644 --- a/crates/unixnotis-core/src/filesystem/tests/directory.rs +++ b/crates/unixnotis-core/src/filesystem/tests/directory.rs @@ -1,16 +1,12 @@ -//! Descriptor-relative directory operation tests +//! Directory creation, marker, and empty-removal tests use std::fs; use std::os::unix::fs::{symlink, PermissionsExt}; -use rustix::fs::{mkfifoat, Mode, CWD}; - use super::{ - classify_directory_creation, create_directory_all, ensure_marked_directory, - open_target_directory, preflight_directory_contents, remove_directory_tree, - remove_empty_directory, remove_marked_directory_tree, revalidate_directory_identity, - validate_child_name, CreateDirectoryOutcome, + create_directory_all, ensure_marked_directory, remove_empty_directory, validate_child_name, }; +use crate::filesystem::descriptor::{classify_directory_creation, CreateDirectoryOutcome}; use crate::test_support::unique_temp_path; #[test] @@ -149,158 +145,26 @@ fn empty_directory_removal_rejects_nonempty_and_link_targets() { } #[test] -fn recursive_directory_removal_deletes_regular_nested_tree() { - let root = unique_temp_path("remove-directory-tree"); - let target = root.join("managed"); - fs::create_dir_all(target.join("nested")).expect("create nested directory"); - fs::write(target.join("root-file"), "root").expect("write root file"); - fs::write(target.join("nested").join("child-file"), "child").expect("write child file"); - - assert!(remove_directory_tree(&target).expect("remove managed tree")); - assert!(!remove_directory_tree(&target).expect("missing tree stays removed")); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn recursive_directory_removal_rejects_a_child_symlink() { - let root = unique_temp_path("remove-directory-child-link"); - let target = root.join("managed"); - let protected = root.join("protected"); - fs::create_dir_all(&target).expect("create managed directory"); - fs::write(&protected, "protected").expect("write protected file"); - symlink(&protected, target.join("linked-child")).expect("create child link"); - - remove_directory_tree(&target).expect_err("child link should fail"); - - assert_eq!( - fs::read_to_string(protected).expect("read protected file"), - "protected" - ); - assert!(target.exists()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn recursive_directory_removal_rejects_a_special_child() { - let root = unique_temp_path("remove-directory-special-child"); - let target = root.join("managed"); - let fifo = target.join("fifo"); - fs::create_dir_all(&target).expect("create managed directory"); - mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create fifo child"); - - remove_directory_tree(&target).expect_err("special child should fail"); - - assert!(fs::symlink_metadata(fifo).is_ok()); - assert!(target.exists()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn marked_tree_preflight_preserves_regular_siblings_when_a_child_is_unsafe() { - let root = unique_temp_path("marked-tree-preflight"); - let target = root.join("managed"); - fs::create_dir_all(&target).expect("create managed directory"); - fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); - fs::write(target.join("regular"), "keep until full preflight").expect("write regular child"); - symlink("regular", target.join("unsafe-link")).expect("create unsafe link"); - - remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") - .expect_err("unsafe child should reject the whole tree"); - - assert_eq!( - fs::read_to_string(target.join("regular")).expect("regular sibling remains"), - "keep until full preflight" - ); - assert!(target.join(".owner").exists()); - assert!(fs::symlink_metadata(target.join("unsafe-link")) - .expect("unsafe link remains") - .file_type() - .is_symlink()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn marked_tree_preflight_directly_rejects_unsafe_descendants() { - let root = unique_temp_path("marked-tree-direct-preflight"); - let target = root.join("managed"); - fs::create_dir_all(target.join("nested")).expect("create nested directory"); - fs::write(target.join("nested").join("regular"), "keep").expect("write regular child"); - symlink("regular", target.join("nested").join("unsafe-link")).expect("create unsafe link"); - let (_parent_fd, _name, directory_fd) = open_target_directory(&target) - .expect("open target") - .expect("target exists"); - - preflight_directory_contents(&directory_fd).expect_err("unsafe descendant must fail preflight"); - - assert_eq!( - fs::read_to_string(target.join("nested").join("regular")).expect("regular child remains"), - "keep" - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn directory_identity_revalidation_rejects_a_same_device_replacement() { - let root = unique_temp_path("directory-identity-replacement"); - let target = root.join("managed"); - let moved = root.join("original"); - fs::create_dir_all(&target).expect("create original directory"); - let (parent_fd, file_name, directory_fd) = open_target_directory(&target) - .expect("open target") - .expect("target exists"); - - revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) - .expect("unchanged identity should pass"); - fs::rename(&target, &moved).expect("move retained directory"); - fs::create_dir(&target).expect("create same-device replacement"); - - revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) - .expect_err("replacement identity must fail"); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn marked_tree_removal_validates_marker_and_deletes_a_preflighted_tree() { - let root = unique_temp_path("marked-tree-remove"); - let target = root.join("managed"); - fs::create_dir_all(target.join("nested")).expect("create nested tree"); - fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); - fs::write(target.join("nested").join("file"), "owned").expect("write nested file"); - - assert!( - remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") - .expect("remove marked tree") - ); - assert!(!target.exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn directory_removal_rejects_linked_ancestors_without_touching_target() { - let root = unique_temp_path("remove-directory-linked-parent"); +fn empty_directory_removal_rejects_linked_ancestors_without_touching_target() { + let root = unique_temp_path("remove-empty-linked-parent"); let outside = root.join("outside"); let linked = root.join("linked"); fs::create_dir_all(outside.join("empty")).expect("create outside directory"); symlink(&outside, &linked).expect("create parent link"); remove_empty_directory(&linked.join("empty")).expect_err("linked parent should fail"); - remove_directory_tree(&linked).expect_err("linked root should fail"); assert!(outside.join("empty").exists()); let _ = fs::remove_dir_all(root); } #[test] -fn directory_removal_does_not_create_missing_parents() { - let root = unique_temp_path("remove-directory-missing-parent"); +fn empty_directory_removal_does_not_create_missing_parents() { + let root = unique_temp_path("remove-empty-missing-parent"); let missing_parent = root.join("missing"); let target = missing_parent.join("directory"); assert!(!remove_empty_directory(&target).expect("empty directory is missing")); - assert!(!remove_directory_tree(&target).expect("directory tree is missing")); assert!(!missing_parent.exists()); let _ = fs::remove_dir_all(root); diff --git a/crates/unixnotis-core/src/filesystem/tests/read.rs b/crates/unixnotis-core/src/filesystem/tests/read.rs deleted file mode 100644 index 6a6104fa0..000000000 --- a/crates/unixnotis-core/src/filesystem/tests/read.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Bounded regular-file read tests - -use std::fs; -use std::io::Read; -use std::os::unix::fs::symlink; - -use super::{open_regular_file, read_regular_file_bounded}; -use crate::test_support::unique_temp_path; - -#[test] -fn bounded_regular_file_read_accepts_the_exact_limit() { - let root = unique_temp_path("read-regular-exact-limit"); - let path = root.join("style.css"); - fs::create_dir_all(&root).expect("create root"); - fs::write(&path, b"12345678").expect("write file"); - - let contents = read_regular_file_bounded(&path, 8).expect("read bounded file"); - - assert_eq!(contents, b"12345678"); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn bounded_regular_file_read_rejects_a_file_over_the_limit() { - let root = unique_temp_path("read-regular-over-limit"); - let path = root.join("style.css"); - fs::create_dir_all(&root).expect("create root"); - fs::write(&path, b"123456789").expect("write file"); - - let error = read_regular_file_bounded(&path, 8).expect_err("oversized file should fail"); - - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn bounded_regular_file_read_rejects_a_source_symlink() { - let root = unique_temp_path("read-regular-symlink"); - let protected = root.join("protected.css"); - let path = root.join("style.css"); - fs::create_dir_all(&root).expect("create root"); - fs::write(&protected, "protected").expect("write protected file"); - symlink(&protected, &path).expect("create file link"); - - read_regular_file_bounded(&path, 1024).expect_err("source link should fail"); - - assert_eq!( - fs::read_to_string(protected).expect("read protected file"), - "protected" - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn bounded_regular_file_read_rejects_a_linked_parent() { - let root = unique_temp_path("read-regular-linked-parent"); - let outside = root.join("outside"); - let linked = root.join("linked"); - fs::create_dir_all(&outside).expect("create outside directory"); - fs::write(outside.join("style.css"), "outside theme").expect("write outside file"); - symlink(&outside, &linked).expect("create parent link"); - - read_regular_file_bounded(&linked.join("style.css"), 1024) - .expect_err("linked parent should fail"); - - assert_eq!( - fs::read_to_string(outside.join("style.css")).expect("read outside file"), - "outside theme" - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn bounded_regular_file_read_rejects_a_directory() { - let root = unique_temp_path("read-regular-directory"); - let path = root.join("style.css"); - fs::create_dir_all(&path).expect("create directory target"); - - read_regular_file_bounded(&path, 1024).expect_err("directory should fail"); - - assert!(path.is_dir()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn open_regular_file_retains_the_validated_object_after_path_replacement() { - let root = unique_temp_path("open-regular-pinned"); - let path = root.join("sound.ogg"); - let moved = root.join("original.ogg"); - fs::create_dir_all(&root).expect("create root"); - fs::write(&path, b"original").expect("write original file"); - let mut file = open_regular_file(&path).expect("open validated file"); - - fs::rename(&path, &moved).expect("move original file"); - fs::write(&path, b"replacement").expect("write replacement file"); - let mut contents = String::new(); - file.read_to_string(&mut contents) - .expect("read retained descriptor"); - - assert_eq!(contents, "original"); - assert_eq!( - fs::read_to_string(path).expect("read replacement"), - "replacement" - ); - let _ = fs::remove_dir_all(root); -} diff --git a/crates/unixnotis-core/src/filesystem/tests/regular.rs b/crates/unixnotis-core/src/filesystem/tests/regular.rs index cf2ba96cb..7024fc976 100644 --- a/crates/unixnotis-core/src/filesystem/tests/regular.rs +++ b/crates/unixnotis-core/src/filesystem/tests/regular.rs @@ -1,11 +1,15 @@ //! Stable regular-file operation tests use std::fs; +use std::io::Read; use std::os::unix::fs::{symlink, PermissionsExt}; use rustix::fs::{mkfifoat, Mode, CWD}; -use super::{make_file_executable, regular_file_contents_equal, set_file_mode}; +use super::{ + make_file_executable, open_regular_file, read_regular_file_bounded, + regular_file_contents_equal, set_file_mode, +}; use crate::test_support::unique_temp_path; #[test] @@ -123,3 +127,101 @@ fn mode_update_rejects_a_symlink_without_touching_its_target() { ); let _ = fs::remove_dir_all(root); } + +#[test] +fn bounded_regular_file_read_accepts_the_exact_limit() { + let root = unique_temp_path("read-regular-exact-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"12345678").expect("write file"); + + let contents = read_regular_file_bounded(&path, 8).expect("read bounded file"); + + assert_eq!(contents, b"12345678"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_file_over_the_limit() { + let root = unique_temp_path("read-regular-over-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"123456789").expect("write file"); + + let error = read_regular_file_bounded(&path, 8).expect_err("oversized file should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_source_symlink() { + let root = unique_temp_path("read-regular-symlink"); + let protected = root.join("protected.css"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &path).expect("create file link"); + + read_regular_file_bounded(&path, 1024).expect_err("source link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_linked_parent() { + let root = unique_temp_path("read-regular-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("style.css"), "outside theme").expect("write outside file"); + symlink(&outside, &linked).expect("create parent link"); + + read_regular_file_bounded(&linked.join("style.css"), 1024) + .expect_err("linked parent should fail"); + + assert_eq!( + fs::read_to_string(outside.join("style.css")).expect("read outside file"), + "outside theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_directory() { + let root = unique_temp_path("read-regular-directory"); + let path = root.join("style.css"); + fs::create_dir_all(&path).expect("create directory target"); + + read_regular_file_bounded(&path, 1024).expect_err("directory should fail"); + + assert!(path.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn open_regular_file_retains_the_validated_object_after_path_replacement() { + let root = unique_temp_path("open-regular-pinned"); + let path = root.join("sound.ogg"); + let moved = root.join("original.ogg"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"original").expect("write original file"); + let mut file = open_regular_file(&path).expect("open validated file"); + + fs::rename(&path, &moved).expect("move original file"); + fs::write(&path, b"replacement").expect("write replacement file"); + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("read retained descriptor"); + + assert_eq!(contents, "original"); + assert_eq!( + fs::read_to_string(path).expect("read replacement"), + "replacement" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/tree.rs b/crates/unixnotis-core/src/filesystem/tests/tree.rs new file mode 100644 index 000000000..2d9d8b643 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/tree.rs @@ -0,0 +1,169 @@ +//! Preflighted directory-tree removal tests + +use std::fs; +use std::os::unix::fs::symlink; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{ + preflight_directory_contents, remove_directory_tree, remove_marked_directory_tree, + revalidate_directory_identity, +}; +use crate::filesystem::descriptor::open_target_directory; +use crate::test_support::unique_temp_path; + +#[test] +fn recursive_directory_removal_deletes_regular_nested_tree() { + let root = unique_temp_path("remove-directory-tree"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("root-file"), "root").expect("write root file"); + fs::write(target.join("nested").join("child-file"), "child").expect("write child file"); + + assert!(remove_directory_tree(&target).expect("remove managed tree")); + assert!(!remove_directory_tree(&target).expect("missing tree stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_child_symlink() { + let root = unique_temp_path("remove-directory-child-link"); + let target = root.join("managed"); + let protected = root.join("protected"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, target.join("linked-child")).expect("create child link"); + + remove_directory_tree(&target).expect_err("child link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_special_child() { + let root = unique_temp_path("remove-directory-special-child"); + let target = root.join("managed"); + let fifo = target.join("fifo"); + fs::create_dir_all(&target).expect("create managed directory"); + mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create fifo child"); + + remove_directory_tree(&target).expect_err("special child should fail"); + + assert!(fs::symlink_metadata(fifo).is_ok()); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_preflight_preserves_regular_siblings_when_a_child_is_unsafe() { + let root = unique_temp_path("marked-tree-preflight"); + let target = root.join("managed"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("regular"), "keep until full preflight").expect("write regular child"); + symlink("regular", target.join("unsafe-link")).expect("create unsafe link"); + + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect_err("unsafe child should reject the whole tree"); + + assert_eq!( + fs::read_to_string(target.join("regular")).expect("regular sibling remains"), + "keep until full preflight" + ); + assert!(target.join(".owner").exists()); + assert!(fs::symlink_metadata(target.join("unsafe-link")) + .expect("unsafe link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_preflight_directly_rejects_unsafe_descendants() { + let root = unique_temp_path("marked-tree-direct-preflight"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("nested").join("regular"), "keep").expect("write regular child"); + symlink("regular", target.join("nested").join("unsafe-link")).expect("create unsafe link"); + let (_parent_fd, _name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + preflight_directory_contents(&directory_fd).expect_err("unsafe descendant must fail preflight"); + + assert_eq!( + fs::read_to_string(target.join("nested").join("regular")).expect("regular child remains"), + "keep" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_identity_revalidation_rejects_a_same_device_replacement() { + let root = unique_temp_path("directory-identity-replacement"); + let target = root.join("managed"); + let moved = root.join("original"); + fs::create_dir_all(&target).expect("create original directory"); + let (parent_fd, file_name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect("unchanged identity should pass"); + fs::rename(&target, &moved).expect("move retained directory"); + fs::create_dir(&target).expect("create same-device replacement"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_removal_validates_marker_and_deletes_a_preflighted_tree() { + let root = unique_temp_path("marked-tree-remove"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested tree"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("nested").join("file"), "owned").expect("write nested file"); + + assert!( + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect("remove marked tree") + ); + assert!(!target.exists()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn tree_removal_rejects_linked_ancestors_without_touching_target() { + let root = unique_temp_path("remove-tree-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(outside.join("empty")).expect("create outside directory"); + symlink(&outside, &linked).expect("create parent link"); + + remove_directory_tree(&linked).expect_err("linked root should fail"); + + assert!(outside.join("empty").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn tree_removal_does_not_create_missing_parents() { + let root = unique_temp_path("remove-tree-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("directory"); + + assert!(!remove_directory_tree(&target).expect("directory tree is missing")); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tree.rs b/crates/unixnotis-core/src/filesystem/tree.rs new file mode 100644 index 000000000..be0ae789c --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tree.rs @@ -0,0 +1,149 @@ +//! Preflighted recursive removal for regular-only directory trees + +use std::ffi::{CStr, OsStr}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use rustix::fs::{fstat, statat, unlinkat, AtFlags, Dir, FileType}; + +use super::descriptor::{open_directory_at, open_target_directory, sync_directory}; +use super::directory::{invalid_marker_error, validate_child_name}; +use super::regular::{file_contents_equal, open_regular_file_at}; + +/// Recursively remove a directory containing only regular files and directories +/// +/// Symbolic links and special files are rejected and left in place +/// +/// # Errors +/// +/// Returns an error when a path component or child has an unsafe shape, an entry changes during +/// traversal, or removal and synchronization cannot complete +pub fn remove_directory_tree(path: &Path) -> io::Result { + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + remove_directory_contents(&directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +/// Remove a marked regular-only directory tree through one retained root descriptor +/// +/// The entire tree is checked before any entry is deleted. The ownership marker is read relative +/// to that same descriptor, and the visible root name must still identify it before final removal +/// +/// # Errors +/// +/// Returns an error when the path or marker is unsafe, marker bytes differ, the tree contains a +/// link or special file, an entry changes shape, or durable removal fails +pub fn remove_marked_directory_tree( + path: &Path, + marker_name: &OsStr, + marker_contents: &[u8], +) -> io::Result { + validate_child_name(marker_name)?; + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + let marker_name = marker_name.to_os_string(); + let mut marker = open_regular_file_at(&directory_fd, &marker_name) + .map_err(|_error| invalid_marker_error())?; + if !file_contents_equal(&mut marker, marker_contents)? { + return Err(invalid_marker_error()); + } + + // Preflight is intentionally read-only so one rejected child cannot cause partial deletion + preflight_directory_contents(&directory_fd)?; + remove_directory_contents(&directory_fd)?; + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + unlinkat(directory_fd, name, AtFlags::empty())?; + sync_directory(directory_fd)?; + } else if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + remove_directory_contents(&child_fd)?; + drop(child_fd); + unlinkat(directory_fd, name, AtFlags::REMOVEDIR)?; + sync_directory(directory_fd)?; + } else { + return Err(unsafe_tree_entry_error(name)); + } + } + Ok(()) +} + +pub(super) fn preflight_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + continue; + } + if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + preflight_directory_contents(&child_fd)?; + continue; + } + return Err(unsafe_tree_entry_error(name)); + } + Ok(()) +} + +pub(super) fn revalidate_directory_identity( + parent_fd: &OwnedFd, + file_name: &OsStr, + directory_fd: &OwnedFd, +) -> io::Result<()> { + let retained = fstat(directory_fd)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev + && retained.st_ino == visible.st_ino + && FileType::from_raw_mode(visible.st_mode).is_dir() + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "directory changed while guarded removal was in progress", + )) +} + +fn unsafe_tree_entry_error(name: &CStr) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing unsafe entry inside directory tree: {}", + name.to_string_lossy() + ), + ) +} + +#[cfg(test)] +#[path = "tests/tree.rs"] +mod tests; From 73659981db949f790470d59a2c62a854a3e920e3 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:25:21 -0500 Subject: [PATCH 070/275] refactor(center): give command widgets explicit ownership Summary: give command widgets explicit ownership. Scope: center. --- .../src/ui/widgets/cards/build.rs | 2 +- .../src/ui/widgets/cards/model.rs | 2 +- .../src/ui/widgets/cards/refresh.rs | 7 +++--- .../backoff.rs} | 2 +- .../command/action.rs | 0 .../command/capture.rs | 0 .../command/command_parse.rs | 0 .../command/exec/builder.rs | 10 +++++--- .../command/exec/mod.rs | 0 .../command/exec/output.rs | 0 .../command/exec/process.rs | 0 .../command/exec/runner.rs | 4 +-- .../command/exec/tests/builder.rs | 0 .../command/exec/tests/output.rs | 0 .../command/exec/tests/runner.rs | 0 .../{utils => command_runtime}/command/mod.rs | 0 .../command/plan.rs | 0 .../command/queue/coalesced.rs | 2 +- .../command/queue/delayed.rs | 0 .../command/queue/metrics.rs | 0 .../command/queue/mod.rs | 0 .../command/queue/tests/coalesced.rs | 2 +- .../command/queue/tests/delayed.rs | 2 +- .../command/queue/tests/metrics.rs | 0 .../command/queue/tests/worker.rs | 0 .../command/queue/worker.rs | 2 +- .../command/tests/action.rs | 0 .../command/tests/capture.rs | 0 .../command/tests/command_parse.rs | 0 .../command/tests/plan.rs | 0 .../command/tests/support.rs | 0 .../src/ui/widgets/command_runtime/mod.rs | 6 +++++ .../tests/backoff.rs} | 2 +- .../{utils => command_runtime}/tests/watch.rs | 0 .../tests/watch_reaper.rs | 0 .../{utils => command_runtime}/watch.rs | 0 .../watch_reaper.rs | 0 .../{utils => }/command_slider/actions/mod.rs | 0 .../command_slider/actions/schedule.rs | 3 ++- .../command_slider/actions/signals.rs | 2 +- .../command_slider/actions/tests/schedule.rs | 0 .../command_slider/actions/tests/signals.rs | 6 ++--- .../widgets/{utils => }/command_slider/mod.rs | 6 +---- .../command_slider/refresh/apply.rs | 0 .../command_slider/refresh/gate.rs | 0 .../{utils => }/command_slider/refresh/mod.rs | 0 .../command_slider/refresh/poll.rs | 3 ++- .../command_slider/refresh/request.rs | 0 .../command_slider/refresh/runner.rs | 2 +- .../command_slider/refresh/state.rs | 2 +- .../command_slider/refresh/tests/apply.rs | 4 +-- .../command_slider/refresh/tests/gate.rs | 0 .../command_slider/refresh/tests/poll.rs | 7 +++--- .../command_slider/refresh/tests/request.rs | 0 .../command_slider/refresh/tests/runner.rs | 4 +-- .../command_slider/refresh/tests/state.rs | 4 +-- .../command_slider/refresh/tests/watch.rs | 2 +- .../command_slider/refresh/watch.rs | 3 ++- .../command_slider/tests/widget.rs | 0 .../command_slider/value/change.rs | 0 .../command_slider/value/format.rs | 0 .../{utils => }/command_slider/value/mod.rs | 0 .../{utils => }/command_slider/value/parse.rs | 0 .../command_slider/value/tests/change.rs | 0 .../command_slider/value/tests/format.rs | 0 .../command_slider/value/tests/parse.rs | 0 .../{utils => }/command_slider/view/build.rs | 0 .../{utils => }/command_slider/view/icons.rs | 0 .../{utils => }/command_slider/view/layout.rs | 0 .../{utils => }/command_slider/view/mod.rs | 0 .../command_slider/view/tests/build.rs | 0 .../command_slider/view/tests/icons.rs | 0 .../command_slider/view/tests/layout.rs | 0 .../{utils => }/command_slider/widget.rs | 4 ++- crates/unixnotis-center/src/ui/widgets/mod.rs | 9 +++---- .../src/ui/widgets/stats/card/build.rs | 2 +- .../src/ui/widgets/stats/card/model.rs | 2 +- .../ui/widgets/stats/card/refresh/command.rs | 2 +- .../ui/widgets/stats/card/refresh/dispatch.rs | 2 +- .../ui/widgets/stats/card/refresh/plugin.rs | 2 +- .../stats/card/refresh/tests/support.rs | 2 +- .../src/ui/widgets/toggles/grid.rs | 3 ++- .../src/ui/widgets/toggles/state.rs | 2 +- .../src/ui/widgets/utils/mod.rs | 25 ------------------- 84 files changed, 66 insertions(+), 80 deletions(-) rename crates/unixnotis-center/src/ui/widgets/{utils/refresh_backoff.rs => command_runtime/backoff.rs} (98%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/action.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/capture.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/command_parse.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/builder.rs (94%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/output.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/process.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/runner.rs (95%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/tests/builder.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/tests/output.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/exec/tests/runner.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/plan.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/coalesced.rs (98%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/delayed.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/metrics.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/tests/coalesced.rs (97%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/tests/delayed.rs (96%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/tests/metrics.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/tests/worker.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/queue/worker.rs (99%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/tests/action.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/tests/capture.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/tests/command_parse.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/tests/plan.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/command/tests/support.rs (100%) create mode 100644 crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs rename crates/unixnotis-center/src/ui/widgets/{utils/tests/refresh_backoff.rs => command_runtime/tests/backoff.rs} (98%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/tests/watch.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/tests/watch_reaper.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/watch.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => command_runtime}/watch_reaper.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/actions/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/actions/schedule.rs (92%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/actions/signals.rs (98%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/actions/tests/schedule.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/actions/tests/signals.rs (95%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/mod.rs (63%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/apply.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/gate.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/poll.rs (88%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/request.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/runner.rs (97%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/state.rs (97%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/apply.rs (97%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/gate.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/poll.rs (91%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/request.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/runner.rs (97%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/state.rs (93%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/tests/watch.rs (94%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/refresh/watch.rs (90%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/tests/widget.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/change.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/format.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/parse.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/tests/change.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/tests/format.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/value/tests/parse.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/build.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/icons.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/layout.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/mod.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/tests/build.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/tests/icons.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/view/tests/layout.rs (100%) rename crates/unixnotis-center/src/ui/widgets/{utils => }/command_slider/widget.rs (97%) delete mode 100644 crates/unixnotis-center/src/ui/widgets/utils/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/cards/build.rs b/crates/unixnotis-center/src/ui/widgets/cards/build.rs index 77d2aac9f..5283a25cf 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/build.rs @@ -11,7 +11,7 @@ use unixnotis_core::{css::hooks, CardLayout, CardWidgetConfig, IconAssetResolver use super::super::icon_image::image_from_icon_config; use super::weather::{apply_card_kind_classes, card_icon_size, configure_card_icon}; use super::{CardGrid, CardItem}; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; impl CardGrid { pub fn new( diff --git a/crates/unixnotis-center/src/ui/widgets/cards/model.rs b/crates/unixnotis-center/src/ui/widgets/cards/model.rs index 3bc54e787..abc518c30 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/model.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/model.rs @@ -6,7 +6,7 @@ use std::time::Instant; use unixnotis_core::CardWidgetConfig; -use super::super::utils::RefreshBackoff; +use super::super::command_runtime::backoff::RefreshBackoff; pub struct CardGrid { // FlowBox root is embedded directly by the panel widget layout diff --git a/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs b/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs index f791f4783..b793b9a9c 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs @@ -10,10 +10,11 @@ use unixnotis_core::{PanelDebugLevel, WidgetPluginConfig}; use super::common::apply_cached_value; use super::CardItem; use crate::diagnostics::panel_debug as debug; -use crate::ui::widgets::plugin::{parse_card_plugin_payload, PluginOutputLimits}; -use crate::ui::widgets::utils::{ - run_command_capture_async, run_command_capture_with_timeout_async, INFLIGHT_REFRESH_RECHECK, +use crate::ui::widgets::command_runtime::backoff::INFLIGHT_REFRESH_RECHECK; +use crate::ui::widgets::command_runtime::command::{ + run_command_capture_async, run_command_capture_with_timeout_async, }; +use crate::ui::widgets::plugin::{parse_card_plugin_payload, PluginOutputLimits}; impl CardItem { pub(super) fn refresh(&self, base_interval: Duration, force: bool) { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs index 17691921c..c3d59ed0a 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs @@ -78,5 +78,5 @@ fn scale_duration(base: Duration, mult: u64) -> Duration { } #[cfg(test)] -#[path = "tests/refresh_backoff.rs"] +#[path = "tests/backoff.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/action.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/action.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/action.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/capture.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/capture.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/command_parse.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/command_parse.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs similarity index 94% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs index 575ccdaaa..af2866692 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs @@ -17,7 +17,9 @@ const SHELL_FALLBACK_CACHE_LIMIT: usize = 64; static COMMAND_CONFIG_DIR: OnceLock = OnceLock::new(); -pub(in crate::ui::widgets::utils::command) fn set_command_config_dir(config_dir: PathBuf) -> bool { +pub(in crate::ui::widgets::command_runtime::command) fn set_command_config_dir( + config_dir: PathBuf, +) -> bool { // Widget commands are built after startup, so retain the active custom config root if COMMAND_CONFIG_DIR.get() == Some(&config_dir) { return true; @@ -35,7 +37,9 @@ pub(super) fn spawn_capture_command(cmd: &CommandSpec) -> std::io::Result command.spawn() } -pub(in crate::ui::widgets::utils::command) fn build_command(cmd: &CommandSpec) -> Command { +pub(in crate::ui::widgets::command_runtime::command) fn build_command( + cmd: &CommandSpec, +) -> Command { let mut command = match cmd { CommandSpec::Direct { program, args, env } => { let mut command = Command::new(resolve_direct_program(program)); @@ -145,7 +149,7 @@ fn resolve_direct_program(program: &Path) -> PathBuf { resolve_direct_program_from_root(command_config_dir().as_deref(), program) } -pub(in crate::ui::widgets::utils::command) fn command_config_dir() -> Option { +pub(in crate::ui::widgets::command_runtime::command) fn command_config_dir() -> Option { if let Some(config_dir) = COMMAND_CONFIG_DIR.get() { return Some(config_dir.clone()); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/output.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/output.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/output.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/output.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/process.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/process.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/process.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/process.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs similarity index 95% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs index cd5ad60af..f5ffbc401 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs @@ -15,7 +15,7 @@ use super::output::{ }; use super::process::{kill_child_process, kill_process_group}; -pub(in crate::ui::widgets::utils::command) fn build_command_runtime() -> Option { +pub(in crate::ui::widgets::command_runtime::command) fn build_command_runtime() -> Option { // A current-thread runtime keeps frequent widget probes lightweight tokio::runtime::Builder::new_current_thread() .enable_io() @@ -31,7 +31,7 @@ pub(in crate::ui::widgets::utils::command) fn build_command_runtime() -> Option< .ok() } -pub(in crate::ui::widgets::utils::command) fn run_command_with_timeout( +pub(in crate::ui::widgets::command_runtime::command) fn run_command_with_timeout( cmd: &CommandSpec, timeout: Duration, runtime: Option<&Runtime>, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/builder.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/builder.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/output.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/output.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/output.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/output.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/runner.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/runner.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/plan.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/plan.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs index 1c1140b8a..f0264b074 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs @@ -9,7 +9,7 @@ use tracing::warn; use unixnotis_core::CommandSpec; use super::worker::CommandJob; -use crate::ui::widgets::utils::command::CommandKind; +use crate::ui::widgets::command_runtime::command::CommandKind; // Keep refresh overflow bounded const COALESCED_REFRESH_CAPACITY: usize = 256; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/delayed.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/delayed.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/delayed.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/delayed.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/metrics.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/metrics.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/metrics.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/metrics.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs index f27ac2bf3..2b964532b 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs @@ -3,7 +3,7 @@ use std::time::Instant; use super::super::worker::CommandJob; use super::{insert_coalesced_job, CoalescedRefreshState}; -use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; +use crate::ui::widgets::command_runtime::command::{CommandKind, CommandPlan}; use unixnotis_core::CommandSpec; fn job(cmd: CommandSpec, kind: CommandKind) -> CommandJob { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs similarity index 96% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs index 816a2408e..53f5afe61 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs @@ -7,7 +7,7 @@ use super::{ next_delayed_wake, next_ready_delayed_job_index, try_enqueue_delayed_job, DelayedSlowQueue, DelayedState, }; -use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; +use crate::ui::widgets::command_runtime::command::{CommandKind, CommandPlan}; use unixnotis_core::CommandSpec; fn job(cmd: &str) -> CommandJob { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/metrics.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/metrics.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/metrics.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/metrics.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/worker.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/worker.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs similarity index 99% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs index f93dd9729..465b7885e 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs @@ -87,7 +87,7 @@ impl CommandWorker { } } -pub(in crate::ui::widgets::utils::command) fn enqueue_command( +pub(in crate::ui::widgets::command_runtime::command) fn enqueue_command( cmd: CommandSpec, plan: CommandPlan, respond: Option>>, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/action.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/action.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/capture.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/capture.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/command_parse.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/command_parse.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/plan.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/plan.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/support.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/support.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/support.rs diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs new file mode 100644 index 000000000..0293650ed --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs @@ -0,0 +1,6 @@ +//! Command execution, refresh backoff, and persistent watch lifecycles + +pub(in crate::ui::widgets) mod backoff; +pub(in crate::ui::widgets) mod command; +pub(in crate::ui::widgets) mod watch; +mod watch_reaper; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs index 11d8e5cd6..8fd080d09 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs @@ -42,6 +42,6 @@ fn refresh_backoff_increases_on_errors() { #[test] fn in_flight_recheck_stays_slower_than_short_command_polling() { - // Async completion updates real deadlines, so rechecks should only be a safety net. + // Async completion updates real deadlines, so rechecks should only be a safety net assert!(INFLIGHT_REFRESH_RECHECK >= Duration::from_secs(1)); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/watch_reaper.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch_reaper.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/watch_reaper.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch_reaper.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/watch.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/watch.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/watch_reaper.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/watch_reaper.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/watch_reaper.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/watch_reaper.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs similarity index 92% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs index aef0316e1..9090edbc6 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs @@ -3,7 +3,8 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use super::super::{run_action_command_with_completion, value::format_command_value}; +use super::super::value::format_command_value; +use crate::ui::widgets::command_runtime::command::run_action_command_with_completion; use unixnotis_core::{CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs index 637a51a88..d8c81c2f3 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs @@ -10,11 +10,11 @@ use unixnotis_core::{PanelDebugLevel, SliderWidgetConfig}; use super::super::refresh::{ build_refresh_state_from_weak, request_refresh, SliderRefreshMeta, SliderRefreshRequest, }; -use super::super::run_action_command_with_completion; use super::super::value::format_display_value; use super::super::view::build_icon_shell; use super::schedule::schedule_command; use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::command_runtime::command::run_action_command_with_completion; pub(in super::super) fn attach_icon_action( root: >k::Box, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/schedule.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/schedule.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs similarity index 95% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs index fb5493964..1c23c0636 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs @@ -6,9 +6,9 @@ use gtk::prelude::*; use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::{attach_icon_action, attach_scale_action}; -use crate::ui::widgets::utils::command_slider::refresh::{SliderRefreshGate, SliderRefreshMeta}; -use crate::ui::widgets::utils::command_slider::view::build_slider_widgets; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::{SliderRefreshGate, SliderRefreshMeta}; +use crate::ui::widgets::command_slider::view::build_slider_widgets; #[gtk::test] fn icon_action_adds_a_static_shell_when_toggle_command_is_absent() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs similarity index 63% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs index c5c8fcfcc..7165f4350 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs @@ -8,11 +8,7 @@ mod refresh; mod value; // GTK construction, layout, and icon resolution mod view; -// Public widget shell that connects each focused subsystem +// Widget shell that connects each focused subsystem mod widget; -use super::{ - run_action_command_with_completion, run_command_capture_status_async, start_command_watch, - CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK, -}; pub use widget::CommandSlider; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/apply.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/apply.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/apply.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/apply.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/gate.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/gate.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/gate.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/gate.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs index aee2ad212..eea9567a1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs @@ -4,8 +4,9 @@ use std::cell::RefCell; use std::rc::Rc; use std::time::{Duration, Instant}; -use super::super::{CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; use super::gate::SliderRefreshGate; +use crate::ui::widgets::command_runtime::backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; +use crate::ui::widgets::command_runtime::watch::CommandWatch; pub(super) fn needs_polling(watch_handle: &RefCell>) -> bool { let mut handle = watch_handle.borrow_mut(); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/request.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/request.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs index 6b191bdf0..cf5791271 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs @@ -9,10 +9,10 @@ use std::time::{Duration, Instant}; use tracing::warn; use unixnotis_core::{util, PanelDebugLevel}; -use super::super::run_command_capture_status_async; use super::apply::{apply_successful_output, note_slider_error}; use super::{SliderRefreshRequest, SliderRefreshState}; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; +use crate::ui::widgets::command_runtime::command::run_command_capture_status_async; pub(in super::super) fn request_refresh( request: SliderRefreshRequest, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs index c72736081..4b9a76a42 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs @@ -3,8 +3,8 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use super::super::RefreshBackoff; use super::gate::SliderRefreshGate; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; #[derive(Clone)] pub(in super::super) struct SliderRefreshState { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs index c1e039830..a8de5dc41 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs @@ -6,10 +6,10 @@ use gtk::prelude::*; use unixnotis_core::{CommandSpec, NumericParseMode}; use super::{apply_slider_icon, apply_slider_value, apply_successful_output, note_slider_error}; -use crate::ui::widgets::utils::command_slider::refresh::{ +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::{ SliderRefreshGate, SliderRefreshRequest, SliderRefreshState, }; -use crate::ui::widgets::utils::RefreshBackoff; #[gtk::test] fn slider_value_application_updates_only_changed_widget_state() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/gate.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/gate.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/gate.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/gate.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs similarity index 91% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs index e11525f5c..13ad4eff8 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs @@ -3,10 +3,9 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use super::{needs_polling, next_poll_in}; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::{ - start_command_watch, CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK, -}; +use crate::ui::widgets::command_runtime::backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; +use crate::ui::widgets::command_runtime::watch::{start_command_watch, CommandWatch}; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; use unixnotis_core::CommandSpec; #[test] diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/request.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/request.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs index 5392641e7..ab501b6c1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs @@ -12,8 +12,8 @@ use super::{ finish_refresh, handle_worker_result, next_refresh_generation, request_refresh, SliderRefreshRequest, SliderRefreshState, }; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; #[test] fn refresh_generation_increments_and_records_the_next_value() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs similarity index 93% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs index e68a8e916..b094e01e0 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs @@ -4,8 +4,8 @@ use std::rc::Rc; use gtk::prelude::*; use super::{build_refresh_state_from_weak, SliderRefreshMeta}; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; #[gtk::test] fn weak_widget_state_builds_while_every_widget_is_alive() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs similarity index 94% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs index ccb29f253..99175d8a7 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs @@ -1,7 +1,7 @@ use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::set_watch_active; -use crate::ui::widgets::utils::command_slider::CommandSlider; +use crate::ui::widgets::command_slider::CommandSlider; #[gtk::test] fn watch_lifecycle_starts_once_and_stops_cleanly() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs similarity index 90% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs index f5114ddda..fad72b0e8 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs @@ -2,8 +2,9 @@ use std::time::Duration; -use super::super::{start_command_watch, CommandSlider, CommandWatch}; +use super::super::CommandSlider; use super::{request_refresh, SliderRefreshRequest}; +use crate::ui::widgets::command_runtime::watch::{start_command_watch, CommandWatch}; pub(in super::super) fn set_watch_active(slider: &CommandSlider, active: bool) { // Widgets without a watch command rely on polling only diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/tests/widget.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/tests/widget.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/change.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/change.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/change.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/change.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/format.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/format.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/format.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/format.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/parse.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/parse.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/parse.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/change.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/change.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/format.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/format.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/format.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/format.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/parse.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/build.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/build.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/build.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/build.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/icons.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/icons.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/icons.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/icons.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/layout.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/layout.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/layout.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/build.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/build.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/build.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/build.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/icons.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/icons.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/icons.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/icons.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/layout.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/layout.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/layout.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs index e786081c8..1ae907db1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs @@ -6,11 +6,13 @@ use std::time::{Duration, Instant}; use unixnotis_core::SliderWidgetConfig; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_runtime::watch::CommandWatch; + use super::refresh::{ request_refresh, SliderRefreshGate, SliderRefreshMeta, SliderRefreshRequest, SliderRefreshState, }; use super::view::build_slider_widgets; -use super::{CommandWatch, RefreshBackoff}; pub struct CommandSlider { // Root widget embedded by higher-level widget wrappers diff --git a/crates/unixnotis-center/src/ui/widgets/mod.rs b/crates/unixnotis-center/src/ui/widgets/mod.rs index ee4c95ae5..715667fa3 100644 --- a/crates/unixnotis-center/src/ui/widgets/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/mod.rs @@ -2,19 +2,18 @@ pub mod brightness; pub mod cards; +mod command_runtime; +mod command_slider; mod icon_image; mod kind_css; // Plugin schema and JSON parsing helpers for widget-backed commands mod plugin; pub mod stats; pub mod toggles; -// Shared helpers are kept in a dedicated module to prevent single-file sprawl -mod utils; pub mod volume; -// Re-export keeps existing call sites stable while internals stay modular -pub use utils::CommandSlider; +pub use command_slider::CommandSlider; pub fn configure_command_config_dir(config_dir: std::path::PathBuf) { - utils::configure_command_config_dir(config_dir); + command_runtime::command::configure_command_config_dir(config_dir); } diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs index ca40563c6..a0a09f939 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs @@ -6,8 +6,8 @@ use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; use super::super::builtin::BuiltinStat; use super::super::style::stat_kind_css_class; use super::StatItem; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; use crate::ui::widgets::icon_image::image_from_icon_config; -use crate::ui::widgets::utils::RefreshBackoff; impl StatItem { pub(in crate::ui::widgets::stats) fn new( diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs index a0eef14f3..618b10ed0 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs @@ -6,7 +6,7 @@ use std::rc::Rc; use unixnotis_core::{CommandSpec, StatWidgetConfig, WidgetPluginConfig}; use super::super::builtin::BuiltinStat; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; #[derive(Clone)] pub(in crate::ui::widgets::stats) struct StatItem { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs index 4da0182ba..022f0380b 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs @@ -7,7 +7,7 @@ use tracing::warn; use unixnotis_core::CommandSpec; use super::super::{render::apply_cached_value, StatItem}; -use crate::ui::widgets::utils::run_command_capture_async; +use crate::ui::widgets::command_runtime::command::run_command_capture_async; impl StatItem { pub(super) fn refresh_command(&self, command: &CommandSpec, base_interval: Duration) { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs index 5bb117b35..d27774570 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs @@ -7,8 +7,8 @@ use unixnotis_core::PanelDebugLevel; use super::super::{StatItem, StatSourceRef}; use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::command_runtime::backoff::INFLIGHT_REFRESH_RECHECK; use crate::ui::widgets::stats::builtin::{BuiltinStat, BuiltinStatKey}; -use crate::ui::widgets::utils::INFLIGHT_REFRESH_RECHECK; impl StatItem { pub(in crate::ui::widgets::stats) fn has_builtin_source(&self) -> bool { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs index d8e1bf8ca..ee5515f04 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs @@ -7,8 +7,8 @@ use tracing::warn; use unixnotis_core::WidgetPluginConfig; use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::command_runtime::command::run_command_capture_with_timeout_async; use crate::ui::widgets::plugin::{parse_stat_plugin_payload, PluginOutputLimits}; -use crate::ui::widgets::utils::run_command_capture_with_timeout_async; impl StatItem { pub(super) fn refresh_plugin(&self, plugin: &WidgetPluginConfig, base_interval: Duration) { diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs index f123c497a..f64a3039d 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs @@ -1,8 +1,8 @@ //! Shared GTK card fixtures +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; use crate::ui::widgets::stats::builtin::BuiltinStat; use crate::ui::widgets::stats::card::StatItem; -use crate::ui::widgets::utils::RefreshBackoff; use unixnotis_core::StatWidgetConfig; static GTK_INIT: std::sync::Once = std::sync::Once::new(); diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs index 17de628e1..65c2d17d6 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs @@ -10,8 +10,9 @@ use unixnotis_core::{ css::hooks, CommandSpec, IconAssetResolver, PanelDebugLevel, ToggleLayout, ToggleWidgetConfig, }; +use super::super::command_runtime::command::run_action_command_with_completion; +use super::super::command_runtime::watch::{start_command_watch, CommandWatch}; use super::super::icon_image::image_from_icon_config; -use super::super::utils::{run_action_command_with_completion, start_command_watch, CommandWatch}; use crate::diagnostics::panel_debug as debug; use super::css::toggle_kind_css_class; diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs index 62fb64ef7..199bddbab 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs @@ -11,7 +11,7 @@ use gtk::prelude::*; use tracing::warn; use unixnotis_core::{css::hooks, util, CommandSpec, PanelDebugLevel, ToggleBackend}; -use super::super::utils::run_command_capture_status_async; +use super::super::command_runtime::command::run_command_capture_status_async; use super::rfkill::parse_rfkill_state; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/mod.rs b/crates/unixnotis-center/src/ui/widgets/utils/mod.rs deleted file mode 100644 index 78d350b08..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Shared widget helpers and command plumbing - -// Command execution and queueing internals -mod command; -// Command-driven slider widget implementation -mod command_slider; -// Shared refresh backoff policy used by cards and stats -mod refresh_backoff; -// Shared watch cleanup worker keeps teardown off the GTK thread -mod watch_reaper; -// Long-running command watch lifecycle helpers -mod watch; - -// Shared command helpers are scoped to widget internals -pub use command::configure_command_config_dir; -pub(super) use command::{ - run_action_command_with_completion, run_command_capture_async, - run_command_capture_status_async, run_command_capture_with_timeout_async, -}; -// Public re-export keeps widget wrappers concise -pub use command_slider::CommandSlider; -// Backoff policy is reused by polling widgets -pub(super) use refresh_backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; -// Watcher helpers are reused by sliders and toggles -pub(super) use watch::{start_command_watch, CommandWatch}; From 8de534e25c6ee66c5f0c9e8f87ec0ce205d1ee8e Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:26:54 -0500 Subject: [PATCH 071/275] refactor(core): centralize UTF-8 byte truncation Summary: centralize UTF-8 byte truncation. Scope: core. --- .../unixnotis-core/src/model/image/hints.rs | 25 +---------- crates/unixnotis-core/src/util/mod.rs | 2 + crates/unixnotis-core/src/util/tests/text.rs | 25 +++++++++++ crates/unixnotis-core/src/util/text.rs | 22 ++++++++++ .../src/daemon/control/sanitize.rs | 27 +----------- .../src/daemon/notifications/payload.rs | 44 +++++-------------- .../src/daemon/notifications/tests/payload.rs | 18 +------- 7 files changed, 64 insertions(+), 99 deletions(-) create mode 100644 crates/unixnotis-core/src/util/tests/text.rs create mode 100644 crates/unixnotis-core/src/util/text.rs diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 9334dc2e1..fee791b9e 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -170,30 +170,7 @@ fn sanitize_metadata_string(value: &str, max_bytes: usize) -> String { // Remove inline display control/problematic characters before trimming and // applying the final UTF-8-safe byte limit let cleaned = util::sanitize_inline_display_text(value); - truncate_utf8_bytes(cleaned.trim(), max_bytes) -} - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - // Fast path: avoid allocation/truncation work when the value already fits - if value.len() <= max_bytes { - return value.to_string(); - } - - // Back up only across the code point that crosses the byte limit - let mut end = max_bytes; - for _ in 0..3 { - if value.is_char_boundary(end) { - break; - } - end -= 1; - } - debug_assert!( - value.is_char_boundary(end), - "bounded backup must reach the current UTF-8 character boundary" - ); - - // Return only the byte-safe prefix - value[..end].to_string() + util::truncate_utf8_bytes(cleaned.trim(), max_bytes) } pub(in crate::model) fn owned_to_string(value: &OwnedValue) -> Option { diff --git a/crates/unixnotis-core/src/util/mod.rs b/crates/unixnotis-core/src/util/mod.rs index 2f819c07e..4ae176f6e 100644 --- a/crates/unixnotis-core/src/util/mod.rs +++ b/crates/unixnotis-core/src/util/mod.rs @@ -4,6 +4,7 @@ mod diagnostics; mod display; mod paths; mod programs; +mod text; pub use diagnostics::{ default_log_limit, diagnostic_log_limit, diagnostic_mode, log_limit, log_snippet, @@ -14,3 +15,4 @@ pub use display::{ }; pub use paths::{expand_tilde, resolve_state_dir, resolve_state_dir_from_env, CONFIG_PATH_ENV}; pub use programs::{program_in_path, trusted_system_program_path, TRUSTED_SYSTEM_TOOL_DIRS}; +pub use text::truncate_utf8_bytes; diff --git a/crates/unixnotis-core/src/util/tests/text.rs b/crates/unixnotis-core/src/util/tests/text.rs new file mode 100644 index 000000000..1b0f412cf --- /dev/null +++ b/crates/unixnotis-core/src/util/tests/text.rs @@ -0,0 +1,25 @@ +use super::truncate_utf8_bytes; + +#[test] +fn truncation_keeps_values_that_fit_the_byte_budget() { + assert_eq!(truncate_utf8_bytes("plain", 5), "plain"); + assert_eq!(truncate_utf8_bytes("🙂", 4), "🙂"); +} + +#[test] +fn truncation_returns_empty_text_for_a_zero_byte_budget() { + assert_eq!(truncate_utf8_bytes("text", 0), ""); +} + +#[test] +fn truncation_stops_before_a_partial_multibyte_character() { + assert_eq!(truncate_utf8_bytes("abc🙂def", 5), "abc"); + assert_eq!(truncate_utf8_bytes("éé", 3), "é"); +} + +#[test] +fn truncation_accepts_every_boundary_inside_a_four_byte_character() { + for limit in 1..4 { + assert_eq!(truncate_utf8_bytes("🙂tail", limit), ""); + } +} diff --git a/crates/unixnotis-core/src/util/text.rs b/crates/unixnotis-core/src/util/text.rs new file mode 100644 index 000000000..a6941500b --- /dev/null +++ b/crates/unixnotis-core/src/util/text.rs @@ -0,0 +1,22 @@ +//! Bounded text operations shared across process and D-Bus boundaries + +/// Return an owned prefix no longer than `max_bytes` without splitting a UTF-8 character +#[must_use] +pub fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + // Preserve the complete value when it already fits the caller's byte budget + return value.to_string(); + } + + // A UTF-8 scalar uses at most four bytes, so only its continuation bytes need inspection + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + + value[..end].to_string() +} + +#[cfg(test)] +#[path = "tests/text.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs index 7f6bc9865..94febfc89 100644 --- a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs @@ -2,7 +2,7 @@ //! //! These helpers are pure and easy to unit test in isolation -use unixnotis_core::{INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; +use unixnotis_core::{util, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; const MAX_INHIBITOR_REASON_BYTES: usize = 256; @@ -13,7 +13,7 @@ pub(super) fn sanitize_inhibit_reason(reason: &str) -> String { // Keep an explicit default for empty reasons to avoid blank UI rows return "manual".to_string(); } - truncate_utf8_bytes(trimmed, MAX_INHIBITOR_REASON_BYTES) + util::truncate_utf8_bytes(trimmed, MAX_INHIBITOR_REASON_BYTES) } pub(super) fn normalize_inhibit_scope(scope: u32) -> zbus::fdo::Result { @@ -31,26 +31,3 @@ pub(super) fn normalize_inhibit_scope(scope: u32) -> zbus::fdo::Result { } Ok(normalized) } - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - if value.len() <= max_bytes { - return value.to_string(); - } - - // Back up only across the code point that crosses the byte limit - let mut end = max_bytes; - for _ in 0..3 { - if value.is_char_boundary(end) { - break; - } - end -= 1; - } - debug_assert!( - value.is_char_boundary(end), - "bounded backup must reach the current UTF-8 character boundary" - ); - value[..end].to_string() -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 55e17f57e..24efb62a3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -46,7 +46,7 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { .and_then(owned_to_string) .map(|value| { // Category stays on one line - truncate_utf8_bytes( + util::truncate_utf8_bytes( &util::sanitize_inline_display_text(&value), MAX_CATEGORY_BYTES, ) @@ -73,19 +73,19 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { // Keep explicit fallback text for empty callers "Unknown".to_string() } else { - truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) + util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) }, - app_icon: truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), + app_icon: util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid // Fold very long unbroken runs so renderer width remains bounded summary: util::fold_text_for_layout( - &truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), + &util::truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), util::MAX_DISPLAY_TOKEN_WIDTH, ), // Apply the same order for body so renderer sees consistent text constraints // Body can be much larger, so apply the same run-folding protection here body: util::fold_text_for_layout( - &truncate_utf8_bytes(&body, MAX_BODY_BYTES), + &util::truncate_utf8_bytes(&body, MAX_BODY_BYTES), util::MAX_DISPLAY_TOKEN_WIDTH, ), actions, @@ -154,7 +154,7 @@ fn reply_hint_text(hints: &HashMap, key: &str) -> String { }; // Reply controls are single-line GTK widgets, so layout controls are removed here let clean = util::sanitize_inline_display_text(&value); - truncate_utf8_bytes(&clean, MAX_HINT_STRING_BYTES) + util::truncate_utf8_bytes(&clean, MAX_HINT_STRING_BYTES) } fn parse_actions(raw: Vec) -> Vec { @@ -172,9 +172,9 @@ fn parse_actions(raw: Vec) -> Vec { } actions.push(Action { // Key is protocol data - key: truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), + key: util::truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), // Label is shown to the user - label: truncate_utf8_bytes( + label: util::truncate_utf8_bytes( &util::sanitize_inline_display_text(&label), MAX_ACTION_LABEL_BYTES, ), @@ -193,7 +193,7 @@ fn sanitize_hints_for_storage(hints: HashMap) -> HashMap) -> HashMap owned_to_string(&value).and_then(|text| { // Keep hint text small - let bounded = truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); + let bounded = util::truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); string_to_owned_value(&bounded) }), "transient" | "resident" | "suppress-sound" => { @@ -242,30 +242,6 @@ fn owned_to_string(value: &OwnedValue) -> Option { .and_then(|owned| String::try_from(owned).ok()) } -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - if value.len() <= max_bytes { - // Fast path for common short payloads - return value.to_string(); - } - - // At most three continuation bytes can sit between the limit and a boundary - let mut end = max_bytes; - for _ in 0..3 { - if value.is_char_boundary(end) { - break; - } - end -= 1; - } - debug_assert!( - value.is_char_boundary(end), - "bounded backup must reach the current UTF-8 character boundary" - ); - value[..end].to_string() -} - #[cfg(test)] #[path = "tests/payload.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index 882ca4211..b16043ed6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -5,25 +5,11 @@ use zbus::zvariant::OwnedValue; use super::{ build_notification, owned_to_string, parse_actions, parse_urgency_hint, resolve_expiration, - sanitize_hints_for_storage, string_to_owned_value, truncate_utf8_bytes, NotificationInput, - SenderMetadata, MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES, + sanitize_hints_for_storage, string_to_owned_value, NotificationInput, SenderMetadata, + MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES, }; use unixnotis_core::{Config, NotificationImage, Urgency}; -#[test] -fn truncate_utf8_bytes_preserves_character_boundaries() { - let value = "abc🙂def"; - let truncated = truncate_utf8_bytes(value, 5); - assert_eq!(truncated, "abc"); -} - -#[test] -fn truncate_utf8_bytes_keeps_exact_boundary_and_handles_zero_limit() { - assert_eq!(truncate_utf8_bytes("abc", 3), "abc"); - assert_eq!(truncate_utf8_bytes("abc", 0), ""); - assert_eq!(truncate_utf8_bytes("éé", 3), "é"); -} - #[test] fn build_notification_clamps_summary_and_body_sizes() { let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); From 53f46b772c444d3bd4a3fc318b25d8f491373f47 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:30:57 -0500 Subject: [PATCH 072/275] chore: remove stale declarations and colocate daemon tests Summary: remove stale declarations and colocate daemon tests. Scope: repository. --- Cargo.toml | 1 - crates/noticenterctl/Cargo.toml | 2 +- .../src/daemon/auth/executable_trust/mod.rs | 4 +-- .../src/daemon/auth/tests/authorization.rs | 2 +- .../src/daemon/notifications/metrics.rs | 23 ------------- .../src/daemon/notifications/tests/metrics.rs | 33 ++++++++++++++++--- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 60b7c2626..fdae4e065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ wait-timeout = "0.2" zbus = { version = "4", default-features = false, features = ["tokio"] } gio = "0.21" -gdk-pixbuf = "0.21" gdk4-wayland = { version = "0.10.3", features = ["v4_18"] } glib = "0.21" gtk = { package = "gtk4", version = "0.10", features = ["v4_18"] } diff --git a/crates/noticenterctl/Cargo.toml b/crates/noticenterctl/Cargo.toml index 6f250e692..9117d293e 100644 --- a/crates/noticenterctl/Cargo.toml +++ b/crates/noticenterctl/Cargo.toml @@ -21,7 +21,7 @@ toml.workspace = true url.workspace = true wait-timeout.workspace = true zbus.workspace = true -shell-words = "1" +shell-words.workspace = true unixnotis-core = { path = "../unixnotis-core" } [dev-dependencies] diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs index 81df92458..97b7b050c 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -2,11 +2,9 @@ mod fingerprint; mod metadata; -mod paths; +pub(in crate::daemon::auth) mod paths; mod snapshots; -#[cfg(test)] -pub(super) use paths::canonicalize_best_effort; pub(super) use paths::is_trusted_control_executable_path; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index a45ecd8a1..ac2b66e1c 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -8,7 +8,7 @@ use super::authorization::{ }; #[cfg(target_os = "linux")] use super::credentials::CallerCredentials; -use super::executable_trust::canonicalize_best_effort; +use super::executable_trust::paths::canonicalize_best_effort; use super::support::write_executable; use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs index 21ca3fdab..962f66130 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs @@ -61,19 +61,6 @@ impl IngressMetrics { } ActiveHandler { metrics: self } } - - #[cfg(test)] - pub(super) fn snapshot(&self) -> IngressMetricsSnapshot { - IngressMetricsSnapshot { - notify_quota_rejections: self.notify_quota_rejections.load(Ordering::Relaxed), - notify_concurrency_rejections: self - .notify_concurrency_rejections - .load(Ordering::Relaxed), - close_quota_rejections: self.close_quota_rejections.load(Ordering::Relaxed), - active_handlers: self.active_handlers.load(Ordering::Relaxed), - peak_active_handlers: self.peak_active_handlers.load(Ordering::Relaxed), - } - } } impl Drop for ActiveHandler<'_> { @@ -82,16 +69,6 @@ impl Drop for ActiveHandler<'_> { } } -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct IngressMetricsSnapshot { - pub(super) notify_quota_rejections: u64, - pub(super) notify_concurrency_rejections: u64, - pub(super) close_quota_rejections: u64, - pub(super) active_handlers: usize, - pub(super) peak_active_handlers: usize, -} - #[cfg(test)] #[path = "tests/metrics.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs index 550ea6809..505d9d552 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs @@ -1,7 +1,30 @@ //! Notification ingress metric tests +use std::sync::atomic::Ordering; + use super::{IngressMetrics, RejectedRequest}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IngressMetricsSnapshot { + notify_quota_rejections: u64, + notify_concurrency_rejections: u64, + close_quota_rejections: u64, + active_handlers: usize, + peak_active_handlers: usize, +} + +fn snapshot(metrics: &IngressMetrics) -> IngressMetricsSnapshot { + IngressMetricsSnapshot { + notify_quota_rejections: metrics.notify_quota_rejections.load(Ordering::Relaxed), + notify_concurrency_rejections: metrics + .notify_concurrency_rejections + .load(Ordering::Relaxed), + close_quota_rejections: metrics.close_quota_rejections.load(Ordering::Relaxed), + active_handlers: metrics.active_handlers.load(Ordering::Relaxed), + peak_active_handlers: metrics.peak_active_handlers.load(Ordering::Relaxed), + } +} + #[test] fn rejection_counters_are_kept_separate_by_request_path() { let metrics = IngressMetrics::new(); @@ -14,7 +37,7 @@ fn rejection_counters_are_kept_separate_by_request_path() { ); assert_eq!(metrics.record_rejection(RejectedRequest::CloseQuota), 1); - let snapshot = metrics.snapshot(); + let snapshot = snapshot(&metrics); assert_eq!(snapshot.notify_quota_rejections, 2); assert_eq!(snapshot.notify_concurrency_rejections, 1); assert_eq!(snapshot.close_quota_rejections, 1); @@ -26,13 +49,13 @@ fn handler_guard_tracks_current_and_peak_concurrency_without_leaking_activity() let first = metrics.enter_handler(); let second = metrics.enter_handler(); - assert_eq!(metrics.snapshot().active_handlers, 2); - assert_eq!(metrics.snapshot().peak_active_handlers, 2); + assert_eq!(snapshot(&metrics).active_handlers, 2); + assert_eq!(snapshot(&metrics).peak_active_handlers, 2); drop(second); - assert_eq!(metrics.snapshot().active_handlers, 1); + assert_eq!(snapshot(&metrics).active_handlers, 1); drop(first); - let snapshot = metrics.snapshot(); + let snapshot = snapshot(&metrics); assert_eq!(snapshot.active_handlers, 0); assert_eq!(snapshot.peak_active_handlers, 2); } From b59ee1636ea4ceedf59e1ede5612b2e5065bd88c Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:34:16 -0500 Subject: [PATCH 073/275] refactor(daemon): move DND lifecycle into shared state Summary: move DND lifecycle into shared state. Scope: daemon. --- .../src/daemon/control/dnd.rs | 99 --------------- .../src/daemon/control/mod.rs | 1 - .../src/daemon/control/server.rs | 6 +- .../src/daemon/control/tests/server.rs | 115 +----------------- .../src/daemon/state/schedulers.rs | 93 +++++++++++++- .../src/daemon/state/tests/scheduler.rs | 111 ++++++++++++++++- crates/unixnotis-daemon/src/dnd_expiration.rs | 5 +- 7 files changed, 209 insertions(+), 221 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/control/dnd.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/dnd.rs b/crates/unixnotis-daemon/src/daemon/control/dnd.rs deleted file mode 100644 index 12a235c2a..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/dnd.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! DND mutation and persistence helpers for `ControlServer` -//! -//! Keeps toggle/set flow and guarded rollback logic out of the main interface file - -use crate::store::DndWrite; -use tracing::{debug, warn}; - -use super::ControlServer; - -const MAX_DND_DURATION_SECONDS: i64 = 366 * 24 * 60 * 60; - -impl ControlServer { - pub(super) async fn apply_dnd_state(&self, enabled: bool) -> zbus::fdo::Result<()> { - let _write_guard = self.state.lock_dnd_write().await; - let write = { - let mut store = self.state.store.lock().await; - // Set request mutates once under lock and records rollback guards - store.set_dnd(enabled) - }; - self.finalize_dnd_write(write).await - } - - pub(super) async fn apply_dnd_until(&self, expires_at: i64) -> zbus::fdo::Result<()> { - let _write_guard = self.state.lock_dnd_write().await; - let now = chrono::Utc::now().timestamp(); - let duration = expires_at.saturating_sub(now); - if duration <= 0 || duration > MAX_DND_DURATION_SECONDS { - return Err(zbus::fdo::Error::InvalidArgs( - "DND expiration must be within the next 366 days".to_string(), - )); - } - let write = { - let mut store = self.state.store.lock().await; - store.set_dnd_until(expires_at) - }; - self.finalize_dnd_write(write).await - } - - pub(super) async fn apply_toggle_dnd(&self) -> zbus::fdo::Result<()> { - let _write_guard = self.state.lock_dnd_write().await; - let write = { - let mut store = self.state.store.lock().await; - // Toggle computation and write stay in one critical section - store.toggle_dnd() - }; - self.finalize_dnd_write(write).await - } - - pub(crate) async fn apply_dnd_expiration(&self, expires_at: i64) -> zbus::fdo::Result<()> { - let _write_guard = self.state.lock_dnd_write().await; - let write = { - let mut store = self.state.store.lock().await; - // The store rejects stale deadlines that were replaced while the task slept - store.expire_dnd_if_current(expires_at, chrono::Utc::now().timestamp()) - }; - self.finalize_dnd_write(write).await - } - - async fn finalize_dnd_write(&self, write: DndWrite) -> zbus::fdo::Result<()> { - if let Some(store) = write.persist.as_ref() { - // Persist outside the main store lock to avoid blocking notify paths on I/O - if let Err(err) = store.persist(write.current, write.current_expires_at) { - warn!(?err, "failed to persist do-not-disturb state"); - // Only rollback if this failing write is still the latest in-memory value - let mut state = self.state.store.lock().await; - let rolled_back = state.rollback_dnd_write_if_current(&write); - if rolled_back { - debug!( - revision = write.revision, - current = write.current, - previous = write.previous, - "rolled back do-not-disturb state after persistence failure" - ); - } else { - debug!( - revision = write.revision, - current = write.current, - "skipped do-not-disturb rollback because newer state already exists" - ); - } - return Err(zbus::fdo::Error::Failed( - "failed to persist do-not-disturb state".to_string(), - )); - } - } - if write.changed { - // Scheduling follows durable commit so failed writes keep the previous timer - self.state.schedule_dnd_expiration(write.current_expires_at); - // Mutation is already committed; signal fanout is best-effort - if let Err(err) = self.state.publish_state_changed().await { - warn!( - ?err, - "do-not-disturb state changed but post-commit signal fanout failed" - ); - } - } - Ok(()) - } -} diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index de3df19be..097462342 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -1,7 +1,6 @@ //! D-Bus server for com.unixnotis.Control mod action; -mod dnd; mod inhibit; mod panel; mod query; diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 0238a93e0..ce3cb19d9 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -128,7 +128,7 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "SetDnd").await?; - self.apply_dnd_state(enabled).await + self.state.apply_dnd_state(enabled).await } pub(super) async fn set_dnd_until( @@ -137,12 +137,12 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "SetDndUntil").await?; - self.apply_dnd_until(expires_at).await + self.state.apply_dnd_until(expires_at).await } async fn toggle_dnd(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ToggleDnd").await?; - self.apply_toggle_dnd().await + self.state.apply_toggle_dnd().await } async fn inhibit( diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 27edf6a71..2d6a5deba 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -2,14 +2,13 @@ use std::collections::HashMap; use std::time::Duration; use chrono::Utc; -use unixnotis_core::{CloseReason, Config, Notification, NotificationImage, Urgency}; +use unixnotis_core::{CloseReason, Notification, NotificationImage, Urgency}; use zbus::zvariant::OwnedValue; use zbus::Message; use super::super::ControlServer; use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::store::NotificationStore; -use crate::test_support::{daemon_state_for_test, TempRoot}; +use crate::test_support::daemon_state_for_test; fn notification(summary: &str) -> Notification { Notification { @@ -111,116 +110,6 @@ async fn clear_saved_history_removes_archived_notifications() { .all(|view| view.id != id)); } -#[tokio::test] -async fn apply_dnd_state_rolls_back_when_persistence_fails() { - let state = daemon_state_for_test(false).await; - let root = TempRoot::new("dnd-persist-failure"); - let state_dir = root.join("state"); - std::fs::create_dir_all(&state_dir).expect("create state dir"); - std::fs::write(state_dir.join("unixnotis"), "not a directory").expect("block dnd parent"); - { - let mut store = state.store.lock().await; - *store = NotificationStore::new_with_state_dir(Config::default(), state_dir); - } - let server = ControlServer::new(state.clone()); - - let error = server - .apply_dnd_state(true) - .await - .expect_err("persistence failure should be reported"); - - assert!(error.to_string().contains("failed to persist")); - assert!(!state.store.lock().await.dnd_enabled()); -} - -#[tokio::test] -async fn apply_toggle_dnd_persists_successful_state_change() { - let state = daemon_state_for_test(false).await; - let root = TempRoot::new("dnd-toggle-success"); - let state_dir = root.join("state"); - { - let mut store = state.store.lock().await; - *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - } - let server = ControlServer::new(state.clone()); - - server - .apply_toggle_dnd() - .await - .expect("toggle should persist"); - - assert!(state.store.lock().await.dnd_enabled()); - let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) - .expect("read persisted dnd state"); - assert!(persisted.contains("\"dnd_enabled\":true")); -} - -#[tokio::test] -async fn apply_timed_dnd_validates_and_persists_a_future_deadline() { - let state = daemon_state_for_test(false).await; - let root = TempRoot::new("dnd-timed-success"); - let state_dir = root.join("state"); - { - let mut store = state.store.lock().await; - *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - } - let server = ControlServer::new(state.clone()); - let expires_at = Utc::now().timestamp() + 3_600; - - server - .apply_dnd_until(expires_at) - .await - .expect("timed DND should persist"); - - let store = state.store.lock().await; - assert!(store.dnd_enabled()); - assert_eq!(store.dnd_expires_at(), Some(expires_at)); - drop(store); - let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) - .expect("read persisted timed DND state"); - assert!(persisted.contains(&format!("\"expires_at\":{expires_at}"))); -} - -#[tokio::test] -async fn apply_timed_dnd_rejects_past_and_excessive_deadlines_without_mutation() { - let state = daemon_state_for_test(false).await; - let server = ControlServer::new(state.clone()); - let now = Utc::now().timestamp(); - - assert!(server.apply_dnd_until(now - 1).await.is_err()); - assert!(server - .apply_dnd_until(now + 367 * 24 * 60 * 60) - .await - .is_err()); - - let store = state.store.lock().await; - assert!(!store.dnd_enabled()); - assert_eq!(store.dnd_expires_at(), None); -} - -#[tokio::test] -async fn dnd_updates_wait_for_the_prior_persistence_commit() { - let state = daemon_state_for_test(false).await; - let guard = state.lock_dnd_write().await; - let server = ControlServer::new(state.clone()); - let mut update = Box::pin(server.apply_dnd_state(true)); - - assert!( - tokio::time::timeout(Duration::from_millis(25), &mut update) - .await - .is_err(), - "later DND update should wait for the current writer" - ); - assert!(!state.store.lock().await.dnd_enabled()); - - drop(guard); - tokio::time::timeout(Duration::from_millis(500), update) - .await - .expect("DND update should resume after the prior commit") - .expect("DND update should succeed"); - assert!(state.store.lock().await.dnd_enabled()); -} - #[tokio::test] async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/state/schedulers.rs b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs index c2a8d883d..6eebad13f 100644 --- a/crates/unixnotis-daemon/src/daemon/state/schedulers.rs +++ b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs @@ -3,13 +3,16 @@ use std::sync::atomic::Ordering; use tokio::sync::MutexGuard; -use tracing::warn; +use tracing::{debug, warn}; use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; +use crate::store::DndWrite; use super::DaemonState; +const MAX_DND_DURATION_SECONDS: i64 = 366 * 24 * 60 * 60; + impl DaemonState { pub(in crate::daemon) async fn lock_dnd_write(&self) -> MutexGuard<'_, ()> { // One writer keeps disk state and the scheduled deadline in the same order @@ -38,6 +41,94 @@ impl DaemonState { scheduler.schedule(expires_at); } + pub(in crate::daemon) async fn apply_dnd_state(&self, enabled: bool) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // The store records the previous revision so failed persistence can roll back safely + store.set_dnd(enabled) + }; + self.finalize_dnd_write(write).await + } + + pub(in crate::daemon) async fn apply_dnd_until( + &self, + expires_at: i64, + ) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let now = chrono::Utc::now().timestamp(); + let duration = expires_at.saturating_sub(now); + if duration <= 0 || duration > MAX_DND_DURATION_SECONDS { + return Err(zbus::fdo::Error::InvalidArgs( + "DND expiration must be within the next 366 days".to_string(), + )); + } + let write = { + let mut store = self.store.lock().await; + store.set_dnd_until(expires_at) + }; + self.finalize_dnd_write(write).await + } + + pub(in crate::daemon) async fn apply_toggle_dnd(&self) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // Toggle computation and mutation share one store revision + store.toggle_dnd() + }; + self.finalize_dnd_write(write).await + } + + pub(crate) async fn apply_dnd_expiration(&self, expires_at: i64) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // Stale timers cannot disable a newer timed or indefinite DND value + store.expire_dnd_if_current(expires_at, chrono::Utc::now().timestamp()) + }; + self.finalize_dnd_write(write).await + } + + async fn finalize_dnd_write(&self, write: DndWrite) -> zbus::fdo::Result<()> { + if let Some(store) = write.persist.as_ref() { + // Disk I/O stays outside the notification-store lock + if let Err(error) = store.persist(write.current, write.current_expires_at) { + warn!(?error, "failed to persist do-not-disturb state"); + let mut state = self.store.lock().await; + let rolled_back = state.rollback_dnd_write_if_current(&write); + if rolled_back { + debug!( + revision = write.revision, + current = write.current, + previous = write.previous, + "rolled back do-not-disturb state after persistence failure" + ); + } else { + debug!( + revision = write.revision, + current = write.current, + "skipped do-not-disturb rollback because newer state already exists" + ); + } + return Err(zbus::fdo::Error::Failed( + "failed to persist do-not-disturb state".to_string(), + )); + } + } + if write.changed { + // Timer and signal updates follow the durable state transition + self.schedule_dnd_expiration(write.current_expires_at); + if let Err(error) = self.publish_state_changed().await { + warn!( + ?error, + "do-not-disturb state changed but post-commit signal fanout failed" + ); + } + } + Ok(()) + } + pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { // Scheduler is wired once during daemon startup if self.scheduler.set(scheduler).is_err() { diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs index 33d8a6eb5..03a73d738 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs @@ -1,7 +1,116 @@ use std::time::Duration; +use chrono::Utc; +use unixnotis_core::Config; + use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::test_support::daemon_state_for_test; +use crate::store::NotificationStore; +use crate::test_support::{daemon_state_for_test, TempRoot}; + +#[tokio::test] +async fn dnd_state_rolls_back_when_persistence_fails() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-persist-failure"); + let state_dir = root.join("state"); + std::fs::create_dir_all(&state_dir).expect("create state dir"); + std::fs::write(state_dir.join("unixnotis"), "not a directory").expect("block DND parent"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir); + } + + let error = state + .apply_dnd_state(true) + .await + .expect_err("persistence failure should be reported"); + + assert!(error.to_string().contains("failed to persist")); + assert!(!state.store.lock().await.dnd_enabled()); +} + +#[tokio::test] +async fn toggled_dnd_persists_the_successful_state_change() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-toggle-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + + state + .apply_toggle_dnd() + .await + .expect("toggle should persist"); + + assert!(state.store.lock().await.dnd_enabled()); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted DND state"); + assert!(persisted.contains("\"dnd_enabled\":true")); +} + +#[tokio::test] +async fn timed_dnd_validates_and_persists_a_future_deadline() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-timed-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + let expires_at = Utc::now().timestamp() + 3_600; + + state + .apply_dnd_until(expires_at) + .await + .expect("timed DND should persist"); + + let store = state.store.lock().await; + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + drop(store); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted timed DND state"); + assert!(persisted.contains(&format!("\"expires_at\":{expires_at}"))); +} + +#[tokio::test] +async fn timed_dnd_rejects_past_and_excessive_deadlines_without_mutation() { + let state = daemon_state_for_test(false).await; + let now = Utc::now().timestamp(); + + assert!(state.apply_dnd_until(now - 1).await.is_err()); + assert!(state + .apply_dnd_until(now + 367 * 24 * 60 * 60) + .await + .is_err()); + + let store = state.store.lock().await; + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); +} + +#[tokio::test] +async fn dnd_updates_wait_for_the_prior_persistence_commit() { + let state = daemon_state_for_test(false).await; + let guard = state.lock_dnd_write().await; + let mut update = Box::pin(state.apply_dnd_state(true)); + + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut update) + .await + .is_err(), + "later DND update should wait for the current writer" + ); + assert!(!state.store.lock().await.dnd_enabled()); + + drop(guard); + tokio::time::timeout(Duration::from_millis(500), update) + .await + .expect("DND update should resume after the prior commit") + .expect("DND update should succeed"); + assert!(state.store.lock().await.dnd_enabled()); +} #[tokio::test] async fn cancel_expiration_sends_cancel_command_when_scheduler_is_installed() { diff --git a/crates/unixnotis-daemon/src/dnd_expiration.rs b/crates/unixnotis-daemon/src/dnd_expiration.rs index 53a979bb2..e082d7828 100644 --- a/crates/unixnotis-daemon/src/dnd_expiration.rs +++ b/crates/unixnotis-daemon/src/dnd_expiration.rs @@ -6,7 +6,7 @@ use std::time::Duration; use tokio::sync::watch; use tracing::warn; -use crate::daemon::{ControlServer, DaemonState}; +use crate::daemon::DaemonState; const MAX_CLOCK_RECHECK: Duration = Duration::from_mins(1); const PERSIST_RETRY_DELAY: Duration = Duration::from_secs(5); @@ -35,8 +35,7 @@ impl DndExpirationScheduler { let delay = delay_until_recheck(chrono::Utc::now().timestamp(), expires_at); if delay.is_zero() { // The store verifies this is still the current deadline before mutating - let server = ControlServer::new(state.clone()); - if let Err(err) = server.apply_dnd_expiration(expires_at).await { + if let Err(err) = state.apply_dnd_expiration(expires_at).await { warn!( ?err, expires_at, "failed to expire timed do-not-disturb state" From 9e823c84842825501bbc3a6cbeadf3c3706e1d98 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:48:53 -0500 Subject: [PATCH 074/275] test: close sound and filesystem mutation gaps Summary: close sound and filesystem mutation gaps. Scope: repository. --- .../unixnotis-core/src/filesystem/atomic.rs | 3 +- crates/unixnotis-core/src/filesystem/exact.rs | 39 ++++--- .../src/filesystem/tests/exact.rs | 106 ++++++++++++++++-- .../src/filesystem/tests/regular.rs | 2 + crates/unixnotis-core/src/util/text.rs | 10 +- .../unixnotis-daemon/src/sound/tests/wav.rs | 30 +++++ crates/unixnotis-daemon/src/sound/wav.rs | 11 +- 7 files changed, 166 insertions(+), 35 deletions(-) diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 00b94d9cd..07d29b0b3 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -11,7 +11,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use super::descriptor::{contained_resolve_flags, open_parent, sync_directory}; -use super::exact::exclusive_create_collided; use super::regular::{existing_target_mode, validate_existing_target}; const TEMP_ATTEMPTS: u8 = 16; @@ -107,7 +106,7 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res contained_resolve_flags(), ) { Ok(fd) => fd, - Err(error) if exclusive_create_collided(error) => { + Err(rustix::io::Errno::EXIST) => { // A collision is safe only when the existing destination is a regular file validate_existing_target(&parent_fd, &file_name)?; return Ok(false); diff --git a/crates/unixnotis-core/src/filesystem/exact.rs b/crates/unixnotis-core/src/filesystem/exact.rs index 147572aa1..84908ba4d 100644 --- a/crates/unixnotis-core/src/filesystem/exact.rs +++ b/crates/unixnotis-core/src/filesystem/exact.rs @@ -41,6 +41,23 @@ struct ExactMember { created: bool, } +#[derive(Clone, Copy)] +struct ExactMode(u32); + +impl ExactMode { + const fn new(mode: u32) -> Self { + Self(mode & 0o777) + } + + const fn rustix(self) -> Mode { + Mode::from_raw_mode(self.0) + } + + const fn permissions(self) -> u32 { + self.0 + } +} + enum ExactMemberResult { Exact(ExactMember), ContentsMismatch, @@ -100,6 +117,8 @@ pub fn ensure_exact_file_pair( )); } + let mode = ExactMode::new(mode); + let marker_mode = ExactMode::new(marker_mode); let file = match create_or_validate_member(&parent_fd, &file_name, contents, mode)? { ExactMemberResult::Exact(member) => member, ExactMemberResult::ContentsMismatch => { @@ -163,6 +182,7 @@ pub(super) fn ensure_exact_file_at( contents: &[u8], mode: u32, ) -> io::Result { + let mode = ExactMode::new(mode); let member = match create_or_validate_member(parent_fd, file_name, contents, mode)? { ExactMemberResult::Exact(member) => member, ExactMemberResult::ContentsMismatch => { @@ -188,7 +208,7 @@ fn create_or_validate_member( parent_fd: &OwnedFd, file_name: &OsString, contents: &[u8], - mode: u32, + mode: ExactMode, ) -> io::Result { let fd = match openat2( parent_fd, @@ -198,11 +218,11 @@ fn create_or_validate_member( .union(OFlags::CLOEXEC) .union(OFlags::CREATE) .union(OFlags::EXCL), - file_mode(mode), + mode.rustix(), contained_resolve_flags(), ) { Ok(fd) => fd, - Err(error) if exclusive_create_collided(error) => { + Err(rustix::io::Errno::EXIST) => { let mut file = open_regular_file_at(parent_fd, file_name)?; if !file_contents_equal(&mut file, contents)? { return Ok(ExactMemberResult::ContentsMismatch); @@ -232,11 +252,6 @@ fn create_or_validate_member( })) } -pub(super) fn exclusive_create_collided(error: rustix::io::Errno) -> bool { - // Only an existing target may enter the create-or-compare collision path - error == rustix::io::Errno::EXIST -} - fn rollback_pair_after_error( parent_fd: &OwnedFd, file: (&OsString, &ExactMember), @@ -299,15 +314,11 @@ fn rollback_created_member( Ok(()) } -fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { - file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; +fn set_mode_and_sync(file: &fs::File, mode: ExactMode) -> io::Result<()> { + file.set_permissions(fs::Permissions::from_mode(mode.permissions()))?; file.sync_all() } -const fn file_mode(mode: u32) -> Mode { - Mode::from_raw_mode(mode & 0o777) -} - #[cfg(test)] #[path = "tests/exact.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/tests/exact.rs b/crates/unixnotis-core/src/filesystem/tests/exact.rs index 76ce7edea..84827052c 100644 --- a/crates/unixnotis-core/src/filesystem/tests/exact.rs +++ b/crates/unixnotis-core/src/filesystem/tests/exact.rs @@ -1,21 +1,16 @@ //! Exact regular-file transaction tests +use std::ffi::OsString; use std::fs; use std::os::unix::fs::{symlink, PermissionsExt}; use super::{ - ensure_exact_file, ensure_exact_file_pair, exclusive_create_collided, EnsureExactFileOutcome, - EnsureExactFilePairOutcome, + ensure_exact_file, ensure_exact_file_pair, rollback_created_member, EnsureExactFileOutcome, + EnsureExactFilePairOutcome, ExactMember, }; +use crate::filesystem::descriptor::open_parent_existing; use crate::test_support::unique_temp_path; -#[test] -fn exclusive_create_collision_classification_accepts_only_existing_targets() { - assert!(exclusive_create_collided(rustix::io::Errno::EXIST)); - assert!(!exclusive_create_collided(rustix::io::Errno::ACCESS)); - assert!(!exclusive_create_collided(rustix::io::Errno::INVAL)); -} - #[test] fn exact_file_creation_accepts_only_identical_existing_bytes() { let root = unique_temp_path("exact-file"); @@ -50,6 +45,25 @@ fn exact_file_creation_accepts_only_identical_existing_bytes() { let _ = fs::remove_dir_all(root); } +#[test] +fn exact_file_creation_masks_non_permission_mode_bits() { + let root = unique_temp_path("exact-file-mode-mask"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + + ensure_exact_file(&target, b"bundle\n", 0o100_600).expect("create exact file"); + + assert_eq!( + fs::metadata(&target) + .expect("target metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + #[test] fn exact_file_creation_never_follows_a_collision_symlink() { let root = unique_temp_path("exact-file-link"); @@ -219,3 +233,77 @@ fn exact_pair_rejects_different_parents_and_reused_names() { assert!(!target.exists()); let _ = fs::remove_dir_all(root); } + +#[test] +fn rollback_accepts_a_created_member_that_is_already_missing() { + let root = unique_temp_path("exact-rollback-missing"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("state"); + fs::write(&target, b"owned").expect("write target"); + let retained = fs::File::open(&target).expect("open retained target"); + let (parent_fd, file_name) = open_parent_existing(&target).expect("open retained parent"); + fs::remove_file(&target).expect("remove visible target"); + let member = ExactMember { + file: retained, + created: true, + }; + + rollback_created_member(&parent_fd, &file_name, &member) + .expect("an already absent created member needs no rollback"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_propagates_non_missing_lookup_errors() { + let root = unique_temp_path("exact-rollback-lookup-error"); + fs::create_dir_all(&root).expect("create test root"); + let retained_path = root.join("retained"); + fs::write(&retained_path, b"owned").expect("write retained file"); + let member = ExactMember { + file: fs::File::open(&retained_path).expect("open retained file"), + created: true, + }; + let (parent_fd, _file_name) = + open_parent_existing(&retained_path).expect("open retained parent"); + let oversized_name = OsString::from("x".repeat(1_024)); + + rollback_created_member(&parent_fd, &oversized_name, &member) + .expect_err("invalid lookup errors must not be treated as a missing target"); + + assert_eq!( + fs::read_to_string(retained_path).expect("read retained file"), + "owned" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_preserves_a_same_device_replacement() { + let root = unique_temp_path("exact-rollback-replacement"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("state"); + let moved = root.join("original"); + fs::write(&target, b"owned").expect("write original target"); + let retained = fs::File::open(&target).expect("open retained target"); + let (parent_fd, file_name) = open_parent_existing(&target).expect("open retained parent"); + fs::rename(&target, &moved).expect("move original target"); + fs::write(&target, b"replacement").expect("write replacement target"); + let member = ExactMember { + file: retained, + created: true, + }; + + rollback_created_member(&parent_fd, &file_name, &member) + .expect_err("identity mismatch must stop rollback"); + + assert_eq!( + fs::read_to_string(target).expect("read replacement target"), + "replacement" + ); + assert_eq!( + fs::read_to_string(moved).expect("read original target"), + "owned" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/regular.rs b/crates/unixnotis-core/src/filesystem/tests/regular.rs index 7024fc976..c4329e72d 100644 --- a/crates/unixnotis-core/src/filesystem/tests/regular.rs +++ b/crates/unixnotis-core/src/filesystem/tests/regular.rs @@ -22,6 +22,8 @@ fn bounded_comparison_accepts_exact_bytes_and_rejects_larger_files() { assert!( regular_file_contents_equal(&target, b"bundle\n", 7).expect("compare exact regular file") ); + assert!(regular_file_contents_equal(&target, b"bundle\n", 8) + .expect("compare regular file below the maximum")); assert!( !regular_file_contents_equal(&target, b"bundle", 6).expect("reject oversized regular file") ); diff --git a/crates/unixnotis-core/src/util/text.rs b/crates/unixnotis-core/src/util/text.rs index a6941500b..6dc1f8b85 100644 --- a/crates/unixnotis-core/src/util/text.rs +++ b/crates/unixnotis-core/src/util/text.rs @@ -8,11 +8,11 @@ pub fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { return value.to_string(); } - // A UTF-8 scalar uses at most four bytes, so only its continuation bytes need inspection - let mut end = max_bytes; - while !value.is_char_boundary(end) { - end -= 1; - } + // A UTF-8 scalar uses at most four bytes, so this range examines no more than four offsets + let end = (max_bytes.saturating_sub(3)..=max_bytes) + .rev() + .find(|offset| value.is_char_boundary(*offset)) + .unwrap_or_default(); value[..end].to_string() } diff --git a/crates/unixnotis-daemon/src/sound/tests/wav.rs b/crates/unixnotis-daemon/src/sound/tests/wav.rs index 76a34a66e..4ed393320 100644 --- a/crates/unixnotis-daemon/src/sound/tests/wav.rs +++ b/crates/unixnotis-daemon/src/sound/tests/wav.rs @@ -56,6 +56,24 @@ fn validate(bytes: &[u8]) -> bool { is_safe_pcm_wav(&file, bytes.len() as u64) } +fn canonical_wave() -> Vec { + wave(&[ + chunk(b"fmt ", &pcm_format(1, 44_100, 16)), + chunk(b"data", &[0; 2]), + ]) +} + +#[test] +fn riff_and_wave_identifiers_are_validated_independently() { + let mut wrong_riff = canonical_wave(); + wrong_riff[..4].copy_from_slice(b"JUNK"); + let mut wrong_wave = canonical_wave(); + wrong_wave[8..12].copy_from_slice(b"AVI "); + + assert!(!validate(&wrong_riff)); + assert!(!validate(&wrong_wave)); +} + #[test] fn canonical_pcm_wave_requires_format_then_nonempty_aligned_data() { let format = chunk(b"fmt ", &pcm_format(2, 48_000, 16)); @@ -96,6 +114,18 @@ fn odd_unknown_chunks_use_declared_padding_without_hiding_following_chunks() { assert!(validate(&wave(&[junk, format, data]))); } +#[test] +fn chunk_budget_accepts_the_limit_and_rejects_one_more_chunk() { + let mut chunks = vec![chunk(b"JUNK", &[]); MAX_WAV_CHUNKS - 2]; + chunks.push(chunk(b"fmt ", &pcm_format(1, 44_100, 16))); + chunks.push(chunk(b"data", &[0; 2])); + + assert!(validate(&wave(&chunks))); + + chunks.insert(0, chunk(b"JUNK", &[])); + assert!(!validate(&wave(&chunks))); +} + #[test] fn pcm_format_bounds_and_derived_rates_must_be_consistent() { for invalid in [ diff --git a/crates/unixnotis-daemon/src/sound/wav.rs b/crates/unixnotis-daemon/src/sound/wav.rs index 57668a037..302d50693 100644 --- a/crates/unixnotis-daemon/src/sound/wav.rs +++ b/crates/unixnotis-daemon/src/sound/wav.rs @@ -56,13 +56,13 @@ pub(super) fn is_safe_pcm_wav(file: &fs::File, file_len: u64) -> bool { None => return false, }; let data_end = match data_start.checked_add(u64::from(chunk_size)) { - Some(offset) if offset <= file_len => offset, - _ => return false, + Some(offset) => offset, + None => return false, }; // RIFF chunks use one padding byte after odd-sized payloads let padded_end = match data_end.checked_add(u64::from(chunk_size & 1)) { - Some(offset) if offset <= file_len => offset, - _ => return false, + Some(offset) => offset, + None => return false, }; match &chunk_header[..4] { @@ -93,7 +93,8 @@ pub(super) fn is_safe_pcm_wav(file: &fs::File, file_len: u64) -> bool { cursor = padded_end; } - cursor == file_len && pcm_format.is_some() && found_data + // Exact cursor equality rejects truncated chunks and bytes outside declared chunk framing + cursor == file_len && found_data } fn read_pcm_format(file: &fs::File, offset: u64, chunk_size: u32) -> Option { From 870ad62325ed8eba6ad700df7284d277e71849de Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 22 Jul 2026 16:52:09 -0500 Subject: [PATCH 075/275] refactor(daemon): simplify ingress peak tracking Summary: simplify ingress peak tracking. Scope: daemon. --- .../src/daemon/notifications/metrics.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs index 962f66130..a9c954f52 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs @@ -46,19 +46,9 @@ impl IngressMetrics { .active_handlers .fetch_add(1, Ordering::Relaxed) .saturating_add(1); - // A compare loop works on every supported Rust release and never lowers the peak - let mut peak = self.peak_active_handlers.load(Ordering::Relaxed); - while active > peak { - match self.peak_active_handlers.compare_exchange_weak( - peak, - active, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => break, - Err(observed) => peak = observed, - } - } + // Atomic maximum records concurrency peaks without locks or retry-loop bookkeeping + self.peak_active_handlers + .fetch_max(active, Ordering::Relaxed); ActiveHandler { metrics: self } } } From 842c5d827c2af43d3be102318092958d2ff419d9 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 16:50:58 -0500 Subject: [PATCH 076/275] fix(dbus): serialize state-bearing signal publication Summary: serialize state-bearing signal publication. Scope: dbus. --- .../src/daemon/bus/clients.rs | 13 +- .../src/daemon/control/action.rs | 7 +- .../src/daemon/control/inhibit.rs | 23 +--- .../src/daemon/control/reply.rs | 19 +-- .../src/daemon/control/tests/action.rs | 41 ++++++- .../src/daemon/control/tests/reply.rs | 54 +++++++-- .../src/daemon/events/inhibitors.rs | 17 +-- .../src/daemon/events/publisher.rs | 19 ++- .../src/daemon/events/state.rs | 29 +++-- .../src/daemon/events/tests/cache.rs | 25 +++- .../src/daemon/events/tests/state.rs | 111 ++++++++++++++++++ 11 files changed, 287 insertions(+), 71 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/bus/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/clients.rs index be144ccb1..5b67e87a9 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/clients.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/clients.rs @@ -7,17 +7,12 @@ impl DaemonState { // Sender metadata is keyed by unique names and cannot survive owner loss self.sender_metadata_cache.remove(owner); - let inhibitor_change = { + let inhibitors_removed = { let mut store = self.store.lock().await; - if store.remove_inhibitors_by_owner(owner) { - Some((store.inhibited(), store.inhibitor_count())) - } else { - None - } + store.remove_inhibitors_by_owner(owner) }; - if let Some((active, count)) = inhibitor_change { - self.publish_inhibitors_changed(active, count, "owner-disconnected") - .await; + if inhibitors_removed { + self.publish_inhibitors_changed("owner-disconnected").await; } } } diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index cd60e5cd5..bdadc973a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -48,7 +48,7 @@ impl ControlServer { .await .map_err(to_fdo_error)?; if !proxy - .name_has_owner(bus_name) + .name_has_owner(bus_name.clone()) .await .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))? { @@ -69,9 +69,10 @@ impl ControlServer { )); } - // Reuse the freedesktop signal path only after identity and liveness checks pass + // Scope the signal to the stored owner so unrelated bus listeners cannot observe it let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; + .map_err(to_fdo_error)? + .set_destination(bus_name.to_owned()); NotificationServer::action_invoked(&context, id, action_key) .await .map_err(to_fdo_error) diff --git a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs index ee074de88..158a0ce90 100644 --- a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs +++ b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs @@ -21,7 +21,7 @@ impl ControlServer { let normalized_scope = sanitize::normalize_inhibit_scope(scope)?; let sanitized_reason = sanitize::sanitize_inhibit_reason(reason); // Track inhibitors by unique bus name so cleanup on disconnect is reliable - let (id, active, count) = { + let id = { let mut store = self.state.store.lock().await; if store.inhibitor_count() >= MAX_ACTIVE_INHIBITORS { // Hard cap blocks unbounded growth from accidental loops or hostile callers @@ -29,14 +29,9 @@ impl ControlServer { "inhibitor limit reached ({MAX_ACTIVE_INHIBITORS})" ))); } - let id = store.add_inhibitor(sender.to_string(), sanitized_reason, normalized_scope); - let active = store.inhibited(); - let count = store.inhibitor_count(); - (id, active, count) + store.add_inhibitor(sender.to_string(), sanitized_reason, normalized_scope) }; - self.state - .publish_inhibitors_changed(active, count, "added") - .await; + self.state.publish_inhibitors_changed("added").await; Ok(id) } @@ -51,14 +46,10 @@ impl ControlServer { .ok_or_else(|| zbus::fdo::Error::Failed("missing sender".to_string()))?; let owner = sender.to_string(); // Only the owner can remove it - let (removed, active, count) = { + let removed = { let mut store = self.state.store.lock().await; match store.remove_inhibitor(id, &owner) { - Ok(removed) => { - let active = store.inhibited(); - let count = store.inhibitor_count(); - (removed, active, count) - } + Ok(removed) => removed, Err(err) => { return Err(zbus::fdo::Error::AccessDenied(err.message())); } @@ -68,9 +59,7 @@ impl ControlServer { // Unknown IDs are treated as a no-op to keep clients resilient return Ok(()); } - self.state - .publish_inhibitors_changed(active, count, "removed") - .await; + self.state.publish_inhibitors_changed("removed").await; Ok(()) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index baa388256..c3f6e73d3 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -4,6 +4,7 @@ use std::future::Future; use unixnotis_core::Notification; use zbus::fdo::DBusProxy; +use zbus::names::BusName; use zbus::SignalContext; use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; @@ -44,11 +45,12 @@ impl ControlServer { ) })? }; - self.ensure_reply_sender_is_live(&target).await?; + let destination = self.reply_destination(&target).await?; - // Emit only after all live-state and text checks have passed + // A destination header keeps sensitive reply text visible only to its owning connection let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; + .map_err(to_fdo_error)? + .set_destination(destination); NotificationServer::notification_replied(&context, id, reply_text) .await .map_err(to_fdo_error)?; @@ -66,12 +68,15 @@ impl ControlServer { Ok(()) } - async fn ensure_reply_sender_is_live(&self, target: &Notification) -> zbus::fdo::Result<()> { + async fn reply_destination( + &self, + target: &Notification, + ) -> zbus::fdo::Result> { let sender = target .sender_name .as_deref() .ok_or_else(application_unavailable_error)?; - let bus_name = zbus::names::BusName::try_from(sender).map_err(|error| { + let bus_name = BusName::try_from(sender).map_err(|error| { // Stored sender names should always be unique D-Bus names from message headers tracing::debug!(?error, "inline reply target has an invalid sender name"); application_unavailable_error() @@ -80,13 +85,13 @@ impl ControlServer { .await .map_err(to_fdo_error)?; let has_owner = proxy - .name_has_owner(bus_name) + .name_has_owner(bus_name.clone()) .await .map_err(|err| zbus::fdo::Error::Failed(err.to_string()))?; if !has_owner { return Err(application_unavailable_error()); } - Ok(()) + Ok(bus_name.to_owned()) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index bda049304..2f368fa73 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -15,7 +15,7 @@ use crate::test_support::daemon_state_for_test; async fn validated_action_emits_only_an_advertised_live_action() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = action_signal_stream(&state).await; + let mut stream = action_signal_stream(&sender).await; let id = { let mut store = state.store.lock().await; store @@ -35,6 +35,41 @@ async fn validated_action_emits_only_an_advertised_live_action() { ); } +#[tokio::test] +async fn action_signal_reaches_owner_but_not_unrelated_observer() { + let state = daemon_state_for_test(false).await; + let owner = Connection::session().await.expect("owner session bus"); + let observer = Connection::session().await.expect("observer session bus"); + let mut owner_stream = action_signal_stream(&owner).await; + let mut observer_stream = action_signal_stream(&observer).await; + let id = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&owner, "open"), 0) + .notification + .id + }; + + ControlServer::new(state) + .invoke_validated_action(id, "open") + .await + .expect("invoke owner action"); + + assert_eq!( + next_action_signal(&mut owner_stream).await, + (id, "open".to_string()) + ); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + observer_stream.try_next() + ) + .await + .is_err(), + "unrelated observer must not receive action signal" + ); +} + #[tokio::test] async fn validated_action_rejects_missing_and_stale_action_generations() { let state = daemon_state_for_test(false).await; @@ -93,7 +128,7 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { } } -async fn action_signal_stream(state: &crate::daemon::DaemonState) -> MessageStream { +async fn action_signal_stream(receiver: &Connection) -> MessageStream { let rule = MatchRule::builder() .msg_type(Type::Signal) .interface("org.freedesktop.Notifications") @@ -103,7 +138,7 @@ async fn action_signal_stream(state: &crate::daemon::DaemonState) -> MessageStre .path(NOTIFICATIONS_OBJECT_PATH) .expect("notification path") .build(); - MessageStream::for_match_rule(rule, state.connection(), Some(8)) + MessageStream::for_match_rule(rule, receiver, Some(8)) .await .expect("action signal stream") } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index f27ff6341..0a13c50ac 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -59,7 +59,7 @@ fn validate_reply_text_rejects_empty_oversized_nul_and_multiline_values() { async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = reply_signal_stream(&state).await; + let mut stream = reply_signal_stream(&state, &sender).await; let id = { let mut store = state.store.lock().await; store @@ -84,7 +84,7 @@ async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { async fn submit_inline_reply_keeps_resident_notification_live() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = reply_signal_stream(&state).await; + let mut stream = reply_signal_stream(&state, &sender).await; let id = { let mut store = state.store.lock().await; store @@ -108,7 +108,7 @@ async fn submit_inline_reply_keeps_resident_notification_live() { async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = reply_signal_stream(&state).await; + let mut stream = reply_signal_stream(&state, &sender).await; let messages = [ "مرحبًا، سأصل قريبًا".to_string(), "שלום, אגיע בקרוב".to_string(), @@ -141,7 +141,7 @@ async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { async fn reply_listener_replacement_survives_generation_safe_dismissal() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = reply_signal_stream(&state).await; + let mut stream = reply_signal_stream(&state, &sender).await; let id = { let mut store = state.store.lock().await; store @@ -178,7 +178,7 @@ async fn reply_listener_replacement_survives_generation_safe_dismissal() { async fn reply_listener_close_removes_replied_notification_without_history() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let mut stream = reply_signal_stream(&state).await; + let mut stream = reply_signal_stream(&state, &sender).await; let id = { let mut store = state.store.lock().await; store @@ -252,6 +252,33 @@ async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { .is_some()); } +#[tokio::test] +async fn inline_reply_signal_reaches_owner_but_not_unrelated_observer() { + let state = daemon_state_for_test(false).await; + let owner = Connection::session().await.expect("owner session bus"); + let observer = Connection::session().await.expect("observer session bus"); + let mut owner_stream = reply_signal_stream(&state, &owner).await; + let mut observer_stream = reply_signal_stream(&state, &observer).await; + let id = { + let mut store = state.store.lock().await; + store + .insert(reply_notification(true, &owner), 0) + .notification + .id + }; + + ControlServer::new(state) + .submit_inline_reply(id, "private reply") + .await + .expect("submit owner reply"); + + assert_eq!( + next_reply_signal(&mut owner_stream).await, + (id, "private reply".to_string()) + ); + assert_no_reply_signal(&mut observer_stream).await; +} + fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { Notification { id: 0, @@ -290,8 +317,10 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { } } -async fn reply_signal_stream(state: &crate::daemon::DaemonState) -> MessageStream { - let receiver = Connection::session().await.expect("receiver session bus"); +async fn reply_signal_stream( + state: &crate::daemon::DaemonState, + receiver: &Connection, +) -> MessageStream { let sender = state .connection() .unique_name() @@ -308,11 +337,20 @@ async fn reply_signal_stream(state: &crate::daemon::DaemonState) -> MessageStrea .member("NotificationReplied") .expect("reply member") .build(); - MessageStream::for_match_rule(rule, &receiver, Some(4)) + MessageStream::for_match_rule(rule, receiver, Some(4)) .await .expect("reply signal stream") } +async fn assert_no_reply_signal(stream: &mut MessageStream) { + assert!( + tokio::time::timeout(Duration::from_millis(100), stream.try_next()) + .await + .is_err(), + "unrelated observer must not receive reply text" + ); +} + async fn next_reply_signal(stream: &mut MessageStream) -> (u32, String) { let signal = tokio::time::timeout(Duration::from_millis(500), stream.try_next()) .await diff --git a/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs index 1bb3b286e..300080428 100644 --- a/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs +++ b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs @@ -7,12 +7,15 @@ use crate::daemon::{ControlServer, DaemonState}; use super::publisher::DaemonEventPublisher; impl DaemonState { - pub(in crate::daemon) async fn publish_inhibitors_changed( - &self, - active: bool, - count: u32, - action: &'static str, - ) { + pub(in crate::daemon) async fn publish_inhibitors_changed(&self, action: &'static str) { + let _publication = self.events.ordered_publication().await; + let state = { + // Read the count only after ordering so stale captured values cannot fan out later + let store = self.store.lock().await; + store.control_state() + }; + let active = state.inhibited; + let count = state.inhibitor_count; if let Err(error) = self.events.inhibitors_changed(active, count).await { warn!( ?error, @@ -21,7 +24,7 @@ impl DaemonState { "inhibitor mutation committed but inhibitor fanout failed" ); } - if let Err(error) = self.publish_state_changed().await { + if let Err(error) = self.events.state_changed(state).await { warn!( ?error, action, "inhibitor mutation committed but state fanout failed" diff --git a/crates/unixnotis-daemon/src/daemon/events/publisher.rs b/crates/unixnotis-daemon/src/daemon/events/publisher.rs index 87ecd7757..73d66a9ca 100644 --- a/crates/unixnotis-daemon/src/daemon/events/publisher.rs +++ b/crates/unixnotis-daemon/src/daemon/events/publisher.rs @@ -1,7 +1,8 @@ //! Shared connection state and error policy for daemon event publication -use std::sync::Mutex; +use std::sync::Mutex as StdMutex; +use tokio::sync::{Mutex, MutexGuard}; use unixnotis_core::{ControlState, PopupGateState, CONTROL_OBJECT_PATH}; use zbus::{Connection, SignalContext}; @@ -9,20 +10,28 @@ use crate::daemon::NOTIFICATIONS_OBJECT_PATH; pub(in crate::daemon) struct DaemonEventPublisher { connection: Connection, + // One async guard keeps state-bearing signals in capture order across await points + publication_order: Mutex<()>, // State snapshots are cached here because publication owns duplicate suppression - pub(super) last_state: Mutex>, - pub(super) last_popup_gate: Mutex>, + pub(super) last_state: StdMutex>, + pub(super) last_popup_gate: StdMutex>, } impl DaemonEventPublisher { pub(in crate::daemon) const fn new(connection: Connection) -> Self { Self { connection, - last_state: Mutex::new(None), - last_popup_gate: Mutex::new(None), + publication_order: Mutex::const_new(()), + last_state: StdMutex::new(None), + last_popup_gate: StdMutex::new(None), } } + pub(super) async fn ordered_publication(&self) -> MutexGuard<'_, ()> { + // The guard spans store capture, signal fanout, and cache acknowledgement + self.publication_order.lock().await + } + pub(super) fn control_context(&self) -> zbus::Result> { SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) } diff --git a/crates/unixnotis-daemon/src/daemon/events/state.rs b/crates/unixnotis-daemon/src/daemon/events/state.rs index 9a703dda8..f451b0435 100644 --- a/crates/unixnotis-daemon/src/daemon/events/state.rs +++ b/crates/unixnotis-daemon/src/daemon/events/state.rs @@ -8,8 +8,9 @@ use super::publisher::{record_first_error, DaemonEventPublisher}; impl DaemonState { pub(in crate::daemon) async fn publish_state_changed(&self) -> zbus::Result<()> { + let _publication = self.events.ordered_publication().await; let state = { - // One store lock captures every public counter and gate from one revision + // Capture after ordering so delayed callers always observe the newest revision let store = self.store.lock().await; store.control_state() }; @@ -34,13 +35,15 @@ impl DaemonEventPublisher { let context = self.control_context()?; let mut first_error = None; if publish_state { - if let Err(error) = ControlServer::state_changed(&context, state).await { - record_first_error(&mut first_error, error); + match ControlServer::state_changed(&context, state.clone()).await { + Ok(()) => update_cached(&self.last_state, state), + Err(error) => record_first_error(&mut first_error, error), } } if publish_popup_gate { - if let Err(error) = ControlServer::popup_gate_changed(&context, popup_gate).await { - record_first_error(&mut first_error, error); + match ControlServer::popup_gate_changed(&context, popup_gate.clone()).await { + Ok(()) => update_cached(&self.last_popup_gate, popup_gate), + Err(error) => record_first_error(&mut first_error, error), } } first_error.map_or(Ok(()), Err) @@ -52,19 +55,23 @@ impl DaemonEventPublisher { } } -pub(super) fn should_publish_cached( +pub(super) fn should_publish_cached( cache: &std::sync::Mutex>, next: &T, ) -> bool { // Poison recovery preserves availability after a prior panicking task + let cached = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cached.as_ref() != Some(next) +} + +pub(super) fn update_cached(cache: &std::sync::Mutex>, published: T) { + // A cache entry means the corresponding D-Bus send completed successfully let mut cached = cache .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if cached.as_ref() == Some(next) { - return false; - } - cached.clone_from(&Some(next.clone())); - true + *cached = Some(published); } pub(super) const fn popup_gate_from_state(state: &ControlState) -> PopupGateState { diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs index a802d5feb..5535578ea 100644 --- a/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use unixnotis_core::{ControlState, PopupGateState}; -use super::super::state::should_publish_cached; +use super::super::state::{should_publish_cached, update_cached}; #[test] fn cached_state_emits_first_value_then_suppresses_duplicates() { @@ -17,6 +17,7 @@ fn cached_state_emits_first_value_then_suppresses_duplicates() { // First value must be emitted because clients have no previous state assert!(should_publish_cached(&cache, &state)); + update_cached(&cache, state.clone()); // Identical values should not wake D-Bus subscribers again assert!(!should_publish_cached(&cache, &state)); } @@ -34,8 +35,10 @@ fn cached_state_emits_when_any_gate_field_changes() { }; assert!(should_publish_cached(&cache, &open)); + update_cached(&cache, open); // A changed popup gate affects visibility policy, so it must emit assert!(should_publish_cached(&cache, &dnd)); + update_cached(&cache, dnd.clone()); assert!(!should_publish_cached(&cache, &dnd)); } @@ -55,7 +58,9 @@ fn cached_state_emits_after_counter_change() { }; assert!(should_publish_cached(&cache, &first)); + update_cached(&cache, first); assert!(should_publish_cached(&cache, &changed)); + update_cached(&cache, changed.clone()); assert!(!should_publish_cached(&cache, &changed)); } @@ -70,6 +75,7 @@ fn cached_state_emits_when_only_the_dnd_deadline_changes() { inhibitor_count: 0, }; assert!(should_publish_cached(&cache, &indefinite)); + update_cached(&cache, indefinite.clone()); let timed = ControlState { dnd_expires_at: 500, @@ -77,6 +83,7 @@ fn cached_state_emits_when_only_the_dnd_deadline_changes() { }; assert!(should_publish_cached(&cache, &timed)); + update_cached(&cache, timed.clone()); assert!(!should_publish_cached(&cache, &timed)); } @@ -94,5 +101,21 @@ fn cached_state_recovers_from_poisoned_mutex() { }; assert!(should_publish_cached(&cache, &state)); + update_cached(&cache, state.clone()); + assert!(!should_publish_cached(&cache, &state)); +} + +#[test] +fn cached_state_is_not_advanced_until_success_is_recorded() { + let cache = Mutex::new(None); + let state = PopupGateState { + dnd_enabled: true, + inhibited: false, + }; + + assert!(should_publish_cached(&cache, &state)); + assert!(should_publish_cached(&cache, &state)); + + update_cached(&cache, state.clone()); assert!(!should_publish_cached(&cache, &state)); } diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/state.rs b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs index 104bc12fc..60e10f271 100644 --- a/crates/unixnotis-daemon/src/daemon/events/tests/state.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs @@ -1,6 +1,8 @@ +use std::sync::Arc; use std::time::Duration; use futures_util::TryStreamExt; +use tokio::sync::Barrier; use unixnotis_core::{CloseReason, Config, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; use zbus::message::Type; use zbus::{Connection, MatchRule, Message, MessageStream}; @@ -228,6 +230,115 @@ async fn publish_state_changed_sends_initial_state_and_suppresses_duplicate() { assert_no_signal(&mut gate_stream).await; } +#[tokio::test] +async fn delayed_state_publisher_captures_latest_revision_before_suppressing_duplicate() { + let state = daemon_state_for_test(false).await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; + let publication = state.events.ordered_publication().await; + let publisher_entered = Arc::new(Barrier::new(2)); + + let delayed_state = state.clone(); + let delayed_entered = publisher_entered.clone(); + let delayed = tokio::spawn(async move { + // The barrier makes the first caller queue behind the held publication guard + delayed_entered.wait().await; + delayed_state.publish_state_changed().await + }); + + publisher_entered.wait().await; + tokio::task::yield_now().await; + state.store.lock().await.set_dnd_until(500); + + let waiting_state = state.clone(); + let waiting = tokio::spawn(async move { waiting_state.publish_state_changed().await }); + tokio::task::yield_now().await; + drop(publication); + + delayed + .await + .expect("delayed publisher task") + .expect("delayed publication"); + waiting + .await + .expect("waiting publisher task") + .expect("waiting publication"); + + let emitted_state = next_signal(&mut state_stream) + .await + .body() + .deserialize::() + .expect("state body"); + assert!(emitted_state.dnd_enabled); + assert_eq!(emitted_state.dnd_expires_at, 500); + + let emitted_gate = next_signal(&mut gate_stream) + .await + .body() + .deserialize::() + .expect("popup gate body"); + assert!(emitted_gate.dnd_enabled); + + assert_no_signal(&mut state_stream).await; + assert_no_signal(&mut gate_stream).await; +} + +#[tokio::test] +async fn delayed_inhibitor_publishers_never_emit_an_older_count_after_a_newer_mutation() { + let state = daemon_state_for_test(false).await; + let mut inhibitor_stream = control_signal_stream(&state, "InhibitorsChanged").await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let publication = state.events.ordered_publication().await; + + state + .store + .lock() + .await + .add_inhibitor(":1.first".to_string(), "first mutation".to_string(), 0); + let first_state = state.clone(); + let first = tokio::spawn(async move { + first_state + .publish_inhibitors_changed("first-test-mutation") + .await; + }); + tokio::task::yield_now().await; + + state.store.lock().await.add_inhibitor( + ":1.second".to_string(), + "second mutation".to_string(), + 0, + ); + let second_state = state.clone(); + let second = tokio::spawn(async move { + second_state + .publish_inhibitors_changed("second-test-mutation") + .await; + }); + drop(publication); + + first.await.expect("first inhibitor publisher"); + second.await.expect("second inhibitor publisher"); + + for _ in 0..2 { + let (active, count) = next_signal(&mut inhibitor_stream) + .await + .body() + .deserialize::<(bool, u32)>() + .expect("inhibitor body"); + assert!(active); + assert_eq!(count, 2); + } + + let emitted_state = next_signal(&mut state_stream) + .await + .body() + .deserialize::() + .expect("state body"); + assert!(emitted_state.inhibited); + assert_eq!(emitted_state.inhibitor_count, 2); + assert_no_signal(&mut state_stream).await; +} + #[tokio::test] async fn publish_snapshot_invalidated_sends_snapshot_signal() { let state = daemon_state_for_test(false).await; From edcdcad52ed978c3cb160938740904a4685a6290 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 16:51:04 -0500 Subject: [PATCH 077/275] fix(daemon): reject oversized notification bodies at ingress Summary: reject oversized notification bodies at ingress. Scope: daemon. --- crates/unixnotis-daemon/src/daemon/mod.rs | 1 + .../src/daemon/notifications/mod.rs | 1 + .../src/daemon/notifications/server/flow.rs | 9 +- .../daemon/notifications/server/ingress.rs | 103 +++++++++++ .../src/daemon/notifications/server/mod.rs | 2 + .../notifications/server/tests/ingress.rs | 161 ++++++++++++++++++ crates/unixnotis-daemon/src/runtime/daemon.rs | 4 +- 7 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs diff --git a/crates/unixnotis-daemon/src/daemon/mod.rs b/crates/unixnotis-daemon/src/daemon/mod.rs index c0360350e..9833dc37f 100644 --- a/crates/unixnotis-daemon/src/daemon/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/mod.rs @@ -14,6 +14,7 @@ pub use bus::{ }; pub use control::ControlServer; pub use errors::to_fdo_error; +pub use notifications::NotificationIngress; pub use notifications::NotificationServer; pub(in crate::daemon) use notifications::NotificationSignalMode; pub use state::DaemonState; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 9dedd3702..08d04960b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -12,4 +12,5 @@ mod server; pub(in crate::daemon) use flow_control::{ notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, }; +pub use server::NotificationIngress; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 25ccdf40a..fda8124ff 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::time::Instant; use tracing::debug; -use unixnotis_core::Notification; +use unixnotis_core::{Notification, NotificationAttribution}; use zbus::message::Header; use zbus::zvariant::OwnedValue; use crate::daemon::notifications::payload::{ build_notification, resolve_expiration, NotificationInput, }; -use crate::daemon::notifications::sender::{app_name_matches_sender, resolve_sender_metadata}; +use crate::daemon::notifications::sender::resolve_sender_metadata; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; @@ -238,7 +238,10 @@ impl NotificationServer { } fn sender_app_name_mismatch(app_name: &str, sender_executable: Option<&str>) -> bool { - sender_executable.is_some_and(|exe| !app_name_matches_sender(app_name, exe)) + sender_executable.is_some_and(|_| { + let (_, attribution) = NotificationAttribution::resolve(app_name, sender_executable); + !attribution.verified + }) } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs new file mode 100644 index 000000000..790eb8590 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -0,0 +1,103 @@ +//! Raw notification method guard applied before zbus deserializes owned payload fields + +use std::collections::HashMap; +use std::fmt::Write; + +use zbus::names::{InterfaceName, MemberName}; +use zbus::object_server::{DispatchResult, Interface, SignalContext}; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{Connection, Message, ObjectServer}; + +use super::NotificationServer; + +// This leaves room for one maximum image plus bounded text, actions, hints, and wire overhead +pub(super) const MAX_NOTIFY_WIRE_BODY_BYTES: usize = 384 * 1024; + +/// Object-server adapter that rejects oversized Notify bodies before typed allocation +pub struct NotificationIngress { + inner: NotificationServer, +} + +impl NotificationIngress { + pub const fn new(inner: NotificationServer) -> Self { + Self { inner } + } +} + +#[zbus::export::async_trait::async_trait] +impl Interface for NotificationIngress { + fn name() -> InterfaceName<'static> { + ::name() + } + + fn spawn_tasks_for_methods(&self) -> bool { + self.inner.spawn_tasks_for_methods() + } + + async fn get(&self, property_name: &str) -> Option> { + self.inner.get(property_name).await + } + + async fn get_all(&self) -> zbus::fdo::Result> { + self.inner.get_all().await + } + + fn set<'call>( + &'call self, + property_name: &'call str, + value: &'call Value<'_>, + context: &'call SignalContext<'_>, + ) -> DispatchResult<'call> { + self.inner.set(property_name, value, context) + } + + async fn set_mut( + &mut self, + property_name: &str, + value: &Value<'_>, + context: &SignalContext<'_>, + ) -> Option> { + self.inner.set_mut(property_name, value, context).await + } + + fn call<'call>( + &'call self, + server: &'call ObjectServer, + connection: &'call Connection, + message: &'call Message, + name: MemberName<'call>, + ) -> DispatchResult<'call> { + if notify_body_is_oversized(name.as_str(), message.body().len()) { + // Construct the D-Bus error without asking the typed interface to decode the body + return DispatchResult::new_async(connection, message, async { + Err::<(), _>(zbus::fdo::Error::LimitsExceeded(format!( + "Notify body exceeds {MAX_NOTIFY_WIRE_BODY_BYTES} bytes" + ))) + }); + } + self.inner.call(server, connection, message, name) + } + + fn call_mut<'call>( + &'call mut self, + server: &'call ObjectServer, + connection: &'call Connection, + message: &'call Message, + name: MemberName<'call>, + ) -> DispatchResult<'call> { + // NotificationServer currently has no mutable methods, but delegation preserves its API + self.inner.call_mut(server, connection, message, name) + } + + fn introspect_to_writer(&self, writer: &mut dyn Write, level: usize) { + self.inner.introspect_to_writer(writer, level); + } +} + +fn notify_body_is_oversized(member: &str, body_len: usize) -> bool { + member.as_bytes() == b"Notify" && body_len > MAX_NOTIFY_WIRE_BODY_BYTES +} + +#[cfg(test)] +#[path = "tests/ingress.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index 3d8e2a52c..80c7dcd3a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -3,8 +3,10 @@ mod capabilities; mod close; mod flow; +mod ingress; mod interface; +pub use ingress::NotificationIngress; pub use interface::NotificationServer; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs new file mode 100644 index 000000000..117671faa --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, Structure, Value}; +use zbus::Connection; + +use super::{notify_body_is_oversized, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES}; +use crate::daemon::{NotificationServer, NOTIFICATIONS_OBJECT_PATH}; +use crate::expire::ExpirationScheduler; +use crate::test_support::daemon_state_for_test; + +const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; + +#[test] +fn notify_wire_limit_applies_only_to_oversized_notify_calls() { + assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 393_216); + assert!(!notify_body_is_oversized( + "Notify", + MAX_NOTIFY_WIRE_BODY_BYTES + )); + assert!(notify_body_is_oversized( + "Notify", + MAX_NOTIFY_WIRE_BODY_BYTES + 1 + )); + assert!(!notify_body_is_oversized( + "CloseNotification", + MAX_NOTIFY_WIRE_BODY_BYTES + 1 + )); +} + +#[tokio::test] +async fn oversized_body_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let body = "b".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), HashMap::new(), body).await; +} + +#[tokio::test] +async fn oversized_action_array_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let actions = vec!["a".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1)]; + + assert_oversized_notify_rejected(&state, &client, actions, HashMap::new(), String::new()).await; +} + +#[tokio::test] +async fn oversized_hint_map_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let mut hints = HashMap::new(); + let value = Value::from("h".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1)); + hints.insert( + "category".to_string(), + OwnedValue::try_from(value).expect("owned hint string"), + ); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +} + +#[tokio::test] +async fn oversized_image_array_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let image = Structure::from(( + 1_i32, + 1_i32, + 4_i32, + true, + 8_i32, + 4_i32, + vec![0_u8; MAX_NOTIFY_WIRE_BODY_BYTES + 1], + )); + let mut hints = HashMap::new(); + hints.insert( + "image-data".to_string(), + OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + ); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +} + +#[tokio::test] +async fn bounded_notify_body_reaches_the_typed_interface() { + let (state, client) = notification_ingress().await; + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ( + "app", + 0_u32, + "", + "summary", + "bounded body", + Vec::::new(), + HashMap::::new(), + 0_i32, + ); + + let reply = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ) + .await + .expect("bounded Notify should reach typed handler"); + let id = reply.body().deserialize::().expect("notification id"); + + assert_eq!(id, 1); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +async fn notification_ingress() -> (std::sync::Arc, Connection) { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + state + .connection() + .object_server() + .at( + NOTIFICATIONS_OBJECT_PATH, + NotificationIngress::new(NotificationServer::new(state.clone(), scheduler)), + ) + .await + .expect("register guarded notification interface"); + let client = Connection::session().await.expect("notification client"); + (state, client) +} + +async fn assert_oversized_notify_rejected( + state: &crate::daemon::DaemonState, + client: &Connection, + actions: Vec, + hints: HashMap, + body: String, +) { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ("app", 0_u32, "", "summary", body, actions, hints, 0_i32); + + let error = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ) + .await + .expect_err("oversized Notify body must fail"); + + assert!( + error.to_string().contains("LimitsExceeded"), + "unexpected D-Bus error: {error}" + ); + assert!(state.store.lock().await.list_active().is_empty()); +} diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index cc1454636..d9a5beb34 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -13,7 +13,7 @@ use crate::child_process::{spawn_center_supervisor, spawn_popups_supervisor}; use crate::cli::Args; use crate::daemon::{ log_current_owner, log_name_reply, request_control_name, request_well_known_name, - spawn_client_owner_watch, ControlServer, DaemonState, NotificationServer, + spawn_client_owner_watch, ControlServer, DaemonState, NotificationIngress, NotificationServer, NOTIFICATIONS_OBJECT_PATH, }; use crate::dnd_expiration::DndExpirationScheduler; @@ -42,7 +42,7 @@ pub(super) async fn run_daemon( .object_server() .at( NOTIFICATIONS_OBJECT_PATH, - NotificationServer::new(state.clone(), scheduler), + NotificationIngress::new(NotificationServer::new(state.clone(), scheduler)), ) .await?; connection From bfca347764fffc6828ec801c567b0232ea166a77 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 16:51:13 -0500 Subject: [PATCH 078/275] fix(ui): authenticate notification attribution Summary: authenticate notification attribution. Scope: ui. --- Cargo.lock | 1 + .../noticenterctl/src/output/notifications.rs | 2 +- .../src/output/tests/notifications.rs | 5 + .../src/control/tests/events.rs | 1 + .../src/ui/icons/resolution.rs | 66 ++++++++-- .../unixnotis-center/src/ui/icons/resolver.rs | 11 ++ .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 69 +++++++++- crates/unixnotis-center/src/ui/icons/theme.rs | 13 +- .../src/ui/notifications/model/grouping.rs | 1 + .../src/ui/notifications/model/tests/item.rs | 1 + .../src/ui/notifications/row/group.rs | 11 +- .../notifications/row/notification/state.rs | 17 +-- .../row/notification/tests/support.rs | 5 + .../row/notification/update/row.rs | 5 +- .../src/ui/notifications/row/tests/group.rs | 25 ++++ .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + .../unixnotis-core/src/model/attribution.rs | 115 ++++++++++++++++ crates/unixnotis-core/src/model/mod.rs | 2 + .../unixnotis-core/src/model/notification.rs | 21 ++- .../src/model/tests/attribution.rs | 49 +++++++ .../src/model/tests/notification.rs | 20 +++ .../src/daemon/notifications/payload.rs | 14 +- .../src/daemon/notifications/sender.rs | 20 --- .../src/daemon/notifications/tests/payload.rs | 48 ++++++- .../src/daemon/notifications/tests/sender.rs | 30 ----- crates/unixnotis-popups/src/ui/entry/build.rs | 7 +- crates/unixnotis-popups/src/ui/icon_state.rs | 22 +--- crates/unixnotis-popups/src/ui/icons/mod.rs | 4 +- .../unixnotis-popups/src/ui/icons/resolver.rs | 123 ++---------------- .../src/ui/icons/tests/resolver/candidates.rs | 27 ++++ .../src/ui/icons/tests/resolver/image_data.rs | 101 -------------- .../src/ui/icons/tests/resolver/mod.rs | 1 - .../src/ui/icons/tests/resolver/support.rs | 25 +--- .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/constructor.rs | 1 + crates/unixnotis-ui/Cargo.toml | 1 + .../unixnotis-ui/src/icons/desktop_index.rs | 27 ++++ .../src/icons/tests/desktop_index.rs | 27 ++++ 40 files changed, 572 insertions(+), 351 deletions(-) create mode 100644 crates/unixnotis-core/src/model/attribution.rs create mode 100644 crates/unixnotis-core/src/model/tests/attribution.rs delete mode 100644 crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs diff --git a/Cargo.lock b/Cargo.lock index 4388d9ae1..7e95c74d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3634,6 +3634,7 @@ dependencies = [ "notify", "serde", "serde_json", + "shell-words", "tracing", "unixnotis-core", "url", diff --git a/crates/noticenterctl/src/output/notifications.rs b/crates/noticenterctl/src/output/notifications.rs index 1af61e4c9..bffddedea 100644 --- a/crates/noticenterctl/src/output/notifications.rs +++ b/crates/noticenterctl/src/output/notifications.rs @@ -30,7 +30,7 @@ fn format_notifications(label: &str, notifications: &[NotificationView], full: b for notification in notifications { // Both fields come from notification clients and must remain single-line - let app = util::sanitize_log_value(¬ification.app_name, limit); + let app = util::sanitize_log_value(¬ification.attribution_label(), limit); let summary = util::sanitize_log_value(¬ification.summary, limit); let action_count = notification.actions.len(); out.push_str(&format!( diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 5df0799bc..96000cc5b 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -8,6 +8,11 @@ fn sample_notification() -> NotificationView { NotificationView { id: 7, app_name: "mailer\n\x1b[31m".to_string(), + attribution: unixnotis_core::NotificationAttribution { + verified: true, + reported_name: String::new(), + badge_icon: "mailer".to_string(), + }, summary: "subject\rline".to_string(), body: "body\ttext\nnext".to_string(), actions: vec![Action { diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 8319d4b85..9d9c072ac 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -6,6 +6,7 @@ fn notification(id: u32) -> NotificationView { NotificationView { id, app_name: "example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index db820e9b0..a990f92f2 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -17,6 +17,20 @@ use super::theme::{ use super::types::{IconDecodeRequest, IconResolution}; impl IconResolverInner { + pub(super) fn apply_badge( + &self, + image: >k::Image, + notification: &NotificationView, + size: i32, + scale: i32, + ) { + if let Some(resolved) = self.resolve_badge(notification, size, scale) { + self.apply_resolution(image, resolved); + return; + } + image.set_visible(false); + } + pub(super) fn apply_icon( &self, image: >k::Image, @@ -25,18 +39,7 @@ impl IconResolverInner { scale: i32, ) { if let Some(resolved) = self.resolve_icon(notification, size, scale) { - match resolved { - IconResolution::Ready { key, paintable } => { - set_image_key(image, key); - image.set_paintable(Some(paintable.paintable())); - image.set_visible(true); - } - IconResolution::Async { request } => { - set_image_key(image, request.key.clone()); - self.enqueue(request, image); - image.set_visible(false); - } - } + self.apply_resolution(image, resolved); return; } @@ -101,6 +104,45 @@ impl IconResolverInner { None } + fn resolve_badge( + &self, + notification: &NotificationView, + size: i32, + scale: i32, + ) -> Option { + let candidates = collect_icon_candidates(notification); + for candidate in &candidates { + if let Some(icons) = self.desktop_index.icons_for(candidate) { + for icon_name in icons { + if let Some(resolution) = self.resolve_icon_name(&icon_name, size, scale) { + return Some(resolution); + } + } + } + } + for candidate in candidates { + if let Some(resolution) = self.resolve_icon_name(&candidate, size, scale) { + return Some(resolution); + } + } + None + } + + fn apply_resolution(&self, image: >k::Image, resolved: IconResolution) { + match resolved { + IconResolution::Ready { key, paintable } => { + set_image_key(image, key); + image.set_paintable(Some(paintable.paintable())); + image.set_visible(true); + } + IconResolution::Async { request } => { + set_image_key(image, request.key.clone()); + self.enqueue(request, image); + image.set_visible(false); + } + } + } + fn resolve_icon_name(&self, name: &str, size: i32, scale: i32) -> Option { if !icon_name_is_usable(name) { return None; diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index 50163f756..ff3e0d1a8 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -54,6 +54,17 @@ impl IconResolver { self.inner.apply_icon(image, notification, size, scale); } + pub fn apply_badge( + &self, + image: >k::Image, + notification: &NotificationView, + size: i32, + scale: i32, + ) { + // Header badges deliberately exclude caller-controlled content image data and paths + self.inner.apply_badge(image, notification, size, scale); + } + pub fn clear_missing_cache(&self) { // Theme reloads must retry names that were previously unavailable self.inner.clear_missing_cache(); diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index 5bd71194d..b456904b4 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -73,6 +73,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { let notification = NotificationView { id: 1, app_name: "Icon test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: String::new(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 093bd1537..65a16cd0e 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -3,9 +3,74 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ - expand_rgb_to_rgba, resolve_icon_source, theme_path_uses_worker, worker_decodes_theme_path, + collect_icon_candidates, expand_rgb_to_rgba, resolve_icon_source, theme_path_uses_worker, + worker_decodes_theme_path, }; -use unixnotis_core::ImageData; +use unixnotis_core::{ImageData, NotificationImage, NotificationView}; + +fn notification_view( + app_name: &str, + attribution: unixnotis_core::NotificationAttribution, + image: NotificationImage, +) -> NotificationView { + NotificationView { + id: 1, + app_name: app_name.to_string(), + attribution, + summary: String::new(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + urgency: 1, + is_transient: false, + image, + } +} + +#[test] +fn badge_candidates_exclude_caller_content_icon() { + let notification = notification_view( + "sender-bin", + unixnotis_core::NotificationAttribution { + verified: false, + reported_name: "Claimed Brand".to_string(), + badge_icon: "sender-bin".to_string(), + }, + NotificationImage { + icon_name: "caller-content-icon".to_string(), + ..NotificationImage::default() + }, + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates.iter().any(|candidate| candidate == "sender-bin")); + assert!(!candidates + .iter() + .any(|candidate| candidate == "caller-content-icon")); +} + +#[test] +fn badge_candidates_exclude_unresolved_application_claim() { + let notification = notification_view( + "Trusted Brand", + unixnotis_core::NotificationAttribution { + verified: false, + reported_name: String::new(), + badge_icon: "dialog-warning-symbolic".to_string(), + }, + NotificationImage::default(), + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "dialog-warning-symbolic")); + assert!(!candidates + .iter() + .any(|candidate| candidate == "Trusted Brand")); +} #[test] fn expand_rgb_to_rgba_appends_alpha() { diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index fea379a3c..d50678ab6 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -96,14 +96,17 @@ fn resolve_icon_paintable(name: &str, size: i32, scale: i32) -> Option Vec { let mut candidates = Vec::new(); - if !notification.image.icon_name.is_empty() { - candidates.push(notification.image.icon_name.clone()); - if let Some(stripped) = notification.image.icon_name.strip_suffix(".desktop") { + if !notification.attribution.badge_icon.is_empty() { + candidates.push(notification.attribution.badge_icon.clone()); + if let Some(stripped) = notification.attribution.badge_icon.strip_suffix(".desktop") { candidates.push(stripped.to_string()); } - candidates.push(notification.image.icon_name.to_lowercase()); + candidates.push(notification.attribution.badge_icon.to_lowercase()); } - if !notification.app_name.is_empty() { + let authenticated_primary = + notification.attribution.verified || !notification.attribution.reported_name.is_empty(); + if authenticated_primary && !notification.app_name.is_empty() { + // Unresolved claims never become badge candidates when the warning icon is unavailable candidates.push(notification.app_name.clone()); let lower = notification.app_name.to_lowercase(); candidates.push(lower.clone()); diff --git a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs index 3a2ae149a..ff136c497 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs @@ -152,6 +152,7 @@ impl NotificationList { return true; }; contains_casefold(&view.app_name, query) + || contains_casefold(&view.attribution.reported_name, query) || contains_casefold(&view.summary, query) || contains_casefold(&view.body, query) } diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 419112e69..cbc85f09f 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -10,6 +10,7 @@ fn notification(id: u32) -> Rc { Rc::new(NotificationView { id, app_name: "Terminal".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 1cc10e3b2..5c68bc979 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -117,12 +117,12 @@ pub(in crate::ui::notifications) fn update_group_row( let display_name = data .notification .as_ref() - .map(|notification| notification.app_name.trim()) + .map(|notification| notification.attribution_label()) .filter(|name| !name.is_empty()) - .unwrap_or_else(|| data.group_key.as_ref()); - // Display the original app label while the normalized key drives grouping behavior + .unwrap_or_else(|| data.group_key.to_string()); + // Display verified attribution while the normalized key drives grouping behavior // Fall back to the group key if no sample notification is available - set_label_text_if_changed(&group.title, display_name); + set_label_text_if_changed(&group.title, &display_name); let next_count = data.count.to_string(); set_label_text_if_changed(&group.count, &next_count); let chevron_name = if data.expanded { @@ -138,7 +138,8 @@ pub(in crate::ui::notifications) fn update_group_row( if let Some(notification) = data.notification.as_ref() { let scale = root.scale_factor(); - icon_resolver.apply_icon(&group.icon, notification.as_ref(), 18, scale); + // Group headers use the authenticated badge path instead of caller content images + icon_resolver.apply_badge(&group.icon, notification.as_ref(), 18, scale); set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); } else { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 340aef1c6..b39301ebc 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -69,15 +69,9 @@ pub(super) struct OptionalLabelState<'a> { #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::ui::notifications) struct IconSignature { - // These fields match the icon resolution inputs - // If none of them change, the existing paintable is still valid - image_path: String, - icon_name: String, + // Header badges depend only on authenticated attribution inputs + badge_icon: String, app_name: String, - has_image_data: bool, - image_len: usize, - image_width: i32, - image_height: i32, } impl IconSignature { @@ -85,13 +79,8 @@ impl IconSignature { // Signature includes all fields that can change icon resolution output // This keeps row refreshes cheap when only text or actions changed Self { - image_path: notification.image.image_path.clone(), - icon_name: notification.image.icon_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), app_name: notification.app_name.clone(), - has_image_data: notification.image.has_image_data, - image_len: notification.image.image_data.data.len(), - image_width: notification.image.image_data.width, - image_height: notification.image.image_data.height, } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 8ea282418..596555b13 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -16,6 +16,11 @@ pub(super) fn sample_notification() -> NotificationView { NotificationView { id: 1, app_name: "demo".to_string(), + attribution: unixnotis_core::NotificationAttribution { + verified: true, + reported_name: String::new(), + badge_icon: "demo".to_string(), + }, summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index a0a044df9..7663cf00a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -32,9 +32,10 @@ pub(in crate::ui::notifications) fn update_notification_row( data.presentation.show_thumbnail && notification_has_thumbnail(notification); apply_visual_state(row, data, notification, has_actions, has_thumbnail); + let attribution_label = notification.attribution_label(); update_notification_text( row, - ¬ification.app_name, + &attribution_label, ¬ification.summary, ¬ification.body, ); @@ -47,7 +48,7 @@ pub(in crate::ui::notifications) fn update_notification_row( let mut sig_guard = row.icon_sig.borrow_mut(); if sig_guard.as_ref() != Some(&next_sig) { let scale = row.card.scale_factor(); - icon_resolver.apply_icon(&row.icon, notification, 22, scale); + icon_resolver.apply_badge(&row.icon, notification, 22, scale); *sig_guard = Some(next_sig); } if has_thumbnail { diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 41003718f..784d40c98 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -14,6 +14,10 @@ fn notification(app_name: &str) -> Rc { Rc::new(NotificationView { id: 1, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution { + verified: true, + ..unixnotis_core::NotificationAttribution::default() + }, summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), @@ -81,6 +85,27 @@ fn update_group_row_falls_back_to_group_key_without_sample() { assert!(root.has_css_class("unixnotis-group-row-no-icon")); } +#[gtk::test] +fn update_group_row_keeps_unverified_brand_claim_secondary() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut unverified = notification("sender-bin").as_ref().clone(); + unverified.attribution = unixnotis_core::NotificationAttribution { + verified: false, + reported_name: "Trusted Brand".to_string(), + badge_icon: "sender-bin".to_string(), + }; + let data = RowData::group_header(Rc::from("sender-bin"), 1, false, Rc::new(unverified)); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!( + widgets.title.text().as_str(), + "sender-bin · unverified claim: Trusted Brand" + ); +} + #[gtk::test] fn group_header_click_sends_toggle_event() { support::init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index cee41fb86..349f9590c 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -7,6 +7,7 @@ fn make_view(is_transient: bool) -> NotificationView { NotificationView { id: 7, app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: "body".to_string(), actions: vec![Action { @@ -24,6 +25,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { NotificationView { id, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 8895fc3ae..4bb650593 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -58,6 +58,7 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { NotificationView { id, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs new file mode 100644 index 000000000..b697751b2 --- /dev/null +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -0,0 +1,115 @@ +//! Authenticated notification identity projected from daemon-owned sender metadata + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use zbus::zvariant::Type; + +use crate::util; + +const MAX_ATTRIBUTION_NAME_BYTES: usize = 256; + +/// Identity details displayed separately from caller-controlled notification content +#[derive(Debug, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct NotificationAttribution { + // False means the claimed app name could not be tied to the sender executable + pub verified: bool, + // A mismatched caller claim remains visible as secondary metadata + pub reported_name: String, + // The authenticated executable basename drives application badge lookup + pub badge_icon: String, +} + +impl NotificationAttribution { + /// Resolve the primary display name and attribution from authenticated process metadata + #[must_use] + pub fn resolve(reported_name: &str, sender_executable: Option<&str>) -> (String, Self) { + let reported_name = bounded_name(reported_name); + let Some(executable_name) = sender_executable.and_then(executable_name) else { + // Unresolved senders keep their claim visible but never gain verified status + let display_name = fallback_display_name(&reported_name); + return ( + display_name, + Self { + verified: false, + reported_name: String::new(), + badge_icon: "dialog-warning-symbolic".to_string(), + }, + ); + }; + + let executable_name = bounded_name(executable_name); + let verified = identity_names_match(&reported_name, &executable_name); + if verified { + return ( + fallback_display_name(&reported_name), + Self { + verified: true, + reported_name: String::new(), + badge_icon: executable_name, + }, + ); + } + + // Mismatches lead with authenticated process identity and retain the claim second + ( + fallback_display_name(&executable_name), + Self { + verified: false, + reported_name, + badge_icon: executable_name, + }, + ) + } + + /// Build a visible one-line identity label for popup and notification-center headers + #[must_use] + pub fn display_label(&self, primary_name: &str) -> String { + if self.verified { + return primary_name.to_string(); + } + if self.reported_name.is_empty() { + return format!("{primary_name} · unverified"); + } + format!("{primary_name} · unverified claim: {}", self.reported_name) + } +} + +fn executable_name(path: &str) -> Option<&str> { + Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) +} + +fn identity_names_match(reported_name: &str, executable_name: &str) -> bool { + let reported = normalized_identity(reported_name); + let executable = normalized_identity(executable_name); + !reported.is_empty() && reported == executable +} + +fn normalized_identity(value: &str) -> String { + // Spaces and ordinary separators differ between desktop names and executable filenames + value + .chars() + .filter(|ch| ch.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn bounded_name(value: &str) -> String { + let clean = util::sanitize_inline_display_text(value); + util::truncate_utf8_bytes(clean.trim(), MAX_ATTRIBUTION_NAME_BYTES) +} + +fn fallback_display_name(value: &str) -> String { + if value.is_empty() { + "Unknown application".to_string() + } else { + value.to_string() + } +} + +#[cfg(test)] +#[path = "tests/attribution.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index 1ef7a1dcf..e15fa2189 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -1,12 +1,14 @@ //! Notification data model and image hint parsing // Keep the public model surface small by splitting large helpers into files. +mod attribution; mod image; mod notification; mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. +pub use attribution::NotificationAttribution; pub use image::{ImageData, NotificationImage}; pub use notification::{Notification, NotificationView}; pub use reply::InlineReply; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 06a84ebad..6f74ee298 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; +use super::attribution::NotificationAttribution; use super::image::NotificationImage; use super::reply::InlineReply; use super::types::{Action, Urgency}; @@ -53,9 +54,12 @@ impl Notification { /// Convert to a lightweight view for UI consumption #[must_use] pub fn to_view(&self) -> NotificationView { + let (app_name, attribution) = + NotificationAttribution::resolve(&self.app_name, self.sender_executable.as_deref()); NotificationView { id: self.id, - app_name: self.app_name.clone(), + app_name, + attribution, summary: notification_display_text(&self.summary), body: notification_display_text(&self.body), actions: self.actions.clone(), @@ -72,9 +76,12 @@ impl Notification { /// Convert to a view for list rows with heavy image data removed #[must_use] pub fn to_list_view(&self) -> NotificationView { + let (app_name, attribution) = + NotificationAttribution::resolve(&self.app_name, self.sender_executable.as_deref()); NotificationView { id: self.id, - app_name: self.app_name.clone(), + app_name, + attribution, summary: notification_display_text(&self.summary), body: notification_display_text(&self.body), actions: self.actions.clone(), @@ -281,6 +288,8 @@ pub struct NotificationView { // Lightweight fields used for UI display and filtering // Intentionally omits daemon-only protocol flags and timestamps pub app_name: String, + // Authenticated badge identity and any mismatched caller-supplied brand claim + pub attribution: NotificationAttribution, pub summary: String, pub body: String, pub actions: Vec, @@ -292,6 +301,14 @@ pub struct NotificationView { pub image: NotificationImage, } +impl NotificationView { + /// Visible primary and secondary attribution for UI and diagnostic surfaces + #[must_use] + pub fn attribution_label(&self) -> String { + self.attribution.display_label(&self.app_name) + } +} + #[cfg(test)] #[path = "tests/notification.rs"] mod tests; diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs new file mode 100644 index 000000000..033889577 --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -0,0 +1,49 @@ +use super::NotificationAttribution; + +#[test] +fn matching_sender_identity_keeps_reported_brand_and_marks_it_verified() { + let (display, attribution) = + NotificationAttribution::resolve("UnixNotis Center", Some("/usr/bin/unixnotis-center")); + + assert_eq!(display, "UnixNotis Center"); + assert!(attribution.verified); + assert!(attribution.reported_name.is_empty()); + assert_eq!(attribution.badge_icon, "unixnotis-center"); + assert_eq!(attribution.display_label(&display), "UnixNotis Center"); +} + +#[test] +fn mismatched_sender_leads_with_executable_and_keeps_claim_secondary() { + let (display, attribution) = + NotificationAttribution::resolve("Password Manager", Some("/usr/bin/unknown-client")); + + assert_eq!(display, "unknown-client"); + assert!(!attribution.verified); + assert_eq!(attribution.reported_name, "Password Manager"); + assert_eq!(attribution.badge_icon, "unknown-client"); + assert_eq!( + attribution.display_label(&display), + "unknown-client · unverified claim: Password Manager" + ); +} + +#[test] +fn unresolved_sender_keeps_claim_but_uses_warning_badge() { + let (display, attribution) = NotificationAttribution::resolve("Calendar", None); + + assert_eq!(display, "Calendar"); + assert!(!attribution.verified); + assert!(attribution.reported_name.is_empty()); + assert_eq!(attribution.badge_icon, "dialog-warning-symbolic"); + assert_eq!(attribution.display_label(&display), "Calendar · unverified"); +} + +#[test] +fn partial_executable_name_match_does_not_verify_a_brand_claim() { + let (display, attribution) = + NotificationAttribution::resolve("Discord", Some("/opt/discord/DiscordCanaryDiscord")); + + assert_eq!(display, "DiscordCanaryDiscord"); + assert!(!attribution.verified); + assert_eq!(attribution.reported_name, "Discord"); +} diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 6c3983fb3..e0ec6a912 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -67,6 +67,8 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { // Live popup views keep enough information for UI actions and close policy assert_eq!(view.id, 42); assert_eq!(view.app_name, "Mail"); + assert!(view.attribution.verified); + assert_eq!(view.attribution.badge_icon, "mail"); assert_eq!(view.summary, "Subject"); assert_eq!(view.body, "Body"); assert_eq!(view.actions.len(), 1); @@ -75,6 +77,24 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { assert!(view.image.has_image_data); } +#[test] +fn notification_view_separates_mismatched_brand_from_authenticated_executable() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.app_name = "Password Manager".to_string(); + notification.sender_executable = Some("/usr/bin/unknown-client".to_string()); + + let view = notification.to_view(); + + assert_eq!(view.app_name, "unknown-client"); + assert!(!view.attribution.verified); + assert_eq!(view.attribution.reported_name, "Password Manager"); + assert_eq!(view.attribution.badge_icon, "unknown-client"); + assert_eq!( + view.attribution_label(), + "unknown-client · unverified claim: Password Manager" + ); +} + #[test] fn notification_view_strips_markup_from_ui_text() { let mut notification = notification_with_image(image_with_raw_bytes()); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 24efb62a3..4bedacc02 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -6,7 +6,10 @@ use std::cmp::Ordering; use std::collections::HashMap; use std::time::{Duration, Instant}; -use unixnotis_core::{util, Action, Config, InlineReply, Notification, NotificationImage, Urgency}; +use unixnotis_core::{ + util, Action, Config, InlineReply, Notification, NotificationAttribution, NotificationImage, + Urgency, +}; use zbus::zvariant::{OwnedValue, Value}; use super::limits::{ @@ -61,7 +64,14 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { .unwrap_or(false); let image = NotificationImage::from_hints(&app_name, &app_icon, &hints); let actions = parse_actions(actions); - let inline_reply = parse_inline_reply(&actions, &hints); + let (_, attribution) = + NotificationAttribution::resolve(&app_name, sender.sender_executable.as_deref()); + let inline_reply = if attribution.verified { + parse_inline_reply(&actions, &hints) + } else { + // Unverified senders cannot place a credential-like text control in trusted UI + InlineReply::default() + }; // Clean text before storing it let app_name = util::sanitize_inline_display_text(&app_name); let summary = util::sanitize_display_text(&summary); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs index b3602c2aa..367c19ddc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs @@ -3,8 +3,6 @@ //! Sender details are optional and best-effort, so failures here must not reject //! notification delivery -use std::path::Path; - use zbus::fdo::DBusProxy; use zbus::message::Header; use zbus::Connection; @@ -86,24 +84,6 @@ pub(super) async fn resolve_sender_metadata( metadata } -pub(super) fn app_name_matches_sender(app_name: &str, sender_executable: &str) -> bool { - // This check is advisory only; many apps use display names that differ from binary names - let app = app_name.trim().to_ascii_lowercase(); - if app.is_empty() { - return true; - } - - let Some(exe_name) = Path::new(sender_executable) - .file_name() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - else { - return true; - }; - - app == exe_name || app.replace(' ', "-") == exe_name || exe_name.contains(&app) -} - #[cfg(target_os = "linux")] async fn read_process_executable_path(pid: u32) -> Option { // Linux path to the executable behind this process id diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index b16043ed6..75565a339 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -82,7 +82,10 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { body: "Are you coming?".to_string(), actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints, - sender: SenderMetadata::default(), + sender: SenderMetadata { + sender_executable: Some("/usr/bin/messages".to_string()), + ..SenderMetadata::default() + }, expire_timeout: 0, }); @@ -93,6 +96,49 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { assert_eq!(notification.inline_reply.submit_icon, "mail-send-symbolic"); } +#[test] +fn build_notification_disables_inline_reply_for_mismatched_sender_identity() { + let notification = build_notification(NotificationInput { + app_name: "Password Manager".to_string(), + app_icon: "password-manager".to_string(), + summary: "Sign in".to_string(), + body: "Enter the account password".to_string(), + actions: vec!["inline-reply".to_string(), "Password".to_string()], + hints: HashMap::new(), + sender: SenderMetadata { + sender_name: Some(":1.hostile".to_string()), + sender_executable: Some("/usr/bin/unknown-client".to_string()), + ..SenderMetadata::default() + }, + expire_timeout: 0, + }); + + assert!(!notification.inline_reply.available); + let view = notification.to_view(); + assert_eq!(view.app_name, "unknown-client"); + assert!(!view.attribution.verified); + assert_eq!(view.attribution.reported_name, "Password Manager"); +} + +#[test] +fn build_notification_disables_inline_reply_when_sender_identity_is_unresolved() { + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints: HashMap::new(), + sender: SenderMetadata::default(), + expire_timeout: 0, + }); + + assert!(!notification.inline_reply.available); + let view = notification.to_view(); + assert_eq!(view.app_name, "Messages"); + assert!(!view.attribution.verified); +} + #[test] fn build_notification_ignores_reply_hints_without_explicit_action() { let mut hints = HashMap::::new(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs index fc9ce1b2f..3b2591a45 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs @@ -1,35 +1,5 @@ use super::*; -#[test] -fn app_name_matches_sender_accepts_empty_or_missing_executable_name() { - // Empty app names are common for simple clients and should not produce warnings - assert!(app_name_matches_sender(" ", "/usr/bin/notify-send")); - // A path without a final file name cannot prove spoofing, so it stays advisory-only - assert!(app_name_matches_sender("Calendar", "/")); -} - -#[test] -fn app_name_matches_sender_accepts_exact_hyphenated_and_contained_names() { - assert!(app_name_matches_sender("firefox", "/usr/bin/firefox")); - assert!(app_name_matches_sender( - "UnixNotis Center", - "/opt/unixnotis/bin/unixnotis-center" - )); - assert!(app_name_matches_sender( - "discord", - "/opt/discord/DiscordCanaryDiscord" - )); -} - -#[test] -fn app_name_matches_sender_rejects_unrelated_display_name() { - assert!(!app_name_matches_sender("Calendar", "/usr/bin/firefox")); - assert!(!app_name_matches_sender( - "noticenterctl", - "/usr/bin/unixnotis-center" - )); -} - #[cfg(target_os = "linux")] #[test] fn parse_process_start_time_handles_spaces_in_comm() { diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index d5117bac4..40ec6ae81 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -105,13 +105,14 @@ impl UiState { // Missing icons also get a root class so themes can rebalance spacing set_class_state(&root, hooks::popup_card::NO_ICON, true); } - // App name stays in the header instead of repeating the full desktop entry name - let app = gtk::Label::new(Some(¬ification.app_name)); + // Identity text includes an explicit marker whenever sender attribution is unresolved + let attribution_label = notification.attribution_label(); + let app = gtk::Label::new(Some(&attribution_label)); app.set_xalign(0.0); app.set_single_line_mode(true); app.set_ellipsize(EllipsizeMode::End); app.set_max_width_chars(POPUP_APP_MAX_CHARS as i32); - app.set_text(clamp_label_text(¬ification.app_name, POPUP_APP_MAX_CHARS).as_ref()); + app.set_text(clamp_label_text(&attribution_label, POPUP_APP_MAX_CHARS).as_ref()); app.add_css_class("unixnotis-popup-header"); let close = gtk::Button::from_icon_name("window-close-symbolic"); diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index d1a8a6be3..7897770dc 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -12,8 +12,8 @@ use tracing::debug; use unixnotis_core::NotificationView; use super::icons::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, - IconDecodePool, IconDecodeResult, + collect_icon_candidates, file_path_from_hint, resolve_icon_image, IconDecodePool, + IconDecodeResult, }; use super::state::IconCacheEntry; use super::UiState; @@ -32,19 +32,11 @@ impl UiState { notification: &NotificationView, ) -> Option { self.refresh_icon_sources_if_needed(); - let image = ¬ification.image; - if let Some(texture) = image_data_texture(image) { - let widget = gtk::Image::from_paintable(Some(&texture)); - set_popup_icon_size(&widget, POPUP_ICON_SIZE); - return Some(widget); - } - - if !image.image_path.is_empty() { - let path = image.image_path.as_str(); - return self.resolve_icon_widget(path, POPUP_ICON_SIZE); - } - - let cache_key = format!("{}|{}", notification.app_name, notification.image.icon_name); + // Caller image hints are content, so the header resolves only authenticated badge inputs + let cache_key = format!( + "{}|{}", + notification.app_name, notification.attribution.badge_icon + ); if let Some(cached) = self.icon_cache.get(&cache_key) { if let Some(icon_name) = &cached.resolved { return self.resolve_icon_widget(icon_name, POPUP_ICON_SIZE); diff --git a/crates/unixnotis-popups/src/ui/icons/mod.rs b/crates/unixnotis-popups/src/ui/icons/mod.rs index 4e8af8b49..fa2733148 100644 --- a/crates/unixnotis-popups/src/ui/icons/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/mod.rs @@ -6,6 +6,4 @@ mod resolver; pub(super) use cache::{IconDecodePool, IconDecodeResult, TextureCache}; pub(super) use decode::{decode_icon_file, RasterIcon}; -pub(super) use resolver::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, -}; +pub(super) use resolver::{collect_icon_candidates, file_path_from_hint, resolve_icon_image}; diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index fd43c09b9..655f926de 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -7,9 +7,8 @@ use std::path::{Path, PathBuf}; use gio::prelude::FileExt; use gtk::gdk; -use gtk::gdk::prelude::*; -use gtk::{gdk::Texture, IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::{NotificationImage, NotificationView}; +use gtk::{IconLookupFlags, IconPaintable, TextDirection}; +use unixnotis_core::NotificationView; pub(in crate::ui) fn file_path_from_hint(path: &str) -> Option { // Accept raw absolute paths and file:// URIs, decoding percent escapes when present. @@ -63,14 +62,17 @@ pub(in crate::ui) fn resolve_icon_image(name: &str, size: i32) -> Option Vec { let mut candidates = Vec::new(); - if !notification.image.icon_name.is_empty() { - candidates.push(notification.image.icon_name.clone()); - if let Some(stripped) = notification.image.icon_name.strip_suffix(".desktop") { + if !notification.attribution.badge_icon.is_empty() { + candidates.push(notification.attribution.badge_icon.clone()); + if let Some(stripped) = notification.attribution.badge_icon.strip_suffix(".desktop") { candidates.push(stripped.to_string()); } - candidates.push(notification.image.icon_name.to_lowercase()); + candidates.push(notification.attribution.badge_icon.to_lowercase()); } - if !notification.app_name.is_empty() { + let authenticated_primary = + notification.attribution.verified || !notification.attribution.reported_name.is_empty(); + if authenticated_primary && !notification.app_name.is_empty() { + // Unresolved claims never become badge candidates when the warning icon is unavailable candidates.push(notification.app_name.clone()); let lower = notification.app_name.to_lowercase(); let dashed = lower.replace(' ', "-"); @@ -93,111 +95,6 @@ fn is_missing_icon(path: &Path) -> bool { stem.starts_with("image-missing") } -pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option { - if !image.has_image_data { - return None; - } - let data = &image.image_data; - if data.bits_per_sample != 8 { - return None; - } - // Negative rowstride is invalid for pixel buffers. - if data.rowstride < 0 { - return None; - } - - // Reject non-positive dimensions before creating the texture. - if data.width <= 0 || data.height <= 0 { - return None; - } - let width = data.width as usize; - let height = data.height as usize; - let width_i32 = i32::try_from(width).ok()?; - let height_i32 = i32::try_from(height).ok()?; - - let (bytes, stride) = match data.channels { - 4 => { - // Rowstride is bytes per row; hint payloads may include padding. - let min_stride = width.checked_mul(4)?; - let stride = if data.rowstride > 0 { - data.rowstride as usize - } else { - min_stride - }; - // Validate rowstride and buffer length before building the texture. - if stride < min_stride { - return None; - } - let required = stride.checked_mul(height)?; - if data.data.len() < required { - return None; - } - (gtk::glib::Bytes::from(&data.data), stride) - } - 3 => { - let (expanded, stride) = expand_rgb_to_rgba(data)?; - (gtk::glib::Bytes::from(&expanded), stride) - } - _ => return None, - }; - Some( - gdk::MemoryTexture::new( - width_i32, - height_i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - stride, - ) - .upcast::(), - ) -} - -fn expand_rgb_to_rgba(data: &unixnotis_core::ImageData) -> Option<(Vec, usize)> { - // Expand RGB to RGBA while honoring per-row padding in the source buffer. - let width = usize::try_from(data.width).ok()?; - let height = usize::try_from(data.height).ok()?; - if width == 0 || height == 0 { - return None; - } - - // Source stride handles optional per-row padding for RGB input. - let min_src_stride = width.checked_mul(3)?; - let src_stride = if data.rowstride > 0 { - data.rowstride as usize - } else { - min_src_stride - }; - if src_stride < min_src_stride { - return None; - } - let required = src_stride.checked_mul(height)?; - if data.data.len() < required { - return None; - } - - // Destination uses tightly packed RGBA rows. - let dst_stride = width.checked_mul(4)?; - let mut rgba = vec![0u8; dst_stride.checked_mul(height)?]; - - // Copy RGB per pixel and append opaque alpha. - for y in 0..height { - let src_row_start = y * src_stride; - let dst_row_start = y * dst_stride; - let src_row = &data.data[src_row_start..src_row_start + min_src_stride]; - let dst_row = &mut rgba[dst_row_start..dst_row_start + dst_stride]; - for x in 0..width { - let src = x * 3; - let dst = x * 4; - dst_row[dst] = src_row[src]; - dst_row[dst + 1] = src_row[src + 1]; - dst_row[dst + 2] = src_row[src + 2]; - dst_row[dst + 3] = 255; - } - } - - Some((rgba, dst_stride)) -} - #[cfg(test)] #[path = "tests/resolver/mod.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index bc0158d2b..3a49cb8e8 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -25,3 +25,30 @@ fn collect_icon_candidates_dedupes_empty_and_repeated_values() { assert_eq!(candidates, vec!["app", "App"]); } + +#[test] +fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { + let mut notification = notification("authenticated-app", "trusted-badge"); + notification.image.icon_name = "caller-controlled-content".to_string(); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates.iter().any(|value| value == "trusted-badge")); + assert!(!candidates + .iter() + .any(|value| value == "caller-controlled-content")); +} + +#[test] +fn collect_icon_candidates_does_not_fallback_to_unresolved_brand_claim() { + let mut notification = notification("Trusted Brand", "dialog-warning-symbolic"); + notification.attribution.verified = false; + notification.attribution.reported_name.clear(); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|value| value == "dialog-warning-symbolic")); + assert!(!candidates.iter().any(|value| value == "Trusted Brand")); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs deleted file mode 100644 index d7a840b4a..000000000 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs +++ /dev/null @@ -1,101 +0,0 @@ -use unixnotis_core::NotificationImage; - -use super::super::{expand_rgb_to_rgba, image_data_texture}; -use super::support::image_data; - -#[test] -fn expand_rgb_to_rgba_appends_alpha() { - let data = image_data(2, 1, 0, 3, vec![10, 20, 30, 40, 50, 60]); - - let (expanded, stride) = expand_rgb_to_rgba(&data).expect("rgb expansion"); - - assert_eq!(stride, 8); - assert_eq!(expanded, vec![10, 20, 30, 255, 40, 50, 60, 255]); -} - -#[test] -fn expand_rgb_to_rgba_honors_row_padding() { - let data = image_data( - 2, - 2, - 8, - 3, - vec![1, 2, 3, 4, 5, 6, 0, 0, 7, 8, 9, 10, 11, 12, 0, 0], - ); - - let (expanded, stride) = expand_rgb_to_rgba(&data).expect("rgb expansion"); - - assert_eq!(stride, 8); - assert_eq!( - expanded, - vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255] - ); -} - -#[test] -fn expand_rgb_to_rgba_rejects_empty_dimensions_short_rows_and_short_buffers() { - assert!(expand_rgb_to_rgba(&image_data(0, 1, 0, 3, vec![1, 2, 3])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(1, 0, 0, 3, vec![1, 2, 3])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(2, 1, 5, 3, vec![1, 2, 3, 4, 5])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(2, 2, 0, 3, vec![1, 2, 3, 4, 5, 6])).is_none()); -} - -#[gtk::test] -fn image_data_texture_accepts_valid_rgba_and_rgb_payloads() { - let rgba = NotificationImage { - has_image_data: true, - image_data: image_data(1, 1, 0, 4, vec![1, 2, 3, 4]), - ..NotificationImage::default() - }; - let rgb = NotificationImage { - has_image_data: true, - image_data: image_data(1, 1, 0, 3, vec![1, 2, 3]), - ..NotificationImage::default() - }; - - assert!(image_data_texture(&rgba).is_some()); - assert!(image_data_texture(&rgb).is_some()); -} - -#[gtk::test] -fn image_data_texture_rejects_missing_flag_bad_bits_dimensions_and_channels() { - let mut image = NotificationImage { - has_image_data: false, - image_data: image_data(1, 1, 0, 4, vec![1, 2, 3, 4]), - ..NotificationImage::default() - }; - assert!(image_data_texture(&image).is_none()); - - image.has_image_data = true; - image.image_data.bits_per_sample = 16; - assert!(image_data_texture(&image).is_none()); - - image.image_data.bits_per_sample = 8; - image.image_data.width = 0; - assert!(image_data_texture(&image).is_none()); - - image.image_data.width = 1; - image.image_data.height = -1; - assert!(image_data_texture(&image).is_none()); - - image.image_data.height = 1; - image.image_data.channels = 2; - assert!(image_data_texture(&image).is_none()); -} - -#[gtk::test] -fn image_data_texture_rejects_bad_stride_and_short_buffers() { - let mut image = NotificationImage { - has_image_data: true, - image_data: image_data(2, 1, 7, 4, vec![0; 8]), - ..NotificationImage::default() - }; - assert!(image_data_texture(&image).is_none()); - - image.image_data.rowstride = -1; - assert!(image_data_texture(&image).is_none()); - - image.image_data.rowstride = 0; - image.image_data.data = vec![0; 7]; - assert!(image_data_texture(&image).is_none()); -} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs index 3ef072eef..99ccc5f57 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs @@ -1,7 +1,6 @@ //! Test index for popup icon resolution mod candidates; -mod image_data; mod path_hints; mod support; mod theme_lookup; diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index 4c5a57505..f07d29abf 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -1,27 +1,14 @@ -use unixnotis_core::{ImageData, NotificationImage, NotificationView}; - -pub(super) fn image_data( - width: i32, - height: i32, - rowstride: i32, - channels: i32, - data: Vec, -) -> ImageData { - ImageData { - width, - height, - rowstride, - has_alpha: channels == 4, - bits_per_sample: 8, - channels, - data, - } -} +use unixnotis_core::{NotificationImage, NotificationView}; pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView { NotificationView { id: 1, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution { + verified: true, + reported_name: String::new(), + badge_icon: icon_name.to_string(), + }, summary: String::new(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index bd7c59b4a..c961ddcf9 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -7,6 +7,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { NotificationView { id, app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: "body".to_string(), actions: vec![Action { diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index e9adf0dfc..4b1b49370 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -46,6 +46,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { let notification = NotificationView { id: 1, app_name: "Demo".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "Summary".to_string(), body: "Body".to_string(), actions: Vec::new(), diff --git a/crates/unixnotis-ui/Cargo.toml b/crates/unixnotis-ui/Cargo.toml index 1c5d9e8ed..685c2cced 100644 --- a/crates/unixnotis-ui/Cargo.toml +++ b/crates/unixnotis-ui/Cargo.toml @@ -10,6 +10,7 @@ notify.workspace = true tracing.workspace = true unixnotis-core = { path = "../unixnotis-core" } serde.workspace = true +shell-words.workspace = true serde_json.workspace = true url.workspace = true diff --git a/crates/unixnotis-ui/src/icons/desktop_index.rs b/crates/unixnotis-ui/src/icons/desktop_index.rs index cbd5cc26c..3fbd31122 100644 --- a/crates/unixnotis-ui/src/icons/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/desktop_index.rs @@ -11,6 +11,7 @@ pub struct DesktopIconIndex { names: HashMap>, wm_classes: HashMap>, ids: HashMap>, + executables: HashMap>, } impl DesktopIconIndex { @@ -26,6 +27,7 @@ impl DesktopIconIndex { self.names.clear(); self.wm_classes.clear(); self.ids.clear(); + self.executables.clear(); for app_info in gio::AppInfo::all() { let Ok(desktop) = app_info.downcast::() else { continue; @@ -48,6 +50,12 @@ impl DesktopIconIndex { if let Some(id) = desktop.id() { self.add_id(id.as_str(), &icon_name); } + if let Some(executable) = desktop + .string("Exec") + .and_then(|exec| executable_basename(exec.as_str())) + { + self.add_executable(&executable, &icon_name); + } } } @@ -67,6 +75,9 @@ impl DesktopIconIndex { if let Some(values) = self.names.get(&normalized) { out.extend(values.iter().cloned()); } + if let Some(values) = self.executables.get(&normalized) { + out.extend(values.iter().cloned()); + } if out.is_empty() { return None; } @@ -92,6 +103,22 @@ impl DesktopIconIndex { add_icon_to_map(&mut self.ids, stripped, icon); } } + + fn add_executable(&mut self, key: &str, icon: &str) { + // Executable basenames come from authenticated daemon metadata + add_icon_to_map(&mut self.executables, key, icon); + } +} + +fn executable_basename(exec: &str) -> Option { + // Desktop Exec fields use shell-like quoting plus field-code arguments + let arguments = shell_words::split(exec).ok()?; + let program = arguments.first()?; + std::path::Path::new(program) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) } fn add_icon_to_map(map: &mut HashMap>, key: &str, icon: &str) { diff --git a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs index bcd6c0227..696874419 100644 --- a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs @@ -1,5 +1,32 @@ use super::*; +#[test] +fn executable_basename_handles_paths_quotes_and_field_codes() { + assert_eq!( + executable_basename("'/opt/Demo App/bin/demo-app' --open %U"), + Some("demo-app".to_string()) + ); + assert_eq!( + executable_basename("firefox %u"), + Some("firefox".to_string()) + ); + assert_eq!(executable_basename(""), None); + assert_eq!(executable_basename("'unterminated"), None); +} + +#[test] +fn desktop_index_resolves_authenticated_executable_to_application_icon() { + let mut index = DesktopIconIndex::default(); + + index.add_executable("demo-app", "org.example.Demo"); + index.add_executable("DEMO-APP", "org.example.Demo"); + + assert_eq!( + index.icons_for("demo-app"), + Some(vec!["org.example.Demo".to_string()]) + ); +} + #[test] fn desktop_index_normalizes_ids_and_removes_duplicate_icons() { let mut index = DesktopIconIndex::default(); From b68b4f36e1de35ed6d6c26b06ce8aba74840f3b9 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 16:51:20 -0500 Subject: [PATCH 079/275] ci(release): pin and attest release inputs Summary: pin and attest release inputs. Scope: release. --- .github/workflows/ci.yml | 5 +++ .github/workflows/release.yml | 77 +++++++++++++++++++++++++++----- scripts/package-release.sh | 6 +-- tests/check-release-hardening.sh | 54 ++++++++++++++++++++++ tests/package-release.sh | 2 +- 5 files changed, 130 insertions(+), 14 deletions(-) create mode 100755 tests/check-release-hardening.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7a587435..e8511fdee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,7 @@ jobs: shellcheck \ scripts/package-release.sh \ tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-test-placement.sh \ tests/check-no-personal-paths.sh \ crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib \ @@ -101,6 +102,7 @@ jobs: shellharden --check \ scripts/package-release.sh \ tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-test-placement.sh \ tests/check-no-personal-paths.sh @@ -110,6 +112,9 @@ jobs: - name: Check tracked paths for personal data run: tests/check-no-personal-paths.sh + - name: Check release workflow hardening + run: tests/check-release-hardening.sh + - name: Test release packaging helpers run: tests/package-release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d6d58034..e774bac60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,11 @@ on: type: string permissions: - # The workflow builds archives only; publishing a GitHub Release stays a manual action + # The token can read sources and publish signed provenance for the built files contents: read + id-token: write + attestations: write + artifact-metadata: write concurrency: # A release tag should produce one archive set, so do not cancel a run already packaging it @@ -21,6 +24,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -30,23 +36,43 @@ jobs: package: name: Build release tarball runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 45 steps: - name: Install system dependencies run: | set -euo pipefail + # Immutable snapshots keep package resolution stable across release reruns + rm -f /etc/apt/sources.list + printf '%s\n' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + # Snapshot signatures are expected to be expired when an old release is rebuilt + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ ca-certificates \ curl \ + gettext-base \ git \ libgtk-4-dev \ libgtk4-layer-shell-dev \ pkg-config \ + python3 \ shellcheck \ xz-utils \ zstd @@ -57,24 +83,31 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --profile minimal + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" \ + -y \ + --profile minimal \ + --default-toolchain 1.96.1 \ + --no-modify-path + rm -f "$rustup_init" echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" - rustup default 1.96.1 - cargo install shellharden --locked --version 4.3.2 - name: Check packaging script run: | shellcheck \ scripts/package-release.sh \ tests/package-release.sh \ - tests/check-no-personal-paths.sh - shellharden --check \ - scripts/package-release.sh \ - tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-no-personal-paths.sh tests/check-no-personal-paths.sh + tests/check-release-hardening.sh tests/package-release.sh - name: Build package archive @@ -90,6 +123,29 @@ jobs: fi scripts/package-release.sh "$RELEASE_TAG" + - name: Install artifact signer + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + with: + cosign-release: v3.1.2 + + - name: Sign package files + run: | + set -euo pipefail + for artifact in dist/*.tar.zst dist/*.sha256; do + # Each bundle carries the certificate, signature, and transparency proof + cosign sign-blob \ + --yes \ + --bundle "${artifact}.sigstore.json" \ + "$artifact" + done + + - name: Attest package provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: | + dist/*.tar.zst + dist/*.sha256 + - name: Upload package archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -97,4 +153,5 @@ jobs: path: | dist/*.tar.zst dist/*.sha256 + dist/*.sigstore.json if-no-files-found: error diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b719a142b..51e8dd92a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -46,7 +46,7 @@ assert_workspace_version() { local actual # cargo pkgid reads Cargo metadata and avoids hand-parsing Cargo.toml - pkgid="$(cargo pkgid -p unixnotis-installer)" + pkgid="$(cargo pkgid --locked -p unixnotis-installer)" actual="${pkgid##*#}" if [[ "$actual" != "$expected" ]]; then @@ -57,7 +57,7 @@ assert_workspace_version() { build_release_binaries() { local binaries=("$@") - local args=(build --release --bin unixnotis-installer) + local args=(build --locked --release --bin unixnotis-installer) for binary in "${binaries[@]}"; do # Managed values are executable targets and do not have to match Cargo package names @@ -154,7 +154,7 @@ write_manifest() { } managed_binaries() { - cargo metadata --no-deps --format-version 1 | + cargo metadata --locked --no-deps --format-version 1 | python3 -c ' import json import sys diff --git a/tests/check-release-hardening.sh b/tests/check-release-hardening.sh new file mode 100755 index 000000000..0ae9f3894 --- /dev/null +++ b/tests/check-release-hardening.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd -- "${script_dir}/.." && pwd -P)" +workflow="${repo_root}/.github/workflows/release.yml" +packager="${repo_root}/scripts/package-release.sh" + +assert_contains() { + local path="${1}" + local expected="${2}" + + # Fixed-string matching keeps workflow syntax out of regular-expression parsing + if ! grep -Fq -- "$expected" "$path"; then + printf 'missing release hardening in %s: %s\n' "$path" "$expected" >&2 + return 1 + fi +} + +assert_excludes() { + local path="${1}" + local rejected="${2}" + + # Mutable installers and live Cargo tools must not enter the release builder + if grep -Fq -- "$rejected" "$path"; then + printf 'mutable release input remains in %s: %s\n' "$path" "$rejected" >&2 + return 1 + fi +} + +# The base image and package repository both resolve to immutable inputs +assert_contains "$workflow" 'container: debian:trixie-slim@sha256:' +assert_contains "$workflow" "snapshot.debian.org/archive/debian/\${DEBIAN_SNAPSHOT}" +assert_contains "$workflow" "snapshot.debian.org/archive/debian-security/\${DEBIAN_SNAPSHOT}" + +# Rustup is downloaded from a versioned archive and checked before execution +assert_contains "$workflow" 'RUSTUP_INIT_VERSION: 1.28.2' +assert_contains "$workflow" 'RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c' +assert_contains "$workflow" '| sha256sum --check --strict' +assert_excludes "$workflow" 'https://sh.rustup.rs' +assert_excludes "$workflow" 'cargo install' + +# Release builds cannot update the dependency lockfile +assert_contains "$packager" 'local args=(build --locked --release' +assert_contains "$packager" 'cargo pkgid --locked' +assert_contains "$packager" 'cargo metadata --locked --no-deps' + +# Archives and checksum manifests receive both portable signatures and provenance +assert_contains "$workflow" 'sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22' +assert_contains "$workflow" 'cosign-release: v3.1.2' +assert_contains "$workflow" 'cosign sign-blob' +assert_contains "$workflow" 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' +assert_contains "$workflow" 'dist/*.sigstore.json' diff --git a/tests/package-release.sh b/tests/package-release.sh index a5be39a7f..07ded975d 100755 --- a/tests/package-release.sh +++ b/tests/package-release.sh @@ -58,7 +58,7 @@ cargo() { build_release_binaries unixnotis-daemon unixnotis-css-validate unset -f cargo -expected_args=$'build\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-css-validate' +expected_args=$'build\n--locked\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-css-validate' actual_args="$(cat -- "$cargo_args")" if [[ "$actual_args" != "$expected_args" ]]; then printf 'release build did not select exact binary targets\n' >&2 From c96f3119e64c54001e1d66ba55bbd1ff00cd7c15 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:43:46 -0500 Subject: [PATCH 080/275] fix(config): keep Night toggle on a healthy backend Summary: keep Night toggle on a healthy backend. Scope: config. --- .../legacy/unixnotis-blue-light-lib-v1 | 104 ++++++++++++++++++ .../scripts/legacy/unixnotis-blue-light-on-v1 | 12 ++ .../assets/scripts/unixnotis-blue-light-lib | 35 ++++++ .../assets/scripts/unixnotis-blue-light-on | 7 +- .../src/config/loading/io/mod.rs | 1 + .../config/loading/io/script_migrations.rs | 39 +++++++ .../src/config/loading/io/scripts.rs | 5 +- .../src/config/loading/io/tests/blue_light.rs | 34 ++++++ .../src/config/loading/io/tests/mod.rs | 1 + .../loading/io/tests/script_migrations.rs | 58 ++++++++++ .../src/config/loading/io/tests/scripts.rs | 28 +++++ 11 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 create mode 100644 crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 create mode 100644 crates/unixnotis-core/src/config/loading/io/script_migrations.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs diff --git a/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 new file mode 100644 index 000000000..3468fd3fe --- /dev/null +++ b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 @@ -0,0 +1,104 @@ +#!/bin/sh +set -eu + +: "${UNIXNOTIS_BLUE_LIGHT_TEMP:=4500}" +: "${UNIXNOTIS_BLUE_LIGHT_GAMMA:=90}" + +has_backend() { + command -v "$1" >/dev/null 2>&1 +} + +backend_running() { + pgrep -x "$1" >/dev/null 2>&1 +} + +active_backend() { + # Prefer the backend already in charge so the toggle does not switch tools + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if backend_running "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +installed_backend() { + # Fall back to the first supported tool installed on the system + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if has_backend "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +selected_backend() { + active_backend || installed_backend +} + +stop_backend() { + case "$1" in + hyprsunset) + pkill -x hyprsunset >/dev/null 2>&1 || true + ;; + gammastep) + if has_backend gammastep; then + gammastep -x >/dev/null 2>&1 || true + fi + pkill -x gammastep >/dev/null 2>&1 || true + ;; + wlsunset) + pkill -x wlsunset >/dev/null 2>&1 || true + ;; + sunsetr) + if has_backend sunsetr; then + sunsetr stop >/dev/null 2>&1 || true + fi + pkill -x sunsetr >/dev/null 2>&1 || true + ;; + esac +} + +stop_conflicting_backends() { + active="$1" + + # Only one color-temperature process should own the display pipeline + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if [ "$candidate" != "$active" ] && backend_running "$candidate"; then + stop_backend "$candidate" + fi + done +} + +stop_active_backends() { + # Off stops every supported backend that is currently active + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if backend_running "$candidate"; then + stop_backend "$candidate" + fi + done +} + +start_backend() { + case "$1" in + hyprsunset) + nohup hyprsunset --temperature "$UNIXNOTIS_BLUE_LIGHT_TEMP" >/dev/null 2>&1 & + ;; + gammastep) + nohup gammastep -m wayland -l 0:0 -t "$UNIXNOTIS_BLUE_LIGHT_TEMP:$UNIXNOTIS_BLUE_LIGHT_TEMP" -P >/dev/null 2>&1 & + ;; + wlsunset) + nohup wlsunset -t "$UNIXNOTIS_BLUE_LIGHT_TEMP" -T "$UNIXNOTIS_BLUE_LIGHT_TEMP" -l 0 -L 0 >/dev/null 2>&1 & + ;; + sunsetr) + nohup sunsetr test "$UNIXNOTIS_BLUE_LIGHT_TEMP" "$UNIXNOTIS_BLUE_LIGHT_GAMMA" >/dev/null 2>&1 & + ;; + *) + return 127 + ;; + esac +} diff --git a/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 new file mode 100644 index 000000000..207ce0ccc --- /dev/null +++ b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +# shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +. "$script_dir/unixnotis-blue-light-lib" + +# Keep the user's active backend when possible, otherwise use the first installed one +backend=$(selected_backend) +stop_conflicting_backends "$backend" +stop_backend "$backend" +start_backend "$backend" diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib index 3468fd3fe..fa127d679 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib @@ -3,6 +3,7 @@ set -eu : "${UNIXNOTIS_BLUE_LIGHT_TEMP:=4500}" : "${UNIXNOTIS_BLUE_LIGHT_GAMMA:=90}" +: "${UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY:=0.2}" has_backend() { command -v "$1" >/dev/null 2>&1 @@ -102,3 +103,37 @@ start_backend() { ;; esac } + +start_backend_if_healthy() { + candidate="$1" + + # Clear a stale instance before asking one candidate to own the display + stop_backend "$candidate" + start_backend "$candidate" + sleep "$UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY" + + # A tool that exits during startup is unavailable even when it exists on PATH + if backend_running "$candidate"; then + stop_conflicting_backends "$candidate" + return 0 + fi + + stop_backend "$candidate" + return 1 +} + +start_available_backend() { + # Preserve a working backend instead of changing tools on every click + if candidate=$(active_backend); then + start_backend_if_healthy "$candidate" && return 0 + fi + + # Broken packages and unsupported compositors fall through to the next tool + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if has_backend "$candidate" && start_backend_if_healthy "$candidate"; then + return 0 + fi + done + + return 1 +} diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on index 207ce0ccc..08a3f5341 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on @@ -5,8 +5,5 @@ script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) # shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib . "$script_dir/unixnotis-blue-light-lib" -# Keep the user's active backend when possible, otherwise use the first installed one -backend=$(selected_backend) -stop_conflicting_backends "$backend" -stop_backend "$backend" -start_backend "$backend" +# Keep a healthy active backend, then fall through installed tools that fail to start +start_available_backend diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs index 1d79d60b0..86d509ed4 100644 --- a/crates/unixnotis-core/src/config/loading/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -3,6 +3,7 @@ mod error; mod load; mod paths; +mod script_migrations; mod scripts; mod theme_files; mod write; diff --git a/crates/unixnotis-core/src/config/loading/io/script_migrations.rs b/crates/unixnotis-core/src/config/loading/io/script_migrations.rs new file mode 100644 index 000000000..7f4812444 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/script_migrations.rs @@ -0,0 +1,39 @@ +//! Exact legacy stock helpers that can be upgraded without replacing user edits + +use std::path::Path; + +struct LegacyScript { + relative_path: &'static str, + contents: &'static [u8], +} + +const LEGACY_SCRIPTS: &[LegacyScript] = &[ + LegacyScript { + relative_path: "scripts/unixnotis-blue-light-lib", + contents: include_bytes!("../../../../assets/scripts/legacy/unixnotis-blue-light-lib-v1"), + }, + LegacyScript { + relative_path: "scripts/unixnotis-blue-light-on", + contents: include_bytes!("../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"), + }, +]; + +pub(super) fn is_legacy_stock_script(path: &Path, relative_path: &str) -> bool { + let Some(legacy) = LEGACY_SCRIPTS + .iter() + .find(|legacy| legacy.relative_path == relative_path) + else { + return false; + }; + + // A metadata length check avoids reading an unrelated large user file + let Ok(metadata) = path.symlink_metadata() else { + return false; + }; + if !metadata.file_type().is_file() || metadata.len() != legacy.contents.len() as u64 { + return false; + } + + // Exact bytes make the migration safe for every customized variant + std::fs::read(path).is_ok_and(|contents| contents == legacy.contents) +} diff --git a/crates/unixnotis-core/src/config/loading/io/scripts.rs b/crates/unixnotis-core/src/config/loading/io/scripts.rs index 7b34f4c98..9595661e9 100644 --- a/crates/unixnotis-core/src/config/loading/io/scripts.rs +++ b/crates/unixnotis-core/src/config/loading/io/scripts.rs @@ -5,6 +5,7 @@ use std::path::Path; use crate::filesystem::{make_file_executable, write_file_atomic}; use crate::{Config, DEFAULT_SCRIPTS}; +use super::script_migrations::is_legacy_stock_script; use super::ConfigError; impl Config { @@ -16,8 +17,8 @@ impl Config { pub fn ensure_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { for script in DEFAULT_SCRIPTS { let path = config_dir.join(script.relative_path); - // Existing files are preserved so user-edited helpers are not overwritten - if !path.exists() { + // Known stock versions can move forward while all edited helpers stay untouched + if !path.exists() || is_legacy_stock_script(&path, script.relative_path) { write_default_script(&path, script.contents)?; } // Relative commands run the helper directly, so execute bits must be present diff --git a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs index 0db3127fa..abf27353b 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs @@ -96,6 +96,40 @@ fn stopping_night_mode_visits_every_active_supported_backend() { let _ = fs::remove_dir_all(root); } +#[test] +fn night_mode_falls_through_when_the_first_installed_backend_cannot_stay_running() { + let (root, log) = backend_fixture("blue-light-fallback"); + let marker = root.join("gammastep.running"); + write_executable( + &root.join("bin/gammastep"), + "#!/bin/sh\nprintf '%s %s\\n' \"${0##*/}\" \"$*\" >> \"$TEST_LOG\"\n: > \"$TEST_MARKER\"\n", + ); + write_executable( + &root.join("bin/pgrep"), + "#!/bin/sh\n[ \"$2\" = gammastep ] && [ -f \"$TEST_MARKER\" ]\n", + ); + write_executable(&root.join("bin/pkill"), "#!/bin/sh\nexit 0\n"); + write_executable(&root.join("bin/sleep"), "#!/bin/sh\nexit 0\n"); + + let status = Command::new("/bin/sh") + .args(["-c", ". \"$1\"; start_available_backend", "blue-light-test"]) + .arg(root.join("blue-light-lib")) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .env("TEST_MARKER", &marker) + .env("UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY", "0") + .status() + .expect("start first healthy blue-light backend"); + + assert!(status.success()); + let calls = fs::read_to_string(&log).expect("read backend calls"); + assert!(calls.lines().any(|call| call.starts_with("hyprsunset "))); + assert!(calls + .lines() + .any(|call| call.starts_with("gammastep -m wayland"))); + let _ = fs::remove_dir_all(root); +} + #[test] fn blue_light_scripts_do_not_use_cross_user_temporary_state() { for script in crate::DEFAULT_SCRIPTS { diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index 083e1028e..740db0af7 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -3,6 +3,7 @@ mod blue_light; mod load; mod paths; +mod script_migrations; mod scripts; mod support; mod theme_files; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs b/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs new file mode 100644 index 000000000..9945669f6 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs @@ -0,0 +1,58 @@ +use std::fs; + +use super::super::script_migrations::is_legacy_stock_script; +use super::support::test_root; + +const LEGACY_BLUE_LIGHT_ON: &[u8] = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"); + +#[test] +fn exact_legacy_stock_script_is_recognized() { + let root = test_root("legacy-stock-script"); + let path = root.join("unixnotis-blue-light-on"); + fs::create_dir_all(&root).expect("legacy helper directory"); + fs::write(&path, LEGACY_BLUE_LIGHT_ON).expect("write legacy helper"); + + assert!(is_legacy_stock_script( + &path, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn edited_legacy_script_is_not_recognized_as_stock() { + let root = test_root("edited-legacy-script"); + let path = root.join("unixnotis-blue-light-on"); + fs::create_dir_all(&root).expect("edited helper directory"); + let mut edited = LEGACY_BLUE_LIGHT_ON.to_vec(); + edited.extend_from_slice(b"\n# local setting\n"); + fs::write(&path, edited).expect("write edited helper"); + + assert!(!is_legacy_stock_script( + &path, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn legacy_bytes_reached_through_a_same_length_symlink_are_not_stock() { + use std::os::unix::fs::symlink; + + let root = test_root("linked-legacy-script"); + fs::create_dir_all(&root).expect("linked helper directory"); + let fixture = root.join("fixture"); + let link = root.join("unixnotis-blue-light-on"); + fs::write(&fixture, LEGACY_BLUE_LIGHT_ON).expect("write linked helper target"); + let link_target = format!("{}fixture", "./".repeat(197)); + assert_eq!(link_target.len(), LEGACY_BLUE_LIGHT_ON.len()); + symlink(link_target, &link).expect("link legacy helper"); + + assert!(!is_legacy_stock_script( + &link, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs index 1be5bb54c..8811e927e 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs @@ -77,6 +77,34 @@ fn ensure_default_scripts_in_preserves_user_edited_script_contents() { let _ = fs::remove_dir_all(&root); } +#[test] +fn ensure_default_scripts_in_upgrades_exact_legacy_blue_light_helpers() { + let root = test_root("default-script-upgrade"); + let _ = fs::remove_dir_all(&root); + let legacy_lib = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-lib-v1"); + let legacy_on = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"); + let scripts = root.join("scripts"); + fs::create_dir_all(&scripts).expect("script directory"); + fs::write(scripts.join("unixnotis-blue-light-lib"), legacy_lib).expect("legacy library"); + fs::write(scripts.join("unixnotis-blue-light-on"), legacy_on).expect("legacy on helper"); + + Config::ensure_default_scripts_in(&root).expect("upgrade stock scripts"); + + for name in ["unixnotis-blue-light-lib", "unixnotis-blue-light-on"] { + let expected = crate::DEFAULT_SCRIPTS + .iter() + .find(|script| script.relative_path.ends_with(name)) + .expect("current stock helper"); + assert_eq!( + fs::read(scripts.join(name)).expect("upgraded helper"), + expected.contents.as_bytes() + ); + } + let _ = fs::remove_dir_all(root); +} + #[cfg(unix)] #[test] fn ensure_default_scripts_in_rejects_symlink_without_changing_external_permissions() { From e00ad6effa7e944de544d83fe73ee00971d25a90 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:43:58 -0500 Subject: [PATCH 081/275] fix(dbus): preflight Notify structure before decoding Summary: preflight Notify structure before decoding. Scope: dbus. --- .../daemon/notifications/server/ingress.rs | 16 + .../src/daemon/notifications/server/mod.rs | 1 + .../daemon/notifications/server/preflight.rs | 459 ++++++++++++++++++ .../notifications/server/tests/ingress.rs | 45 ++ .../notifications/server/tests/preflight.rs | 204 ++++++++ 5 files changed, 725 insertions(+) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs index 790eb8590..819c7d974 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -8,6 +8,7 @@ use zbus::object_server::{DispatchResult, Interface, SignalContext}; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, Message, ObjectServer}; +use super::preflight::{preflight_notify, PreflightError}; use super::NotificationServer; // This leaves room for one maximum image plus bounded text, actions, hints, and wire overhead @@ -75,6 +76,21 @@ impl Interface for NotificationIngress { ))) }); } + if name.as_bytes() == b"Notify" { + if let Err(error) = preflight_notify(message) { + // Structural limits are checked from borrowed bytes before owned argument decoding + return DispatchResult::new_async(connection, message, async move { + match error { + PreflightError::LimitsExceeded(reason) => { + Err::<(), _>(zbus::fdo::Error::LimitsExceeded(reason.to_string())) + } + PreflightError::Malformed(reason) => { + Err::<(), _>(zbus::fdo::Error::InvalidArgs(reason.to_string())) + } + } + }); + } + } self.inner.call(server, connection, message, name) } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index 80c7dcd3a..819a7a277 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -5,6 +5,7 @@ mod close; mod flow; mod ingress; mod interface; +mod preflight; pub use ingress::NotificationIngress; pub use interface::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs new file mode 100644 index 000000000..309e6d385 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs @@ -0,0 +1,459 @@ +//! Allocation-bounded structural preflight for the fixed Notify D-Bus body + +use zbus::zvariant::Endian; +use zbus::Message; + +use crate::daemon::notifications::limits::{ + MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, + MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, + MAX_HINT_STRING_BYTES, MAX_SUMMARY_BYTES, +}; + +const NOTIFY_SIGNATURE: &str = "susssasa{sv}i"; +const MAX_IMAGE_BYTES: usize = 256 * 1024; +const MAX_NON_IMAGE_ARRAY_BYTES: usize = 16 * 1024; +const MAX_NON_IMAGE_STRING_BYTES: usize = 64 * 1024; +const MAX_NESTED_CONTAINER_ELEMENTS: usize = 64; +const MAX_SIGNATURE_DEPTH: usize = 16; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(super) enum PreflightError { + LimitsExceeded(&'static str), + Malformed(&'static str), +} + +pub(super) fn preflight_notify(message: &Message) -> Result<(), PreflightError> { + let body = message.body(); + // The fixed wire shape is checked before the typed interface creates owned containers + if body + .signature() + .as_ref() + .map(ToString::to_string) + .as_deref() + != Some(NOTIFY_SIGNATURE) + { + return Err(PreflightError::Malformed("Notify has an invalid signature")); + } + let data = body.data(); + let context = data.context(); + let mut cursor = Cursor::new(data.bytes(), context.position(), context.endian()); + let mut budget = StringBudget::default(); + + // Fields are consumed in the exact org.freedesktop.Notifications Notify order + cursor.read_string(MAX_APP_NAME_BYTES, &mut budget)?; + cursor.read_fixed(4, 4)?; + cursor.read_string(MAX_APP_ICON_BYTES, &mut budget)?; + cursor.read_string(MAX_SUMMARY_BYTES, &mut budget)?; + cursor.read_string(MAX_BODY_BYTES, &mut budget)?; + preflight_actions(&mut cursor, &mut budget)?; + preflight_hints(&mut cursor, &mut budget)?; + cursor.read_fixed(4, 4)?; + if cursor.offset != cursor.bytes.len() { + return Err(PreflightError::Malformed("Notify body has trailing data")); + } + Ok(()) +} + +fn preflight_actions( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(4)?; + let mut count = 0_usize; + while cursor.offset < end { + // Actions alternate key and label, with eight complete pairs allowed + if count >= MAX_ACTIONS * 2 { + return Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements", + )); + } + let limit = if count.is_multiple_of(2) { + MAX_ACTION_KEY_BYTES + } else { + MAX_ACTION_LABEL_BYTES + }; + cursor.read_string(limit, budget)?; + count += 1; + } + cursor.finish_array(end)?; + Ok(()) +} + +fn preflight_hints( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(8)?; + let mut count = 0_usize; + while cursor.offset < end { + // Entry count is bounded before zbus can construct the owned map + if count >= MAX_HINT_ENTRIES { + return Err(PreflightError::LimitsExceeded( + "Notify hint dictionary has too many entries", + )); + } + cursor.align(8)?; + let key = cursor.read_string(MAX_HINT_KEY_BYTES, budget)?; + // Only standard image aliases receive the larger byte-array allowance + let image_hint = matches!(key, b"image-data" | b"image_data" | b"icon_data"); + let signature = cursor.read_signature()?; + let value_type = SignatureParser::one(signature)?; + cursor.skip_value(&value_type, budget, image_hint, 0)?; + count += 1; + } + cursor.finish_array(end)?; + Ok(()) +} + +#[derive(Default)] +struct StringBudget { + bytes: usize, +} + +impl StringBudget { + fn add(&mut self, bytes: usize) -> Result<(), PreflightError> { + // One cumulative budget prevents many individually valid strings from amplifying memory + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or(PreflightError::LimitsExceeded( + "Notify string budget overflowed", + ))?; + if self.bytes > MAX_NON_IMAGE_STRING_BYTES { + return Err(PreflightError::LimitsExceeded( + "Notify contains too much non-image string data", + )); + } + Ok(()) + } +} + +struct Cursor<'a> { + bytes: &'a [u8], + absolute_start: usize, + endian: Endian, + offset: usize, +} + +impl<'a> Cursor<'a> { + const fn new(bytes: &'a [u8], absolute_start: usize, endian: Endian) -> Self { + Self { + bytes, + absolute_start, + endian, + offset: 0, + } + } + + fn align(&mut self, alignment: usize) -> Result<(), PreflightError> { + // D-Bus alignment is relative to the whole message rather than this body slice + let absolute = self + .absolute_start + .checked_add(self.offset) + .ok_or(PreflightError::Malformed("Notify alignment overflowed"))?; + let padding = (alignment - absolute % alignment) % alignment; + self.advance(padding) + } + + fn advance(&mut self, bytes: usize) -> Result<(), PreflightError> { + // Checked offsets turn malformed lengths into errors instead of wraparound + let end = self + .offset + .checked_add(bytes) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify body is truncated")); + } + self.offset = end; + Ok(()) + } + + fn read_fixed(&mut self, alignment: usize, bytes: usize) -> Result<(), PreflightError> { + self.align(alignment)?; + self.advance(bytes) + } + + fn read_u8(&mut self) -> Result { + let value = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset += 1; + Ok(value) + } + + fn read_u32(&mut self) -> Result { + self.align(4)?; + let end = self + .offset + .checked_add(4) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + let bytes = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset = end; + Ok(self.endian.read_u32(bytes)) + } + + fn read_string( + &mut self, + limit: usize, + budget: &mut StringBudget, + ) -> Result<&'a [u8], PreflightError> { + // Length is rejected before a slice is exposed to later parsing + let length = usize::try_from(self.read_u32()?) + .map_err(|_| PreflightError::LimitsExceeded("Notify string is too large"))?; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit", + )); + } + budget.add(length)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify string offset overflowed"))?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify string is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify string is missing its terminator", + )); + } + Ok(value) + } + + fn read_signature(&mut self) -> Result<&'a [u8], PreflightError> { + let length = usize::from(self.read_u8()?); + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed( + "Notify signature offset overflowed", + ))?; + let signature = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify signature is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify signature is missing its terminator", + )); + } + Ok(signature) + } + + fn begin_array(&mut self, element_alignment: usize) -> Result { + // Array byte lengths are validated before any element walk begins + let length = usize::try_from(self.read_u32()?) + .map_err(|_| PreflightError::LimitsExceeded("Notify array is too large"))?; + self.align(element_alignment)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify array offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify array is truncated")); + } + Ok(end) + } + + fn finish_array(&self, end: usize) -> Result<(), PreflightError> { + if self.offset == end { + Ok(()) + } else { + Err(PreflightError::Malformed( + "Notify array elements do not match its byte length", + )) + } + } + + fn skip_value( + &mut self, + value_type: &SignatureType, + budget: &mut StringBudget, + image_hint: bool, + depth: usize, + ) -> Result<(), PreflightError> { + // Recursive variants and containers share one small depth limit + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep", + )); + } + match value_type { + SignatureType::Basic(kind) => match kind { + b'y' => self.advance(1), + b'n' | b'q' => self.read_fixed(2, 2), + b'b' | b'i' | b'u' | b'h' => self.read_fixed(4, 4), + b'x' | b't' | b'd' => self.read_fixed(8, 8), + b's' | b'o' => self.read_string(MAX_HINT_STRING_BYTES, budget).map(drop), + b'g' => { + let signature = self.read_signature()?; + budget.add(signature.len()) + } + _ => Err(PreflightError::Malformed( + "Notify variant has an unsupported basic type", + )), + }, + SignatureType::Variant => { + let signature = self.read_signature()?; + let nested = SignatureParser::one(signature)?; + self.skip_value(&nested, budget, image_hint, depth + 1) + } + SignatureType::Array(element) => { + let end = self.begin_array(element.alignment())?; + if matches!(element.as_ref(), SignatureType::Basic(b'y')) { + // Raw bytes are skipped in place without constructing an intermediate vector + let length = end - self.offset; + let limit = if image_hint { + MAX_IMAGE_BYTES + } else { + MAX_NON_IMAGE_ARRAY_BYTES + }; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance", + )); + } + self.offset = end; + return Ok(()); + } + let mut count = 0_usize; + while self.offset < end { + // Non-byte arrays receive an element cap as well as the wire-byte cap + if count >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify nested array has too many elements", + )); + } + self.skip_value(element, budget, image_hint, depth + 1)?; + count += 1; + } + self.finish_array(end) + } + SignatureType::Structure(fields) | SignatureType::DictEntry(fields) => { + self.align(8)?; + for field in fields { + self.skip_value(field, budget, image_hint, depth + 1)?; + } + Ok(()) + } + } + } +} + +#[derive(Debug)] +enum SignatureType { + Basic(u8), + Variant, + Array(Box), + Structure(Vec), + DictEntry(Vec), +} + +impl SignatureType { + const fn alignment(&self) -> usize { + match self { + Self::Basic(b'y' | b'g') | Self::Variant => 1, + Self::Basic(b'n' | b'q') => 2, + Self::Basic(b'b' | b'i' | b'u' | b'h' | b's' | b'o') | Self::Array(_) => 4, + Self::Basic(b'x' | b't' | b'd') | Self::Structure(_) | Self::DictEntry(_) => 8, + Self::Basic(_) => 1, + } + } +} + +struct SignatureParser<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> SignatureParser<'a> { + fn one(bytes: &'a [u8]) -> Result { + // A variant signature must describe exactly one complete value + let mut parser = Self { bytes, offset: 0 }; + let value_type = parser.parse_type(0)?; + if parser.offset != bytes.len() { + return Err(PreflightError::Malformed( + "Notify variant signature has trailing types", + )); + } + Ok(value_type) + } + + fn parse_type(&mut self, depth: usize) -> Result { + // Parsing the tiny signature first makes the later byte walk deterministic + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep", + )); + } + let kind = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed( + "Notify variant signature is empty", + ))?; + self.offset += 1; + match kind { + b'y' | b'b' | b'n' | b'q' | b'i' | b'u' | b'x' | b't' | b'd' | b's' | b'o' | b'g' + | b'h' => Ok(SignatureType::Basic(kind)), + b'v' => Ok(SignatureType::Variant), + b'a' => Ok(SignatureType::Array(Box::new(self.parse_type(depth + 1)?))), + b'(' => self.parse_fields(b')', depth).map(SignatureType::Structure), + b'{' => self.parse_fields(b'}', depth).and_then(|fields| { + if fields.len() == 2 { + Ok(SignatureType::DictEntry(fields)) + } else { + Err(PreflightError::Malformed( + "Notify dictionary entry has an invalid signature", + )) + } + }), + _ => Err(PreflightError::Malformed( + "Notify variant signature contains an invalid type", + )), + } + } + + fn parse_fields( + &mut self, + terminator: u8, + depth: usize, + ) -> Result, PreflightError> { + let mut fields = Vec::new(); + loop { + // Container signatures are bounded independently from data-array element counts + let Some(kind) = self.bytes.get(self.offset).copied() else { + return Err(PreflightError::Malformed( + "Notify container signature is unterminated", + )); + }; + if kind == terminator { + self.offset += 1; + if fields.is_empty() { + return Err(PreflightError::Malformed( + "Notify container signature is empty", + )); + } + return Ok(fields); + } + if fields.len() >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify container signature has too many fields", + )); + } + fields.push(self.parse_type(depth + 1)?); + } + } +} + +#[cfg(test)] +#[path = "tests/preflight.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index 117671faa..a56c8ec07 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -43,6 +43,30 @@ async fn oversized_action_array_is_rejected_before_notify_deserialization() { assert_oversized_notify_rejected(&state, &client, actions, HashMap::new(), String::new()).await; } +#[tokio::test] +async fn under_wire_limit_tiny_action_flood_never_reaches_typed_notify() { + let (state, client) = notification_ingress().await; + let actions = (0..20_000).map(|_| "a".to_string()).collect::>(); + let probe = zbus::Message::method(NOTIFICATIONS_OBJECT_PATH, "Notify") + .expect("method builder") + .interface(NOTIFICATIONS_INTERFACE) + .expect("notification interface") + .build(&( + "app", + 0_u32, + "", + "summary", + "", + &actions, + HashMap::::new(), + 0_i32, + )) + .expect("action flood probe"); + assert!(probe.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + + assert_oversized_notify_rejected(&state, &client, actions, HashMap::new(), String::new()).await; +} + #[tokio::test] async fn oversized_hint_map_is_rejected_before_notify_deserialization() { let (state, client) = notification_ingress().await; @@ -77,6 +101,27 @@ async fn oversized_image_array_is_rejected_before_notify_deserialization() { assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; } +#[tokio::test] +async fn under_wire_limit_image_above_its_allowance_never_reaches_typed_notify() { + let (state, client) = notification_ingress().await; + let image = Structure::from(( + 256_i32, + 256_i32, + 1024_i32, + true, + 8_i32, + 4_i32, + vec![0_u8; 256 * 1024 + 1], + )); + let mut hints = HashMap::new(); + hints.insert( + "image-data".to_string(), + OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + ); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +} + #[tokio::test] async fn bounded_notify_body_reaches_the_typed_interface() { let (state, client) = notification_ingress().await; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs new file mode 100644 index 000000000..8390ccb6b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs @@ -0,0 +1,204 @@ +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, Structure, Value}; +use zbus::Message; + +use super::{preflight_notify, PreflightError}; +use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; + +fn notify_message( + app_name: &str, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, +) -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&( + app_name, 0_u32, app_icon, summary, body, actions, hints, 0_i32, + )) + .expect("Notify message") +} + +#[test] +fn ordinary_notify_body_passes_structural_preflight() { + let message = notify_message( + "Example", + "example", + "Summary", + "Body", + vec!["default".to_string(), "Open".to_string()], + HashMap::new(), + ); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn under_wire_limit_tiny_action_flood_is_rejected() { + let actions = (0..20_000).map(|_| "a".to_string()).collect(); + let message = notify_message("app", "", "summary", "", actions, HashMap::new()); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn action_array_accepts_eight_pairs_and_rejects_the_next_element() { + let exact = vec!["a".to_string(); 16]; + let exact_message = notify_message("app", "", "", "", exact, HashMap::new()); + assert_eq!(preflight_notify(&exact_message), Ok(())); + + let over = vec!["a".to_string(); 17]; + let over_message = notify_message("app", "", "", "", over, HashMap::new()); + assert_eq!( + preflight_notify(&over_message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn hint_entry_flood_is_rejected_before_map_allocation() { + let hints = (0..17) + .map(|index| (format!("hint-{index}"), OwnedValue::from(index as u32))) + .collect(); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify hint dictionary has too many entries" + )) + ); +} + +#[test] +fn field_string_limit_is_enforced_before_owned_string_creation() { + let summary = "s".repeat(crate::daemon::notifications::limits::MAX_SUMMARY_BYTES + 1); + let message = notify_message("app", "", &summary, "", Vec::new(), HashMap::new()); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); +} + +#[test] +fn contiguous_image_array_keeps_its_separate_large_allowance() { + let image = Structure::from(( + 256_i32, + 256_i32, + 1024_i32, + true, + 8_i32, + 4_i32, + vec![0_u8; 256 * 1024], + )); + let mut hints = HashMap::new(); + hints.insert( + "image-data".to_string(), + OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn image_array_above_its_allowance_is_rejected_below_the_wire_limit() { + let image = Structure::from(( + 256_i32, + 256_i32, + 1024_i32, + true, + 8_i32, + 4_i32, + vec![0_u8; 256 * 1024 + 1], + )); + let mut hints = HashMap::new(); + hints.insert( + "image-data".to_string(), + OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance" + )) + ); +} + +#[test] +fn cumulative_nested_string_data_is_bounded() { + let text = "h".repeat(crate::daemon::notifications::limits::MAX_HINT_STRING_BYTES); + let hints = (0..16) + .map(|index| { + let values = Value::from(vec![text.as_str(); 4]); + ( + format!("hint-{index}"), + OwnedValue::try_from(values).expect("owned nested strings"), + ) + }) + .collect(); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify contains too much non-image string data" + )) + ); +} + +#[test] +fn cumulative_string_budget_accepts_its_exact_limit() { + let hints = (0..16) + .map(|index| { + // Hint keys consume 38 bytes, so one shorter value keeps the total at 64 KiB + let first_length = if index == 0 { 2_010 } else { 2_048 }; + let values = Value::from(vec!["h".repeat(first_length), "h".repeat(2_048)]); + ( + format!("h{index}"), + OwnedValue::try_from(values).expect("owned exact-budget strings"), + ) + }) + .collect(); + let message = notify_message("", "", "", "", Vec::new(), hints); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn nested_non_image_array_fanout_is_bounded() { + let nested = Value::from(vec!["x"; 65]); + let mut hints = HashMap::new(); + hints.insert( + "x-example-values".to_string(), + OwnedValue::try_from(nested).expect("owned nested string array"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify nested array has too many elements" + )) + ); +} From cdb3e2042b24217a5f7dc285f8455a6049156d27 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:52:57 -0500 Subject: [PATCH 082/275] feat(daemon): index desktop application evidence Summary: index desktop application evidence. Scope: daemon. --- .../unixnotis-core/src/model/attribution.rs | 196 ++++++----- crates/unixnotis-core/src/model/mod.rs | 2 +- .../unixnotis-core/src/model/notification.rs | 30 +- .../src/model/tests/attribution.rs | 83 +++-- .../src/model/tests/notification.rs | 36 +- crates/unixnotis-daemon/Cargo.toml | 1 + .../notifications/identity/desktop_index.rs | 323 ++++++++++++++++++ .../notifications/identity/executable.rs | 71 ++++ .../identity/tests/desktop_index.rs | 41 +++ 9 files changed, 640 insertions(+), 143 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index b697751b2..668642e23 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -1,112 +1,150 @@ -//! Authenticated notification identity projected from daemon-owned sender metadata - -use std::path::Path; +//! Notification application association and interaction policy use serde::{Deserialize, Serialize}; use zbus::zvariant::Type; use crate::util; -const MAX_ATTRIBUTION_NAME_BYTES: usize = 256; +const MAX_ATTRIBUTION_TEXT_BYTES: usize = 256; + +/// Evidence class used to present an application without claiming universal authentication +#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum AttributionClass { + SystemAssociated = 0, + PortalAssociated = 1, + UserAssociated = 2, + TrustedRelay = 3, + #[default] + Unknown = 4, + Conflict = 5, +} + +/// Independent policy for credential-like inline text controls +#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum InlineReplyPolicy { + Allow = 0, + Confirm = 1, + #[default] + Deny = 2, +} -/// Identity details displayed separately from caller-controlled notification content -#[derive(Debug, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +/// Application presentation derived by the daemon from sender and desktop metadata +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationAttribution { - // False means the claimed app name could not be tied to the sender executable - pub verified: bool, - // A mismatched caller claim remains visible as secondary metadata - pub reported_name: String, - // The authenticated executable basename drives application badge lookup + // Primary titles stay short and never contain diagnostics + pub display_name: String, + // Empty values represent unavailable optional D-Bus fields + pub desktop_id: String, pub badge_icon: String, + // Secondary source or warning text belongs in a tooltip or separate status element + pub source_label: String, + pub class: AttributionClass, + // Risk presentation stays separate from association and interaction policy + pub warning: bool, + // Opaque daemon-built identity key prevents claimed names from merging trusted groups + pub group_key: String, +} + +impl Default for NotificationAttribution { + fn default() -> Self { + Self { + display_name: "Unknown application".to_string(), + desktop_id: String::new(), + badge_icon: "dialog-warning-symbolic".to_string(), + source_label: String::new(), + class: AttributionClass::Unknown, + warning: false, + group_key: "unknown".to_string(), + } + } } impl NotificationAttribution { - /// Resolve the primary display name and attribution from authenticated process metadata #[must_use] - pub fn resolve(reported_name: &str, sender_executable: Option<&str>) -> (String, Self) { - let reported_name = bounded_name(reported_name); - let Some(executable_name) = sender_executable.and_then(executable_name) else { - // Unresolved senders keep their claim visible but never gain verified status - let display_name = fallback_display_name(&reported_name); - return ( - display_name, - Self { - verified: false, - reported_name: String::new(), - badge_icon: "dialog-warning-symbolic".to_string(), - }, - ); - }; - - let executable_name = bounded_name(executable_name); - let verified = identity_names_match(&reported_name, &executable_name); - if verified { - return ( - fallback_display_name(&reported_name), - Self { - verified: true, - reported_name: String::new(), - badge_icon: executable_name, - }, - ); + pub fn associated( + display_name: &str, + desktop_id: &str, + badge_icon: &str, + source_label: &str, + class: AttributionClass, + warning: bool, + group_key: String, + ) -> Self { + Self { + display_name: display_name_or_unknown(display_name), + desktop_id: bounded_text(desktop_id), + badge_icon: bounded_text(badge_icon), + source_label: bounded_text(source_label), + class, + warning, + group_key, } + } - // Mismatches lead with authenticated process identity and retain the claim second - ( - fallback_display_name(&executable_name), - Self { - verified: false, - reported_name, - badge_icon: executable_name, - }, + #[must_use] + pub fn unknown(display_name: &str, source_label: &str, group_key: String) -> Self { + Self::associated( + display_name, + "", + "dialog-question-symbolic", + source_label, + AttributionClass::Unknown, + false, + group_key, ) } - /// Build a visible one-line identity label for popup and notification-center headers #[must_use] - pub fn display_label(&self, primary_name: &str) -> String { - if self.verified { - return primary_name.to_string(); - } - if self.reported_name.is_empty() { - return format!("{primary_name} · unverified"); - } - format!("{primary_name} · unverified claim: {}", self.reported_name) + pub fn conflict(claimed_name: &str, source_label: &str, group_key: String) -> Self { + let claim = display_name_or_unknown(claimed_name); + Self::associated( + "Unknown application", + "", + "dialog-warning-symbolic", + &format!("Claims to be {claim}; {source_label}"), + AttributionClass::Conflict, + true, + group_key, + ) } -} - -fn executable_name(path: &str) -> Option<&str> { - Path::new(path) - .file_name() - .and_then(|value| value.to_str()) - .filter(|value| !value.trim().is_empty()) -} -fn identity_names_match(reported_name: &str, executable_name: &str) -> bool { - let reported = normalized_identity(reported_name); - let executable = normalized_identity(executable_name); - !reported.is_empty() && reported == executable -} + #[must_use] + pub fn trusted_relay( + display_name: &str, + source_label: &str, + warning: bool, + group_key: String, + ) -> Self { + Self::associated( + display_name, + "", + "dialog-information-symbolic", + source_label, + AttributionClass::TrustedRelay, + warning, + group_key, + ) + } -fn normalized_identity(value: &str) -> String { - // Spaces and ordinary separators differ between desktop names and executable filenames - value - .chars() - .filter(|ch| ch.is_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() + #[must_use] + pub const fn has_warning(&self) -> bool { + self.warning + } } -fn bounded_name(value: &str) -> String { +fn bounded_text(value: &str) -> String { let clean = util::sanitize_inline_display_text(value); - util::truncate_utf8_bytes(clean.trim(), MAX_ATTRIBUTION_NAME_BYTES) + util::truncate_utf8_bytes(clean.trim(), MAX_ATTRIBUTION_TEXT_BYTES) } -fn fallback_display_name(value: &str) -> String { +fn display_name_or_unknown(value: &str) -> String { + let value = bounded_text(value); if value.is_empty() { "Unknown application".to_string() } else { - value.to_string() + value } } diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index e15fa2189..d717d6121 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -8,7 +8,7 @@ mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. -pub use attribution::NotificationAttribution; +pub use attribution::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; pub use image::{ImageData, NotificationImage}; pub use notification::{Notification, NotificationView}; pub use reply::InlineReply; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 6f74ee298..bba24a119 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; -use super::attribution::NotificationAttribution; +use super::attribution::{InlineReplyPolicy, NotificationAttribution}; use super::image::NotificationImage; use super::reply::InlineReply; use super::types::{Action, Urgency}; @@ -20,6 +20,8 @@ pub struct Notification { // Origin metadata for display and filtering pub app_name: String, pub app_icon: String, + // Daemon-resolved application association stays stable for the notification lifetime + pub attribution: NotificationAttribution, // User-facing content as provided by the sender pub summary: String, pub body: String, @@ -27,6 +29,7 @@ pub struct Notification { pub actions: Vec, // Reply metadata exists only for an explicit KDE-compatible action pub inline_reply: InlineReply, + pub inline_reply_policy: InlineReplyPolicy, // Raw hints preserved for storage and downstream consumers pub hints: HashMap, // Derived urgency used for styling and escalation @@ -54,16 +57,15 @@ impl Notification { /// Convert to a lightweight view for UI consumption #[must_use] pub fn to_view(&self) -> NotificationView { - let (app_name, attribution) = - NotificationAttribution::resolve(&self.app_name, self.sender_executable.as_deref()); NotificationView { id: self.id, - app_name, - attribution, + app_name: self.attribution.display_name.clone(), + attribution: self.attribution.clone(), summary: notification_display_text(&self.summary), body: notification_display_text(&self.body), actions: self.actions.clone(), inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), // Center and popup policy both need the transient bit to stay in sync is_transient: self.is_transient, @@ -76,16 +78,15 @@ impl Notification { /// Convert to a view for list rows with heavy image data removed #[must_use] pub fn to_list_view(&self) -> NotificationView { - let (app_name, attribution) = - NotificationAttribution::resolve(&self.app_name, self.sender_executable.as_deref()); NotificationView { id: self.id, - app_name, - attribution, + app_name: self.attribution.display_name.clone(), + attribution: self.attribution.clone(), summary: notification_display_text(&self.summary), body: notification_display_text(&self.body), actions: self.actions.clone(), inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), // History policy still depends on the transient bit in panel rows is_transient: self.is_transient, @@ -106,10 +107,12 @@ impl Notification { id: self.id, app_name: self.app_name.clone(), app_icon: self.app_icon.clone(), + attribution: self.attribution.clone(), summary: self.summary.clone(), body: self.body.clone(), actions: self.actions.clone(), inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, // Keep history entries lightweight by dropping raw hint payloads hints: HashMap::new(), urgency: self.urgency, @@ -294,6 +297,7 @@ pub struct NotificationView { pub body: String, pub actions: Vec, pub inline_reply: InlineReply, + pub inline_reply_policy: InlineReplyPolicy, pub urgency: u8, // Close handling needs this flag so history policy stays shared pub is_transient: bool, @@ -301,14 +305,6 @@ pub struct NotificationView { pub image: NotificationImage, } -impl NotificationView { - /// Visible primary and secondary attribution for UI and diagnostic surfaces - #[must_use] - pub fn attribution_label(&self) -> String { - self.attribution.display_label(&self.app_name) - } -} - #[cfg(test)] #[path = "tests/notification.rs"] mod tests; diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 033889577..9886918e7 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -1,49 +1,62 @@ -use super::NotificationAttribution; +use super::{AttributionClass, NotificationAttribution}; #[test] -fn matching_sender_identity_keeps_reported_brand_and_marks_it_verified() { - let (display, attribution) = - NotificationAttribution::resolve("UnixNotis Center", Some("/usr/bin/unixnotis-center")); - - assert_eq!(display, "UnixNotis Center"); - assert!(attribution.verified); - assert!(attribution.reported_name.is_empty()); - assert_eq!(attribution.badge_icon, "unixnotis-center"); - assert_eq!(attribution.display_label(&display), "UnixNotis Center"); +fn associated_identity_keeps_presentation_and_grouping_fields_separate() { + let attribution = NotificationAttribution::associated( + "Signal", + "org.signal.Signal", + "org.signal.Signal", + "/usr/bin/signal-desktop", + AttributionClass::SystemAssociated, + false, + "desktop:org.signal.Signal".to_string(), + ); + + assert_eq!(attribution.display_name, "Signal"); + assert_eq!(attribution.desktop_id, "org.signal.Signal"); + assert_eq!(attribution.class, AttributionClass::SystemAssociated); + assert!(!attribution.has_warning()); } #[test] -fn mismatched_sender_leads_with_executable_and_keeps_claim_secondary() { - let (display, attribution) = - NotificationAttribution::resolve("Password Manager", Some("/usr/bin/unknown-client")); - - assert_eq!(display, "unknown-client"); - assert!(!attribution.verified); - assert_eq!(attribution.reported_name, "Password Manager"); - assert_eq!(attribution.badge_icon, "unknown-client"); - assert_eq!( - attribution.display_label(&display), - "unknown-client · unverified claim: Password Manager" +fn conflict_diagnostics_do_not_enter_the_primary_display_name() { + let attribution = NotificationAttribution::conflict( + "KeePassXC", + "source /tmp/keepassxc", + "executable:1:2".to_string(), ); + + assert_eq!(attribution.display_name, "Unknown application"); + assert!(attribution.source_label.contains("Claims to be KeePassXC")); + assert!(!attribution.display_name.contains("unverified claim")); + assert!(attribution.has_warning()); } #[test] -fn unresolved_sender_keeps_claim_but_uses_warning_badge() { - let (display, attribution) = NotificationAttribution::resolve("Calendar", None); - - assert_eq!(display, "Calendar"); - assert!(!attribution.verified); - assert!(attribution.reported_name.is_empty()); - assert_eq!(attribution.badge_icon, "dialog-warning-symbolic"); - assert_eq!(attribution.display_label(&display), "Calendar · unverified"); +fn trusted_relay_keeps_the_callers_label_without_granting_association() { + let attribution = NotificationAttribution::trusted_relay( + "Screenshot", + "Sent via /usr/bin/notify-send", + false, + "relay:1:2:screenshot".to_string(), + ); + + assert_eq!(attribution.display_name, "Screenshot"); + assert_eq!(attribution.class, AttributionClass::TrustedRelay); + assert!(!attribution.has_warning()); } #[test] -fn partial_executable_name_match_does_not_verify_a_brand_claim() { - let (display, attribution) = - NotificationAttribution::resolve("Discord", Some("/opt/discord/DiscordCanaryDiscord")); +fn unknown_sender_keeps_bounded_presentation_without_gaining_association() { + let attribution = NotificationAttribution::unknown( + "Local helper", + "Source: /opt/local-helper", + "executable:7:9:localhelper".to_string(), + ); - assert_eq!(display, "DiscordCanaryDiscord"); - assert!(!attribution.verified); - assert_eq!(attribution.reported_name, "Discord"); + assert_eq!(attribution.display_name, "Local helper"); + assert_eq!(attribution.source_label, "Source: /opt/local-helper"); + assert_eq!(attribution.class, AttributionClass::Unknown); + assert_eq!(attribution.group_key, "executable:7:9:localhelper"); + assert!(!attribution.has_warning()); } diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index e0ec6a912..8d6b138a2 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -4,7 +4,10 @@ use chrono::Utc; use zbus::zvariant::Value; use super::{Notification, NotificationImage}; -use crate::{Action, ImageData, InlineReply, Urgency}; +use crate::{ + Action, AttributionClass, ImageData, InlineReply, InlineReplyPolicy, NotificationAttribution, + Urgency, +}; fn notification_with_image(image: NotificationImage) -> Notification { let mut hints = HashMap::new(); @@ -17,6 +20,15 @@ fn notification_with_image(image: NotificationImage) -> Notification { id: 42, app_name: "Mail".to_string(), app_icon: "mail".to_string(), + attribution: NotificationAttribution::associated( + "Mail", + "org.example.Mail", + "mail", + "/usr/bin/mail", + AttributionClass::SystemAssociated, + false, + "desktop:org.example.Mail".to_string(), + ), summary: "Subject".to_string(), body: "Body".to_string(), actions: vec![Action { @@ -24,6 +36,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { label: "Open".to_string(), }], inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, hints, urgency: Urgency::Critical, category: Some("email".to_string()), @@ -67,7 +80,7 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { // Live popup views keep enough information for UI actions and close policy assert_eq!(view.id, 42); assert_eq!(view.app_name, "Mail"); - assert!(view.attribution.verified); + assert_eq!(view.attribution.class, AttributionClass::SystemAssociated); assert_eq!(view.attribution.badge_icon, "mail"); assert_eq!(view.summary, "Subject"); assert_eq!(view.body, "Body"); @@ -78,21 +91,22 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { } #[test] -fn notification_view_separates_mismatched_brand_from_authenticated_executable() { +fn notification_view_keeps_conflict_warning_separate_from_primary_name() { let mut notification = notification_with_image(image_with_raw_bytes()); notification.app_name = "Password Manager".to_string(); notification.sender_executable = Some("/usr/bin/unknown-client".to_string()); + notification.attribution = NotificationAttribution::conflict( + "Password Manager", + "source /usr/bin/unknown-client", + "executable:1:2".to_string(), + ); let view = notification.to_view(); - assert_eq!(view.app_name, "unknown-client"); - assert!(!view.attribution.verified); - assert_eq!(view.attribution.reported_name, "Password Manager"); - assert_eq!(view.attribution.badge_icon, "unknown-client"); - assert_eq!( - view.attribution_label(), - "unknown-client · unverified claim: Password Manager" - ); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!(view.attribution.class, AttributionClass::Conflict); + assert!(view.attribution.source_label.contains("Password Manager")); + assert!(!view.app_name.contains("unverified claim")); } #[test] diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index 1e62c3256..ee5795f78 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true clap.workspace = true chrono.workspace = true futures-util.workspace = true +gio.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs new file mode 100644 index 000000000..a37ca6d6e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs @@ -0,0 +1,323 @@ +//! Desktop application index preserving system and user entry origins + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use gio::prelude::AppInfoExt; + +use super::executable::{executable_evidence_for_path, FileIdentity}; + +const MAX_DESKTOP_FILES: usize = 8_192; + +#[derive(Debug, Clone)] +pub(super) struct DesktopRecord { + pub(super) id: String, + pub(super) display_name: String, + pub(super) badge_icon: String, + pub(super) executable_path: Option, + pub(super) executable_identity: Option, + pub(super) system_entry: bool, + pub(super) dbus_activatable: bool, + names: HashSet, +} + +impl DesktopRecord { + pub(super) fn claim_matches(&self, claim: &str) -> bool { + // Normalized aliases cover desktop names without trusting free-form display text + self.names.contains(&normalize_name(claim)) + } + + #[cfg(test)] + pub(super) fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + dbus_activatable: bool, + ) -> Self { + let mut names = HashSet::new(); + names.insert(normalize_name(display_name)); + Self { + id: id.to_string(), + display_name: display_name.to_string(), + badge_icon: id.to_string(), + executable_path: Some(PathBuf::from(executable_path)), + executable_identity: Some(identity), + system_entry, + dbus_activatable, + names, + } + } +} + +#[derive(Debug, Default)] +pub(in crate::daemon) struct DesktopIdentityIndex { + records: Vec, + by_id: HashMap>, + by_identity: HashMap<(u64, u64), Vec>, + system_names: HashSet, + trusted_relays: Vec, +} + +#[derive(Debug, Clone)] +struct ExecutableIdentity { + path: PathBuf, + identity: FileIdentity, +} + +impl DesktopIdentityIndex { + pub(in crate::daemon) fn shared() -> Arc { + static INDEX: OnceLock> = OnceLock::new(); + // One immutable snapshot serves the daemon lifetime and every notification burst + INDEX.get_or_init(|| Arc::new(Self::new())).clone() + } + + #[must_use] + pub(in crate::daemon) fn new() -> Self { + let mut index = Self::default(); + // User entries are scanned first so local desktop overrides keep normal precedence + for (root, system_entry) in desktop_roots() { + index.scan_root(&root, system_entry); + if index.records.len() >= MAX_DESKTOP_FILES { + break; + } + } + // Relay trust is tied to the installed file identity instead of its basename + index.index_trusted_relay(Path::new("/usr/bin/notify-send")); + index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); + index + } + + pub(super) fn records_for_id(&self, id: &str) -> Vec<&DesktopRecord> { + self.by_id + .get(&normalize_desktop_id(id)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(super) fn records_for_executable(&self, identity: FileIdentity) -> Vec<&DesktopRecord> { + self.by_identity + .get(&(identity.device, identity.inode)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(super) fn claim_matches_system_app(&self, claim: &str) -> bool { + self.system_names.contains(&normalize_name(claim)) + } + + pub(super) fn trusted_relay_path(&self, identity: FileIdentity) -> Option<&Path> { + self.trusted_relays + .iter() + .find(|relay| relay.identity.same_file(identity)) + .map(|relay| relay.path.as_path()) + } + + fn scan_root(&mut self, root: &Path, system_entry: bool) { + // A bounded iterative walk avoids recursion and unlimited desktop-file growth + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + if self.records.len() >= MAX_DESKTOP_FILES { + return; + } + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + pending.push(entry.path()); + continue; + } + if file_type.is_file() + && entry.path().extension().and_then(|value| value.to_str()) == Some("desktop") + { + self.add_desktop_file(&entry.path(), system_entry); + } + } + } + } + + fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { + // GIO applies desktop-entry parsing rules before any identity is indexed + let Some(desktop) = gio::DesktopAppInfo::from_filename(path) else { + return; + }; + let Some(id) = desktop + .id() + .map(|value| normalize_desktop_id(value.as_str())) + else { + return; + }; + if id.is_empty() { + return; + } + let display_name = desktop.display_name().to_string(); + let executable_path = desktop_executable(&desktop) + .as_deref() + .and_then(resolve_program); + let executable_identity = executable_path + .as_deref() + .and_then(executable_evidence_for_path) + .map(|evidence| evidence.identity); + let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); + // System association requires protected metadata and a protected executable + let system_entry = system_origin + && desktop_identity.is_some_and(FileIdentity::is_system_managed) + && executable_identity.is_some_and(FileIdentity::is_system_managed); + let badge_icon = desktop + .string("Icon") + .map_or_else(|| id.clone(), |value| value.to_string()); + let mut names = HashSet::new(); + // Each alias is only a claim matcher after executable identity already agrees + names.insert(normalize_name(&display_name)); + names.insert(normalize_name(desktop.name().as_str())); + if let Some(generic_name) = desktop.generic_name() { + names.insert(normalize_name(generic_name.as_str())); + } + if let Some(wm_class) = desktop.startup_wm_class() { + names.insert(normalize_name(wm_class.as_str())); + } + names.insert(normalize_name(&id)); + if let Some(executable) = executable_path + .as_deref() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + { + names.insert(normalize_name(executable)); + } + names.retain(|name| !name.is_empty()); + + let record = DesktopRecord { + id: id.clone(), + display_name, + badge_icon, + executable_path, + executable_identity, + system_entry, + dbus_activatable: desktop.boolean("DBusActivatable"), + names, + }; + let record_index = self.records.len(); + // Protected names help detect spoofing but never establish identity on their own + if system_entry { + self.system_names.extend(record.names.iter().cloned()); + } + self.by_id.entry(id).or_default().push(record_index); + if let Some(identity) = record.executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + self.records.push(record); + } + + fn index_trusted_relay(&mut self, path: &Path) { + let Some(evidence) = executable_evidence_for_path(path) else { + return; + }; + // Writable relay binaries stay ordinary unknown senders + if evidence.identity.is_system_managed() { + self.trusted_relays.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + + #[cfg(test)] + pub(super) fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self { + let mut index = Self::default(); + for record in records { + let record_index = index.records.len(); + if record.system_entry { + index.system_names.extend(record.names.iter().cloned()); + } + index + .by_id + .entry(normalize_desktop_id(&record.id)) + .or_default() + .push(record_index); + if let Some(identity) = record.executable_identity { + index + .by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + index.records.push(record); + } + index.trusted_relays = trusted_relays + .into_iter() + .map(|(path, identity)| ExecutableIdentity { path, identity }) + .collect(); + index + } +} + +fn desktop_roots() -> Vec<(PathBuf, bool)> { + let mut roots = Vec::new(); + // The user data root remains distinct because its entries are not system evidence + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) + { + roots.push((data_home.join("applications"), false)); + } + let data_dirs = + std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + roots.extend(std::env::split_paths(&data_dirs).map(|root| (root.join("applications"), true))); + roots +} + +fn resolve_program(program: &Path) -> Option { + // Canonical paths are presentation data while device and inode carry the proof + if program.is_absolute() { + return program.canonicalize().ok(); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(program)) + .find_map(|candidate| candidate.canonicalize().ok()) +} + +fn desktop_executable(desktop: &gio::DesktopAppInfo) -> Option { + // GIO exposes a nullable executable for valid D-Bus-activated entries without Exec + desktop.commandline()?; + let executable = desktop.executable(); + (!executable.as_os_str().is_empty()).then_some(executable) +} + +pub(super) fn normalize_desktop_id(value: &str) -> String { + // Desktop hints commonly include an optional suffix and mixed case + value + .trim() + .strip_suffix(".desktop") + .unwrap_or_else(|| value.trim()) + .to_ascii_lowercase() +} + +pub(super) fn normalize_name(value: &str) -> String { + // Punctuation and case do not create separate branding aliases + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +#[cfg(test)] +#[path = "tests/desktop_index.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs new file mode 100644 index 000000000..3df118bbb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs @@ -0,0 +1,71 @@ +//! Stable executable identity captured from open file metadata + +use std::fs::{File, Metadata}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(in crate::daemon) struct FileIdentity { + pub(super) device: u64, + pub(super) inode: u64, + pub(super) uid: u32, + pub(super) mode: u32, +} + +impl FileIdentity { + pub(super) fn from_metadata(metadata: &Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + uid: metadata.uid(), + mode: metadata.mode(), + } + } + + pub(super) const fn same_file(self, other: Self) -> bool { + // Device and inode survive symlink aliases and ordinary path spelling changes + self.device == other.device && self.inode == other.inode + } + + pub(super) const fn is_system_managed(self) -> bool { + // Same-user attackers cannot replace a root-owned non-writable file + self.uid == 0 && self.mode & 0o022 == 0 + } + + pub(super) fn group_fragment(self) -> String { + // Group keys expose no path while remaining stable for the running file + format!("{}:{}", self.device, self.inode) + } +} + +#[derive(Debug, Clone)] +pub(in crate::daemon) struct ExecutableEvidence { + pub(in crate::daemon) canonical_path: PathBuf, + pub(in crate::daemon) identity: FileIdentity, +} + +pub(in crate::daemon) fn executable_evidence_for_pid(pid: u32) -> Option { + let proc_executable = PathBuf::from(format!("/proc/{pid}/exe")); + // Opening the procfs link binds metadata to the running file instead of a mutable path + let file = File::open(&proc_executable).ok()?; + let identity = FileIdentity::from_metadata(&file.metadata().ok()?); + let canonical_path = proc_executable + .canonicalize() + .or_else(|_| std::fs::read_link(&proc_executable)) + .ok()?; + Some(ExecutableEvidence { + canonical_path, + identity, + }) +} + +pub(super) fn executable_evidence_for_path(path: &Path) -> Option { + // Open-file metadata prevents a path replacement from changing the checked identity + let file = File::open(path).ok()?; + let identity = FileIdentity::from_metadata(&file.metadata().ok()?); + let canonical_path = path.canonicalize().ok()?; + Some(ExecutableEvidence { + canonical_path, + identity, + }) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs new file mode 100644 index 000000000..98dc8dd19 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs @@ -0,0 +1,41 @@ +use std::fs; + +use super::*; +use crate::test_support::TempRoot; + +#[test] +fn dbus_activated_desktop_entry_without_exec_has_no_executable() { + let root = TempRoot::new("desktop-without-exec"); + let path = root.join("org.example.NoExec.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=No Exec\nDBusActivatable=true\n", + ) + .expect("desktop entry without Exec"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("valid desktop entry"); + + assert!(desktop_executable(&desktop).is_none()); + + let mut index = DesktopIdentityIndex::default(); + index.add_desktop_file(&path, true); + assert_eq!(index.records.len(), 1); + assert!(index.records[0].executable_path.is_none()); + assert!(!index.records[0].system_entry); +} + +#[test] +fn desktop_entry_exec_is_reduced_by_gio_to_its_program() { + let root = TempRoot::new("desktop-with-exec"); + let path = root.join("org.example.True.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=True\nExec=/usr/bin/true %U\n", + ) + .expect("desktop entry with Exec"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("valid desktop entry"); + + assert_eq!( + desktop_executable(&desktop).as_deref(), + Some(Path::new("/usr/bin/true")) + ); +} From d7499932ed2a282ed099ae102174b2bfafbd7016 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:53:51 -0500 Subject: [PATCH 083/275] fix(daemon): resolve and enforce notification attribution Summary: resolve and enforce notification attribution. Scope: daemon. --- Cargo.lock | 2 +- .../noticenterctl/src/output/notifications.rs | 2 +- .../src/output/tests/notifications.rs | 5 +- .../src/control/tests/events.rs | 1 + .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 12 +- crates/unixnotis-center/src/ui/icons/theme.rs | 12 +- .../src/ui/notifications/model/grouping.rs | 4 +- .../src/ui/notifications/model/tests/item.rs | 1 + .../src/ui/notifications/row/group.rs | 18 +- .../row/notification/reply/binding.rs | 9 +- .../notification/reply/tests/availability.rs | 23 +- .../notifications/row/notification/state.rs | 6 +- .../row/notification/tests/support.rs | 6 +- .../row/notification/update/actions.rs | 9 +- .../row/notification/update/row.rs | 9 +- .../row/notification/update/tests/actions.rs | 2 + .../src/ui/notifications/row/tests/group.rs | 29 +- .../src/ui/notifications/store/lifecycle.rs | 2 +- .../src/ui/notifications/store/mutation.rs | 7 +- .../ui/notifications/store/tests/lifecycle.rs | 2 +- .../ui/notifications/store/tests/mutation.rs | 14 +- .../src/ui/notifications/tests/support.rs | 7 +- .../src/daemon/control/tests/action.rs | 2 + .../src/daemon/control/tests/reply.rs | 2 + .../src/daemon/control/tests/server.rs | 2 + .../src/daemon/notifications/identity/mod.rs | 10 + .../daemon/notifications/identity/policy.rs | 15 + .../daemon/notifications/identity/resolver.rs | 238 +++++++++++++++ .../notifications/identity/tests/resolver.rs | 286 ++++++++++++++++++ .../src/daemon/notifications/mod.rs | 1 + .../src/daemon/notifications/payload.rs | 22 +- .../src/daemon/notifications/sender.rs | 28 +- .../src/daemon/notifications/server/flow.rs | 30 +- .../daemon/notifications/server/tests/flow.rs | 23 +- .../src/daemon/notifications/tests/payload.rs | 63 +++- .../notifications/tests/sender_cache.rs | 1 + .../src/daemon/state/model.rs | 4 + .../state/tests/notification_lifecycle.rs | 2 + crates/unixnotis-daemon/src/store/core.rs | 5 +- .../unixnotis-daemon/src/store/tests/reply.rs | 17 +- .../src/store/tests/support.rs | 2 + crates/unixnotis-daemon/src/tests/expire.rs | 2 + crates/unixnotis-popups/src/ui/entry/build.rs | 15 +- .../unixnotis-popups/src/ui/icons/resolver.rs | 13 +- .../src/ui/icons/tests/resolver/candidates.rs | 11 +- .../src/ui/icons/tests/resolver/support.rs | 5 +- .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/constructor.rs | 1 + crates/unixnotis-ui/Cargo.toml | 1 - .../unixnotis-ui/src/icons/desktop_index.rs | 18 +- .../src/icons/tests/desktop_index.rs | 11 +- 52 files changed, 856 insertions(+), 158 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs diff --git a/Cargo.lock b/Cargo.lock index 7e95c74d9..9e263799d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3575,6 +3575,7 @@ dependencies = [ "chrono", "clap", "futures-util", + "gio", "indexmap", "rustix", "serde", @@ -3634,7 +3635,6 @@ dependencies = [ "notify", "serde", "serde_json", - "shell-words", "tracing", "unixnotis-core", "url", diff --git a/crates/noticenterctl/src/output/notifications.rs b/crates/noticenterctl/src/output/notifications.rs index bffddedea..1dbc927d5 100644 --- a/crates/noticenterctl/src/output/notifications.rs +++ b/crates/noticenterctl/src/output/notifications.rs @@ -30,7 +30,7 @@ fn format_notifications(label: &str, notifications: &[NotificationView], full: b for notification in notifications { // Both fields come from notification clients and must remain single-line - let app = util::sanitize_log_value(¬ification.attribution_label(), limit); + let app = util::sanitize_log_value(¬ification.attribution.display_name, limit); let summary = util::sanitize_log_value(¬ification.summary, limit); let action_count = notification.actions.len(); out.push_str(&format!( diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 96000cc5b..3547be29f 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -9,9 +9,9 @@ fn sample_notification() -> NotificationView { id: 7, app_name: "mailer\n\x1b[31m".to_string(), attribution: unixnotis_core::NotificationAttribution { - verified: true, - reported_name: String::new(), + display_name: "mailer\n\x1b[31m".to_string(), badge_icon: "mailer".to_string(), + ..unixnotis_core::NotificationAttribution::default() }, summary: "subject\rline".to_string(), body: "body\ttext\nnext".to_string(), @@ -20,6 +20,7 @@ fn sample_notification() -> NotificationView { label: "Open".to_string(), }], inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, is_transient: false, // CLI formatting only needs the lightweight transport fields diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 9d9c072ac..44e3660af 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -11,6 +11,7 @@ fn notification(id: u32) -> NotificationView { body: "body".to_string(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index b456904b4..dab5cacf7 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -78,6 +78,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient: false, image: NotificationImage { diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 65a16cd0e..9bcd5f0d0 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -21,6 +21,7 @@ fn notification_view( body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, is_transient: false, image, @@ -32,9 +33,12 @@ fn badge_candidates_exclude_caller_content_icon() { let notification = notification_view( "sender-bin", unixnotis_core::NotificationAttribution { - verified: false, - reported_name: "Claimed Brand".to_string(), + display_name: "Unknown application".to_string(), badge_icon: "sender-bin".to_string(), + source_label: "Claims to be Claimed Brand".to_string(), + class: unixnotis_core::AttributionClass::Conflict, + group_key: "executable:1:2".to_string(), + ..unixnotis_core::NotificationAttribution::default() }, NotificationImage { icon_name: "caller-content-icon".to_string(), @@ -55,9 +59,9 @@ fn badge_candidates_exclude_unresolved_application_claim() { let notification = notification_view( "Trusted Brand", unixnotis_core::NotificationAttribution { - verified: false, - reported_name: String::new(), + display_name: "Trusted Brand".to_string(), badge_icon: "dialog-warning-symbolic".to_string(), + ..unixnotis_core::NotificationAttribution::default() }, NotificationImage::default(), ); diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index d50678ab6..1dab35845 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -103,14 +103,10 @@ pub(super) fn collect_icon_candidates(notification: &NotificationView) -> Vec Rc { body: "body".to_string(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 5c68bc979..c90aeddeb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -117,10 +117,10 @@ pub(in crate::ui::notifications) fn update_group_row( let display_name = data .notification .as_ref() - .map(|notification| notification.attribution_label()) + .map(|notification| notification.attribution.display_name.clone()) .filter(|name| !name.is_empty()) .unwrap_or_else(|| data.group_key.to_string()); - // Display verified attribution while the normalized key drives grouping behavior + // Display application presentation while the daemon identity key drives grouping behavior // Fall back to the group key if no sample notification is available set_label_text_if_changed(&group.title, &display_name); let next_count = data.count.to_string(); @@ -137,8 +137,20 @@ pub(in crate::ui::notifications) fn update_group_row( *group.group_key.borrow_mut() = data.group_key.clone(); if let Some(notification) = data.notification.as_ref() { + if notification.attribution.source_label.is_empty() { + group.title.set_tooltip_text(None); + } else { + group + .title + .set_tooltip_text(Some(¬ification.attribution.source_label)); + } + set_class_state( + root, + "unixnotis-attribution-warning", + notification.attribution.has_warning(), + ); let scale = root.scale_factor(); - // Group headers use the authenticated badge path instead of caller content images + // Group headers use the associated badge path instead of caller content images icon_resolver.apply_badge(&group.icon, notification.as_ref(), 18, scale); set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs index d9c9077fc..def416d41 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::NotificationView; +use unixnotis_core::{InlineReplyPolicy, NotificationView}; use super::lifecycle::invalidate_reply_attempt; use super::presentation::{clear_reply_error, update_submit_content, DEFAULT_PLACEHOLDER}; @@ -17,7 +17,9 @@ pub(in super::super) fn configure_inline_reply( let id = notification.id; let reply = ¬ification.inline_reply; // History rows keep metadata for display but never expose a live reply control - let available = is_active && reply.available; + let available = is_active + && reply.available + && notification.inline_reply_policy == InlineReplyPolicy::Allow; let snapshot_changed = widgets .bound_snapshot .borrow() @@ -29,9 +31,10 @@ pub(in super::super) fn configure_inline_reply( reset_reply_form(widgets); } if snapshot_changed { - widgets.state.bound_id.set(id); *widgets.bound_snapshot.borrow_mut() = Rc::downgrade(notification); } + // Unavailable policies also clear the command target used by click handlers + widgets.state.bound_id.set(if available { id } else { 0 }); if !available { // History and ordinary actions never expose a stale reply field return; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs index 35dd3d752..061cf99f4 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::{Action, InlineReply}; +use unixnotis_core::{Action, InlineReply, InlineReplyPolicy}; use crate::ui::icons::IconResolver; use crate::ui::notifications::test_support::init_gtk; @@ -101,6 +101,27 @@ fn inline_reply_action_does_not_open_an_unbound_or_submitted_form() { assert!(!widgets.revealer.reveals_child()); } +#[gtk::test] +fn denied_inline_reply_policy_never_binds_the_form() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let widgets = build_inline_reply(command_tx); + let mut notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + Rc::make_mut(&mut notification).inline_reply_policy = InlineReplyPolicy::Deny; + + configure_inline_reply(&widgets, ¬ification, true); + + assert_eq!(widgets.state.bound_id.get(), 0); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.revealer.reveals_child()); +} + #[gtk::test] fn inactive_inline_reply_binding_clears_the_live_draft() { init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index b39301ebc..afed5bcf1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -69,9 +69,9 @@ pub(super) struct OptionalLabelState<'a> { #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::ui::notifications) struct IconSignature { - // Header badges depend only on authenticated attribution inputs + // Header badges depend only on daemon-associated attribution inputs badge_icon: String, - app_name: String, + desktop_id: String, } impl IconSignature { @@ -80,7 +80,7 @@ impl IconSignature { // This keeps row refreshes cheap when only text or actions changed Self { badge_icon: notification.attribution.badge_icon.clone(), - app_name: notification.app_name.clone(), + desktop_id: notification.attribution.desktop_id.clone(), } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 596555b13..6253cb10f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -17,14 +17,16 @@ pub(super) fn sample_notification() -> NotificationView { id: 1, app_name: "demo".to_string(), attribution: unixnotis_core::NotificationAttribution { - verified: true, - reported_name: String::new(), + display_name: "demo".to_string(), badge_icon: "demo".to_string(), + group_key: "test:demo".to_string(), + ..unixnotis_core::NotificationAttribution::default() }, summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: Urgency::Normal as u8, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 57e447d79..cdd72ecc9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -7,7 +7,7 @@ use std::time::Duration; use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; -use unixnotis_core::NotificationView; +use unixnotis_core::{InlineReplyPolicy, NotificationView}; use crate::control::UiCommand; use crate::ui::panel::behavior::input::ClickCooldown; @@ -72,7 +72,11 @@ pub(super) fn update_actions( let mut reply_button_added = false; for action in ¬ification.actions { if action.key == "inline-reply" { - if reply_button_added || !is_active || !notification.inline_reply.available { + if reply_button_added + || !is_active + || !notification.inline_reply.available + || notification.inline_reply_policy != InlineReplyPolicy::Allow + { continue; } reply_button_added = true; @@ -125,6 +129,7 @@ pub(super) fn visible_action_count(notification: &NotificationView, is_active: b .count(); let reply = is_active && notification.inline_reply.available + && notification.inline_reply_policy == InlineReplyPolicy::Allow && notification .actions .iter() diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 7663cf00a..226584c87 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -32,13 +32,18 @@ pub(in crate::ui::notifications) fn update_notification_row( data.presentation.show_thumbnail && notification_has_thumbnail(notification); apply_visual_state(row, data, notification, has_actions, has_thumbnail); - let attribution_label = notification.attribution_label(); update_notification_text( row, - &attribution_label, + ¬ification.attribution.display_name, ¬ification.summary, ¬ification.body, ); + if notification.attribution.source_label.is_empty() { + row.app_label.set_tooltip_text(None); + } else { + row.app_label + .set_tooltip_text(Some(¬ification.attribution.source_label)); + } update_metadata_labels(row, data, notification); row.notify_id.set(notification.id); update_actions(row, command_tx, notification_snapshot, data.is_active); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 5edb4ca43..4c48de5ea 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -285,4 +285,6 @@ fn visible_action_count_requires_a_live_available_explicit_reply() { notification.inline_reply.available = true; assert_eq!(visible_action_count(¬ification, false), 2); assert_eq!(visible_action_count(¬ification, true), 3); + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + assert_eq!(visible_action_count(¬ification, true), 2); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 784d40c98..7229ce61b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -15,13 +15,14 @@ fn notification(app_name: &str) -> Rc { id: 1, app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { - verified: true, + display_name: app_name.to_string(), ..unixnotis_core::NotificationAttribution::default() }, summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, is_transient: false, image: NotificationImage::default(), @@ -86,24 +87,26 @@ fn update_group_row_falls_back_to_group_key_without_sample() { } #[gtk::test] -fn update_group_row_keeps_unverified_brand_claim_secondary() { +fn update_group_row_keeps_conflict_warning_out_of_the_title() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); - let mut unverified = notification("sender-bin").as_ref().clone(); - unverified.attribution = unixnotis_core::NotificationAttribution { - verified: false, - reported_name: "Trusted Brand".to_string(), - badge_icon: "sender-bin".to_string(), - }; - let data = RowData::group_header(Rc::from("sender-bin"), 1, false, Rc::new(unverified)); + let mut conflicting = notification("Unknown application").as_ref().clone(); + conflicting.attribution = unixnotis_core::NotificationAttribution::conflict( + "Trusted Brand", + "source /tmp/sender-bin", + "executable:1:2".to_string(), + ); + let data = RowData::group_header(Rc::from("executable:1:2"), 1, false, Rc::new(conflicting)); update_group_row(&widgets, &root, &data, &IconResolver::new()); - assert_eq!( - widgets.title.text().as_str(), - "sender-bin · unverified claim: Trusted Brand" - ); + assert_eq!(widgets.title.text().as_str(), "Unknown application"); + assert!(widgets + .title + .tooltip_text() + .is_some_and(|text| text.contains("Trusted Brand"))); + assert!(root.has_css_class("unixnotis-attribution-warning")); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 3b2e2e3c9..4daea452b 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -87,7 +87,7 @@ impl NotificationList { is_active: bool, ) -> Rc { let id = notification.id; - let app_key = self.intern_key(¬ification.app_name); + let app_key = self.intern_key(¬ification.attribution.group_key); let view = Rc::new(notification); let received_at_ms = now_millis(); let presentation = RowPresentation { diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index a39ac91d0..5f04ad4f0 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -17,10 +17,11 @@ impl NotificationList { // Snapshot ordering state before any mutations; used to decide whether a full rebuild // is necessary because rebuilds are expensive for large histories let was_front = self.active_order.front().copied() == Some(id); - let needs_new_key = - existing_entry.is_some_and(|entry| entry.view.app_name != notification.app_name); + let needs_new_key = existing_entry.is_some_and(|entry| { + entry.view.attribution.group_key != notification.attribution.group_key + }); let new_key = if needs_new_key { - Some(self.intern_key(¬ification.app_name)) + Some(self.intern_key(¬ification.attribution.group_key)) } else { None }; diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs index 9a12728be..7eb4326f2 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs @@ -246,7 +246,7 @@ fn insert_entry_records_recent_local_timestamp() { let after = super::now_millis(); let entry = list.entries.get(&9).expect("entry should be stored"); - assert_eq!(key.as_ref(), "terminal"); + assert_eq!(key.as_ref(), "test:terminal"); assert!(entry.received_at_ms >= before); assert!(entry.received_at_ms <= after); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 349f9590c..c7f82db8b 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -7,7 +7,11 @@ fn make_view(is_transient: bool) -> NotificationView { NotificationView { id: 7, app_name: "Test".to_string(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "Test".to_string(), + group_key: "test:Test".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: "summary".to_string(), body: "body".to_string(), actions: vec![Action { @@ -15,6 +19,7 @@ fn make_view(is_transient: bool) -> NotificationView { label: "Open".to_string(), }], inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient, image: NotificationImage::default(), @@ -25,11 +30,16 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { NotificationView { id, app_name: app_name.to_string(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution { + display_name: app_name.to_string(), + group_key: format!("test:{app_name}"), + ..unixnotis_core::NotificationAttribution::default() + }, summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient, image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 4bb650593..edc3c759a 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -58,11 +58,16 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { NotificationView { id, app_name: app_name.to_string(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution { + display_name: app_name.to_string(), + group_key: format!("test:{app_name}"), + ..unixnotis_core::NotificationAttribution::default() + }, summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index 2f368fa73..f66a03f2a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -104,6 +104,7 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { id: 0, app_name: "ActionApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "Action".to_string(), body: String::new(), actions: vec![Action { @@ -111,6 +112,7 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { label: "Run".to_string(), }], inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 0a13c50ac..9777ae656 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -284,6 +284,7 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { id: 0, app_name: "Messages".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "New message".to_string(), body: "Are you coming?".to_string(), actions: vec![unixnotis_core::Action { @@ -295,6 +296,7 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { label: "Reply".to_string(), ..InlineReply::default() }, + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 2d6a5deba..1f6175e45 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -15,10 +15,12 @@ fn notification(summary: &str) -> Notification { id: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs new file mode 100644 index 000000000..bcc78d1bf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -0,0 +1,10 @@ +//! Daemon-owned application association from process and desktop metadata + +mod desktop_index; +mod executable; +mod policy; +mod resolver; + +pub(in crate::daemon) use desktop_index::DesktopIdentityIndex; +pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; +pub(in crate::daemon) use resolver::{resolve_attribution, AppClaim}; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs new file mode 100644 index 000000000..1cd993184 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs @@ -0,0 +1,15 @@ +//! Interaction decisions kept independent from presentation association + +use unixnotis_core::{AttributionClass, InlineReplyPolicy}; + +pub(super) const fn inline_reply_policy(class: AttributionClass) -> InlineReplyPolicy { + match class { + AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { + InlineReplyPolicy::Allow + } + AttributionClass::UserAssociated => InlineReplyPolicy::Confirm, + AttributionClass::TrustedRelay | AttributionClass::Unknown | AttributionClass::Conflict => { + InlineReplyPolicy::Deny + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs new file mode 100644 index 000000000..45aa86af7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -0,0 +1,238 @@ +//! Ordered application association from desktop hints, bus ownership, and file identity + +use std::collections::HashSet; + +use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::sender::SenderMetadata; +use super::desktop_index::{ + normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, +}; +use super::policy::inline_reply_policy; + +const MAX_DESKTOP_ID_BYTES: usize = 256; + +pub(in crate::daemon) struct AppClaim<'a> { + pub(in crate::daemon) reported_name: &'a str, + pub(in crate::daemon) desktop_entry: Option<&'a str>, +} + +pub(in crate::daemon) struct AttributionResolution { + pub(in crate::daemon) attribution: NotificationAttribution, + pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, +} + +pub(in crate::daemon) async fn resolve_attribution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + connection: &Connection, +) -> AttributionResolution { + let mut owned_desktop_ids = HashSet::new(); + // Bus ownership is collected as diagnostic context and never replaces file evidence + if let (Some(sender_name), Some(desktop_id)) = ( + sender.sender_name.as_deref(), + claim.desktop_entry.and_then(validate_desktop_id), + ) { + let records = index.records_for_id(&desktop_id); + if records.iter().any(|record| record.dbus_activatable) + && sender_owns_name(connection, sender_name, &desktop_id).await + { + owned_desktop_ids.insert(normalize_desktop_id(&desktop_id)); + } + } + resolve_with_evidence(claim, sender, index, &owned_desktop_ids) +} + +fn resolve_with_evidence( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + owned_desktop_ids: &HashSet, +) -> AttributionResolution { + // An explicit desktop hint is accepted only when its executable is the sender file + let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); + if let Some(desktop_id) = desktop_entry.as_deref() { + let records = index.records_for_id(desktop_id); + if !records.is_empty() { + if let Some(record) = records + .iter() + .find(|record| record_matches_sender(record, sender)) + { + return resolution_for_record(record, claim.reported_name, sender); + } + if records + .iter() + .any(|record| owned_desktop_ids.contains(&normalize_desktop_id(&record.id))) + { + // Session applications can request names, so ownership is context rather than proof + return conflict_resolution( + claim.reported_name, + sender, + "bus name ownership lacks executable association", + ); + } + return conflict_resolution(claim.reported_name, sender, "desktop identity mismatch"); + } + } + + if let Some(identity) = sender.sender_executable_identity { + // Exact file association is stronger than every caller-controlled application name + let records = index.records_for_executable(identity); + if let Some(record) = records + .iter() + .find(|record| record.claim_matches(claim.reported_name)) + { + return resolution_for_record(record, claim.reported_name, sender); + } + if records.iter().any(|record| record.system_entry) { + // A known executable with a conflicting name must fail closed + return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); + } + if let Some(path) = index.trusted_relay_path(identity) { + // Relay groups include both relay identity and the relayed claim + let group_key = format!( + "relay:{}:{}", + identity.group_fragment(), + normalize_name(claim.reported_name) + ); + let attribution = NotificationAttribution::trusted_relay( + claim.reported_name, + &format!("Sent via {}", path.display()), + index.claim_matches_system_app(claim.reported_name), + group_key, + ); + return policy_resolution(attribution); + } + } + + if index.claim_matches_system_app(claim.reported_name) { + // Protected branding without the matching executable is an explicit conflict + return conflict_resolution(claim.reported_name, sender, "executable identity mismatch"); + } + + let source = sender + .sender_executable + .as_deref() + .map(|path| format!("Source: {path}")) + .unwrap_or_default(); + let group_key = unknown_group_key(claim.reported_name, sender); + policy_resolution(NotificationAttribution::unknown( + claim.reported_name, + &source, + group_key, + )) +} + +fn resolution_for_record( + record: &DesktopRecord, + reported_name: &str, + sender: &SenderMetadata, +) -> AttributionResolution { + // Display metadata is projected only after the record and sender identities agree + if !record.claim_matches(reported_name) { + return conflict_resolution(reported_name, sender, "application claim mismatch"); + } + let class = if record.system_entry { + AttributionClass::SystemAssociated + } else { + AttributionClass::UserAssociated + }; + let source_label = record + .executable_path + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let attribution = NotificationAttribution::associated( + &record.display_name, + &record.id, + &record.badge_icon, + &source_label, + class, + false, + format!("desktop:{}", record.id), + ); + policy_resolution(attribution) +} + +fn conflict_resolution( + reported_name: &str, + sender: &SenderMetadata, + reason: &str, +) -> AttributionResolution { + let source = sender.sender_executable.as_deref().map_or_else( + || reason.to_string(), + |path| format!("{reason}; source {path}"), + ); + policy_resolution(NotificationAttribution::conflict( + reported_name, + &source, + unknown_group_key(reported_name, sender), + )) +} + +const fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { + // Interaction policy remains separate so presentation changes cannot enable replies + AttributionResolution { + inline_reply_policy: inline_reply_policy(attribution.class), + attribution, + } +} + +const fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { + match ( + record.executable_identity, + sender.sender_executable_identity, + ) { + (Some(record_identity), Some(sender_identity)) => { + record_identity.same_file(sender_identity) + } + _ => false, + } +} + +fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { + // Unknown senders cannot merge into a trusted desktop group by copying its name + let claim = normalize_name(reported_name); + sender.sender_executable_identity.map_or_else( + || format!("unknown:{claim}"), + |identity| format!("executable:{}:{claim}", identity.group_fragment()), + ) +} + +async fn sender_owns_name(connection: &Connection, sender_name: &str, desktop_id: &str) -> bool { + // Invalid well-known names are rejected before contacting the bus daemon + let Ok(bus_name) = zbus::names::BusName::try_from(desktop_id) else { + return false; + }; + let Ok(proxy) = DBusProxy::new(connection).await else { + return false; + }; + proxy + .get_name_owner(bus_name) + .await + .is_ok_and(|owner| owner.as_str() == sender_name) +} + +pub(super) fn validate_desktop_id(value: &str) -> Option { + // Desktop hints stay short, single-component, and safe for later lookups + let value = value.trim(); + if value.is_empty() + || value.len() > MAX_DESKTOP_ID_BYTES + || value.contains(['/', '\\', '\0']) + || value.chars().any(char::is_control) + { + return None; + } + let value = value.strip_suffix(".desktop").unwrap_or(value); + if value == "." || value == ".." || value.is_empty() { + return None; + } + Some(value.to_string()) +} + +#[cfg(test)] +#[path = "tests/resolver.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs new file mode 100644 index 000000000..c652f0558 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -0,0 +1,286 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use unixnotis_core::{AttributionClass, InlineReplyPolicy}; + +use super::*; +use crate::daemon::notifications::identity::desktop_index::{DesktopIdentityIndex, DesktopRecord}; +use crate::daemon::notifications::identity::FileIdentity; + +fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { + FileIdentity { + device, + inode, + uid, + mode: 0o100_755, + } +} + +fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { + SenderMetadata { + sender_name: Some(":1.42".to_string()), + sender_executable: Some(path.to_string()), + sender_executable_identity: Some(identity), + ..SenderMetadata::default() + } +} + +fn system_record(id: &str, name: &str, path: &str, identity: FileIdentity) -> DesktopRecord { + DesktopRecord::fixture(id, name, path, identity, true, false) +} + +#[test] +fn system_desktop_identity_allows_legitimate_signal_reply() { + let signal_identity = identity(1, 10, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: Some("org.signal.Signal.desktop"), + }, + &sender("/usr/bin/signal-desktop", signal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert!(!resolution.attribution.source_label.contains("unverified")); +} + +#[test] +fn user_desktop_identity_requires_confirmation_instead_of_immediate_reply() { + let app_identity = identity(6, 60, 1000); + let index = DesktopIdentityIndex::from_records( + vec![DesktopRecord::fixture( + "org.example.LocalApp", + "Local App", + "/home/user/bin/local-app", + app_identity, + false, + false, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.LocalApp"), + }, + &sender("/home/user/bin/local-app", app_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::UserAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Confirm); + assert!(!resolution.attribution.has_warning()); +} + +#[test] +fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { + let signal_identity = identity(1, 10, 0); + let hostile_identity = identity(7, 70, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: None, + }, + &sender("/tmp/signal-desktop", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!( + resolution.attribution.group_key, + "desktop:org.signal.Signal" + ); +} + +#[test] +fn exact_keepassxc_name_spoof_never_becomes_system_associated() { + let keepass_identity = identity(2, 20, 0); + let hostile_identity = identity(8, 80, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.keepassxc.KeePassXC", + "KeePassXC", + "/usr/bin/keepassxc", + keepass_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "KeePassXC", + desktop_entry: None, + }, + &sender("/tmp/keepassxc", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn exact_system_notify_send_identity_is_a_non_replying_relay() { + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); + assert_eq!(resolution.attribution.display_name, "Screenshot"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(!resolution.attribution.has_warning()); + assert!(!resolution.attribution.source_label.contains("unverified")); +} + +#[test] +fn trusted_relay_claiming_a_system_app_keeps_the_relay_class_and_adds_a_warning() { + let signal_identity = identity(1, 10, 0); + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution.attribution.has_warning()); + assert_ne!( + resolution.attribution.group_key, + "desktop:org.signal.Signal" + ); +} + +#[test] +fn malicious_notify_send_basename_is_not_a_trusted_relay() { + let real_relay = identity(3, 30, 0); + let hostile_identity = identity(9, 90, 1000); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), real_relay)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/tmp/notify-send", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn owned_dbus_application_name_cannot_replace_executable_association() { + let app_identity = identity(4, 40, 0); + let mut record = DesktopRecord::fixture( + "org.example.App", + "Example App", + "/usr/bin/example-app", + app_identity, + true, + true, + ); + record.executable_identity = None; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let owned = HashSet::from(["org.example.app".to_string()]); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender("/usr/lib/example-launcher", identity(5, 50, 0)), + &index, + &owned, + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution + .attribution + .source_label + .contains("bus name ownership lacks executable association")); +} + +#[test] +fn desktop_id_validation_never_accepts_a_path_or_control_character() { + assert_eq!( + validate_desktop_id("org.signal.Signal.desktop").as_deref(), + Some("org.signal.Signal") + ); + assert_eq!(validate_desktop_id("../signal"), None); + assert_eq!(validate_desktop_id("org.example.\nApp"), None); + assert_eq!(validate_desktop_id("."), None); + assert_eq!(validate_desktop_id(".desktop"), None); + assert_eq!( + validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), + Some(256) + ); + assert_eq!(validate_desktop_id(&"a".repeat(257)), None); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 08d04960b..09456d8d4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -1,6 +1,7 @@ //! D-Bus server for org.freedesktop.Notifications mod flow_control; +pub(in crate::daemon) mod identity; mod limits; mod metrics; mod payload; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index 4bedacc02..83e98c76a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use unixnotis_core::{ - util, Action, Config, InlineReply, Notification, NotificationAttribution, NotificationImage, - Urgency, + util, Action, Config, InlineReply, InlineReplyPolicy, Notification, NotificationAttribution, + NotificationImage, Urgency, }; use zbus::zvariant::{OwnedValue, Value}; @@ -27,6 +27,8 @@ pub(super) struct NotificationInput { pub(super) actions: Vec, pub(super) hints: HashMap, pub(super) sender: SenderMetadata, + pub(super) attribution: NotificationAttribution, + pub(super) inline_reply_policy: InlineReplyPolicy, pub(super) expire_timeout: i32, } @@ -39,6 +41,8 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { actions, hints, sender, + attribution, + inline_reply_policy, expire_timeout, } = input; @@ -64,14 +68,8 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { .unwrap_or(false); let image = NotificationImage::from_hints(&app_name, &app_icon, &hints); let actions = parse_actions(actions); - let (_, attribution) = - NotificationAttribution::resolve(&app_name, sender.sender_executable.as_deref()); - let inline_reply = if attribution.verified { - parse_inline_reply(&actions, &hints) - } else { - // Unverified senders cannot place a credential-like text control in trusted UI - InlineReply::default() - }; + // Protocol metadata is parsed independently from the daemon's interaction decision + let inline_reply = parse_inline_reply(&actions, &hints); // Clean text before storing it let app_name = util::sanitize_inline_display_text(&app_name); let summary = util::sanitize_display_text(&summary); @@ -86,6 +84,7 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) }, app_icon: util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), + attribution, // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid // Fold very long unbroken runs so renderer width remains bounded summary: util::fold_text_for_layout( @@ -100,6 +99,7 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { ), actions, inline_reply, + inline_reply_policy, // Keep only needed hints hints: sanitize_hints_for_storage(hints), urgency, @@ -245,7 +245,7 @@ fn parse_urgency_hint(value: &OwnedValue) -> Option { None } -fn owned_to_string(value: &OwnedValue) -> Option { +pub(super) fn owned_to_string(value: &OwnedValue) -> Option { value .try_clone() .ok() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs index 367c19ddc..f8bf3de9c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs @@ -7,18 +7,21 @@ use zbus::fdo::DBusProxy; use zbus::message::Header; use zbus::Connection; +use super::identity::{executable_evidence_for_pid, FileIdentity}; use super::sender_cache::SenderMetadataCache; #[derive(Debug, Clone, Default)] -pub(super) struct SenderMetadata { +pub(in crate::daemon) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks pub(super) sender_name: Option, // Process id is paired with start time so reused pids do not inherit ownership pub(super) sender_pid: Option, // Linux start time identifies one concrete process lifetime pub(super) sender_start_time: Option, - // Executable path is used for diagnostics and app-name mismatch logging + // Executable path is presentation-only evidence for diagnostics and source labels pub(super) sender_executable: Option, + // Device and inode bind policy to the open running executable rather than its basename + pub(super) sender_executable_identity: Option, } pub(super) async fn resolve_sender_metadata( @@ -34,6 +37,7 @@ pub(super) async fn resolve_sender_metadata( sender_pid: None, sender_start_time: None, sender_executable: None, + sender_executable_identity: None, }; }; @@ -49,6 +53,7 @@ pub(super) async fn resolve_sender_metadata( sender_pid: None, sender_start_time: None, sender_executable: None, + sender_executable_identity: None, }; }; @@ -58,24 +63,25 @@ pub(super) async fn resolve_sender_metadata( sender_pid: None, sender_start_time: None, sender_executable: None, + sender_executable_identity: None, }; }; // PID and executable come from the bus owner, not caller-provided payload fields let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); let sender_start_time = sender_pid.and_then(read_process_start_time); - let sender_executable = match sender_pid { - Some(pid) => read_process_executable_path(pid) - .await - .map(|path| path.display().to_string()), - None => None, - }; + let executable_evidence = sender_pid.and_then(executable_evidence_for_pid); + let sender_executable = executable_evidence + .as_ref() + .map(|evidence| evidence.canonical_path.display().to_string()); + let sender_executable_identity = executable_evidence.map(|evidence| evidence.identity); let metadata = SenderMetadata { sender_name, sender_pid, sender_start_time, sender_executable, + sender_executable_identity, }; // Failed lookups remain retryable instead of becoming persistent unknown identities if metadata.sender_pid.is_some() { @@ -85,10 +91,9 @@ pub(super) async fn resolve_sender_metadata( } #[cfg(target_os = "linux")] +#[cfg(test)] async fn read_process_executable_path(pid: u32) -> Option { - // Linux path to the executable behind this process id - let path = format!("/proc/{pid}/exe"); - tokio::fs::read_link(path).await.ok() + executable_evidence_for_pid(pid).map(|evidence| evidence.canonical_path) } #[cfg(target_os = "linux")] @@ -100,6 +105,7 @@ fn read_process_start_time(pid: u32) -> Option { } #[cfg(not(target_os = "linux"))] +#[cfg(test)] async fn read_process_executable_path(_pid: u32) -> Option { // On other platforms this metadata is optional None diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index fda8124ff..fb2db48d8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -2,12 +2,13 @@ use std::collections::HashMap; use std::time::Instant; use tracing::debug; -use unixnotis_core::{Notification, NotificationAttribution}; +use unixnotis_core::Notification; use zbus::message::Header; use zbus::zvariant::OwnedValue; +use crate::daemon::notifications::identity::{resolve_attribution, AppClaim}; use crate::daemon::notifications::payload::{ - build_notification, resolve_expiration, NotificationInput, + build_notification, owned_to_string, resolve_expiration, NotificationInput, }; use crate::daemon::notifications::sender::resolve_sender_metadata; use crate::daemon::{to_fdo_error, NotificationSignalMode}; @@ -113,12 +114,24 @@ impl NotificationServer { header, ) .await; - if sender_app_name_mismatch(&input.app_name, sender.sender_executable.as_deref()) { + let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); + let resolution = resolve_attribution( + AppClaim { + reported_name: &input.app_name, + desktop_entry: desktop_entry.as_deref(), + }, + &sender, + &self.state.desktop_identity_index, + self.state.connection(), + ) + .await; + if resolution.attribution.has_warning() { debug!( app_name = %input.app_name, sender = sender.sender_name.as_deref().unwrap_or("unknown"), sender_executable = sender.sender_executable.as_deref().unwrap_or("unknown"), - "notification app_name does not match sender executable" + source = %resolution.attribution.source_label, + "notification application claim conflicts with sender evidence" ); } @@ -131,6 +144,8 @@ impl NotificationServer { actions: input.actions, hints: input.hints, sender, + attribution: resolution.attribution, + inline_reply_policy: resolution.inline_reply_policy, expire_timeout: input.expire_timeout, }) } @@ -237,13 +252,6 @@ impl NotificationServer { } } -fn sender_app_name_mismatch(app_name: &str, sender_executable: Option<&str>) -> bool { - sender_executable.is_some_and(|_| { - let (_, attribution) = NotificationAttribution::resolve(app_name, sender_executable); - !attribution.verified - }) -} - #[cfg(test)] #[path = "tests/flow.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 2c7e3aa8e..758b9f194 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -25,10 +25,12 @@ fn notification_with_id(id: u32) -> Arc { id, app_name: "app".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::new(), urgency: Urgency::Normal, category: None, @@ -57,27 +59,6 @@ fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { } } -#[test] -fn sender_app_name_mismatch_is_false_without_executable_metadata() { - assert!(!super::sender_app_name_mismatch("Calendar", None)); -} - -#[test] -fn sender_app_name_mismatch_is_false_when_app_matches_executable() { - assert!(!super::sender_app_name_mismatch( - "UnixNotis Center", - Some("/usr/bin/unixnotis-center"), - )); -} - -#[test] -fn sender_app_name_mismatch_is_true_when_app_does_not_match_executable() { - assert!(super::sender_app_name_mismatch( - "Calendar", - Some("/usr/bin/firefox"), - )); -} - fn notify_header_message() -> Message { Message::method("/org/freedesktop/Notifications", "Notify") .expect("method builder") diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs index 75565a339..fc0aa9bbb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs @@ -27,7 +27,10 @@ fn build_notification_clamps_summary_and_body_sizes() { sender_pid: Some(42), sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, }, + attribution: unixnotis_core::NotificationAttribution::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -49,7 +52,10 @@ fn build_notification_strips_display_spoofing_controls() { sender_pid: Some(42), sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, }, + attribution: unixnotis_core::NotificationAttribution::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -86,6 +92,16 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { sender_executable: Some("/usr/bin/messages".to_string()), ..SenderMetadata::default() }, + attribution: unixnotis_core::NotificationAttribution::associated( + "Messages", + "org.example.Messages", + "messages", + "/usr/bin/messages", + unixnotis_core::AttributionClass::SystemAssociated, + false, + "desktop:org.example.Messages".to_string(), + ), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, expire_timeout: 0, }); @@ -97,7 +113,7 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { } #[test] -fn build_notification_disables_inline_reply_for_mismatched_sender_identity() { +fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy() { let notification = build_notification(NotificationInput { app_name: "Password Manager".to_string(), app_icon: "password-manager".to_string(), @@ -110,18 +126,30 @@ fn build_notification_disables_inline_reply_for_mismatched_sender_identity() { sender_executable: Some("/usr/bin/unknown-client".to_string()), ..SenderMetadata::default() }, + attribution: unixnotis_core::NotificationAttribution::conflict( + "Password Manager", + "source /usr/bin/unknown-client", + "executable:1:2".to_string(), + ), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); - assert!(!notification.inline_reply.available); + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); let view = notification.to_view(); - assert_eq!(view.app_name, "unknown-client"); - assert!(!view.attribution.verified); - assert_eq!(view.attribution.reported_name, "Password Manager"); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!( + view.attribution.class, + unixnotis_core::AttributionClass::Conflict + ); } #[test] -fn build_notification_disables_inline_reply_when_sender_identity_is_unresolved() { +fn build_notification_keeps_unknown_sender_reply_policy_denied() { let notification = build_notification(NotificationInput { app_name: "Messages".to_string(), app_icon: String::new(), @@ -130,13 +158,26 @@ fn build_notification_disables_inline_reply_when_sender_identity_is_unresolved() actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints: HashMap::new(), sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unknown( + "Messages", + "", + "unknown:messages".to_string(), + ), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); - assert!(!notification.inline_reply.available); + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); let view = notification.to_view(); assert_eq!(view.app_name, "Messages"); - assert!(!view.attribution.verified); + assert_eq!( + view.attribution.class, + unixnotis_core::AttributionClass::Unknown + ); } #[test] @@ -155,6 +196,8 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { actions: vec!["default".to_string(), "Open".to_string()], hints, sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -264,10 +307,12 @@ fn resolve_expiration_respects_protocol_and_config_rules() { id: 1, app_name: "app".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, hints: HashMap::new(), urgency: Urgency::Normal, category: None, @@ -316,10 +361,12 @@ fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_ id: 1, app_name: "app".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, hints: HashMap::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs index fa19f81dc..e6f2f2887 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs @@ -7,6 +7,7 @@ fn metadata(sender: &str, pid: u32) -> SenderMetadata { sender_pid: Some(pid), sender_start_time: Some(u64::from(pid)), sender_executable: Some(format!("/usr/bin/app-{pid}")), + sender_executable_identity: None, } } diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 6a428dabf..d930a3db3 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -11,6 +11,7 @@ use crate::sound::SoundSettings; use crate::store::NotificationStore; use crate::daemon::events::DaemonEventPublisher; +use crate::daemon::notifications::identity::DesktopIdentityIndex; use crate::daemon::notifications::sender_cache::SenderMetadataCache; use crate::daemon::notifications::NotificationBurstState; @@ -41,6 +42,8 @@ pub struct DaemonState { StdMutex>, // Unique sender identities avoid repeated bus and procfs lookups during bursts pub(in crate::daemon) sender_metadata_cache: SenderMetadataCache, + // Desktop records are indexed once so notification bursts never rescan application files + pub(in crate::daemon) desktop_identity_index: Arc, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, } @@ -77,6 +80,7 @@ impl DaemonState { events: DaemonEventPublisher::new(connection), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), sender_metadata_cache: SenderMetadataCache::new(), + desktop_identity_index: DesktopIdentityIndex::shared(), trial_mode, }) } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 7c2eafed7..74d45fe91 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -13,10 +13,12 @@ fn notification(summary: &str) -> Notification { id: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs index 2c9444c85..474351c43 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -126,7 +126,10 @@ impl NotificationStore { .actions .iter() .any(|action| action.key == "inline-reply"); - (notification.inline_reply.available && has_reply_action).then(|| Arc::clone(notification)) + (notification.inline_reply.available + && notification.inline_reply_policy == unixnotis_core::InlineReplyPolicy::Allow + && has_reply_action) + .then(|| Arc::clone(notification)) } pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/reply.rs index b5c76cb1b..9dc7b1565 100644 --- a/crates/unixnotis-daemon/src/store/tests/reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/reply.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use unixnotis_core::{Action, CloseReason, InlineReply}; +use unixnotis_core::{Action, CloseReason, InlineReply, InlineReplyPolicy}; use super::{make_notification, make_store_with_limits}; @@ -90,6 +90,21 @@ fn inline_reply_metadata_without_the_protocol_action_is_rejected() { assert!(store.active_inline_reply_target(id).is_none()); } +#[test] +fn inline_reply_policy_denies_a_complete_reply_action() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("unassociated reply"); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Deny; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let id = store.insert(notification, 0).notification.id; + + assert!(store.active_inline_reply_target(id).is_none()); +} + #[test] fn generation_safe_reply_dismissal_keeps_same_id_replacement() { let mut store = make_store_with_limits(12, 20); diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/tests/support.rs index deba3bac5..7d9248c79 100644 --- a/crates/unixnotis-daemon/src/store/tests/support.rs +++ b/crates/unixnotis-daemon/src/store/tests/support.rs @@ -15,10 +15,12 @@ pub(super) fn make_notification(summary: &str) -> Notification { id: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 67f04c912..73915fbab 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -141,10 +141,12 @@ fn make_notification(summary: &str) -> Notification { id: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 40ec6ae81..c2d5d3a47 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -105,14 +105,21 @@ impl UiState { // Missing icons also get a root class so themes can rebalance spacing set_class_state(&root, hooks::popup_card::NO_ICON, true); } - // Identity text includes an explicit marker whenever sender attribution is unresolved - let attribution_label = notification.attribution_label(); - let app = gtk::Label::new(Some(&attribution_label)); + // Primary identity stays short while source evidence lives in a tooltip + let app = gtk::Label::new(Some(¬ification.attribution.display_name)); app.set_xalign(0.0); app.set_single_line_mode(true); app.set_ellipsize(EllipsizeMode::End); app.set_max_width_chars(POPUP_APP_MAX_CHARS as i32); - app.set_text(clamp_label_text(&attribution_label, POPUP_APP_MAX_CHARS).as_ref()); + app.set_text( + clamp_label_text(¬ification.attribution.display_name, POPUP_APP_MAX_CHARS).as_ref(), + ); + if !notification.attribution.source_label.is_empty() { + app.set_tooltip_text(Some(¬ification.attribution.source_label)); + } + if notification.attribution.has_warning() { + app.add_css_class("unixnotis-attribution-warning"); + } app.add_css_class("unixnotis-popup-header"); let close = gtk::Button::from_icon_name("window-close-symbolic"); diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index 655f926de..9dfb94261 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -69,15 +69,10 @@ pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> } candidates.push(notification.attribution.badge_icon.to_lowercase()); } - let authenticated_primary = - notification.attribution.verified || !notification.attribution.reported_name.is_empty(); - if authenticated_primary && !notification.app_name.is_empty() { - // Unresolved claims never become badge candidates when the warning icon is unavailable - candidates.push(notification.app_name.clone()); - let lower = notification.app_name.to_lowercase(); - let dashed = lower.replace(' ', "-"); - candidates.push(lower); - candidates.push(dashed); + if !notification.attribution.desktop_id.is_empty() { + // Desktop ids are daemon-associated metadata and safe badge lookup candidates + candidates.push(notification.attribution.desktop_id.clone()); + candidates.push(notification.attribution.desktop_id.to_lowercase()); } let mut seen = HashSet::new(); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index 3a49cb8e8..369efb110 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -2,7 +2,7 @@ use super::super::collect_icon_candidates; use super::support::notification; #[test] -fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { +fn collect_icon_candidates_uses_only_daemon_associated_badge_variants() { let candidates = collect_icon_candidates(¬ification("UnixNotis Center", "org.demo.App.desktop")); @@ -12,9 +12,6 @@ fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { "org.demo.App.desktop", "org.demo.App", "org.demo.app.desktop", - "UnixNotis Center", - "unixnotis center", - "unixnotis-center", ] ); } @@ -23,7 +20,7 @@ fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { fn collect_icon_candidates_dedupes_empty_and_repeated_values() { let candidates = collect_icon_candidates(¬ification("App", "app")); - assert_eq!(candidates, vec!["app", "App"]); + assert_eq!(candidates, vec!["app"]); } #[test] @@ -42,8 +39,8 @@ fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { #[test] fn collect_icon_candidates_does_not_fallback_to_unresolved_brand_claim() { let mut notification = notification("Trusted Brand", "dialog-warning-symbolic"); - notification.attribution.verified = false; - notification.attribution.reported_name.clear(); + notification.attribution.class = unixnotis_core::AttributionClass::Unknown; + notification.attribution.desktop_id.clear(); let candidates = collect_icon_candidates(¬ification); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index f07d29abf..98734b418 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -5,14 +5,15 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView id: 1, app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { - verified: true, - reported_name: String::new(), + display_name: app_name.to_string(), badge_icon: icon_name.to_string(), + ..unixnotis_core::NotificationAttribution::default() }, summary: String::new(), body: String::new(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, is_transient: false, image: NotificationImage { diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index c961ddcf9..27577ae0e 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -15,6 +15,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { label: "Open".to_string(), }], inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: urgency as u8, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 4b1b49370..24e926f94 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -51,6 +51,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { body: "Body".to_string(), actions: Vec::new(), inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, is_transient: false, image: NotificationImage::default(), diff --git a/crates/unixnotis-ui/Cargo.toml b/crates/unixnotis-ui/Cargo.toml index 685c2cced..1c5d9e8ed 100644 --- a/crates/unixnotis-ui/Cargo.toml +++ b/crates/unixnotis-ui/Cargo.toml @@ -10,7 +10,6 @@ notify.workspace = true tracing.workspace = true unixnotis-core = { path = "../unixnotis-core" } serde.workspace = true -shell-words.workspace = true serde_json.workspace = true url.workspace = true diff --git a/crates/unixnotis-ui/src/icons/desktop_index.rs b/crates/unixnotis-ui/src/icons/desktop_index.rs index 3fbd31122..66da713aa 100644 --- a/crates/unixnotis-ui/src/icons/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/desktop_index.rs @@ -50,11 +50,11 @@ impl DesktopIconIndex { if let Some(id) = desktop.id() { self.add_id(id.as_str(), &icon_name); } - if let Some(executable) = desktop - .string("Exec") - .and_then(|exec| executable_basename(exec.as_str())) - { - self.add_executable(&executable, &icon_name); + // D-Bus-activated desktop entries may validly omit Exec + if desktop.commandline().is_some() { + if let Some(executable) = executable_basename(&desktop.executable()) { + self.add_executable(&executable, &icon_name); + } } } } @@ -110,11 +110,9 @@ impl DesktopIconIndex { } } -fn executable_basename(exec: &str) -> Option { - // Desktop Exec fields use shell-like quoting plus field-code arguments - let arguments = shell_words::split(exec).ok()?; - let program = arguments.first()?; - std::path::Path::new(program) +fn executable_basename(program: &std::path::Path) -> Option { + // GIO follows desktop Exec syntax and exposes only the executable component + program .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) diff --git a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs index 696874419..f9882d03e 100644 --- a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs @@ -1,21 +1,20 @@ use super::*; #[test] -fn executable_basename_handles_paths_quotes_and_field_codes() { +fn executable_basename_handles_paths_and_program_names() { assert_eq!( - executable_basename("'/opt/Demo App/bin/demo-app' --open %U"), + executable_basename(std::path::Path::new("/opt/Demo App/bin/demo-app")), Some("demo-app".to_string()) ); assert_eq!( - executable_basename("firefox %u"), + executable_basename(std::path::Path::new("firefox")), Some("firefox".to_string()) ); - assert_eq!(executable_basename(""), None); - assert_eq!(executable_basename("'unterminated"), None); + assert_eq!(executable_basename(std::path::Path::new("")), None); } #[test] -fn desktop_index_resolves_authenticated_executable_to_application_icon() { +fn desktop_index_resolves_associated_executable_to_application_icon() { let mut index = DesktopIconIndex::default(); index.add_executable("demo-app", "org.example.Demo"); From 2556211172a1e862ca7f3250fb7782c14a19b4e9 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:44:59 -0500 Subject: [PATCH 084/275] fix(popups): restore notification content images Summary: restore notification content images. Scope: popups. --- crates/unixnotis-core/assets/popup.css | 7 ++ .../unixnotis-core/src/css/hooks/classes.rs | 1 + .../src/css/hooks/tests/hooks.rs | 1 + crates/unixnotis-popups/src/ui/entry/build.rs | 10 +- crates/unixnotis-popups/src/ui/icon_state.rs | 22 ++++- .../unixnotis-popups/src/ui/icons/content.rs | 98 +++++++++++++++++++ crates/unixnotis-popups/src/ui/icons/mod.rs | 2 + .../src/ui/icons/tests/content.rs | 41 ++++++++ 8 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/icons/content.rs create mode 100644 crates/unixnotis-popups/src/ui/icons/tests/content.rs diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 16f268bc0..9dab8dec6 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -96,6 +96,13 @@ margin-top: 2px; } +.unixnotis-popup-content-image { + min-width: 96px; + min-height: 96px; + margin-top: 6px; + border-radius: 10px; +} + .unixnotis-popup-actions { margin-top: 8px; margin-top: var(--unixnotis-popup-actions-gap); diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 6ca1b93ee..0cf90bff2 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -177,6 +177,7 @@ pub mod popup_card { pub const HAS_ACTIONS: &str = "unixnotis-popup-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-popup-card-has-body"; pub const HAS_ICON: &str = "unixnotis-popup-card-has-icon"; + pub const HAS_IMAGE: &str = "unixnotis-popup-card-has-image"; pub const HAS_SUMMARY: &str = "unixnotis-popup-card-has-summary"; pub const NO_ICON: &str = "unixnotis-popup-card-no-icon"; } diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 57ec26cd5..11651ae9a 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -157,6 +157,7 @@ fn hook_names_stay_unique() { popup_card::HAS_ACTIONS, popup_card::HAS_BODY, popup_card::HAS_ICON, + popup_card::HAS_IMAGE, popup_card::HAS_SUMMARY, popup_card::NO_ICON, group_row::ROOT, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index c2d5d3a47..836f877ca 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -153,11 +153,19 @@ impl UiState { body.add_css_class("unixnotis-popup-body"); update_optional_label(&body, ¬ification.body, POPUP_BODY_MAX_CHARS); - // The root order is stable so CSS can assume header, summary, body, actions + // The root order is stable so CSS can assume header, summary, body, image, actions root.append(&header); root.append(&summary); root.append(&body); + if let Some(image) = self.build_content_image_widget(notification) { + // Caller content stays in the body and never becomes the application badge + set_class_state(&root, hooks::popup_card::HAS_IMAGE, true); + image.set_halign(Align::Start); + image.add_css_class("unixnotis-popup-content-image"); + root.append(&image); + } + // Action buttons are only built when the payload exposes actions if has_popup_actions { let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index 7897770dc..22cf8dfa7 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -12,8 +12,8 @@ use tracing::debug; use unixnotis_core::NotificationView; use super::icons::{ - collect_icon_candidates, file_path_from_hint, resolve_icon_image, IconDecodePool, - IconDecodeResult, + collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, + IconDecodePool, IconDecodeResult, }; use super::state::IconCacheEntry; use super::UiState; @@ -23,10 +23,28 @@ const ICON_CACHE_MAX_ENTRIES: usize = 256; const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1024 * 1024; // Popup icon size is fixed so rows stay visually consistent across icon sources const POPUP_ICON_SIZE: i32 = 20; +// Content stays visibly separate from the daemon-associated application badge +const POPUP_CONTENT_IMAGE_SIZE: i32 = 96; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); impl UiState { + pub(super) fn build_content_image_widget( + &self, + notification: &NotificationView, + ) -> Option { + if let Some(texture) = image_data_texture(¬ification.image) { + let widget = gtk::Image::from_paintable(Some(&texture)); + set_popup_icon_size(&widget, POPUP_CONTENT_IMAGE_SIZE); + return Some(widget); + } + + if notification.image.image_path.trim().is_empty() { + return None; + } + self.resolve_icon_widget(¬ification.image.image_path, POPUP_CONTENT_IMAGE_SIZE) + } + pub(super) fn build_image_widget( &mut self, notification: &NotificationView, diff --git a/crates/unixnotis-popups/src/ui/icons/content.rs b/crates/unixnotis-popups/src/ui/icons/content.rs new file mode 100644 index 000000000..439094f44 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/content.rs @@ -0,0 +1,98 @@ +//! Caller-supplied notification content image decoding + +use gtk::gdk; +use gtk::glib::object::Cast; +use unixnotis_core::{ImageData, NotificationImage}; + +pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option { + // Content images stay separate from the authenticated application badge + if !image.has_image_data { + return None; + } + + let data = &image.image_data; + // GTK memory textures need positive dimensions and eight-bit channels + if data.bits_per_sample != 8 || data.rowstride < 0 || data.width <= 0 || data.height <= 0 { + return None; + } + + let width = usize::try_from(data.width).ok()?; + let height = usize::try_from(data.height).ok()?; + let width_i32 = i32::try_from(width).ok()?; + let height_i32 = i32::try_from(height).ok()?; + + let (bytes, stride) = match data.channels { + 4 => { + // Row padding is valid, but every visible pixel must fit in each row + let min_stride = width.checked_mul(4)?; + let stride = if data.rowstride > 0 { + usize::try_from(data.rowstride).ok()? + } else { + min_stride + }; + if stride < min_stride || data.data.len() < stride.checked_mul(height)? { + return None; + } + (gtk::glib::Bytes::from(&data.data), stride) + } + 3 => { + // GTK has no matching packed RGB format here, so add an opaque alpha channel + let (expanded, stride) = expand_rgb_to_rgba(data)?; + (gtk::glib::Bytes::from(&expanded), stride) + } + _ => return None, + }; + + Some( + gdk::MemoryTexture::new( + width_i32, + height_i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + stride, + ) + .upcast::(), + ) +} + +fn expand_rgb_to_rgba(data: &ImageData) -> Option<(Vec, usize)> { + // Every multiplication is checked before allocating or slicing image storage + let width = usize::try_from(data.width).ok()?; + let height = usize::try_from(data.height).ok()?; + if width == 0 || height == 0 { + return None; + } + + let min_source_stride = width.checked_mul(3)?; + let source_stride = if data.rowstride > 0 { + usize::try_from(data.rowstride).ok()? + } else { + min_source_stride + }; + if source_stride < min_source_stride || data.data.len() < source_stride.checked_mul(height)? { + return None; + } + + let target_stride = width.checked_mul(4)?; + let mut rgba = vec![0; target_stride.checked_mul(height)?]; + for row in 0..height { + // Source padding is skipped while target rows remain tightly packed + let source_start = row.checked_mul(source_stride)?; + let target_start = row.checked_mul(target_stride)?; + let source = &data.data[source_start..source_start + min_source_stride]; + let target = &mut rgba[target_start..target_start + target_stride]; + for column in 0..width { + let source_pixel = column * 3; + let target_pixel = column * 4; + target[target_pixel..target_pixel + 3] + .copy_from_slice(&source[source_pixel..source_pixel + 3]); + target[target_pixel + 3] = u8::MAX; + } + } + + Some((rgba, target_stride)) +} + +#[cfg(test)] +#[path = "tests/content.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/icons/mod.rs b/crates/unixnotis-popups/src/ui/icons/mod.rs index fa2733148..b79165c1c 100644 --- a/crates/unixnotis-popups/src/ui/icons/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/mod.rs @@ -1,9 +1,11 @@ //! Popup icon lookup, decoding, and cache ownership mod cache; +mod content; mod decode; mod resolver; pub(super) use cache::{IconDecodePool, IconDecodeResult, TextureCache}; +pub(super) use content::image_data_texture; pub(super) use decode::{decode_icon_file, RasterIcon}; pub(super) use resolver::{collect_icon_candidates, file_path_from_hint, resolve_icon_image}; diff --git a/crates/unixnotis-popups/src/ui/icons/tests/content.rs b/crates/unixnotis-popups/src/ui/icons/tests/content.rs new file mode 100644 index 000000000..8ecffd5f9 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/content.rs @@ -0,0 +1,41 @@ +use super::*; + +fn image_data(channels: i32, rowstride: i32, data: Vec) -> NotificationImage { + NotificationImage { + has_image_data: true, + image_data: ImageData { + width: 2, + height: 1, + rowstride, + has_alpha: channels == 4, + bits_per_sample: 8, + channels, + data, + }, + ..NotificationImage::default() + } +} + +#[test] +fn rgb_content_image_expands_to_opaque_rgba() { + let image = image_data(3, 8, vec![1, 2, 3, 4, 5, 6, 90, 91]); + + let (bytes, stride) = expand_rgb_to_rgba(&image.image_data).expect("valid RGB data"); + + assert_eq!(stride, 8); + assert_eq!(bytes, vec![1, 2, 3, 255, 4, 5, 6, 255]); +} + +#[gtk::test] +fn valid_rgba_content_image_creates_a_texture() { + let image = image_data(4, 8, vec![1, 2, 3, 4, 5, 6, 7, 8]); + + assert!(image_data_texture(&image).is_some()); +} + +#[gtk::test] +fn undersized_content_buffer_is_rejected() { + let image = image_data(4, 8, vec![1, 2, 3, 4]); + + assert!(image_data_texture(&image).is_none()); +} From 2901dc7699a65d2843d4a0b2269c9e61cc171f5c Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:45:12 -0500 Subject: [PATCH 085/275] ci(release): bind signatures to tagged source Summary: bind signatures to tagged source. Scope: release. --- .github/workflows/release.yml | 75 +++++++++++++++++++++++++++----- tests/check-release-hardening.sh | 32 ++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e774bac60..d1f5d46de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,8 @@ on: required: true type: string -permissions: - # The token can read sources and publish signed provenance for the built files - contents: read - id-token: write - attestations: write - artifact-metadata: write +# Jobs opt into only the token capabilities used by their own phase +permissions: {} concurrency: # A release tag should produce one archive set, so do not cancel a run already packaging it @@ -38,8 +34,24 @@ jobs: runs-on: ubuntu-24.04 container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 45 + permissions: + contents: read steps: + - name: Validate release tag input + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'release tag must look like v1.1.0: %s\n' "$RELEASE_TAG" >&2 + exit 2 + fi + if [[ "$GITHUB_REF" != "refs/tags/${RELEASE_TAG}" ]]; then + printf 'workflow ref %s must match release tag %s\n' "$GITHUB_REF" "$RELEASE_TAG" >&2 + exit 2 + fi + - name: Install system dependencies run: | set -euo pipefail @@ -79,6 +91,21 @@ jobs: - name: Check out repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + + - name: Verify release source commit + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag_ref="refs/tags/${RELEASE_TAG}" + tag_commit="$(git rev-parse "${tag_ref}^{commit}")" + checked_out_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" != "$GITHUB_SHA" || "$checked_out_commit" != "$GITHUB_SHA" ]]; then + printf 'release tag, workflow commit, and checkout must resolve to one commit\n' >&2 + exit 2 + fi - name: Install Rust toolchain run: | @@ -111,18 +138,42 @@ jobs: tests/package-release.sh - name: Build package archive - # The script checks that Cargo's version matches the requested release tag env: # Environment transport prevents workflow input from becoming Bash source text RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'release tag must look like v1.1.0: %s\n' "$RELEASE_TAG" >&2 - exit 2 - fi + # The packager also binds the requested tag to Cargo's workspace version scripts/package-release.sh "$RELEASE_TAG" + - name: Upload unsigned package archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unixnotis-${{ inputs.tag }}-unsigned + path: | + dist/*.tar.zst + dist/*.sha256 + if-no-files-found: error + + sign: + name: Sign and attest release tarball + needs: package + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + # Only this job can request the short-lived signing identity + id-token: write + attestations: write + artifact-metadata: write + + steps: + - name: Download unsigned package archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unixnotis-${{ inputs.tag }}-unsigned + path: dist + - name: Install artifact signer uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 with: @@ -146,7 +197,7 @@ jobs: dist/*.tar.zst dist/*.sha256 - - name: Upload package archive + - name: Upload signed package archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unixnotis-${{ inputs.tag }}-release diff --git a/tests/check-release-hardening.sh b/tests/check-release-hardening.sh index 0ae9f3894..b53acfdb3 100755 --- a/tests/check-release-hardening.sh +++ b/tests/check-release-hardening.sh @@ -29,6 +29,20 @@ assert_excludes() { fi } +assert_count() { + local path="${1}" + local expected_count="${2}" + local needle="${3}" + local actual_count + + actual_count="$(grep -Fc -- "$needle" "$path")" + if [[ "$actual_count" != "$expected_count" ]]; then + printf 'expected %s occurrences in %s, found %s: %s\n' \ + "$expected_count" "$path" "$actual_count" "$needle" >&2 + return 1 + fi +} + # The base image and package repository both resolve to immutable inputs assert_contains "$workflow" 'container: debian:trixie-slim@sha256:' assert_contains "$workflow" "snapshot.debian.org/archive/debian/\${DEBIAN_SNAPSHOT}" @@ -41,6 +55,15 @@ assert_contains "$workflow" '| sha256sum --check --strict' assert_excludes "$workflow" 'https://sh.rustup.rs' assert_excludes "$workflow" 'cargo install' +# A release input must select the same tag and commit that triggered the workflow +# Literal workflow expressions must stay unexpanded in these fixed-string checks +# shellcheck disable=SC2016 +assert_contains "$workflow" 'if [[ "$GITHUB_REF" != "refs/tags/${RELEASE_TAG}" ]]; then' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'tag_commit="$(git rev-parse "${tag_ref}^{commit}")"' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'if [[ "$tag_commit" != "$GITHUB_SHA" || "$checked_out_commit" != "$GITHUB_SHA" ]]; then' + # Release builds cannot update the dependency lockfile assert_contains "$packager" 'local args=(build --locked --release' assert_contains "$packager" 'cargo pkgid --locked' @@ -52,3 +75,12 @@ assert_contains "$workflow" 'cosign-release: v3.1.2' assert_contains "$workflow" 'cosign sign-blob' assert_contains "$workflow" 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' assert_contains "$workflow" 'dist/*.sigstore.json' + +# Build steps cannot mint identities; only the dependent signing job receives OIDC +assert_contains "$workflow" 'needs: package' +assert_contains "$workflow" 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'name: unixnotis-${{ inputs.tag }}-unsigned' +assert_count "$workflow" 1 'id-token: write' +assert_count "$workflow" 1 'attestations: write' +assert_count "$workflow" 1 'artifact-metadata: write' From 1198b099fbb276c6a91fbb1088b7088b8f69fd84 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 18:59:27 -0500 Subject: [PATCH 086/275] refactor(dbus): split Notify preflight by parser layer Summary: split Notify preflight by parser layer. Scope: dbus. --- .../daemon/notifications/server/ingress.rs | 2 +- .../src/daemon/notifications/server/mod.rs | 2 +- .../server/notify_body/cursor.rs | 177 +++++++ .../server/notify_body/limits.rs | 36 ++ .../notifications/server/notify_body/mod.rs | 13 + .../server/notify_body/signature.rs | 108 +++++ .../server/notify_body/tests/actions.rs | 71 +++ .../server/notify_body/tests/body.rs | 67 +++ .../server/notify_body/tests/cursor.rs | 95 ++++ .../tests/hints.rs} | 114 +---- .../server/notify_body/tests/limits.rs | 13 + .../server/notify_body/tests/mod.rs | 12 + .../server/notify_body/tests/signature.rs | 107 ++++ .../server/notify_body/tests/support.rs | 24 + .../server/notify_body/tests/value.rs | 116 +++++ .../server/notify_body/validator.rs | 97 ++++ .../notifications/server/notify_body/value.rs | 87 ++++ .../daemon/notifications/server/preflight.rs | 459 ------------------ 18 files changed, 1044 insertions(+), 556 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs rename crates/unixnotis-daemon/src/daemon/notifications/server/{tests/preflight.rs => notify_body/tests/hints.rs} (53%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs index 819c7d974..a5449e236 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -8,7 +8,7 @@ use zbus::object_server::{DispatchResult, Interface, SignalContext}; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, Message, ObjectServer}; -use super::preflight::{preflight_notify, PreflightError}; +use super::notify_body::{preflight_notify, PreflightError}; use super::NotificationServer; // This leaves room for one maximum image plus bounded text, actions, hints, and wire overhead diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index 819a7a277..b8b7f882b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -5,7 +5,7 @@ mod close; mod flow; mod ingress; mod interface; -mod preflight; +mod notify_body; pub use ingress::NotificationIngress; pub use interface::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs new file mode 100644 index 000000000..d4cde3a3e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs @@ -0,0 +1,177 @@ +//! Checked byte cursor with D-Bus alignment and primitive readers + +use zbus::zvariant::Endian; + +use super::limits::{PreflightError, StringBudget}; + +pub(super) struct Cursor<'a> { + bytes: &'a [u8], + absolute_start: usize, + endian: Endian, + offset: usize, +} + +impl<'a> Cursor<'a> { + pub(super) const fn new(bytes: &'a [u8], absolute_start: usize, endian: Endian) -> Self { + Self { + bytes, + absolute_start, + endian, + offset: 0, + } + } + + pub(super) const fn position(&self) -> usize { + self.offset + } + + pub(super) const fn is_finished(&self) -> bool { + self.offset == self.bytes.len() + } + + pub(super) fn align(&mut self, alignment: usize) -> Result<(), PreflightError> { + // D-Bus alignment is relative to the whole message rather than this body slice + let absolute = self + .absolute_start + .checked_add(self.offset) + .ok_or(PreflightError::Malformed("Notify alignment overflowed"))?; + let padding = (alignment - absolute % alignment) % alignment; + self.advance(padding) + } + + pub(super) fn advance(&mut self, bytes: usize) -> Result<(), PreflightError> { + // Checked offsets turn malformed lengths into errors instead of wraparound + let end = self + .offset + .checked_add(bytes) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify body is truncated")); + } + self.offset = end; + Ok(()) + } + + pub(super) fn read_fixed( + &mut self, + alignment: usize, + bytes: usize, + ) -> Result<(), PreflightError> { + self.align(alignment)?; + self.advance(bytes) + } + + pub(super) fn read_u8(&mut self) -> Result { + let value = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset += 1; + Ok(value) + } + + pub(super) fn read_u32(&mut self) -> Result { + self.align(4)?; + let end = self + .offset + .checked_add(4) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + let bytes = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset = end; + Ok(self.endian.read_u32(bytes)) + } + + pub(super) fn read_string( + &mut self, + limit: usize, + budget: &mut StringBudget, + ) -> Result<&'a [u8], PreflightError> { + // Length is rejected before a slice is exposed to later parsing + let length = usize::try_from(self.read_u32()?) + .map_err(|_| PreflightError::LimitsExceeded("Notify string is too large"))?; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit", + )); + } + budget.add(length)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify string offset overflowed"))?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify string is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify string is missing its terminator", + )); + } + Ok(value) + } + + pub(super) fn read_signature(&mut self) -> Result<&'a [u8], PreflightError> { + let length = usize::from(self.read_u8()?); + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed( + "Notify signature offset overflowed", + ))?; + let signature = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify signature is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify signature is missing its terminator", + )); + } + Ok(signature) + } + + pub(super) fn begin_array( + &mut self, + element_alignment: usize, + ) -> Result { + // Array byte lengths are validated before any element walk begins + let length = usize::try_from(self.read_u32()?) + .map_err(|_| PreflightError::LimitsExceeded("Notify array is too large"))?; + self.align(element_alignment)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify array offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify array is truncated")); + } + Ok(end) + } + + pub(super) const fn finish_array(&self, end: usize) -> Result<(), PreflightError> { + if self.offset == end { + Ok(()) + } else { + Err(PreflightError::Malformed( + "Notify array elements do not match its byte length", + )) + } + } + + pub(super) fn remaining_to(&self, end: usize) -> Result { + end.checked_sub(self.offset) + .ok_or(PreflightError::Malformed( + "Notify array cursor passed its end", + )) + } + + pub(super) const fn finish_at(&mut self, end: usize) { + self.offset = end; + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs new file mode 100644 index 000000000..84bfcbcc1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs @@ -0,0 +1,36 @@ +//! Limits and errors shared by raw Notify body readers + +pub(super) const MAX_IMAGE_BYTES: usize = 256 * 1024; +pub(super) const MAX_NON_IMAGE_ARRAY_BYTES: usize = 16 * 1024; +pub(super) const MAX_NON_IMAGE_STRING_BYTES: usize = 64 * 1024; +pub(super) const MAX_NESTED_CONTAINER_ELEMENTS: usize = 64; +pub(super) const MAX_SIGNATURE_DEPTH: usize = 16; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::server) enum PreflightError { + LimitsExceeded(&'static str), + Malformed(&'static str), +} + +#[derive(Default)] +pub(super) struct StringBudget { + bytes: usize, +} + +impl StringBudget { + pub(super) fn add(&mut self, bytes: usize) -> Result<(), PreflightError> { + // One cumulative budget prevents many valid strings from amplifying memory + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or(PreflightError::LimitsExceeded( + "Notify string budget overflowed", + ))?; + if self.bytes > MAX_NON_IMAGE_STRING_BYTES { + return Err(PreflightError::LimitsExceeded( + "Notify contains too much non-image string data", + )); + } + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs new file mode 100644 index 000000000..15313e818 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs @@ -0,0 +1,13 @@ +//! Raw Notify body validation before typed D-Bus decoding + +mod cursor; +mod limits; +mod signature; +mod validator; +mod value; + +pub(super) use limits::PreflightError; +pub(super) use validator::preflight_notify; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs new file mode 100644 index 000000000..0456f94ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs @@ -0,0 +1,108 @@ +//! Bounded parser for variant-contained D-Bus signatures + +use super::limits::{PreflightError, MAX_NESTED_CONTAINER_ELEMENTS, MAX_SIGNATURE_DEPTH}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum SignatureType { + Basic(u8), + Variant, + Array(Box), + Structure(Vec), + DictEntry(Vec), +} + +impl SignatureType { + pub(super) const fn alignment(&self) -> usize { + match self { + Self::Basic(b'n' | b'q') => 2, + Self::Basic(b'b' | b'i' | b'u' | b'h' | b's' | b'o') | Self::Array(_) => 4, + Self::Basic(b'x' | b't' | b'd') | Self::Structure(_) | Self::DictEntry(_) => 8, + Self::Basic(_) | Self::Variant => 1, + } + } +} + +pub(super) struct SignatureParser<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> SignatureParser<'a> { + pub(super) fn one(bytes: &'a [u8]) -> Result { + // A variant signature must describe exactly one complete value + let mut parser = Self { bytes, offset: 0 }; + let value_type = parser.parse_type(0)?; + if parser.offset != bytes.len() { + return Err(PreflightError::Malformed( + "Notify variant signature has trailing types", + )); + } + Ok(value_type) + } + + fn parse_type(&mut self, depth: usize) -> Result { + // Parsing the tiny signature first makes the later byte walk deterministic + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep", + )); + } + let kind = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed( + "Notify variant signature is empty", + ))?; + self.offset += 1; + match kind { + b'y' | b'b' | b'n' | b'q' | b'i' | b'u' | b'x' | b't' | b'd' | b's' | b'o' | b'g' + | b'h' => Ok(SignatureType::Basic(kind)), + b'v' => Ok(SignatureType::Variant), + b'a' => Ok(SignatureType::Array(Box::new(self.parse_type(depth + 1)?))), + b'(' => self.parse_fields(b')', depth).map(SignatureType::Structure), + b'{' => self.parse_fields(b'}', depth).and_then(|fields| { + if fields.len() == 2 { + Ok(SignatureType::DictEntry(fields)) + } else { + Err(PreflightError::Malformed( + "Notify dictionary entry has an invalid signature", + )) + } + }), + _ => Err(PreflightError::Malformed( + "Notify variant signature contains an invalid type", + )), + } + } + + fn parse_fields( + &mut self, + terminator: u8, + depth: usize, + ) -> Result, PreflightError> { + let mut fields = Vec::new(); + loop { + // Container signatures are bounded independently from data element counts + let Some(kind) = self.bytes.get(self.offset).copied() else { + return Err(PreflightError::Malformed( + "Notify container signature is unterminated", + )); + }; + if kind == terminator { + self.offset += 1; + if fields.is_empty() { + return Err(PreflightError::Malformed( + "Notify container signature is empty", + )); + } + return Ok(fields); + } + if fields.len() >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify container signature has too many fields", + )); + } + fields.push(self.parse_type(depth + 1)?); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs new file mode 100644 index 000000000..7466421ff --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; + +use super::super::{preflight_notify, PreflightError}; +use super::notify_message; +use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; + +#[test] +fn under_wire_limit_tiny_action_flood_is_rejected() { + let actions = (0..20_000).map(|_| "a".to_string()).collect(); + let message = notify_message("app", "", "summary", "", actions, HashMap::new()); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn action_array_accepts_eight_pairs_and_rejects_the_next_element() { + let exact = vec!["a".to_string(); 16]; + let exact_message = notify_message("app", "", "", "", exact, HashMap::new()); + assert_eq!(preflight_notify(&exact_message), Ok(())); + + let over = vec!["a".to_string(); 17]; + let over_message = notify_message("app", "", "", "", over, HashMap::new()); + assert_eq!( + preflight_notify(&over_message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn action_key_and_label_keep_independent_field_limits() { + let oversized_key = "k".repeat(crate::daemon::notifications::limits::MAX_ACTION_KEY_BYTES + 1); + let key_message = notify_message( + "app", + "", + "", + "", + vec![oversized_key, "label".to_string()], + HashMap::new(), + ); + assert_eq!( + preflight_notify(&key_message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); + + let oversized_label = + "l".repeat(crate::daemon::notifications::limits::MAX_ACTION_LABEL_BYTES + 1); + let label_message = notify_message( + "app", + "", + "", + "", + vec!["key".to_string(), oversized_label], + HashMap::new(), + ); + assert_eq!( + preflight_notify(&label_message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs new file mode 100644 index 000000000..fa3bc55b6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, Value}; +use zbus::Message; + +use super::super::{preflight_notify, PreflightError}; +use super::notify_message; + +#[test] +fn ordinary_notify_body_passes_structural_preflight() { + let message = notify_message( + "Example", + "example", + "Summary", + "Body", + vec!["default".to_string(), "Open".to_string()], + HashMap::new(), + ); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn notify_method_with_the_wrong_body_signature_is_rejected() { + let message = Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&("only-one-field",)) + .expect("wrong-signature message"); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::Malformed("Notify has an invalid signature")) + ); +} + +#[test] +fn field_string_limit_is_enforced_before_owned_string_creation() { + let summary = "s".repeat(crate::daemon::notifications::limits::MAX_SUMMARY_BYTES + 1); + let message = notify_message("app", "", &summary, "", Vec::new(), HashMap::new()); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); +} + +#[test] +fn cumulative_string_budget_accepts_its_exact_limit() { + let hints = (0..16) + .map(|index| { + // Hint keys consume 38 bytes, so one shorter value keeps the total at 64 KiB + let first_length = if index == 0 { 2_010 } else { 2_048 }; + let values = Value::from(vec!["h".repeat(first_length), "h".repeat(2_048)]); + ( + format!("h{index}"), + OwnedValue::try_from(values).expect("owned exact-budget strings"), + ) + }) + .collect(); + let message = notify_message("", "", "", "", Vec::new(), hints); + + assert_eq!(preflight_notify(&message), Ok(())); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs new file mode 100644 index 000000000..18e164836 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs @@ -0,0 +1,95 @@ +use zbus::zvariant::Endian; + +use super::super::cursor::Cursor; +use super::super::limits::StringBudget; +use super::super::PreflightError; + +#[test] +fn cursor_rejects_fixed_reads_past_the_body() { + let mut cursor = Cursor::new(&[0_u8; 3], 0, Endian::Little); + + assert_eq!( + cursor.read_fixed(4, 4), + Err(PreflightError::Malformed("Notify body is truncated")) + ); +} + +#[test] +fn cursor_rejects_string_without_nul_terminator() { + let bytes = [1_u8, 0, 0, 0, b'x', b'!']; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + + assert_eq!( + cursor.read_string(8, &mut budget), + Err(PreflightError::Malformed( + "Notify string is missing its terminator" + )) + ); +} + +#[test] +fn cursor_rejects_truncated_signature_and_bad_terminator() { + let truncated = [2_u8, b'a']; + let mut truncated_cursor = Cursor::new(&truncated, 0, Endian::Little); + assert_eq!( + truncated_cursor.read_signature(), + Err(PreflightError::Malformed("Notify signature is truncated")) + ); + + let bad_terminator = [1_u8, b's', b'!']; + let mut terminator_cursor = Cursor::new(&bad_terminator, 0, Endian::Little); + assert_eq!( + terminator_cursor.read_signature(), + Err(PreflightError::Malformed( + "Notify signature is missing its terminator" + )) + ); +} + +#[test] +fn cursor_reads_big_endian_u32_after_absolute_alignment() { + let bytes = [0_u8, 0, 0, 0x01, 0x02, 0x03, 0x04]; + let mut cursor = Cursor::new(&bytes, 1, Endian::Big); + + assert_eq!(cursor.read_u32(), Ok(0x0102_0304)); + assert_eq!(cursor.position(), 7); +} + +#[test] +fn cursor_rejects_array_length_beyond_remaining_bytes() { + let bytes = [8_u8, 0, 0, 0, 1, 2, 3, 4]; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + + assert_eq!( + cursor.begin_array(4), + Err(PreflightError::Malformed("Notify array is truncated")) + ); +} + +#[test] +fn cursor_reports_completion_only_after_consuming_every_byte() { + let mut cursor = Cursor::new(&[7_u8], 0, Endian::Little); + + assert!(!cursor.is_finished()); + assert_eq!(cursor.advance(1), Ok(())); + assert!(cursor.is_finished()); +} + +#[test] +fn array_cursor_accepts_an_exact_body_and_rejects_element_mismatch() { + let bytes = [4_u8, 0, 0, 0, 1, 2, 3, 4]; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + + let end = cursor.begin_array(4).expect("exact array body"); + assert_eq!(end, bytes.len()); + assert_eq!( + cursor.finish_array(end), + Err(PreflightError::Malformed( + "Notify array elements do not match its byte length" + )) + ); + + cursor.advance(4).expect("consume array body"); + assert_eq!(cursor.finish_array(end), Ok(())); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs similarity index 53% rename from crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs rename to crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs index 8390ccb6b..c0af8a889 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/preflight.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs @@ -1,73 +1,11 @@ use std::collections::HashMap; use zbus::zvariant::{OwnedValue, Structure, Value}; -use zbus::Message; -use super::{preflight_notify, PreflightError}; +use super::super::{preflight_notify, PreflightError}; +use super::notify_message; use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; -fn notify_message( - app_name: &str, - app_icon: &str, - summary: &str, - body: &str, - actions: Vec, - hints: HashMap, -) -> Message { - Message::method("/org/freedesktop/Notifications", "Notify") - .expect("method builder") - .interface("org.freedesktop.Notifications") - .expect("notification interface") - .build(&( - app_name, 0_u32, app_icon, summary, body, actions, hints, 0_i32, - )) - .expect("Notify message") -} - -#[test] -fn ordinary_notify_body_passes_structural_preflight() { - let message = notify_message( - "Example", - "example", - "Summary", - "Body", - vec!["default".to_string(), "Open".to_string()], - HashMap::new(), - ); - - assert_eq!(preflight_notify(&message), Ok(())); -} - -#[test] -fn under_wire_limit_tiny_action_flood_is_rejected() { - let actions = (0..20_000).map(|_| "a".to_string()).collect(); - let message = notify_message("app", "", "summary", "", actions, HashMap::new()); - - assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); - assert_eq!( - preflight_notify(&message), - Err(PreflightError::LimitsExceeded( - "Notify action array has too many elements" - )) - ); -} - -#[test] -fn action_array_accepts_eight_pairs_and_rejects_the_next_element() { - let exact = vec!["a".to_string(); 16]; - let exact_message = notify_message("app", "", "", "", exact, HashMap::new()); - assert_eq!(preflight_notify(&exact_message), Ok(())); - - let over = vec!["a".to_string(); 17]; - let over_message = notify_message("app", "", "", "", over, HashMap::new()); - assert_eq!( - preflight_notify(&over_message), - Err(PreflightError::LimitsExceeded( - "Notify action array has too many elements" - )) - ); -} - #[test] fn hint_entry_flood_is_rejected_before_map_allocation() { let hints = (0..17) @@ -83,19 +21,6 @@ fn hint_entry_flood_is_rejected_before_map_allocation() { ); } -#[test] -fn field_string_limit_is_enforced_before_owned_string_creation() { - let summary = "s".repeat(crate::daemon::notifications::limits::MAX_SUMMARY_BYTES + 1); - let message = notify_message("app", "", &summary, "", Vec::new(), HashMap::new()); - - assert_eq!( - preflight_notify(&message), - Err(PreflightError::LimitsExceeded( - "Notify string exceeds its field limit" - )) - ); -} - #[test] fn contiguous_image_array_keeps_its_separate_large_allowance() { let image = Structure::from(( @@ -144,6 +69,23 @@ fn image_array_above_its_allowance_is_rejected_below_the_wire_limit() { ); } +#[test] +fn non_image_byte_array_does_not_inherit_the_image_allowance() { + let mut hints = HashMap::new(); + hints.insert( + "x-example-bytes".to_string(), + OwnedValue::try_from(Value::from(vec![0_u8; 16 * 1024 + 1])).expect("owned byte hint"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance" + )) + ); +} + #[test] fn cumulative_nested_string_data_is_bounded() { let text = "h".repeat(crate::daemon::notifications::limits::MAX_HINT_STRING_BYTES); @@ -167,24 +109,6 @@ fn cumulative_nested_string_data_is_bounded() { ); } -#[test] -fn cumulative_string_budget_accepts_its_exact_limit() { - let hints = (0..16) - .map(|index| { - // Hint keys consume 38 bytes, so one shorter value keeps the total at 64 KiB - let first_length = if index == 0 { 2_010 } else { 2_048 }; - let values = Value::from(vec!["h".repeat(first_length), "h".repeat(2_048)]); - ( - format!("h{index}"), - OwnedValue::try_from(values).expect("owned exact-budget strings"), - ) - }) - .collect(); - let message = notify_message("", "", "", "", Vec::new(), hints); - - assert_eq!(preflight_notify(&message), Ok(())); -} - #[test] fn nested_non_image_array_fanout_is_bounded() { let nested = Value::from(vec!["x"; 65]); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs new file mode 100644 index 000000000..1bcd0e242 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs @@ -0,0 +1,13 @@ +use super::super::limits::{ + MAX_IMAGE_BYTES, MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, + MAX_NON_IMAGE_STRING_BYTES, MAX_SIGNATURE_DEPTH, +}; + +#[test] +fn raw_body_limits_keep_the_reviewed_byte_and_depth_boundaries() { + assert_eq!(MAX_IMAGE_BYTES, 262_144); + assert_eq!(MAX_NON_IMAGE_ARRAY_BYTES, 16_384); + assert_eq!(MAX_NON_IMAGE_STRING_BYTES, 65_536); + assert_eq!(MAX_NESTED_CONTAINER_ELEMENTS, 64); + assert_eq!(MAX_SIGNATURE_DEPTH, 16); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs new file mode 100644 index 000000000..d4d398cf9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs @@ -0,0 +1,12 @@ +//! Raw Notify body regression coverage split by structural responsibility + +mod actions; +mod body; +mod cursor; +mod hints; +mod limits; +mod signature; +mod support; +mod value; + +use support::notify_message; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs new file mode 100644 index 000000000..3803940a3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs @@ -0,0 +1,107 @@ +use super::super::limits::{PreflightError, MAX_SIGNATURE_DEPTH}; +use super::super::signature::{SignatureParser, SignatureType}; + +#[test] +fn signature_parser_accepts_one_nested_dictionary_array() { + assert_eq!( + SignatureParser::one(b"a{sv}"), + Ok(SignatureType::Array(Box::new(SignatureType::DictEntry( + vec![SignatureType::Basic(b's'), SignatureType::Variant] + )))) + ); +} + +#[test] +fn signature_parser_rejects_empty_trailing_and_unknown_types() { + assert_eq!( + SignatureParser::one(b""), + Err(PreflightError::Malformed( + "Notify variant signature is empty" + )) + ); + assert_eq!( + SignatureParser::one(b"ss"), + Err(PreflightError::Malformed( + "Notify variant signature has trailing types" + )) + ); + assert_eq!( + SignatureParser::one(b"z"), + Err(PreflightError::Malformed( + "Notify variant signature contains an invalid type" + )) + ); +} + +#[test] +fn signature_parser_rejects_empty_unterminated_and_invalid_containers() { + assert_eq!( + SignatureParser::one(b"()"), + Err(PreflightError::Malformed( + "Notify container signature is empty" + )) + ); + assert_eq!( + SignatureParser::one(b"(s"), + Err(PreflightError::Malformed( + "Notify container signature is unterminated" + )) + ); + assert_eq!( + SignatureParser::one(b"{s}"), + Err(PreflightError::Malformed( + "Notify dictionary entry has an invalid signature" + )) + ); +} + +#[test] +fn signature_parser_rejects_nesting_beyond_the_depth_limit() { + let mut signature = vec![b'a'; MAX_SIGNATURE_DEPTH + 2]; + signature.push(b'y'); + + assert_eq!( + SignatureParser::one(&signature), + Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep" + )) + ); +} + +#[test] +fn signature_parser_accepts_the_exact_depth_limit() { + let mut signature = vec![b'a'; MAX_SIGNATURE_DEPTH]; + signature.push(b'y'); + + assert!(SignatureParser::one(&signature).is_ok()); +} + +#[test] +fn signature_alignment_matches_each_dbus_wire_class() { + assert_eq!(SignatureType::Basic(b'y').alignment(), 1); + assert_eq!(SignatureType::Basic(b'n').alignment(), 2); + assert_eq!(SignatureType::Basic(b'u').alignment(), 4); + assert_eq!(SignatureType::Basic(b'x').alignment(), 8); + assert_eq!( + SignatureType::Array(Box::new(SignatureType::Basic(b'y'))).alignment(), + 4 + ); + assert_eq!( + SignatureType::Structure(vec![SignatureType::Basic(b'y')]).alignment(), + 8 + ); +} + +#[test] +fn nested_structure_signatures_enforce_the_depth_limit() { + let mut signature = vec![b'('; MAX_SIGNATURE_DEPTH + 2]; + signature.push(b'y'); + signature.extend(std::iter::repeat_n(b')', MAX_SIGNATURE_DEPTH + 2)); + + assert_eq!( + SignatureParser::one(&signature), + Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs new file mode 100644 index 000000000..076d9ecc6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs @@ -0,0 +1,24 @@ +//! Shared raw Notify message fixture + +use std::collections::HashMap; + +use zbus::zvariant::OwnedValue; +use zbus::Message; + +pub(super) fn notify_message( + app_name: &str, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, +) -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&( + app_name, 0_u32, app_icon, summary, body, actions, hints, 0_i32, + )) + .expect("Notify message") +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs new file mode 100644 index 000000000..ce21b7dd9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs @@ -0,0 +1,116 @@ +use zbus::zvariant::Endian; + +use super::super::cursor::Cursor; +use super::super::limits::{PreflightError, StringBudget, MAX_SIGNATURE_DEPTH}; +use super::super::signature::SignatureType; + +#[test] +fn primitive_value_skips_consume_their_complete_wire_payloads() { + let cases: &[(SignatureType, &[u8])] = &[ + (SignatureType::Basic(b'y'), &[7]), + (SignatureType::Basic(b'n'), &[1, 0]), + (SignatureType::Basic(b'q'), &[1, 0]), + (SignatureType::Basic(b'x'), &[0; 8]), + (SignatureType::Basic(b't'), &[0; 8]), + (SignatureType::Basic(b'd'), &[0; 8]), + (SignatureType::Basic(b'g'), &[1, b's', 0]), + ]; + + for (value_type, bytes) in cases { + let mut cursor = Cursor::new(bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + + assert_eq!(cursor.skip_value(value_type, &mut budget, false, 0), Ok(())); + assert!(cursor.is_finished(), "wire type was not fully consumed"); + } +} + +#[test] +fn value_skip_accepts_exact_depth_and_rejects_the_next_level() { + let value_type = SignatureType::Basic(b'y'); + let mut accepted = Cursor::new(&[1], 0, Endian::Little); + let mut accepted_budget = StringBudget::default(); + assert_eq!( + accepted.skip_value( + &value_type, + &mut accepted_budget, + false, + MAX_SIGNATURE_DEPTH + ), + Ok(()) + ); + + let mut rejected = Cursor::new(&[1], 0, Endian::Little); + let mut rejected_budget = StringBudget::default(); + assert_eq!( + rejected.skip_value( + &value_type, + &mut rejected_budget, + false, + MAX_SIGNATURE_DEPTH + 1 + ), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_variants_enforce_the_value_depth_limit() { + let mut bytes = Vec::new(); + for _ in 0..=MAX_SIGNATURE_DEPTH { + bytes.extend_from_slice(&[1, b'v', 0]); + } + bytes.extend_from_slice(&[1, b'y', 0, 7]); + + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&SignatureType::Variant, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_arrays_enforce_the_value_depth_limit() { + let mut value_type = SignatureType::Basic(b'y'); + let mut bytes = vec![7_u8]; + // The innermost byte array is consumed in place, so one extra array reaches the guard + for _ in 0..=MAX_SIGNATURE_DEPTH + 1 { + let mut container = u32::try_from(bytes.len()) + .expect("nested array length") + .to_le_bytes() + .to_vec(); + container.extend(bytes); + bytes = container; + value_type = SignatureType::Array(Box::new(value_type)); + } + + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&value_type, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_structures_enforce_the_value_depth_limit() { + let mut value_type = SignatureType::Basic(b'y'); + for _ in 0..=MAX_SIGNATURE_DEPTH { + value_type = SignatureType::Structure(vec![value_type]); + } + + let mut cursor = Cursor::new(&[7], 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&value_type, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs new file mode 100644 index 000000000..cc33afdeb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs @@ -0,0 +1,97 @@ +//! Validation flow for the fixed Notify D-Bus body shape + +use zbus::Message; + +use super::cursor::Cursor; +use super::limits::{PreflightError, StringBudget}; +use super::signature::SignatureParser; +use crate::daemon::notifications::limits::{ + MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, + MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, MAX_SUMMARY_BYTES, +}; + +const NOTIFY_SIGNATURE: &str = "susssasa{sv}i"; + +pub(in crate::daemon::notifications::server) fn preflight_notify( + message: &Message, +) -> Result<(), PreflightError> { + let body = message.body(); + // The wire shape is checked before the typed interface creates owned containers + if body + .signature() + .as_ref() + .map(ToString::to_string) + .as_deref() + != Some(NOTIFY_SIGNATURE) + { + return Err(PreflightError::Malformed("Notify has an invalid signature")); + } + + let data = body.data(); + let context = data.context(); + let mut cursor = Cursor::new(data.bytes(), context.position(), context.endian()); + let mut budget = StringBudget::default(); + + // Fields follow the exact org.freedesktop.Notifications Notify order + cursor.read_string(MAX_APP_NAME_BYTES, &mut budget)?; + cursor.read_fixed(4, 4)?; + cursor.read_string(MAX_APP_ICON_BYTES, &mut budget)?; + cursor.read_string(MAX_SUMMARY_BYTES, &mut budget)?; + cursor.read_string(MAX_BODY_BYTES, &mut budget)?; + preflight_actions(&mut cursor, &mut budget)?; + preflight_hints(&mut cursor, &mut budget)?; + cursor.read_fixed(4, 4)?; + if !cursor.is_finished() { + return Err(PreflightError::Malformed("Notify body has trailing data")); + } + Ok(()) +} + +fn preflight_actions( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(4)?; + let mut count = 0_usize; + while cursor.position() < end { + // Actions alternate key and label, with eight complete pairs allowed + if count >= MAX_ACTIONS * 2 { + return Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements", + )); + } + let limit = if count.is_multiple_of(2) { + MAX_ACTION_KEY_BYTES + } else { + MAX_ACTION_LABEL_BYTES + }; + cursor.read_string(limit, budget)?; + count += 1; + } + cursor.finish_array(end) +} + +fn preflight_hints( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(8)?; + let mut count = 0_usize; + while cursor.position() < end { + // Entry count is bounded before zbus can construct the owned map + if count >= MAX_HINT_ENTRIES { + return Err(PreflightError::LimitsExceeded( + "Notify hint dictionary has too many entries", + )); + } + cursor.align(8)?; + let key = cursor.read_string(MAX_HINT_KEY_BYTES, budget)?; + // Only standard image aliases receive the larger byte-array allowance + let image_hint = matches!(key, b"image-data" | b"image_data" | b"icon_data"); + let signature = cursor.read_signature()?; + let value_type = SignatureParser::one(signature)?; + cursor.skip_value(&value_type, budget, image_hint, 0)?; + count += 1; + } + cursor.finish_array(end) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs new file mode 100644 index 000000000..cd61fc6fb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs @@ -0,0 +1,87 @@ +//! Recursive variant-value traversal without owned payload construction + +use crate::daemon::notifications::limits::MAX_HINT_STRING_BYTES; + +use super::cursor::Cursor; +use super::limits::{ + PreflightError, StringBudget, MAX_IMAGE_BYTES, MAX_NESTED_CONTAINER_ELEMENTS, + MAX_NON_IMAGE_ARRAY_BYTES, MAX_SIGNATURE_DEPTH, +}; +use super::signature::{SignatureParser, SignatureType}; + +impl Cursor<'_> { + pub(super) fn skip_value( + &mut self, + value_type: &SignatureType, + budget: &mut StringBudget, + image_hint: bool, + depth: usize, + ) -> Result<(), PreflightError> { + // Recursive variants and containers share one small depth limit + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep", + )); + } + match value_type { + SignatureType::Basic(kind) => match kind { + b'y' => self.advance(1), + b'n' | b'q' => self.read_fixed(2, 2), + b'b' | b'i' | b'u' | b'h' => self.read_fixed(4, 4), + b'x' | b't' | b'd' => self.read_fixed(8, 8), + b's' | b'o' => self.read_string(MAX_HINT_STRING_BYTES, budget).map(drop), + b'g' => { + let signature = self.read_signature()?; + budget.add(signature.len()) + } + _ => Err(PreflightError::Malformed( + "Notify variant has an unsupported basic type", + )), + }, + SignatureType::Variant => { + let signature = self.read_signature()?; + let nested = SignatureParser::one(signature)?; + self.skip_value(&nested, budget, image_hint, depth + 1) + } + SignatureType::Array(element) => { + let end = self.begin_array(element.alignment())?; + if matches!(element.as_ref(), SignatureType::Basic(b'y')) { + // Raw bytes are skipped in place without constructing a vector + let length = self.remaining_to(end)?; + let limit = if image_hint { + MAX_IMAGE_BYTES + } else { + MAX_NON_IMAGE_ARRAY_BYTES + }; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance", + )); + } + self.finish_at(end); + return Ok(()); + } + + let mut count = 0_usize; + while self.position() < end { + // Non-byte arrays receive an element cap as well as the wire-byte cap + if count >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify nested array has too many elements", + )); + } + self.skip_value(element, budget, image_hint, depth + 1)?; + count += 1; + } + self.finish_array(end) + } + SignatureType::Structure(fields) | SignatureType::DictEntry(fields) => { + self.align(8)?; + for field in fields { + self.skip_value(field, budget, image_hint, depth + 1)?; + } + Ok(()) + } + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs deleted file mode 100644 index 309e6d385..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/preflight.rs +++ /dev/null @@ -1,459 +0,0 @@ -//! Allocation-bounded structural preflight for the fixed Notify D-Bus body - -use zbus::zvariant::Endian; -use zbus::Message; - -use crate::daemon::notifications::limits::{ - MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, - MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, - MAX_HINT_STRING_BYTES, MAX_SUMMARY_BYTES, -}; - -const NOTIFY_SIGNATURE: &str = "susssasa{sv}i"; -const MAX_IMAGE_BYTES: usize = 256 * 1024; -const MAX_NON_IMAGE_ARRAY_BYTES: usize = 16 * 1024; -const MAX_NON_IMAGE_STRING_BYTES: usize = 64 * 1024; -const MAX_NESTED_CONTAINER_ELEMENTS: usize = 64; -const MAX_SIGNATURE_DEPTH: usize = 16; - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(super) enum PreflightError { - LimitsExceeded(&'static str), - Malformed(&'static str), -} - -pub(super) fn preflight_notify(message: &Message) -> Result<(), PreflightError> { - let body = message.body(); - // The fixed wire shape is checked before the typed interface creates owned containers - if body - .signature() - .as_ref() - .map(ToString::to_string) - .as_deref() - != Some(NOTIFY_SIGNATURE) - { - return Err(PreflightError::Malformed("Notify has an invalid signature")); - } - let data = body.data(); - let context = data.context(); - let mut cursor = Cursor::new(data.bytes(), context.position(), context.endian()); - let mut budget = StringBudget::default(); - - // Fields are consumed in the exact org.freedesktop.Notifications Notify order - cursor.read_string(MAX_APP_NAME_BYTES, &mut budget)?; - cursor.read_fixed(4, 4)?; - cursor.read_string(MAX_APP_ICON_BYTES, &mut budget)?; - cursor.read_string(MAX_SUMMARY_BYTES, &mut budget)?; - cursor.read_string(MAX_BODY_BYTES, &mut budget)?; - preflight_actions(&mut cursor, &mut budget)?; - preflight_hints(&mut cursor, &mut budget)?; - cursor.read_fixed(4, 4)?; - if cursor.offset != cursor.bytes.len() { - return Err(PreflightError::Malformed("Notify body has trailing data")); - } - Ok(()) -} - -fn preflight_actions( - cursor: &mut Cursor<'_>, - budget: &mut StringBudget, -) -> Result<(), PreflightError> { - let end = cursor.begin_array(4)?; - let mut count = 0_usize; - while cursor.offset < end { - // Actions alternate key and label, with eight complete pairs allowed - if count >= MAX_ACTIONS * 2 { - return Err(PreflightError::LimitsExceeded( - "Notify action array has too many elements", - )); - } - let limit = if count.is_multiple_of(2) { - MAX_ACTION_KEY_BYTES - } else { - MAX_ACTION_LABEL_BYTES - }; - cursor.read_string(limit, budget)?; - count += 1; - } - cursor.finish_array(end)?; - Ok(()) -} - -fn preflight_hints( - cursor: &mut Cursor<'_>, - budget: &mut StringBudget, -) -> Result<(), PreflightError> { - let end = cursor.begin_array(8)?; - let mut count = 0_usize; - while cursor.offset < end { - // Entry count is bounded before zbus can construct the owned map - if count >= MAX_HINT_ENTRIES { - return Err(PreflightError::LimitsExceeded( - "Notify hint dictionary has too many entries", - )); - } - cursor.align(8)?; - let key = cursor.read_string(MAX_HINT_KEY_BYTES, budget)?; - // Only standard image aliases receive the larger byte-array allowance - let image_hint = matches!(key, b"image-data" | b"image_data" | b"icon_data"); - let signature = cursor.read_signature()?; - let value_type = SignatureParser::one(signature)?; - cursor.skip_value(&value_type, budget, image_hint, 0)?; - count += 1; - } - cursor.finish_array(end)?; - Ok(()) -} - -#[derive(Default)] -struct StringBudget { - bytes: usize, -} - -impl StringBudget { - fn add(&mut self, bytes: usize) -> Result<(), PreflightError> { - // One cumulative budget prevents many individually valid strings from amplifying memory - self.bytes = self - .bytes - .checked_add(bytes) - .ok_or(PreflightError::LimitsExceeded( - "Notify string budget overflowed", - ))?; - if self.bytes > MAX_NON_IMAGE_STRING_BYTES { - return Err(PreflightError::LimitsExceeded( - "Notify contains too much non-image string data", - )); - } - Ok(()) - } -} - -struct Cursor<'a> { - bytes: &'a [u8], - absolute_start: usize, - endian: Endian, - offset: usize, -} - -impl<'a> Cursor<'a> { - const fn new(bytes: &'a [u8], absolute_start: usize, endian: Endian) -> Self { - Self { - bytes, - absolute_start, - endian, - offset: 0, - } - } - - fn align(&mut self, alignment: usize) -> Result<(), PreflightError> { - // D-Bus alignment is relative to the whole message rather than this body slice - let absolute = self - .absolute_start - .checked_add(self.offset) - .ok_or(PreflightError::Malformed("Notify alignment overflowed"))?; - let padding = (alignment - absolute % alignment) % alignment; - self.advance(padding) - } - - fn advance(&mut self, bytes: usize) -> Result<(), PreflightError> { - // Checked offsets turn malformed lengths into errors instead of wraparound - let end = self - .offset - .checked_add(bytes) - .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; - if end > self.bytes.len() { - return Err(PreflightError::Malformed("Notify body is truncated")); - } - self.offset = end; - Ok(()) - } - - fn read_fixed(&mut self, alignment: usize, bytes: usize) -> Result<(), PreflightError> { - self.align(alignment)?; - self.advance(bytes) - } - - fn read_u8(&mut self) -> Result { - let value = *self - .bytes - .get(self.offset) - .ok_or(PreflightError::Malformed("Notify body is truncated"))?; - self.offset += 1; - Ok(value) - } - - fn read_u32(&mut self) -> Result { - self.align(4)?; - let end = self - .offset - .checked_add(4) - .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; - let bytes = self - .bytes - .get(self.offset..end) - .ok_or(PreflightError::Malformed("Notify body is truncated"))?; - self.offset = end; - Ok(self.endian.read_u32(bytes)) - } - - fn read_string( - &mut self, - limit: usize, - budget: &mut StringBudget, - ) -> Result<&'a [u8], PreflightError> { - // Length is rejected before a slice is exposed to later parsing - let length = usize::try_from(self.read_u32()?) - .map_err(|_| PreflightError::LimitsExceeded("Notify string is too large"))?; - if length > limit { - return Err(PreflightError::LimitsExceeded( - "Notify string exceeds its field limit", - )); - } - budget.add(length)?; - let end = self - .offset - .checked_add(length) - .ok_or(PreflightError::Malformed("Notify string offset overflowed"))?; - let value = self - .bytes - .get(self.offset..end) - .ok_or(PreflightError::Malformed("Notify string is truncated"))?; - self.offset = end; - if self.read_u8()? != 0 { - return Err(PreflightError::Malformed( - "Notify string is missing its terminator", - )); - } - Ok(value) - } - - fn read_signature(&mut self) -> Result<&'a [u8], PreflightError> { - let length = usize::from(self.read_u8()?); - let end = self - .offset - .checked_add(length) - .ok_or(PreflightError::Malformed( - "Notify signature offset overflowed", - ))?; - let signature = self - .bytes - .get(self.offset..end) - .ok_or(PreflightError::Malformed("Notify signature is truncated"))?; - self.offset = end; - if self.read_u8()? != 0 { - return Err(PreflightError::Malformed( - "Notify signature is missing its terminator", - )); - } - Ok(signature) - } - - fn begin_array(&mut self, element_alignment: usize) -> Result { - // Array byte lengths are validated before any element walk begins - let length = usize::try_from(self.read_u32()?) - .map_err(|_| PreflightError::LimitsExceeded("Notify array is too large"))?; - self.align(element_alignment)?; - let end = self - .offset - .checked_add(length) - .ok_or(PreflightError::Malformed("Notify array offset overflowed"))?; - if end > self.bytes.len() { - return Err(PreflightError::Malformed("Notify array is truncated")); - } - Ok(end) - } - - fn finish_array(&self, end: usize) -> Result<(), PreflightError> { - if self.offset == end { - Ok(()) - } else { - Err(PreflightError::Malformed( - "Notify array elements do not match its byte length", - )) - } - } - - fn skip_value( - &mut self, - value_type: &SignatureType, - budget: &mut StringBudget, - image_hint: bool, - depth: usize, - ) -> Result<(), PreflightError> { - // Recursive variants and containers share one small depth limit - if depth > MAX_SIGNATURE_DEPTH { - return Err(PreflightError::LimitsExceeded( - "Notify variant nesting is too deep", - )); - } - match value_type { - SignatureType::Basic(kind) => match kind { - b'y' => self.advance(1), - b'n' | b'q' => self.read_fixed(2, 2), - b'b' | b'i' | b'u' | b'h' => self.read_fixed(4, 4), - b'x' | b't' | b'd' => self.read_fixed(8, 8), - b's' | b'o' => self.read_string(MAX_HINT_STRING_BYTES, budget).map(drop), - b'g' => { - let signature = self.read_signature()?; - budget.add(signature.len()) - } - _ => Err(PreflightError::Malformed( - "Notify variant has an unsupported basic type", - )), - }, - SignatureType::Variant => { - let signature = self.read_signature()?; - let nested = SignatureParser::one(signature)?; - self.skip_value(&nested, budget, image_hint, depth + 1) - } - SignatureType::Array(element) => { - let end = self.begin_array(element.alignment())?; - if matches!(element.as_ref(), SignatureType::Basic(b'y')) { - // Raw bytes are skipped in place without constructing an intermediate vector - let length = end - self.offset; - let limit = if image_hint { - MAX_IMAGE_BYTES - } else { - MAX_NON_IMAGE_ARRAY_BYTES - }; - if length > limit { - return Err(PreflightError::LimitsExceeded( - "Notify byte array exceeds its allowance", - )); - } - self.offset = end; - return Ok(()); - } - let mut count = 0_usize; - while self.offset < end { - // Non-byte arrays receive an element cap as well as the wire-byte cap - if count >= MAX_NESTED_CONTAINER_ELEMENTS { - return Err(PreflightError::LimitsExceeded( - "Notify nested array has too many elements", - )); - } - self.skip_value(element, budget, image_hint, depth + 1)?; - count += 1; - } - self.finish_array(end) - } - SignatureType::Structure(fields) | SignatureType::DictEntry(fields) => { - self.align(8)?; - for field in fields { - self.skip_value(field, budget, image_hint, depth + 1)?; - } - Ok(()) - } - } - } -} - -#[derive(Debug)] -enum SignatureType { - Basic(u8), - Variant, - Array(Box), - Structure(Vec), - DictEntry(Vec), -} - -impl SignatureType { - const fn alignment(&self) -> usize { - match self { - Self::Basic(b'y' | b'g') | Self::Variant => 1, - Self::Basic(b'n' | b'q') => 2, - Self::Basic(b'b' | b'i' | b'u' | b'h' | b's' | b'o') | Self::Array(_) => 4, - Self::Basic(b'x' | b't' | b'd') | Self::Structure(_) | Self::DictEntry(_) => 8, - Self::Basic(_) => 1, - } - } -} - -struct SignatureParser<'a> { - bytes: &'a [u8], - offset: usize, -} - -impl<'a> SignatureParser<'a> { - fn one(bytes: &'a [u8]) -> Result { - // A variant signature must describe exactly one complete value - let mut parser = Self { bytes, offset: 0 }; - let value_type = parser.parse_type(0)?; - if parser.offset != bytes.len() { - return Err(PreflightError::Malformed( - "Notify variant signature has trailing types", - )); - } - Ok(value_type) - } - - fn parse_type(&mut self, depth: usize) -> Result { - // Parsing the tiny signature first makes the later byte walk deterministic - if depth > MAX_SIGNATURE_DEPTH { - return Err(PreflightError::LimitsExceeded( - "Notify variant signature is too deep", - )); - } - let kind = *self - .bytes - .get(self.offset) - .ok_or(PreflightError::Malformed( - "Notify variant signature is empty", - ))?; - self.offset += 1; - match kind { - b'y' | b'b' | b'n' | b'q' | b'i' | b'u' | b'x' | b't' | b'd' | b's' | b'o' | b'g' - | b'h' => Ok(SignatureType::Basic(kind)), - b'v' => Ok(SignatureType::Variant), - b'a' => Ok(SignatureType::Array(Box::new(self.parse_type(depth + 1)?))), - b'(' => self.parse_fields(b')', depth).map(SignatureType::Structure), - b'{' => self.parse_fields(b'}', depth).and_then(|fields| { - if fields.len() == 2 { - Ok(SignatureType::DictEntry(fields)) - } else { - Err(PreflightError::Malformed( - "Notify dictionary entry has an invalid signature", - )) - } - }), - _ => Err(PreflightError::Malformed( - "Notify variant signature contains an invalid type", - )), - } - } - - fn parse_fields( - &mut self, - terminator: u8, - depth: usize, - ) -> Result, PreflightError> { - let mut fields = Vec::new(); - loop { - // Container signatures are bounded independently from data-array element counts - let Some(kind) = self.bytes.get(self.offset).copied() else { - return Err(PreflightError::Malformed( - "Notify container signature is unterminated", - )); - }; - if kind == terminator { - self.offset += 1; - if fields.is_empty() { - return Err(PreflightError::Malformed( - "Notify container signature is empty", - )); - } - return Ok(fields); - } - if fields.len() >= MAX_NESTED_CONTAINER_ELEMENTS { - return Err(PreflightError::LimitsExceeded( - "Notify container signature has too many fields", - )); - } - fields.push(self.parse_type(depth + 1)?); - } - } -} - -#[cfg(test)] -#[path = "tests/preflight.rs"] -mod tests; From 959b2e09a2834e2ad0dc291f4af00d3481936c37 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 21:09:46 -0500 Subject: [PATCH 087/275] refactor(store): separate notification state domains Summary: separate notification state domains. Scope: store. --- crates/unixnotis-daemon/src/store/dnd/mod.rs | 12 + .../store/{state.rs => dnd/persistence.rs} | 20 +- .../src/store/{dnd.rs => dnd/state.rs} | 2 +- .../src/store/dnd/tests/mod.rs | 3 + .../dnd.rs => dnd/tests/persistence.rs} | 147 +-------- .../src/store/dnd/tests/state.rs | 140 ++++++++ .../src/store/dnd/tests/support.rs | 5 + .../{inhibitor_api.rs => inhibitors/api.rs} | 6 +- .../src/store/inhibitors/mod.rs | 9 + .../store/{inhibit.rs => inhibitors/model.rs} | 2 +- .../inhibit.rs => inhibitors/tests/api.rs} | 35 +- .../src/store/inhibitors/tests/mod.rs | 2 + .../src/store/inhibitors/tests/model.rs | 31 ++ crates/unixnotis-daemon/src/store/mod.rs | 23 +- .../src/store/{types.rs => model.rs} | 4 +- .../src/store/{ => notifications}/history.rs | 24 +- .../insertion.rs} | 93 +----- .../src/store/notifications/lifecycle.rs | 96 ++++++ .../src/store/notifications/mod.rs | 12 + .../ownership.rs} | 2 +- .../src/store/{ => notifications}/rules.rs | 2 +- .../src/store/notifications/tests/history.rs | 68 ++++ .../store/notifications/tests/insertion.rs | 93 ++++++ .../store/notifications/tests/lifecycle.rs | 92 ++++++ .../src/store/notifications/tests/mod.rs | 6 + .../{ => notifications}/tests/ownership.rs | 49 ++- .../store/{ => notifications}/tests/rules.rs | 2 +- .../src/store/notifications/tests/support.rs | 7 + .../src/store/{core.rs => runtime.rs} | 4 +- .../{tests/support.rs => test_support.rs} | 29 +- .../src/store/tests/lifecycle.rs | 306 ------------------ .../unixnotis-daemon/src/store/tests/mod.rs | 22 +- .../unixnotis-daemon/src/store/tests/model.rs | 20 ++ .../src/store/tests/{reply.rs => runtime.rs} | 88 ++--- 34 files changed, 743 insertions(+), 713 deletions(-) create mode 100644 crates/unixnotis-daemon/src/store/dnd/mod.rs rename crates/unixnotis-daemon/src/store/{state.rs => dnd/persistence.rs} (74%) rename crates/unixnotis-daemon/src/store/{dnd.rs => dnd/state.rs} (98%) create mode 100644 crates/unixnotis-daemon/src/store/dnd/tests/mod.rs rename crates/unixnotis-daemon/src/store/{tests/dnd.rs => dnd/tests/persistence.rs} (57%) create mode 100644 crates/unixnotis-daemon/src/store/dnd/tests/state.rs create mode 100644 crates/unixnotis-daemon/src/store/dnd/tests/support.rs rename crates/unixnotis-daemon/src/store/{inhibitor_api.rs => inhibitors/api.rs} (94%) create mode 100644 crates/unixnotis-daemon/src/store/inhibitors/mod.rs rename crates/unixnotis-daemon/src/store/{inhibit.rs => inhibitors/model.rs} (96%) rename crates/unixnotis-daemon/src/store/{tests/inhibit.rs => inhibitors/tests/api.rs} (72%) create mode 100644 crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs rename crates/unixnotis-daemon/src/store/{types.rs => model.rs} (96%) rename crates/unixnotis-daemon/src/store/{ => notifications}/history.rs (79%) rename crates/unixnotis-daemon/src/store/{lifecycle.rs => notifications/insertion.rs} (65%) create mode 100644 crates/unixnotis-daemon/src/store/notifications/lifecycle.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/mod.rs rename crates/unixnotis-daemon/src/store/{identity.rs => notifications/ownership.rs} (98%) rename crates/unixnotis-daemon/src/store/{ => notifications}/rules.rs (98%) create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/history.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/mod.rs rename crates/unixnotis-daemon/src/store/{ => notifications}/tests/ownership.rs (83%) rename crates/unixnotis-daemon/src/store/{ => notifications}/tests/rules.rs (99%) create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/support.rs rename crates/unixnotis-daemon/src/store/{core.rs => runtime.rs} (97%) rename crates/unixnotis-daemon/src/store/{tests/support.rs => test_support.rs} (76%) delete mode 100644 crates/unixnotis-daemon/src/store/tests/lifecycle.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/model.rs rename crates/unixnotis-daemon/src/store/tests/{reply.rs => runtime.rs} (61%) diff --git a/crates/unixnotis-daemon/src/store/dnd/mod.rs b/crates/unixnotis-daemon/src/store/dnd/mod.rs new file mode 100644 index 000000000..c217b12ac --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/mod.rs @@ -0,0 +1,12 @@ +//! Do-not-disturb state changes and persistence + +mod persistence; +mod state; + +pub(in crate::store) use persistence::{DndStateStore, DND_STATE_VERSION}; + +#[cfg(test)] +pub(in crate::store) use persistence::{PersistedDndState, DND_STATE_FILE}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/state.rs b/crates/unixnotis-daemon/src/store/dnd/persistence.rs similarity index 74% rename from crates/unixnotis-daemon/src/store/state.rs rename to crates/unixnotis-daemon/src/store/dnd/persistence.rs index c341e42e8..854eb61b1 100644 --- a/crates/unixnotis-daemon/src/store/state.rs +++ b/crates/unixnotis-daemon/src/store/dnd/persistence.rs @@ -11,16 +11,16 @@ use serde::{Deserialize, Serialize}; use unixnotis_core::filesystem::write_file_atomic; use unixnotis_core::util; -pub(super) const DND_STATE_VERSION: u32 = 1; -pub(super) const DND_STATE_FILE: &str = "state.json"; +pub(in crate::store) const DND_STATE_VERSION: u32 = 1; +pub(in crate::store) const DND_STATE_FILE: &str = "state.json"; #[derive(Debug, Serialize, Deserialize)] -pub(super) struct PersistedDndState { - pub(super) version: u32, - pub(super) dnd_enabled: bool, +pub(in crate::store) struct PersistedDndState { + pub(in crate::store) version: u32, + pub(in crate::store) dnd_enabled: bool, #[serde(default)] - pub(super) expires_at: Option, - pub(super) updated_at: Option, + pub(in crate::store) expires_at: Option, + pub(in crate::store) updated_at: Option, } #[derive(Debug, Clone)] @@ -29,17 +29,17 @@ pub struct DndStateStore { } impl DndStateStore { - pub(super) fn new() -> Option { + pub(in crate::store) fn new() -> Option { let state_dir = util::resolve_state_dir()?; Some(Self::from_state_dir(state_dir)) } - pub(super) fn from_state_dir(state_dir: PathBuf) -> Self { + pub(in crate::store) fn from_state_dir(state_dir: PathBuf) -> Self { let path = state_dir.join("unixnotis").join(DND_STATE_FILE); Self { path } } - pub(super) fn load(&self) -> io::Result> { + pub(in crate::store) fn load(&self) -> io::Result> { let contents = match fs::read_to_string(&self.path) { Ok(contents) => contents, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), diff --git a/crates/unixnotis-daemon/src/store/dnd.rs b/crates/unixnotis-daemon/src/store/dnd/state.rs similarity index 98% rename from crates/unixnotis-daemon/src/store/dnd.rs rename to crates/unixnotis-daemon/src/store/dnd/state.rs index a631e75cb..08ee9d5f4 100644 --- a/crates/unixnotis-daemon/src/store/dnd.rs +++ b/crates/unixnotis-daemon/src/store/dnd/state.rs @@ -1,4 +1,4 @@ -use super::{DndWrite, NotificationStore}; +use crate::store::{DndWrite, NotificationStore}; impl NotificationStore { pub const fn dnd_enabled(&self) -> bool { diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs b/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs new file mode 100644 index 000000000..ce87e3631 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs @@ -0,0 +1,3 @@ +mod persistence; +mod state; +mod support; diff --git a/crates/unixnotis-daemon/src/store/tests/dnd.rs b/crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs similarity index 57% rename from crates/unixnotis-daemon/src/store/tests/dnd.rs rename to crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs index c441eca46..e7a552200 100644 --- a/crates/unixnotis-daemon/src/store/tests/dnd.rs +++ b/crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs @@ -1,4 +1,4 @@ -use super::*; +use super::support::*; #[test] fn dnd_state_overrides_default() { @@ -31,7 +31,7 @@ fn dnd_state_invalid_payload_falls_back_to_default() { #[test] fn dnd_state_store_load_returns_none_when_file_is_missing() { let state_dir = make_temp_state_dir("dnd-missing-file"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); // A first run has no state file yet, which should not be treated as corruption let loaded = state_store @@ -47,7 +47,7 @@ fn dnd_state_store_load_reports_non_missing_filesystem_errors() { let state_dir = make_temp_state_dir("dnd-path-is-directory"); let path = state_dir.join("unixnotis").join(DND_STATE_FILE); std::fs::create_dir_all(&path).expect("create directory at state file path"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); // Wrong path shape is a real filesystem problem and should not look like first run let err = state_store @@ -121,7 +121,7 @@ fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { let outside = state_dir.join("outside.json"); std::fs::write(&outside, "keep").expect("write outside state"); symlink(&outside, state_parent.join(DND_STATE_FILE)).expect("create state symlink"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); let error = state_store .persist(true, None) @@ -188,142 +188,3 @@ fn expired_timed_dnd_is_disabled_during_startup_and_cleared_on_disk() { assert_eq!(persisted.expires_at, None); cleanup_temp_dir(&state_dir); } - -#[test] -fn plain_dnd_enable_replaces_a_timed_deadline_with_indefinite_state() { - let state_dir = make_temp_state_dir("dnd-timed-to-indefinite"); - let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - let expires_at = chrono::Utc::now().timestamp() + 600; - - let timed = store.set_dnd_until(expires_at); - assert!(timed.changed); - assert_eq!(store.dnd_expires_at(), Some(expires_at)); - - let indefinite = store.set_dnd(true); - assert!(indefinite.changed); - assert!(store.dnd_enabled()); - assert_eq!(store.dnd_expires_at(), None); - cleanup_temp_dir(&state_dir); -} - -#[test] -fn expiration_mutation_requires_the_current_due_deadline() { - let state_dir = make_temp_state_dir("dnd-current-expiration"); - let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - let expires_at = 500; - store.set_dnd_until(expires_at); - - assert!( - !store - .expire_dnd_if_current(expires_at + 1, expires_at) - .changed - ); - assert!( - !store - .expire_dnd_if_current(expires_at, expires_at - 1) - .changed - ); - assert!(store.dnd_enabled()); - - let expired = store.expire_dnd_if_current(expires_at, expires_at); - assert!(expired.changed); - assert!(!store.dnd_enabled()); - assert_eq!(store.dnd_expires_at(), None); - cleanup_temp_dir(&state_dir); -} - -#[test] -fn dnd_toggle_flips_state_in_one_store_mutation() { - let state_dir = make_temp_state_dir("dnd-toggle"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let first = store.toggle_dnd(); - assert!(first.changed); - assert!(!first.previous); - assert!(first.current); - assert!(store.dnd_enabled()); - - let second = store.toggle_dnd(); - assert!(second.changed); - assert!(second.previous); - assert!(!second.current); - assert!(!store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn stale_dnd_rollback_cannot_overwrite_newer_write() { - let state_dir = make_temp_state_dir("dnd-stale-rollback"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let write_a = store.set_dnd(true); - assert!(store.dnd_enabled()); - - let write_b = store.set_dnd(false); - assert!(write_b.changed); - assert!(!store.dnd_enabled()); - - // Simulate late failure from write_a and verify guarded rollback is rejected - let rolled_back = store.rollback_dnd_write_if_current(&write_a); - assert!(!rolled_back); - assert!(!store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn stale_dnd_rollback_cannot_overwrite_when_current_value_matches_old_write() { - let state_dir = make_temp_state_dir("dnd-stale-current-matches"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let write_a = store.set_dnd(true); - let _write_b = store.set_dnd(false); - let _write_c = store.set_dnd(true); - - // Revision must win even when the current value happens to match the stale write - assert!(!store.rollback_dnd_write_if_current(&write_a)); - assert!(store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn dnd_rollback_restores_state_when_write_is_still_current() { - let state_dir = make_temp_state_dir("dnd-rollback"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let write = store.set_dnd(true); - assert!(store.dnd_enabled()); - - // Simulate persistence failure with no newer writes in between - let rolled_back = store.rollback_dnd_write_if_current(&write); - assert!(rolled_back); - assert!(!store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn failed_timed_write_rollback_restores_the_previous_deadline() { - let state_dir = make_temp_state_dir("dnd-timed-rollback"); - let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - let original = chrono::Utc::now().timestamp() + 600; - let replacement = original + 600; - store.set_dnd_until(original); - - let write = store.set_dnd_until(replacement); - assert!(store.rollback_dnd_write_if_current(&write)); - - assert!(store.dnd_enabled()); - assert_eq!(store.dnd_expires_at(), Some(original)); - cleanup_temp_dir(&state_dir); -} diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/state.rs b/crates/unixnotis-daemon/src/store/dnd/tests/state.rs new file mode 100644 index 000000000..b7448ee0b --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/state.rs @@ -0,0 +1,140 @@ +use super::support::*; + +#[test] +fn plain_dnd_enable_replaces_a_timed_deadline_with_indefinite_state() { + let state_dir = make_temp_state_dir("dnd-timed-to-indefinite"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = chrono::Utc::now().timestamp() + 600; + + let timed = store.set_dnd_until(expires_at); + assert!(timed.changed); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + + let indefinite = store.set_dnd(true); + assert!(indefinite.changed); + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn expiration_mutation_requires_the_current_due_deadline() { + let state_dir = make_temp_state_dir("dnd-current-expiration"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = 500; + store.set_dnd_until(expires_at); + + assert!( + !store + .expire_dnd_if_current(expires_at + 1, expires_at) + .changed + ); + assert!( + !store + .expire_dnd_if_current(expires_at, expires_at - 1) + .changed + ); + assert!(store.dnd_enabled()); + + let expired = store.expire_dnd_if_current(expires_at, expires_at); + assert!(expired.changed); + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn dnd_toggle_flips_state_in_one_store_mutation() { + let state_dir = make_temp_state_dir("dnd-toggle"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let first = store.toggle_dnd(); + assert!(first.changed); + assert!(!first.previous); + assert!(first.current); + assert!(store.dnd_enabled()); + + let second = store.toggle_dnd(); + assert!(second.changed); + assert!(second.previous); + assert!(!second.current); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn stale_dnd_rollback_cannot_overwrite_newer_write() { + let state_dir = make_temp_state_dir("dnd-stale-rollback"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write_a = store.set_dnd(true); + assert!(store.dnd_enabled()); + + let write_b = store.set_dnd(false); + assert!(write_b.changed); + assert!(!store.dnd_enabled()); + + // Simulate late failure from write_a and verify guarded rollback is rejected + let rolled_back = store.rollback_dnd_write_if_current(&write_a); + assert!(!rolled_back); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn stale_dnd_rollback_cannot_overwrite_when_current_value_matches_old_write() { + let state_dir = make_temp_state_dir("dnd-stale-current-matches"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write_a = store.set_dnd(true); + let _write_b = store.set_dnd(false); + let _write_c = store.set_dnd(true); + + // Revision must win even when the current value happens to match the stale write + assert!(!store.rollback_dnd_write_if_current(&write_a)); + assert!(store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn dnd_rollback_restores_state_when_write_is_still_current() { + let state_dir = make_temp_state_dir("dnd-rollback"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write = store.set_dnd(true); + assert!(store.dnd_enabled()); + + // Simulate persistence failure with no newer writes in between + let rolled_back = store.rollback_dnd_write_if_current(&write); + assert!(rolled_back); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn failed_timed_write_rollback_restores_the_previous_deadline() { + let state_dir = make_temp_state_dir("dnd-timed-rollback"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let original = chrono::Utc::now().timestamp() + 600; + let replacement = original + 600; + store.set_dnd_until(original); + + let write = store.set_dnd_until(replacement); + assert!(store.rollback_dnd_write_if_current(&write)); + + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(original)); + cleanup_temp_dir(&state_dir); +} diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/support.rs b/crates/unixnotis-daemon/src/store/dnd/tests/support.rs new file mode 100644 index 000000000..370eca4cb --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/support.rs @@ -0,0 +1,5 @@ +pub(super) use unixnotis_core::Config; + +pub(super) use super::super::persistence::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; +pub(super) use crate::store::test_support::*; +pub(super) use crate::store::NotificationStore; diff --git a/crates/unixnotis-daemon/src/store/inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitors/api.rs similarity index 94% rename from crates/unixnotis-daemon/src/store/inhibitor_api.rs rename to crates/unixnotis-daemon/src/store/inhibitors/api.rs index f85544c2a..e02962fa8 100644 --- a/crates/unixnotis-daemon/src/store/inhibitor_api.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/api.rs @@ -1,7 +1,7 @@ use unixnotis_core::InhibitMode; -use super::inhibit::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; -use super::NotificationStore; +use super::model::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; +use crate::store::NotificationStore; impl NotificationStore { pub fn add_inhibitor(&mut self, owner: String, reason: String, scope: u32) -> u64 { @@ -72,7 +72,7 @@ impl NotificationStore { inhibitors } - pub(super) const fn should_drop_inhibited(&self) -> bool { + pub(in crate::store) const fn should_drop_inhibited(&self) -> bool { // DropAll means suppression happens before insertion and history work self.inhibited && matches!(self.config.inhibit.mode, InhibitMode::DropAll) } diff --git a/crates/unixnotis-daemon/src/store/inhibitors/mod.rs b/crates/unixnotis-daemon/src/store/inhibitors/mod.rs new file mode 100644 index 000000000..92f12f082 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/mod.rs @@ -0,0 +1,9 @@ +//! Inhibitor bookkeeping and suppression state + +mod api; +mod model; + +pub(in crate::store) use model::Inhibitor; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/inhibit.rs b/crates/unixnotis-daemon/src/store/inhibitors/model.rs similarity index 96% rename from crates/unixnotis-daemon/src/store/inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibitors/model.rs index 3bcc83b4c..07dce447e 100644 --- a/crates/unixnotis-daemon/src/store/inhibit.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/model.rs @@ -5,7 +5,7 @@ use unixnotis_core::INHIBIT_SCOPE_POPUPS; #[derive(Debug, Clone)] -pub(super) struct Inhibitor { +pub(in crate::store) struct Inhibitor { pub(super) id: u64, pub(super) owner: String, pub(super) reason: String, diff --git a/crates/unixnotis-daemon/src/store/tests/inhibit.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs similarity index 72% rename from crates/unixnotis-daemon/src/store/tests/inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs index 8d7b6e9c6..f427a1a3f 100644 --- a/crates/unixnotis-daemon/src/store/tests/inhibit.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs @@ -1,30 +1,17 @@ -use super::*; +use unixnotis_core::Config; -#[test] -fn inhibit_no_popups_suppresses_show_popup() { - let mut config = Config::default(); - config.inhibit.mode = InhibitMode::NoPopups; - let mut store = NotificationStore::new(config); - store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); - - let outcome = store.insert(make_notification("inhibited"), 0); - assert!(!outcome.dropped); - assert!(!outcome.show_popup); - assert!(!outcome.allow_sound); - assert_eq!(store.list_active().len(), 1); -} +use crate::store::NotificationStore; #[test] -fn inhibit_drop_all_skips_storage() { - let mut config = Config::default(); - config.inhibit.mode = InhibitMode::DropAll; - let mut store = NotificationStore::new(config); - store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); - - let outcome = store.insert(make_notification("inhibited"), 0); - assert!(outcome.dropped); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 0); +fn inhibitor_owner_mismatch_is_rejected() { + let mut store = NotificationStore::new(Config::default()); + let id = store.add_inhibitor("owner-a".to_string(), "reason".to_string(), 0); + + let error = store + .remove_inhibitor(id, "owner-b") + .expect_err("owner mismatch should error"); + + assert!(error.message().contains("owner-a")); } #[test] diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs new file mode 100644 index 000000000..50c3bb76c --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs @@ -0,0 +1,2 @@ +mod api; +mod model; diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs new file mode 100644 index 000000000..f28551785 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs @@ -0,0 +1,31 @@ +use unixnotis_core::{Config, InhibitMode}; + +use crate::store::test_support::make_notification; +use crate::store::NotificationStore; + +#[test] +fn inhibit_no_popups_suppresses_show_popup() { + let mut config = Config::default(); + config.inhibit.mode = InhibitMode::NoPopups; + let mut store = NotificationStore::new(config); + store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); + + let outcome = store.insert(make_notification("inhibited"), 0); + assert!(!outcome.dropped); + assert!(!outcome.show_popup); + assert!(!outcome.allow_sound); + assert_eq!(store.list_active().len(), 1); +} + +#[test] +fn inhibit_drop_all_skips_storage() { + let mut config = Config::default(); + config.inhibit.mode = InhibitMode::DropAll; + let mut store = NotificationStore::new(config); + store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); + + let outcome = store.insert(make_notification("inhibited"), 0); + assert!(outcome.dropped); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 0); +} diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index c49462191..0269cb104 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -1,23 +1,14 @@ //! Notification store with ordering, history, and suppression policies -// Focused modules keep policy and lifecycle logic isolated and easier to test -mod core; mod dnd; -mod history; -mod identity; -mod inhibit; -mod inhibitor_api; -mod lifecycle; -mod rules; -mod state; -mod types; +mod inhibitors; +mod model; +mod notifications; +mod runtime; -// Internal store primitives used by the main NotificationStore type -use history::HistoryStore; -use inhibit::Inhibitor; -use state::{DndStateStore, DND_STATE_VERSION}; -pub use types::DndWrite; -pub use types::{DismissOutcome, InsertOutcome, NotificationStore}; +pub use model::{DismissOutcome, DndWrite, InsertOutcome, NotificationStore}; +#[cfg(test)] +mod test_support; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/store/types.rs b/crates/unixnotis-daemon/src/store/model.rs similarity index 96% rename from crates/unixnotis-daemon/src/store/types.rs rename to crates/unixnotis-daemon/src/store/model.rs index 78d9d65c0..74c478365 100644 --- a/crates/unixnotis-daemon/src/store/types.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -5,7 +5,9 @@ use std::time::Instant; use indexmap::IndexMap; use unixnotis_core::{Config, Notification}; -use super::{DndStateStore, HistoryStore, Inhibitor}; +use super::dnd::DndStateStore; +use super::inhibitors::Inhibitor; +use super::notifications::HistoryStore; /// Mutable notification state owned by the daemon pub struct NotificationStore { diff --git a/crates/unixnotis-daemon/src/store/history.rs b/crates/unixnotis-daemon/src/store/notifications/history.rs similarity index 79% rename from crates/unixnotis-daemon/src/store/history.rs rename to crates/unixnotis-daemon/src/store/notifications/history.rs index 113f4eb96..2e63fe7d5 100644 --- a/crates/unixnotis-daemon/src/store/history.rs +++ b/crates/unixnotis-daemon/src/store/notifications/history.rs @@ -14,37 +14,37 @@ struct HistoryEntry { source: Weak, } -pub(super) struct HistoryStore { +pub(in crate::store) struct HistoryStore { entries: HashMap, order: VecDeque, } impl HistoryStore { - pub(super) fn new() -> Self { + pub(in crate::store) fn new() -> Self { Self { entries: HashMap::new(), order: VecDeque::new(), } } - pub(super) fn len(&self) -> usize { + pub(in crate::store) fn len(&self) -> usize { self.entries.len() } - pub(super) fn contains(&self, id: &u32) -> bool { + pub(in crate::store) fn contains(&self, id: &u32) -> bool { self.entries.contains_key(id) } - pub(super) fn get(&self, id: &u32) -> Option<&Arc> { + pub(in crate::store) fn get(&self, id: &u32) -> Option<&Arc> { self.entries.get(id).map(|entry| &entry.notification) } - pub(super) fn clear(&mut self) { + pub(in crate::store) fn clear(&mut self) { self.entries.clear(); self.order.clear(); } - pub(super) fn list_views(&self) -> Vec { + pub(in crate::store) fn list_views(&self) -> Vec { let mut views = Vec::with_capacity(self.entries.len()); for id in self.order.iter().rev() { if let Some(entry) = self.entries.get(id) { @@ -54,7 +54,7 @@ impl HistoryStore { views } - pub(super) fn remove(&mut self, id: &u32) -> Option> { + pub(in crate::store) fn remove(&mut self, id: &u32) -> Option> { let removed = self.entries.remove(id).map(|entry| entry.notification); if removed.is_some() { // Removal is infrequent compared to insertion; pay the cost here to keep order clean @@ -63,7 +63,7 @@ impl HistoryStore { removed } - pub(super) fn insert(&mut self, notification: Arc) { + pub(in crate::store) fn insert(&mut self, notification: Arc) { let id = notification.id; if self.entries.contains_key(&id) { // Avoid duplicate IDs in order when a notification is replaced @@ -79,13 +79,13 @@ impl HistoryStore { self.order.push_back(id); } - pub(super) fn set_source(&mut self, id: u32, source: Weak) { + pub(in crate::store) fn set_source(&mut self, id: u32, source: Weak) { if let Some(entry) = self.entries.get_mut(&id) { entry.source = source; } } - pub(super) fn remove_if_source( + pub(in crate::store) fn remove_if_source( &mut self, id: u32, expected: &Arc, @@ -101,7 +101,7 @@ impl HistoryStore { self.remove(&id) } - pub(super) fn evict_to_limit(&mut self, max_entries: usize) { + pub(in crate::store) fn evict_to_limit(&mut self, max_entries: usize) { if max_entries == 0 { self.clear(); return; diff --git a/crates/unixnotis-daemon/src/store/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs similarity index 65% rename from crates/unixnotis-daemon/src/store/lifecycle.rs rename to crates/unixnotis-daemon/src/store/notifications/insertion.rs index 477b5b42f..fbf1bfbde 100644 --- a/crates/unixnotis-daemon/src/store/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -1,12 +1,11 @@ use std::sync::Arc; -use std::time::Instant; use unixnotis_core::{ popup_allowed_by_state, should_archive_closed_notification, CloseReason, ControlState, Notification, Urgency, }; -use super::{DismissOutcome, InsertOutcome, NotificationStore}; +use crate::store::{InsertOutcome, NotificationStore}; // Hard ceiling for concurrently active notifications to protect panel/popups stability const ACTIVE_HARD_CAP: usize = 12; @@ -68,94 +67,6 @@ impl NotificationStore { } } - pub fn close(&mut self, id: u32, reason: CloseReason) -> Option> { - // Active removal and expiration cleanup always happen together - let removed = self.active.shift_remove(&id); - self.expirations.remove(&id); - if let Some(notification) = removed.clone() { - // Closed rows and panel rows should follow the same archive rule - self.push_history(notification, reason); - } - removed - } - - pub fn dismiss_from_panel(&mut self, id: u32) -> DismissOutcome { - // Panel dismissal can target active, history, or both - let removed_active = self.active.shift_remove(&id).is_some(); - if removed_active { - self.expirations.remove(&id); - } - - let removed_history = self.history.remove(&id).is_some(); - - DismissOutcome { - removed_active, - removed_history, - } - } - - pub fn dismiss_active_if_current(&mut self, id: u32, expected: &Arc) -> bool { - // A replacement can reuse the numeric ID but never the same Arc allocation - let is_current = self - .active - .get(&id) - .is_some_and(|active| Arc::ptr_eq(active, expected)); - if !is_current { - // Keep a replacement that arrived while an earlier action was in flight - return false; - } - - self.active.shift_remove(&id); - self.expirations.remove(&id); - true - } - - pub fn dismiss_replied_generation( - &mut self, - id: u32, - expected: &Arc, - ) -> DismissOutcome { - let removed_active = self.dismiss_active_if_current(id, expected); - let removed_history = if removed_active { - // Active cleanup already removed the exact generation - false - } else if self.active.contains_key(&id) { - // Any remaining active entry is a replacement with the same numeric id - false - } else { - // A close may archive the replied generation before reply cleanup resumes - self.history.remove_if_source(id, expected).is_some() - }; - DismissOutcome { - removed_active, - removed_history, - } - } - - pub fn drain_active_ids(&mut self) -> Vec { - // Drain in one pass so callers do not need repeated lookups - let ids = self.active.keys().rev().copied().collect(); - self.active.clear(); - self.expirations.clear(); - ids - } - - pub fn set_expiration(&mut self, id: u32, deadline: Option) { - // None removes a stale timer for resident or already-dismissed notifications - match deadline { - Some(deadline) => { - self.expirations.insert(id, deadline); - } - None => { - self.expirations.remove(&id); - } - } - } - - pub fn expiration_for(&self, id: u32) -> Option { - self.expirations.get(&id).copied() - } - fn enforce_active_limit(&mut self) -> Vec { // Config limit still applies, but active list never exceeds the global safety cap let max_active = self.config.history.max_active.min(ACTIVE_HARD_CAP); @@ -189,7 +100,7 @@ impl NotificationStore { evicted } - fn push_history(&mut self, notification: Arc, reason: CloseReason) { + pub(super) fn push_history(&mut self, notification: Arc, reason: CloseReason) { if self.config.history.max_entries == 0 { // Clear keeps memory bounded when history feature is disabled self.history.clear(); diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs new file mode 100644 index 000000000..4999b1b1c --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; +use std::time::Instant; + +use unixnotis_core::{CloseReason, Notification}; + +use crate::store::{DismissOutcome, NotificationStore}; + +impl NotificationStore { + pub fn close(&mut self, id: u32, reason: CloseReason) -> Option> { + // Active removal and expiration cleanup always happen together + let removed = self.active.shift_remove(&id); + self.expirations.remove(&id); + if let Some(notification) = removed.clone() { + // Closed rows and panel rows should follow the same archive rule + self.push_history(notification, reason); + } + removed + } + + pub fn dismiss_from_panel(&mut self, id: u32) -> DismissOutcome { + // Panel dismissal can target active, history, or both + let removed_active = self.active.shift_remove(&id).is_some(); + if removed_active { + self.expirations.remove(&id); + } + + let removed_history = self.history.remove(&id).is_some(); + + DismissOutcome { + removed_active, + removed_history, + } + } + + pub fn dismiss_active_if_current(&mut self, id: u32, expected: &Arc) -> bool { + // A replacement can reuse the numeric ID but never the same Arc allocation + let is_current = self + .active + .get(&id) + .is_some_and(|active| Arc::ptr_eq(active, expected)); + if !is_current { + // Keep a replacement that arrived while an earlier action was in flight + return false; + } + + self.active.shift_remove(&id); + self.expirations.remove(&id); + true + } + + pub fn dismiss_replied_generation( + &mut self, + id: u32, + expected: &Arc, + ) -> DismissOutcome { + let removed_active = self.dismiss_active_if_current(id, expected); + let removed_history = if removed_active { + // Active cleanup already removed the exact generation + false + } else if self.active.contains_key(&id) { + // Any remaining active entry is a replacement with the same numeric id + false + } else { + // A close may archive the replied generation before reply cleanup resumes + self.history.remove_if_source(id, expected).is_some() + }; + DismissOutcome { + removed_active, + removed_history, + } + } + + pub fn drain_active_ids(&mut self) -> Vec { + // Drain in one pass so callers do not need repeated lookups + let ids = self.active.keys().rev().copied().collect(); + self.active.clear(); + self.expirations.clear(); + ids + } + + pub fn set_expiration(&mut self, id: u32, deadline: Option) { + // None removes a stale timer for resident or already-dismissed notifications + match deadline { + Some(deadline) => { + self.expirations.insert(id, deadline); + } + None => { + self.expirations.remove(&id); + } + } + } + + pub fn expiration_for(&self, id: u32) -> Option { + self.expirations.get(&id).copied() + } +} diff --git a/crates/unixnotis-daemon/src/store/notifications/mod.rs b/crates/unixnotis-daemon/src/store/notifications/mod.rs new file mode 100644 index 000000000..52f280909 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/mod.rs @@ -0,0 +1,12 @@ +//! Active notification lifecycle, history, ownership, and rule policy + +mod history; +mod insertion; +mod lifecycle; +mod ownership; +pub(super) mod rules; + +pub(super) use history::HistoryStore; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/identity.rs b/crates/unixnotis-daemon/src/store/notifications/ownership.rs similarity index 98% rename from crates/unixnotis-daemon/src/store/identity.rs rename to crates/unixnotis-daemon/src/store/notifications/ownership.rs index 3e80108d0..f6c0ec154 100644 --- a/crates/unixnotis-daemon/src/store/identity.rs +++ b/crates/unixnotis-daemon/src/store/notifications/ownership.rs @@ -1,7 +1,7 @@ use tracing::warn; use unixnotis_core::Notification; -use super::NotificationStore; +use crate::store::NotificationStore; impl NotificationStore { pub fn is_notification_owned_by( diff --git a/crates/unixnotis-daemon/src/store/rules.rs b/crates/unixnotis-daemon/src/store/notifications/rules.rs similarity index 98% rename from crates/unixnotis-daemon/src/store/rules.rs rename to crates/unixnotis-daemon/src/store/notifications/rules.rs index bbe5c1258..46782e225 100644 --- a/crates/unixnotis-daemon/src/store/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/rules.rs @@ -1,6 +1,6 @@ use unixnotis_core::{Notification, RuleConfig, Urgency}; -use super::NotificationStore; +use crate::store::NotificationStore; impl NotificationStore { pub(super) fn apply_rules(&self, notification: &mut Notification) { diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs new file mode 100644 index 000000000..5edf51dbb --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs @@ -0,0 +1,68 @@ +use super::support::*; + +#[test] +fn max_entries_zero_drops_history_on_close() { + let mut store = make_store_with_limits(10, 0); + let outcome = store.insert(make_notification("first"), 0); + + store.close(outcome.notification.id, CloseReason::Expired); + + assert_eq!(store.history_len(), 0); +} + +#[test] +fn history_eviction_keeps_most_recent_entries() { + let mut store = make_store_with_limits(0, 2); + store.insert(make_notification("first"), 0); + store.insert(make_notification("second"), 0); + store.insert(make_notification("third"), 0); + + let history = store.list_history(); + + assert_eq!(history.len(), 2); + assert_eq!(history[0].summary, "third"); + assert_eq!(history[1].summary, "second"); +} + +#[test] +fn history_reinsert_replaces_existing_order_entry() { + let mut store = make_store_with_limits(0, 10); + let first = store.insert(make_notification("first"), 0); + let mut replacement = make_notification("replacement"); + replacement.id = first.notification.id; + + store.history.insert(Arc::new(replacement)); + + let history = store.list_history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, first.notification.id); + assert_eq!(history[0].summary, "replacement"); +} + +#[test] +fn transient_close_obeys_the_history_policy() { + for (enabled, expected) in [(false, 0), (true, 1)] { + let mut config = Config::default(); + config.history.transient_to_history = enabled; + let mut store = NotificationStore::new(config); + let mut notification = make_notification("transient"); + notification.is_transient = true; + let outcome = store.insert(notification, 0); + + store.close(outcome.notification.id, CloseReason::Expired); + + assert_eq!(store.history_len(), expected); + } +} + +#[test] +fn clear_history_removes_archived_notifications() { + let mut store = make_store_with_limits(10, 10); + let first = store.insert(make_notification("first"), 0); + store.close(first.notification.id, CloseReason::Expired); + + store.clear_history(); + + assert_eq!(store.history_len(), 0); + assert!(store.list_history().is_empty()); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs new file mode 100644 index 000000000..2fa5b4b8d --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs @@ -0,0 +1,93 @@ +use super::support::*; + +#[test] +fn max_active_zero_archives_immediately() { + let mut store = make_store_with_limits(0, 10); + + let outcome = store.insert(make_notification("first"), 0); + assert_eq!(outcome.evicted.len(), 1); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 1); + + store.insert(make_notification("second"), 0); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 2); +} + +#[test] +fn max_active_evicts_oldest_to_history() { + let mut store = make_store_with_limits(1, 10); + store.insert(make_notification("first"), 0); + + let outcome = store.insert(make_notification("second"), 0); + + assert_eq!(outcome.evicted.len(), 1); + let active = store.list_active(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].summary, "second"); + assert_eq!(store.history_len(), 1); +} + +#[test] +fn max_active_hard_cap_limits_even_when_config_is_higher() { + let mut store = make_store_with_limits(32, 64); + for index in 0..18 { + store.insert(make_notification(&format!("entry-{index}")), 0); + } + + let active = store.list_active(); + let history = store.list_history(); + + assert_eq!(active.len(), 12); + assert_eq!(history.len(), 6); + assert_eq!(active[0].summary, "entry-17"); + assert_eq!(active[11].summary, "entry-6"); +} + +#[test] +fn zero_history_limit_keeps_active_notifications_and_drops_evictions() { + let mut active_store = make_store_with_limits(2, 0); + active_store.insert(make_notification("first"), 0); + let active = active_store.insert(make_notification("second"), 0); + assert!(active.evicted.is_empty()); + assert_eq!(active_store.list_active().len(), 2); + + let mut evicting_store = make_store_with_limits(0, 0); + let evicted = evicting_store.insert(make_notification("first"), 0); + assert_eq!(evicted.evicted.len(), 1); + assert!(evicting_store.list_active().is_empty()); + assert_eq!(evicting_store.history_len(), 0); +} + +#[test] +fn insert_outcome_reflects_popup_and_sound_policy() { + let state_dir = make_temp_state_dir("insert-outcome-policy"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + let allowed = store.insert(make_notification("normal"), 0); + assert!(allowed.show_popup); + assert!(allowed.allow_sound); + + let dnd_state_dir = make_temp_state_dir("insert-outcome-dnd"); + let mut dnd_config = Config::default(); + dnd_config.general.dnd_default = true; + let mut dnd_store = NotificationStore::new_with_state_dir(dnd_config, dnd_state_dir.clone()); + let normal = dnd_store.insert(make_notification("normal dnd"), 0); + assert!(!normal.show_popup); + assert!(!normal.allow_sound); + + let mut critical = make_notification("critical dnd"); + critical.urgency = unixnotis_core::Urgency::Critical; + let critical = dnd_store.insert(critical, 0); + assert!(critical.show_popup); + assert!(critical.allow_sound); + + let mut silent = make_notification("silent"); + silent.suppress_sound = true; + let silent = store.insert(silent, 0); + assert!(!silent.allow_sound); + + cleanup_temp_dir(&state_dir); + cleanup_temp_dir(&dnd_state_dir); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs new file mode 100644 index 000000000..e09020d82 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -0,0 +1,92 @@ +use super::support::*; + +#[test] +fn drain_active_ids_returns_newest_first_and_clears_expirations() { + let mut store = make_store_with_limits(10, 10); + let first = store.insert(make_notification("first"), 0); + let second = store.insert(make_notification("second"), 0); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + store.set_expiration(first.notification.id, Some(deadline)); + + let ids = store.drain_active_ids(); + + assert_eq!(ids, vec![second.notification.id, first.notification.id]); + assert!(store.list_active().is_empty()); + assert_eq!(store.expiration_for(first.notification.id), None); +} + +#[test] +fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert(make_notification("timer"), 0); + let first = std::time::Instant::now() + std::time::Duration::from_secs(1); + let second = std::time::Instant::now() + std::time::Duration::from_secs(2); + + store.set_expiration(outcome.notification.id, Some(first)); + assert_eq!(store.expiration_for(outcome.notification.id), Some(first)); + + store.set_expiration(outcome.notification.id, Some(second)); + assert_eq!(store.expiration_for(outcome.notification.id), Some(second)); + + store.set_expiration(outcome.notification.id, None); + assert_eq!(store.expiration_for(outcome.notification.id), None); +} + +#[test] +fn generation_safe_reply_dismissal_keeps_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let mut original = make_notification("original"); + original.inline_reply.available = true; + original.actions.push(unixnotis_core::Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let original = store.insert(original, 0).notification; + let id = original.id; + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + + assert!(!store.dismiss_active_if_current(id, &original)); + assert_eq!( + store + .active_notification_view(id) + .expect("replacement should remain active") + .summary, + "replacement" + ); + assert!(store.dismiss_active_if_current(id, &replacement.notification)); + assert!(store.active_notification_view(id).is_none()); +} + +#[test] +fn replied_generation_is_removed_after_sender_archives_it() { + let mut store = make_store_with_limits(12, 20); + let original = store.insert(make_notification("original"), 0).notification; + let id = original.id; + store.close(id, CloseReason::ClosedByCall); + assert_eq!(store.list_history().len(), 1); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(!outcome.removed_active); + assert!(outcome.removed_history); + assert!(store.list_history().is_empty()); +} + +#[test] +fn replied_generation_cleanup_keeps_archived_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let original = store.insert(make_notification("original"), 0).notification; + let id = original.id; + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + store.close(id, CloseReason::ClosedByCall); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(!outcome.removed_any()); + let history = store.list_history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].summary, "replacement"); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs new file mode 100644 index 000000000..c457e5701 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs @@ -0,0 +1,6 @@ +mod history; +mod insertion; +mod lifecycle; +mod ownership; +mod rules; +mod support; diff --git a/crates/unixnotis-daemon/src/store/tests/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs similarity index 83% rename from crates/unixnotis-daemon/src/store/tests/ownership.rs rename to crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs index bf84f7baa..96d568754 100644 --- a/crates/unixnotis-daemon/src/store/tests/ownership.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs @@ -1,4 +1,4 @@ -use super::*; +use super::support::*; #[test] fn replace_id_in_history_reuses_id_and_clears_entry() { @@ -46,16 +46,6 @@ fn replace_id_rejected_for_different_sender() { assert_eq!(store.history_len(), 1); } -#[test] -fn inhibit_owner_mismatch_is_rejected() { - let mut store = make_store_with_limits(10, 10); - let id = store.add_inhibitor("owner-a".to_string(), "reason".to_string(), 0); - let err = store - .remove_inhibitor(id, "owner-b") - .expect_err("owner mismatch should error"); - assert!(err.message().contains("owner-a")); -} - #[test] fn is_notification_owned_by_matches_sender() { let mut store = make_store_with_limits(10, 10); @@ -161,3 +151,40 @@ fn replacement_allows_same_process_after_bus_reconnect() { assert!(replacement.replaced); assert_eq!(replacement.notification.id, first.notification.id); } + +#[test] +fn next_id_skips_used_ids_within_used_window() { + let mut store = make_store_with_limits(5, 5); + store.next_id = 1; + + let mut active = make_notification("active"); + active.id = 1; + store.active.insert(1, Arc::new(active)); + + let mut history = make_notification("history"); + history.id = 3; + store.history.insert(Arc::new(history)); + + assert_eq!(store.next_id(), 2); +} + +#[test] +fn next_id_skips_ids_that_exist_only_in_history() { + let mut store = make_store_with_limits(5, 5); + store.next_id = 7; + + let mut history = make_notification("history-only"); + history.id = 7; + store.history.insert(Arc::new(history)); + + assert_eq!(store.next_id(), 8); +} + +#[test] +fn next_id_wraps_internal_cursor_back_to_one_after_max_id() { + let mut store = make_store_with_limits(5, 5); + store.next_id = u32::MAX; + + assert_eq!(store.next_id(), u32::MAX); + assert_eq!(store.next_id, 1); +} diff --git a/crates/unixnotis-daemon/src/store/tests/rules.rs b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs similarity index 99% rename from crates/unixnotis-daemon/src/store/tests/rules.rs rename to crates/unixnotis-daemon/src/store/notifications/tests/rules.rs index 215c09e36..db57b83f7 100644 --- a/crates/unixnotis-daemon/src/store/tests/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs @@ -1,4 +1,4 @@ -use super::*; +use super::support::*; #[test] fn contains_ci_matches_ascii() { diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/support.rs b/crates/unixnotis-daemon/src/store/notifications/tests/support.rs new file mode 100644 index 000000000..2bb137b6c --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/support.rs @@ -0,0 +1,7 @@ +pub(super) use std::sync::Arc; + +pub(super) use unixnotis_core::{CloseReason, Config}; + +pub(super) use super::super::rules::contains_ci; +pub(super) use crate::store::test_support::*; +pub(super) use crate::store::NotificationStore; diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/runtime.rs similarity index 97% rename from crates/unixnotis-daemon/src/store/core.rs rename to crates/unixnotis-daemon/src/store/runtime.rs index 474351c43..66ba5d4c1 100644 --- a/crates/unixnotis-daemon/src/store/core.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -5,7 +5,9 @@ use indexmap::IndexMap; use tracing::{debug, warn}; use unixnotis_core::{Config, ControlState, Notification, NotificationView}; -use super::{DndStateStore, HistoryStore, NotificationStore, DND_STATE_VERSION}; +use super::dnd::{DndStateStore, DND_STATE_VERSION}; +use super::model::NotificationStore; +use super::notifications::HistoryStore; impl NotificationStore { pub fn new(config: Config) -> Self { diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/test_support.rs similarity index 76% rename from crates/unixnotis-daemon/src/store/tests/support.rs rename to crates/unixnotis-daemon/src/store/test_support.rs index 7d9248c79..112537d21 100644 --- a/crates/unixnotis-daemon/src/store/tests/support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -1,16 +1,24 @@ //! Shared notification and persistence fixtures for store tests -use super::*; +use std::collections::HashMap; + +use chrono::Utc; +use unixnotis_core::{Config, Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; + +use super::dnd::DndStateStore; +use super::dnd::{PersistedDndState, DND_STATE_FILE}; +use super::model::NotificationStore; impl NotificationStore { pub(crate) fn new_with_state_dir(config: Config, state_dir: std::path::PathBuf) -> Self { // Isolated persistence roots keep tests away from the live XDG state directory - let state_store = Some(super::super::DndStateStore::from_state_dir(state_dir)); + let state_store = Some(DndStateStore::from_state_dir(state_dir)); Self::new_with_state_store(config, state_store) } } -pub(super) fn make_notification(summary: &str) -> Notification { +pub(in crate::store) fn make_notification(summary: &str) -> Notification { Notification { id: 0, app_name: "TestApp".to_string(), @@ -38,7 +46,7 @@ pub(super) fn make_notification(summary: &str) -> Notification { } } -pub(super) fn make_notification_with_sender( +pub(in crate::store) fn make_notification_with_sender( summary: &str, sender: &str, pid: u32, @@ -51,7 +59,10 @@ pub(super) fn make_notification_with_sender( notification } -pub(super) fn make_store_with_limits(max_active: usize, max_entries: usize) -> NotificationStore { +pub(in crate::store) fn make_store_with_limits( + max_active: usize, + max_entries: usize, +) -> NotificationStore { let mut config = Config::default(); // Test helper uses explicit limits so each case isolates one policy branch config.history.max_active = max_active; @@ -59,7 +70,7 @@ pub(super) fn make_store_with_limits(max_active: usize, max_entries: usize) -> N NotificationStore::new(config) } -pub(super) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { +pub(in crate::store) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { let mut path = std::env::temp_dir(); let pid = std::process::id(); let nanos = std::time::SystemTime::now() @@ -70,7 +81,7 @@ pub(super) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { path } -pub(super) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { +pub(in crate::store) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { let state = PersistedDndState { version, dnd_enabled: enabled, @@ -83,11 +94,11 @@ pub(super) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32 std::fs::write(&path, payload).expect("write state"); } -pub(super) fn cleanup_temp_dir(dir: &std::path::Path) { +pub(in crate::store) fn cleanup_temp_dir(dir: &std::path::Path) { let _ = std::fs::remove_dir_all(dir); } -pub(super) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { +pub(in crate::store) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { let write = store.set_dnd(enabled); if let Some(state_store) = write.persist.as_ref() { state_store diff --git a/crates/unixnotis-daemon/src/store/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/lifecycle.rs deleted file mode 100644 index f507ae4d1..000000000 --- a/crates/unixnotis-daemon/src/store/tests/lifecycle.rs +++ /dev/null @@ -1,306 +0,0 @@ -use super::*; - -#[test] -fn max_active_zero_archives_immediately() { - let mut store = make_store_with_limits(0, 10); - - let outcome = store.insert(make_notification("first"), 0); - assert_eq!(outcome.evicted.len(), 1); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 1); - - store.insert(make_notification("second"), 0); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 2); -} - -#[test] -fn config_accessor_returns_runtime_config_snapshot() { - let mut config = Config::default(); - config.history.max_entries = 77; - config.history.max_active = 3; - let store = NotificationStore::new(config); - - assert_eq!(store.config().history.max_entries, 77); - assert_eq!(store.config().history.max_active, 3); -} - -#[test] -fn max_active_evicts_oldest_to_history() { - let mut store = make_store_with_limits(1, 10); - - store.insert(make_notification("first"), 0); - let outcome = store.insert(make_notification("second"), 0); - - assert_eq!(outcome.evicted.len(), 1); - let active = store.list_active(); - assert_eq!(active.len(), 1); - assert_eq!(active[0].summary, "second"); - assert_eq!(store.history_len(), 1); -} - -#[test] -fn max_active_hard_cap_limits_even_when_config_is_higher() { - // Config may request a larger active window, but runtime hard-cap protects UI stability - let mut store = make_store_with_limits(32, 64); - - for idx in 0..18 { - // Insert in-order so expected active/history boundaries are easy to assert - store.insert(make_notification(&format!("entry-{idx}")), 0); - } - - let active = store.list_active(); - let history = store.list_history(); - - assert_eq!(active.len(), 12); - assert_eq!(history.len(), 6); - // Newest remains at front after cap-based eviction - assert_eq!(active[0].summary, "entry-17"); - // Oldest retained active entry starts where cap boundary begins - assert_eq!(active[11].summary, "entry-6"); -} - -#[test] -fn max_entries_zero_drops_history_on_close() { - let mut store = make_store_with_limits(10, 0); - - let outcome = store.insert(make_notification("first"), 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 0); -} - -#[test] -fn max_entries_zero_keeps_active_notifications_when_active_limit_allows() { - let mut store = make_store_with_limits(2, 0); - - store.insert(make_notification("first"), 0); - let outcome = store.insert(make_notification("second"), 0); - - assert!(outcome.evicted.is_empty()); - assert_eq!(store.list_active().len(), 2); - assert_eq!(store.history_len(), 0); -} - -#[test] -fn history_eviction_keeps_most_recent_entries() { - let mut store = make_store_with_limits(0, 2); - - store.insert(make_notification("first"), 0); - store.insert(make_notification("second"), 0); - store.insert(make_notification("third"), 0); - - // History listing returns most-recent-first order - let history = store.list_history(); - assert_eq!(history.len(), 2); - assert_eq!(history[0].summary, "third"); - assert_eq!(history[1].summary, "second"); -} - -#[test] -fn history_reinsert_replaces_existing_order_entry() { - let mut store = make_store_with_limits(0, 10); - let first = store.insert(make_notification("first"), 0); - assert_eq!(store.history_len(), 1); - - let mut replacement = make_notification("replacement"); - replacement.id = first.notification.id; - store.history.insert(Arc::new(replacement)); - - // Replacing an archived id must not leave a stale duplicate in history order - let history = store.list_history(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].id, first.notification.id); - assert_eq!(history[0].summary, "replacement"); -} - -#[test] -fn max_entries_zero_drops_history_on_insert() { - let mut store = make_store_with_limits(0, 0); - - let outcome = store.insert(make_notification("first"), 0); - - // Eviction should archive the active entry, then drop it due to the zero history limit - assert_eq!(outcome.evicted.len(), 1); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 0); -} - -#[test] -fn transient_close_skips_history_when_config_disables_it() { - let mut config = Config::default(); - // This case is the policy that the center must mirror exactly - config.history.transient_to_history = false; - let mut store = NotificationStore::new(config); - - let mut notification = make_notification("transient"); - notification.is_transient = true; - let outcome = store.insert(notification, 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 0); -} - -#[test] -fn transient_close_archives_when_config_allows_it() { - let mut config = Config::default(); - // Explicit opt-in should keep the closed row in history - config.history.transient_to_history = true; - let mut store = NotificationStore::new(config); - - let mut notification = make_notification("transient"); - notification.is_transient = true; - let outcome = store.insert(notification, 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 1); -} - -#[test] -fn next_id_skips_used_ids_within_used_window() { - let mut store = make_store_with_limits(5, 5); - store.next_id = 1; - - let mut active = make_notification("active"); - active.id = 1; - store.active.insert(1, Arc::new(active)); - - let mut history = make_notification("history"); - history.id = 3; - store.history.insert(Arc::new(history)); - - let id = store.next_id(); - assert_eq!(id, 2); -} - -#[test] -fn next_id_skips_ids_that_exist_only_in_history() { - let mut store = make_store_with_limits(5, 5); - store.next_id = 7; - - let mut history = make_notification("history-only"); - history.id = 7; - store.history.insert(Arc::new(history)); - - // History IDs still belong to notification identity and must not be reused - assert_eq!(store.next_id(), 8); -} - -#[test] -fn next_id_wraps_internal_cursor_back_to_one_after_max_id() { - let mut store = make_store_with_limits(5, 5); - store.next_id = u32::MAX; - - assert_eq!(store.next_id(), u32::MAX); - // The stored cursor must not remain zero after wrapping past u32::MAX - assert_eq!(store.next_id, 1); -} - -#[test] -fn clear_history_removes_archived_notifications() { - let mut store = make_store_with_limits(10, 10); - let first = store.insert(make_notification("first"), 0); - store.close(first.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 1); - store.clear_history(); - assert_eq!(store.history_len(), 0); - assert!(store.list_history().is_empty()); -} - -#[test] -fn dismiss_outcome_reports_any_removed_side() { - assert!(crate::store::DismissOutcome { - removed_active: true, - removed_history: false, - } - .removed_any()); - assert!(crate::store::DismissOutcome { - removed_active: false, - removed_history: true, - } - .removed_any()); - assert!(!crate::store::DismissOutcome { - removed_active: false, - removed_history: false, - } - .removed_any()); -} - -#[test] -fn active_notification_view_returns_current_active_payload() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert(make_notification("visible"), 0); - - let view = store - .active_notification_view(outcome.notification.id) - .expect("active notification should be visible"); - - assert_eq!(view.id, outcome.notification.id); - assert_eq!(view.summary, "visible"); -} - -#[test] -fn drain_active_ids_returns_newest_first_and_clears_expirations() { - let mut store = make_store_with_limits(10, 10); - let first = store.insert(make_notification("first"), 0); - let second = store.insert(make_notification("second"), 0); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - store.set_expiration(first.notification.id, Some(deadline)); - - let ids = store.drain_active_ids(); - - assert_eq!(ids, vec![second.notification.id, first.notification.id]); - assert!(store.list_active().is_empty()); - assert_eq!(store.expiration_for(first.notification.id), None); -} - -#[test] -fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert(make_notification("timer"), 0); - let first = std::time::Instant::now() + std::time::Duration::from_secs(1); - let second = std::time::Instant::now() + std::time::Duration::from_secs(2); - - store.set_expiration(outcome.notification.id, Some(first)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(first)); - - store.set_expiration(outcome.notification.id, Some(second)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(second)); - - store.set_expiration(outcome.notification.id, None); - assert_eq!(store.expiration_for(outcome.notification.id), None); -} - -#[test] -fn insert_outcome_reflects_popup_and_sound_policy() { - let state_dir = make_temp_state_dir("insert-outcome-policy"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - let allowed = store.insert(make_notification("normal"), 0); - assert!(allowed.show_popup); - assert!(allowed.allow_sound); - - let dnd_state_dir = make_temp_state_dir("insert-outcome-dnd"); - let mut dnd_config = Config::default(); - dnd_config.general.dnd_default = true; - let mut dnd_store = NotificationStore::new_with_state_dir(dnd_config, dnd_state_dir.clone()); - let normal = dnd_store.insert(make_notification("normal dnd"), 0); - assert!(!normal.show_popup); - assert!(!normal.allow_sound); - - let mut critical = make_notification("critical dnd"); - critical.urgency = unixnotis_core::Urgency::Critical; - let critical = dnd_store.insert(critical, 0); - assert!(critical.show_popup); - assert!(critical.allow_sound); - - let mut silent = make_notification("silent"); - silent.suppress_sound = true; - let silent = store.insert(silent, 0); - assert!(!silent.allow_sound); - - cleanup_temp_dir(&state_dir); - cleanup_temp_dir(&dnd_state_dir); -} diff --git a/crates/unixnotis-daemon/src/store/tests/mod.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs index 9d2b5651a..af9952687 100644 --- a/crates/unixnotis-daemon/src/store/tests/mod.rs +++ b/crates/unixnotis-daemon/src/store/tests/mod.rs @@ -1,20 +1,2 @@ -//! Store regression coverage and persistence validation - -use super::rules::contains_ci; -use super::state::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; -use super::NotificationStore; -use chrono::Utc; -use std::collections::HashMap; -use std::sync::Arc; -use unixnotis_core::{CloseReason, Config, InhibitMode, Notification, NotificationImage, Urgency}; -use zbus::zvariant::OwnedValue; - -mod dnd; -mod inhibit; -mod lifecycle; -mod ownership; -mod reply; -mod rules; -mod support; - -use support::*; +mod model; +mod runtime; diff --git a/crates/unixnotis-daemon/src/store/tests/model.rs b/crates/unixnotis-daemon/src/store/tests/model.rs new file mode 100644 index 000000000..6a0a2fa12 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/model.rs @@ -0,0 +1,20 @@ +use crate::store::DismissOutcome; + +#[test] +fn dismiss_outcome_reports_any_removed_side() { + assert!(DismissOutcome { + removed_active: true, + removed_history: false, + } + .removed_any()); + assert!(DismissOutcome { + removed_active: false, + removed_history: true, + } + .removed_any()); + assert!(!DismissOutcome { + removed_active: false, + removed_history: false, + } + .removed_any()); +} diff --git a/crates/unixnotis-daemon/src/store/tests/reply.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs similarity index 61% rename from crates/unixnotis-daemon/src/store/tests/reply.rs rename to crates/unixnotis-daemon/src/store/tests/runtime.rs index 9dc7b1565..74cfc90b5 100644 --- a/crates/unixnotis-daemon/src/store/tests/reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -1,8 +1,33 @@ use std::sync::Arc; -use unixnotis_core::{Action, CloseReason, InlineReply, InlineReplyPolicy}; +use unixnotis_core::{Action, CloseReason, Config, InlineReply, InlineReplyPolicy}; -use super::{make_notification, make_store_with_limits}; +use crate::store::test_support::{make_notification, make_store_with_limits}; +use crate::store::NotificationStore; + +#[test] +fn config_accessor_returns_runtime_config_snapshot() { + let mut config = Config::default(); + config.history.max_entries = 77; + config.history.max_active = 3; + let store = NotificationStore::new(config); + + assert_eq!(store.config().history.max_entries, 77); + assert_eq!(store.config().history.max_active, 3); +} + +#[test] +fn active_notification_view_returns_current_active_payload() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert(make_notification("visible"), 0); + + let view = store + .active_notification_view(outcome.notification.id) + .expect("active notification should be visible"); + + assert_eq!(view.id, outcome.notification.id); + assert_eq!(view.summary, "visible"); +} #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { @@ -104,62 +129,3 @@ fn inline_reply_policy_denies_a_complete_reply_action() { assert!(store.active_inline_reply_target(id).is_none()); } - -#[test] -fn generation_safe_reply_dismissal_keeps_same_id_replacement() { - let mut store = make_store_with_limits(12, 20); - let mut original = make_notification("original"); - original.inline_reply.available = true; - original.actions.push(Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }); - let original = store.insert(original, 0).notification; - let id = original.id; - - let replacement = store.insert(make_notification("replacement"), id); - assert!(replacement.replaced); - - assert!(!store.dismiss_active_if_current(id, &original)); - assert_eq!( - store - .active_notification_view(id) - .expect("replacement should remain active") - .summary, - "replacement" - ); - assert!(store.dismiss_active_if_current(id, &replacement.notification)); - assert!(store.active_notification_view(id).is_none()); -} - -#[test] -fn replied_generation_is_removed_after_sender_archives_it() { - let mut store = make_store_with_limits(12, 20); - let original = store.insert(make_notification("original"), 0).notification; - let id = original.id; - store.close(id, CloseReason::ClosedByCall); - assert_eq!(store.list_history().len(), 1); - - let outcome = store.dismiss_replied_generation(id, &original); - - assert!(!outcome.removed_active); - assert!(outcome.removed_history); - assert!(store.list_history().is_empty()); -} - -#[test] -fn replied_generation_cleanup_keeps_archived_same_id_replacement() { - let mut store = make_store_with_limits(12, 20); - let original = store.insert(make_notification("original"), 0).notification; - let id = original.id; - let replacement = store.insert(make_notification("replacement"), id); - assert!(replacement.replaced); - store.close(id, CloseReason::ClosedByCall); - - let outcome = store.dismiss_replied_generation(id, &original); - - assert!(!outcome.removed_any()); - let history = store.list_history(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].summary, "replacement"); -} From 6b6de1042b0dea21562ac1bf7a73e5f524a2a1f6 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 21:10:16 -0500 Subject: [PATCH 088/275] refactor(daemon): colocate entrypoint, ingress, and identity coverage Summary: colocate entrypoint, ingress, and identity coverage. Scope: daemon. --- crates/noticenterctl/src/cli/tests/help.rs | 53 +++++ crates/noticenterctl/src/cli/tests/mod.rs | 1 + crates/noticenterctl/tests/doctor.rs | 40 ---- crates/noticenterctl/tests/errors.rs | 33 --- crates/noticenterctl/tests/help.rs | 65 ------ crates/noticenterctl/tests/preset_inspect.rs | 93 -------- .../notifications/identity/executable.rs | 4 + .../src/daemon/notifications/identity/mod.rs | 5 + .../notifications/{ => identity}/sender.rs | 37 +++- .../{ => identity}/sender_cache.rs | 4 + .../identity/tests/executable.rs | 67 ++++++ .../{ => identity}/tests/sender.rs | 20 ++ .../{ => identity}/tests/sender_cache.rs | 2 +- .../daemon/notifications/ingress/limits.rs | 26 +++ .../notifications/{ => ingress}/metrics.rs | 15 +- .../src/daemon/notifications/ingress/mod.rs | 6 + .../notifications/{ => ingress}/payload.rs | 35 +-- .../notifications/{ => ingress}/quota.rs | 12 +- .../{ => ingress}/tests/metrics.rs | 0 .../{ => ingress}/tests/payload.rs | 0 .../{ => ingress}/tests/quota.rs | 0 .../src/daemon/notifications/limits.rs | 26 --- .../src/daemon/notifications/mod.rs | 8 +- .../src/daemon/notifications/server/close.rs | 2 +- .../src/daemon/notifications/server/flow.rs | 4 +- .../daemon/notifications/server/interface.rs | 4 +- .../server/notify_body/tests/actions.rs | 7 +- .../server/notify_body/tests/body.rs | 4 +- .../server/notify_body/tests/hints.rs | 4 +- .../server/notify_body/tests/mod.rs | 2 - .../server/notify_body/validator.rs | 2 +- .../notifications/server/notify_body/value.rs | 2 +- crates/unixnotis-daemon/src/main.rs | 2 +- crates/unixnotis-daemon/src/runtime/runner.rs | 8 +- .../src/runtime/tests/runner.rs | 20 +- crates/unixnotis-daemon/src/tests/cli.rs | 11 +- crates/unixnotis-daemon/tests/cli.rs | 75 ------- .../src/tests/support/mod.rs | 8 +- .../src/tests/support/paths.rs | 6 + .../src/tests/support/tests/env.rs | 16 ++ .../src/tests/support/tests/mod.rs | 1 + crates/unixnotis-installer/tests/cli.rs | 30 --- crates/unixnotis-ui/Cargo.toml | 1 - crates/unixnotis-ui/src/bin/css_validate.rs | 58 +++-- .../src/bin/tests/css_validate.rs | 115 ++++++++++ .../unixnotis-ui/src/cut_corner/tests/mod.rs | 1 + .../cut_corner/tests/widget.rs} | 3 +- crates/unixnotis-ui/tests/css_validate.rs | 207 ------------------ 48 files changed, 480 insertions(+), 665 deletions(-) create mode 100644 crates/noticenterctl/src/cli/tests/help.rs delete mode 100644 crates/noticenterctl/tests/doctor.rs delete mode 100644 crates/noticenterctl/tests/errors.rs delete mode 100644 crates/noticenterctl/tests/help.rs delete mode 100644 crates/noticenterctl/tests/preset_inspect.rs rename crates/unixnotis-daemon/src/daemon/notifications/{ => identity}/sender.rs (76%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => identity}/sender_cache.rs (86%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs rename crates/unixnotis-daemon/src/daemon/notifications/{ => identity}/tests/sender.rs (67%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => identity}/tests/sender_cache.rs (95%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/metrics.rs (78%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/payload.rs (88%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/quota.rs (93%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/tests/metrics.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/tests/payload.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/{ => ingress}/tests/quota.rs (100%) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/limits.rs delete mode 100644 crates/unixnotis-daemon/tests/cli.rs create mode 100644 crates/unixnotis-installer/src/tests/support/paths.rs create mode 100644 crates/unixnotis-installer/src/tests/support/tests/env.rs delete mode 100644 crates/unixnotis-installer/tests/cli.rs create mode 100644 crates/unixnotis-ui/src/bin/tests/css_validate.rs rename crates/unixnotis-ui/{tests/cut_corner.rs => src/cut_corner/tests/widget.rs} (98%) delete mode 100644 crates/unixnotis-ui/tests/css_validate.rs diff --git a/crates/noticenterctl/src/cli/tests/help.rs b/crates/noticenterctl/src/cli/tests/help.rs new file mode 100644 index 000000000..79e5c0889 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/help.rs @@ -0,0 +1,53 @@ +use clap::{CommandFactory, Parser}; + +use super::super::Args; + +#[test] +fn root_help_lists_the_supported_command_groups() { + let help = Args::command().render_help().to_string(); + + assert!(help.contains("Usage:")); + assert!(help.contains("css-check")); + assert!(help.contains("doctor")); + assert!(help.contains("preset")); +} + +#[test] +fn command_help_lists_output_debug_and_preset_controls() { + for (arguments, expected) in [ + ( + vec!["noticenterctl", "doctor", "--help"], + vec!["--json", "--verbose", "--service-manager", "manual"], + ), + ( + vec!["noticenterctl", "open-panel", "--help"], + vec!["--debug", "critical", "verbose"], + ), + ( + vec!["noticenterctl", "preset", "--help"], + vec!["export", "import", "inspect"], + ), + ] { + let error = Args::try_parse_from(arguments).expect_err("help should stop parsing"); + let help = error.to_string(); + + for value in expected { + assert!(help.contains(value), "missing {value} in {help}"); + } + } +} + +#[test] +fn invalid_commands_and_dnd_values_are_rejected_by_the_parser() { + let command = Args::try_parse_from(["noticenterctl", "definitely-not-a-command"]) + .expect_err("unknown command should fail") + .to_string(); + assert!(command.contains("unrecognized subcommand")); + assert!(command.contains("definitely-not-a-command")); + + let dnd = Args::try_parse_from(["noticenterctl", "dnd", "maybe"]) + .expect_err("invalid DND state should fail") + .to_string(); + assert!(dnd.contains("invalid value")); + assert!(dnd.contains("maybe")); +} diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs index ad7ba9253..ae7f8f06a 100644 --- a/crates/noticenterctl/src/cli/tests/mod.rs +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -1,3 +1,4 @@ mod args; mod command; mod dnd; +mod help; diff --git a/crates/noticenterctl/tests/doctor.rs b/crates/noticenterctl/tests/doctor.rs deleted file mode 100644 index 38ef18153..000000000 --- a/crates/noticenterctl/tests/doctor.rs +++ /dev/null @@ -1,40 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_doctor_runs_all_checks_and_emits_versioned_json() -> TestResult { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-doctor-binary-{}-{stamp}", - std::process::id() - )); - std::fs::create_dir_all(&root)?; - let config = root.join("config.toml"); - std::fs::write(&config, "config_version = 2\n")?; - let missing_bus = root.join("missing-session-bus.sock"); - - // A missing private bus keeps the integration deterministic without touching the desktop - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["doctor", "--json", "--service-manager", "manual"]) - .env("UNIXNOTIS_CONFIG_PATH", &config) - .env( - "DBUS_SESSION_BUS_ADDRESS", - format!("unix:path={}", missing_bus.display()), - ) - .output()?; - - assert!(!output.status.success()); - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report["schema_version"], 1); - assert!(report["checks"].as_array().is_some_and(|checks| checks - .iter() - .any(|check| { check["id"] == "dbus.session" && check["severity"] == "error" }))); - std::fs::remove_dir_all(root)?; - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/errors.rs b/crates/noticenterctl/tests/errors.rs deleted file mode 100644 index b2a442773..000000000 --- a/crates/noticenterctl/tests/errors.rs +++ /dev/null @@ -1,33 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_rejects_unknown_command_before_dbus_setup() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("definitely-not-a-command") - .output()?; - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("unrecognized subcommand")); - assert!(stderr.contains("definitely-not-a-command")); - Ok(()) - } - - #[test] - fn binary_rejects_invalid_dnd_state_before_dbus_setup() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["dnd", "maybe"]) - .output()?; - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("invalid value")); - assert!(stderr.contains("maybe")); - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/help.rs b/crates/noticenterctl/tests/help.rs deleted file mode 100644 index e88ed101b..000000000 --- a/crates/noticenterctl/tests/help.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_help_prints_cli_usage() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("--help") - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage:")); - assert!(stdout.contains("css-check")); - assert!(stdout.contains("doctor")); - assert!(stdout.contains("preset")); - Ok(()) - } - - #[test] - fn binary_doctor_help_lists_output_and_manager_controls() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["doctor", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("--json")); - assert!(stdout.contains("--verbose")); - assert!(stdout.contains("--service-manager")); - assert!(stdout.contains("manual")); - Ok(()) - } - - #[test] - fn binary_open_panel_help_lists_optional_debug_flag() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["open-panel", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("--debug")); - assert!(stdout.contains("critical")); - assert!(stdout.contains("verbose")); - Ok(()) - } - - #[test] - fn binary_preset_help_lists_local_bundle_commands() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["preset", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("export")); - assert!(stdout.contains("import")); - assert!(stdout.contains("inspect")); - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/preset_inspect.rs b/crates/noticenterctl/tests/preset_inspect.rs deleted file mode 100644 index 62cf0fb20..000000000 --- a/crates/noticenterctl/tests/preset_inspect.rs +++ /dev/null @@ -1,93 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::fs; - use std::path::PathBuf; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - type TestResult = Result<(), Box>; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Result> { - // Unique roots keep binary tests from sharing config state through XDG_CONFIG_HOME - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-cli-preset-inspect-{name}-{stamp}-{serial}" - )); - fs::create_dir_all(&path)?; - Ok(Self { path }) - } - - fn write(&self, relative_path: &str, contents: &str) -> Result<(), Box> { - // The CLI reads the default config root, so tests place files under an isolated XDG root - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents)?; - Ok(()) - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn binary_preset_inspect_prints_bundle_report() -> TestResult { - let root = TempDirGuard::new("report")?; - let config_root = root.path.join("xdg"); - let unixnotis_root = config_root.join("unixnotis"); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"base.css\"\n", - )?; - root.write("xdg/unixnotis/base.css", ".panel { color: red; }\n")?; - let bundle_path = root.path.join("demo.unixnotis"); - - let export_output = noticenterctl() - .env("XDG_CONFIG_HOME", &config_root) - .args(["preset", "export", "--force"]) - .arg(&bundle_path) - .output()?; - assert!( - export_output.status.success(), - "export failed: {}", - String::from_utf8_lossy(&export_output.stderr) - ); - - let inspect_output = noticenterctl() - .args(["preset", "inspect"]) - .arg(&bundle_path) - .output()?; - - assert!( - inspect_output.status.success(), - "inspect failed: {}", - String::from_utf8_lossy(&inspect_output.stderr) - ); - let stdout = String::from_utf8(inspect_output.stdout)?; - assert!(stdout.contains("preset: demo")); - assert!(stdout.contains("files: 2")); - assert!(stdout.contains("file list:")); - assert!(stdout.contains("config.toml")); - assert!(unixnotis_root.exists()); - Ok(()) - } - - fn noticenterctl() -> Command { - // Cargo provides the freshly-built binary path to integration tests - Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - } -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs index 3df118bbb..0eec9403f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs @@ -69,3 +69,7 @@ pub(super) fn executable_evidence_for_path(path: &Path) -> Option, + pub(in crate::daemon::notifications) sender_name: Option, // Process id is paired with start time so reused pids do not inherit ownership - pub(super) sender_pid: Option, + pub(in crate::daemon::notifications) sender_pid: Option, // Linux start time identifies one concrete process lifetime - pub(super) sender_start_time: Option, + pub(in crate::daemon::notifications) sender_start_time: Option, // Executable path is presentation-only evidence for diagnostics and source labels - pub(super) sender_executable: Option, + pub(in crate::daemon::notifications) sender_executable: Option, // Device and inode bind policy to the open running executable rather than its basename - pub(super) sender_executable_identity: Option, + pub(in crate::daemon::notifications) sender_executable_identity: Option, } -pub(super) async fn resolve_sender_metadata( +pub(in crate::daemon) async fn resolve_sender_metadata( cache: &SenderMetadataCache, connection: &Connection, header: &Header<'_>, @@ -69,8 +69,12 @@ pub(super) async fn resolve_sender_metadata( // PID and executable come from the bus owner, not caller-provided payload fields let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); - let sender_start_time = sender_pid.and_then(read_process_start_time); - let executable_evidence = sender_pid.and_then(executable_evidence_for_pid); + let (sender_start_time, executable_evidence) = sender_pid.map_or((None, None), |pid| { + let start_before = read_process_start_time(pid); + let evidence = executable_evidence_for_pid(pid); + let start_after = read_process_start_time(pid); + stable_process_evidence(start_before, evidence, start_after) + }); let sender_executable = executable_evidence .as_ref() .map(|evidence| evidence.canonical_path.display().to_string()); @@ -84,7 +88,7 @@ pub(super) async fn resolve_sender_metadata( sender_executable_identity, }; // Failed lookups remain retryable instead of becoming persistent unknown identities - if metadata.sender_pid.is_some() { + if metadata.sender_start_time.is_some() && metadata.sender_executable_identity.is_some() { cache.insert(cache_key, metadata.clone()); } metadata @@ -117,6 +121,19 @@ fn read_process_start_time(_pid: u32) -> Option { None } +fn stable_process_evidence( + start_before: Option, + evidence: Option, + start_after: Option, +) -> (Option, Option) { + // Both lifetime reads must name the same process before executable evidence is trusted + if start_before.is_some() && start_before == start_after { + (start_before, evidence) + } else { + (None, None) + } +} + #[cfg(target_os = "linux")] fn parse_process_start_time(stat: &str) -> Option { // The comm field is wrapped in parentheses and may contain spaces diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs similarity index 86% rename from crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs index 45a591fa1..b92e6997a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs @@ -32,6 +32,7 @@ impl SenderMetadataCache { } pub(super) fn get(&self, sender: &str) -> Option { + // A poisoned cache fails closed and forces fresh sender resolution let mut state = self.state.lock().ok()?; let sequence = state.next_sequence(); let entry = state.entries.get_mut(sender)?; @@ -44,6 +45,7 @@ impl SenderMetadataCache { return; }; let sequence = state.next_sequence(); + // The least recently used connection yields before the fixed bound is exceeded if !state.entries.contains_key(&sender) && state.entries.len() >= MAX_CACHED_SENDERS { state.evict_oldest(); } @@ -65,11 +67,13 @@ impl SenderMetadataCache { impl CacheState { const fn next_sequence(&mut self) -> u64 { + // Wrapping preserves ordering for realistic cache lifetimes without panicking self.sequence = self.sequence.wrapping_add(1); self.sequence } fn evict_oldest(&mut self) { + // A tiny bounded map keeps a linear selection cheaper than another index let oldest = self .entries .iter() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs new file mode 100644 index 000000000..c99519fdd --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs @@ -0,0 +1,67 @@ +use std::os::unix::fs::MetadataExt; + +use super::*; + +#[test] +fn system_managed_identity_requires_root_ownership_without_shared_writes() { + let protected = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + + assert!(protected.is_system_managed()); + assert!(!FileIdentity { + uid: 1000, + ..protected + } + .is_system_managed()); + assert!(!FileIdentity { + mode: 0o100_775, + ..protected + } + .is_system_managed()); + assert!(!FileIdentity { + mode: 0o100_757, + ..protected + } + .is_system_managed()); +} + +#[test] +fn same_file_uses_device_and_inode_instead_of_mutable_labels() { + let first = FileIdentity { + device: 5, + inode: 8, + uid: 0, + mode: 0o100_755, + }; + let relabeled = FileIdentity { + uid: 1000, + mode: 0o100_777, + ..first + }; + + assert!(first.same_file(relabeled)); + assert!(!first.same_file(FileIdentity { inode: 9, ..first })); +} + +#[test] +fn executable_path_evidence_matches_open_file_metadata() { + let executable = std::env::current_exe().expect("current test executable path"); + let evidence = executable_evidence_for_path(&executable).expect("current executable evidence"); + let metadata = std::fs::metadata(&executable).expect("current executable metadata"); + + assert!(evidence.canonical_path.is_absolute()); + assert_eq!(evidence.identity.device, metadata.dev()); + assert_eq!(evidence.identity.inode, metadata.ino()); +} + +#[test] +fn missing_executable_path_has_no_identity_evidence() { + assert!(executable_evidence_for_path(std::path::Path::new( + "/path/that/does/not/exist/unixnotis" + )) + .is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs similarity index 67% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 3b2591a45..5b9b7a1c0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -30,3 +30,23 @@ async fn process_metadata_helpers_read_current_process_on_linux() { let start_time = read_process_start_time(pid).expect("current process start time should exist"); assert!(start_time > 1); } + +#[test] +fn stable_process_evidence_keeps_matching_lifetime_observations() { + assert_eq!( + stable_process_evidence(Some(42), Some("evidence"), Some(42)), + (Some(42), Some("evidence")) + ); +} + +#[test] +fn stable_process_evidence_discards_pid_reuse_or_missing_observations() { + assert_eq!( + stable_process_evidence(Some(42), Some("evidence"), Some(43)), + (None, None) + ); + assert_eq!( + stable_process_evidence(None, Some("evidence"), None), + (None, None) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs similarity index 95% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index e6f2f2887..5e1943ed0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -1,5 +1,5 @@ use super::{SenderMetadataCache, MAX_CACHED_SENDERS}; -use crate::daemon::notifications::sender::SenderMetadata; +use crate::daemon::notifications::identity::sender::SenderMetadata; fn metadata(sender: &str, pid: u32) -> SenderMetadata { SenderMetadata { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs new file mode 100644 index 000000000..25e834cc3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs @@ -0,0 +1,26 @@ +//! Bounds for untrusted notification payload data +//! +//! Keeping limits in one file makes audits and tuning easier + +pub(in crate::daemon::notifications) const MAX_APP_NAME_BYTES: usize = 256; +// Icon names/paths can be longer than app names, but still need a hard cap +pub(in crate::daemon::notifications) const MAX_APP_ICON_BYTES: usize = 1024; +// Summary is shown prominently, so keep it short and bounded +pub(in crate::daemon::notifications) const MAX_SUMMARY_BYTES: usize = 1024; +// Body can be larger, but still needs a strict upper bound +pub(in crate::daemon::notifications) const MAX_BODY_BYTES: usize = 16 * 1024; +// Category is used for grouping and rules, so keep values compact +pub(in crate::daemon::notifications) const MAX_CATEGORY_BYTES: usize = 256; +// Keep action rows compact so one notification cannot stretch list layout +// This limit is shared by popup and center action rendering expectations +pub(in crate::daemon::notifications) const MAX_ACTIONS: usize = 8; +// Action keys are internal identifiers +pub(in crate::daemon::notifications) const MAX_ACTION_KEY_BYTES: usize = 128; +// Action labels are user-facing button text +pub(in crate::daemon::notifications) const MAX_ACTION_LABEL_BYTES: usize = 256; +// Limit hint map size so map copies stay cheap +pub(in crate::daemon::notifications) const MAX_HINT_ENTRIES: usize = 16; +// Hint keys are short protocol labels +pub(in crate::daemon::notifications) const MAX_HINT_KEY_BYTES: usize = 64; +// String hints can be descriptive, but still capped for memory safety +pub(in crate::daemon::notifications) const MAX_HINT_STRING_BYTES: usize = 2048; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs similarity index 78% rename from crates/unixnotis-daemon/src/daemon/notifications/metrics.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs index a9c954f52..c96a9f47b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/metrics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs @@ -3,13 +3,13 @@ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum RejectedRequest { +pub(in crate::daemon::notifications) enum RejectedRequest { NotifyQuota, NotifyConcurrency, CloseQuota, } -pub(super) struct IngressMetrics { +pub(in crate::daemon::notifications) struct IngressMetrics { notify_quota_rejections: AtomicU64, notify_concurrency_rejections: AtomicU64, close_quota_rejections: AtomicU64, @@ -17,12 +17,12 @@ pub(super) struct IngressMetrics { peak_active_handlers: AtomicUsize, } -pub(super) struct ActiveHandler<'a> { +pub(in crate::daemon::notifications) struct ActiveHandler<'a> { metrics: &'a IngressMetrics, } impl IngressMetrics { - pub(super) const fn new() -> Self { + pub(in crate::daemon::notifications) const fn new() -> Self { Self { notify_quota_rejections: AtomicU64::new(0), notify_concurrency_rejections: AtomicU64::new(0), @@ -32,7 +32,10 @@ impl IngressMetrics { } } - pub(super) fn record_rejection(&self, rejected: RejectedRequest) -> u64 { + pub(in crate::daemon::notifications) fn record_rejection( + &self, + rejected: RejectedRequest, + ) -> u64 { let counter = match rejected { RejectedRequest::NotifyQuota => &self.notify_quota_rejections, RejectedRequest::NotifyConcurrency => &self.notify_concurrency_rejections, @@ -41,7 +44,7 @@ impl IngressMetrics { counter.fetch_add(1, Ordering::Relaxed).saturating_add(1) } - pub(super) fn enter_handler(&self) -> ActiveHandler<'_> { + pub(in crate::daemon::notifications) fn enter_handler(&self) -> ActiveHandler<'_> { let active = self .active_handlers .fetch_add(1, Ordering::Relaxed) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs new file mode 100644 index 000000000..29b39597b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs @@ -0,0 +1,6 @@ +//! Request admission, payload bounds, and notification construction + +pub(super) mod limits; +pub(super) mod metrics; +pub(super) mod payload; +pub(super) mod quota; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs similarity index 88% rename from crates/unixnotis-daemon/src/daemon/notifications/payload.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index 83e98c76a..7ef73c519 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -12,27 +12,29 @@ use unixnotis_core::{ }; use zbus::zvariant::{OwnedValue, Value}; +use super::super::identity::SenderMetadata; use super::limits::{ MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, MAX_HINT_STRING_BYTES, MAX_SUMMARY_BYTES, }; -use super::sender::SenderMetadata; -pub(super) struct NotificationInput { - pub(super) app_name: String, - pub(super) app_icon: String, - pub(super) summary: String, - pub(super) body: String, - pub(super) actions: Vec, - pub(super) hints: HashMap, - pub(super) sender: SenderMetadata, - pub(super) attribution: NotificationAttribution, - pub(super) inline_reply_policy: InlineReplyPolicy, - pub(super) expire_timeout: i32, +pub(in crate::daemon::notifications) struct NotificationInput { + pub(in crate::daemon::notifications) app_name: String, + pub(in crate::daemon::notifications) app_icon: String, + pub(in crate::daemon::notifications) summary: String, + pub(in crate::daemon::notifications) body: String, + pub(in crate::daemon::notifications) actions: Vec, + pub(in crate::daemon::notifications) hints: HashMap, + pub(in crate::daemon::notifications) sender: SenderMetadata, + pub(in crate::daemon::notifications) attribution: NotificationAttribution, + pub(in crate::daemon::notifications) inline_reply_policy: InlineReplyPolicy, + pub(in crate::daemon::notifications) expire_timeout: i32, } -pub(super) fn build_notification(input: NotificationInput) -> Notification { +pub(in crate::daemon::notifications) fn build_notification( + input: NotificationInput, +) -> Notification { let NotificationInput { app_name, app_icon, @@ -118,7 +120,10 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { } } -pub(super) fn resolve_expiration(config: &Config, notification: &Notification) -> Option { +pub(in crate::daemon::notifications) fn resolve_expiration( + config: &Config, + notification: &Notification, +) -> Option { // Resident notifications never auto-expire if notification.is_resident { return None; @@ -245,7 +250,7 @@ fn parse_urgency_hint(value: &OwnedValue) -> Option { None } -pub(super) fn owned_to_string(value: &OwnedValue) -> Option { +pub(in crate::daemon::notifications) fn owned_to_string(value: &OwnedValue) -> Option { value .try_clone() .ok() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs similarity index 93% rename from crates/unixnotis-daemon/src/daemon/notifications/quota.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs index aa91718c8..6513df3a4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs @@ -16,7 +16,7 @@ const MAX_TRACKED_SENDERS: usize = 256; const SENDER_IDLE_TTL_SECONDS: u64 = 60; const UNKNOWN_SENDER: &str = ""; -pub(super) struct NotificationQuota { +pub(in crate::daemon::notifications) struct NotificationQuota { state: Mutex, policy: QuotaPolicy, } @@ -47,11 +47,11 @@ struct TokenBucket { } impl NotificationQuota { - pub(super) fn new_notify() -> Self { + pub(in crate::daemon::notifications) fn new_notify() -> Self { Self::new_at(Instant::now()) } - pub(super) fn new_close() -> Self { + pub(in crate::daemon::notifications) fn new_close() -> Self { Self::new_close_at(Instant::now()) } @@ -89,7 +89,11 @@ impl NotificationQuota { } } - pub(super) fn admit(&self, sender: Option<&str>, now: Instant) -> bool { + pub(in crate::daemon::notifications) fn admit( + &self, + sender: Option<&str>, + now: Instant, + ) -> bool { let Ok(mut state) = self.state.lock() else { // A poisoned limiter fails closed instead of disabling ingress control return false; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/metrics.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/metrics.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/metrics.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/quota.rs rename to crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/limits.rs deleted file mode 100644 index 3aa076134..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/limits.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Bounds for untrusted notification payload data -//! -//! Keeping limits in one file makes audits and tuning easier - -pub(super) const MAX_APP_NAME_BYTES: usize = 256; -// Icon names/paths can be longer than app names, but still need a hard cap -pub(super) const MAX_APP_ICON_BYTES: usize = 1024; -// Summary is shown prominently, so keep it short and bounded -pub(super) const MAX_SUMMARY_BYTES: usize = 1024; -// Body can be larger, but still needs a strict upper bound -pub(super) const MAX_BODY_BYTES: usize = 16 * 1024; -// Category is used for grouping and rules, so keep values compact -pub(super) const MAX_CATEGORY_BYTES: usize = 256; -// Keep action rows compact so one notification cannot stretch list layout -// This limit is shared by popup and center action rendering expectations -pub(super) const MAX_ACTIONS: usize = 8; -// Action keys are internal identifiers -pub(super) const MAX_ACTION_KEY_BYTES: usize = 128; -// Action labels are user-facing button text -pub(super) const MAX_ACTION_LABEL_BYTES: usize = 256; -// Limit hint map size so map copies stay cheap -pub(super) const MAX_HINT_ENTRIES: usize = 16; -// Hint keys are short protocol labels -pub(super) const MAX_HINT_KEY_BYTES: usize = 64; -// String hints can be descriptive, but still capped for memory safety -pub(super) const MAX_HINT_STRING_BYTES: usize = 2048; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 09456d8d4..8309c210b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -2,16 +2,12 @@ mod flow_control; pub(in crate::daemon) mod identity; -mod limits; -mod metrics; -mod payload; -mod quota; -mod sender; -pub(in crate::daemon) mod sender_cache; +mod ingress; mod server; pub(in crate::daemon) use flow_control::{ notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, }; +pub(in crate::daemon) use identity::SenderMetadataCache; pub use server::NotificationIngress; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs index 67675c889..ef93978f8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -5,7 +5,7 @@ use zbus::message::Header; use crate::daemon::to_fdo_error; use super::NotificationServer; -use crate::daemon::notifications::sender::resolve_sender_metadata; +use crate::daemon::notifications::identity::resolve_sender_metadata; impl NotificationServer { pub(super) async fn close_notification_if_owned( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index fb2db48d8..0986c1a38 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -6,11 +6,11 @@ use unixnotis_core::Notification; use zbus::message::Header; use zbus::zvariant::OwnedValue; +use crate::daemon::notifications::identity::resolve_sender_metadata; use crate::daemon::notifications::identity::{resolve_attribution, AppClaim}; -use crate::daemon::notifications::payload::{ +use crate::daemon::notifications::ingress::payload::{ build_notification, owned_to_string, resolve_expiration, NotificationInput, }; -use crate::daemon::notifications::sender::resolve_sender_metadata; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index b70d391e2..fff737bfb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -13,8 +13,8 @@ use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; -use crate::daemon::notifications::metrics::{IngressMetrics, RejectedRequest}; -use crate::daemon::notifications::quota::NotificationQuota; +use crate::daemon::notifications::ingress::metrics::{IngressMetrics, RejectedRequest}; +use crate::daemon::notifications::ingress::quota::NotificationQuota; use crate::daemon::DaemonState; const MAX_CONCURRENT_NOTIFY_HANDLERS: usize = 8; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs index 7466421ff..583345936 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use super::super::{preflight_notify, PreflightError}; -use super::notify_message; +use super::support::notify_message; use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; #[test] @@ -36,7 +36,8 @@ fn action_array_accepts_eight_pairs_and_rejects_the_next_element() { #[test] fn action_key_and_label_keep_independent_field_limits() { - let oversized_key = "k".repeat(crate::daemon::notifications::limits::MAX_ACTION_KEY_BYTES + 1); + let oversized_key = + "k".repeat(crate::daemon::notifications::ingress::limits::MAX_ACTION_KEY_BYTES + 1); let key_message = notify_message( "app", "", @@ -53,7 +54,7 @@ fn action_key_and_label_keep_independent_field_limits() { ); let oversized_label = - "l".repeat(crate::daemon::notifications::limits::MAX_ACTION_LABEL_BYTES + 1); + "l".repeat(crate::daemon::notifications::ingress::limits::MAX_ACTION_LABEL_BYTES + 1); let label_message = notify_message( "app", "", diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs index fa3bc55b6..75d3538ec 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs @@ -4,7 +4,7 @@ use zbus::zvariant::{OwnedValue, Value}; use zbus::Message; use super::super::{preflight_notify, PreflightError}; -use super::notify_message; +use super::support::notify_message; #[test] fn ordinary_notify_body_passes_structural_preflight() { @@ -37,7 +37,7 @@ fn notify_method_with_the_wrong_body_signature_is_rejected() { #[test] fn field_string_limit_is_enforced_before_owned_string_creation() { - let summary = "s".repeat(crate::daemon::notifications::limits::MAX_SUMMARY_BYTES + 1); + let summary = "s".repeat(crate::daemon::notifications::ingress::limits::MAX_SUMMARY_BYTES + 1); let message = notify_message("app", "", &summary, "", Vec::new(), HashMap::new()); assert_eq!( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs index c0af8a889..d35c0728c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use zbus::zvariant::{OwnedValue, Structure, Value}; use super::super::{preflight_notify, PreflightError}; -use super::notify_message; +use super::support::notify_message; use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; #[test] @@ -88,7 +88,7 @@ fn non_image_byte_array_does_not_inherit_the_image_allowance() { #[test] fn cumulative_nested_string_data_is_bounded() { - let text = "h".repeat(crate::daemon::notifications::limits::MAX_HINT_STRING_BYTES); + let text = "h".repeat(crate::daemon::notifications::ingress::limits::MAX_HINT_STRING_BYTES); let hints = (0..16) .map(|index| { let values = Value::from(vec![text.as_str(); 4]); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs index d4d398cf9..7a77f0d05 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs @@ -8,5 +8,3 @@ mod limits; mod signature; mod support; mod value; - -use support::notify_message; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs index cc33afdeb..1e18a8e71 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs @@ -5,7 +5,7 @@ use zbus::Message; use super::cursor::Cursor; use super::limits::{PreflightError, StringBudget}; use super::signature::SignatureParser; -use crate::daemon::notifications::limits::{ +use crate::daemon::notifications::ingress::limits::{ MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, MAX_SUMMARY_BYTES, }; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs index cd61fc6fb..1e7784fd6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs @@ -1,6 +1,6 @@ //! Recursive variant-value traversal without owned payload construction -use crate::daemon::notifications::limits::MAX_HINT_STRING_BYTES; +use crate::daemon::notifications::ingress::limits::MAX_HINT_STRING_BYTES; use super::cursor::Cursor; use super::limits::{ diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index 2e871cad6..dba49d910 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -73,5 +73,5 @@ async fn main() -> Result<()> { ensure_wayland_session(Duration::from_secs(20)) .await .context("wait for Wayland session")?; - runtime::run(&args, config).await + Box::pin(runtime::run(&args, config)).await } diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index af9ef63ca..373b6d528 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -13,8 +13,12 @@ use super::{daemon, trial_cleanup}; const DAEMON_DBUS_QUEUE_CAPACITY: usize = 16; pub async fn run(args: &Args, config: Config) -> Result<()> { - let connection = Builder::session() - .context("create session bus connection")? + let builder = Builder::session().context("create session bus connection")?; + Box::pin(run_with_builder(args, config, builder)).await +} + +async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> Result<()> { + let connection = builder .max_queued(DAEMON_DBUS_QUEUE_CAPACITY) .build() .await diff --git a/crates/unixnotis-daemon/src/runtime/tests/runner.rs b/crates/unixnotis-daemon/src/runtime/tests/runner.rs index 863e0d31d..1cf797a91 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/runner.rs @@ -1,7 +1,9 @@ use clap::Parser; -use super::trial_requested; +use super::{run_with_builder, trial_requested}; use crate::cli::Args; +use unixnotis_core::Config; +use zbus::connection::Builder; #[test] fn trial_preparation_is_enabled_only_by_the_trial_flag() { @@ -12,3 +14,19 @@ fn trial_preparation_is_enabled_only_by_the_trial_flag() { assert!(!trial_requested(&normal)); assert!(trial_requested(&trial)); } + +#[tokio::test(flavor = "current_thread")] +async fn runtime_reports_an_unreachable_session_bus() { + let args = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); + let builder = Builder::address("unix:path=/nonexistent/unixnotis-test-session-bus") + .expect("valid unreachable bus address"); + + let error = Box::pin(run_with_builder(&args, Config::default(), builder)) + .await + .expect_err("unreachable session bus should reject startup"); + + assert!( + error.to_string().contains("connect to session bus"), + "unexpected error: {error:#}" + ); +} diff --git a/crates/unixnotis-daemon/src/tests/cli.rs b/crates/unixnotis-daemon/src/tests/cli.rs index 3f1436960..f914f66f7 100644 --- a/crates/unixnotis-daemon/src/tests/cli.rs +++ b/crates/unixnotis-daemon/src/tests/cli.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use clap::{CommandFactory, Parser}; use super::{Args, RestoreStrategy}; @@ -33,3 +33,12 @@ fn args_parse_trial_restore_process_and_run_seconds() { assert_eq!(args.restore_wait_ms, 125); assert_eq!(args.run_seconds, Some(9)); } + +#[test] +fn daemon_help_lists_the_supported_entrypoint_flags() { + let help = Args::command().render_help().to_string(); + + assert!(help.contains("Usage:")); + assert!(help.contains("--check")); + assert!(help.contains("--trial")); +} diff --git a/crates/unixnotis-daemon/tests/cli.rs b/crates/unixnotis-daemon/tests/cli.rs deleted file mode 100644 index 295eea49f..000000000 --- a/crates/unixnotis-daemon/tests/cli.rs +++ /dev/null @@ -1,75 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::fs; - use std::os::unix::net::UnixListener; - use std::path::{Path, PathBuf}; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - type TestResult = Result>; - - #[test] - fn daemon_help_prints_usage_from_entrypoint() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-daemon")) - .arg("--help") - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage:")); - assert!(stdout.contains("--check")); - assert!(stdout.contains("--trial")); - Ok(()) - } - - struct TempRoot(PathBuf); - - impl TempRoot { - fn new() -> TestResult { - let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let path = std::env::temp_dir().join(format!( - "unixnotis-daemon-cli-{}-{nonce}", - std::process::id() - )); - fs::create_dir(&path)?; - Ok(Self(path)) - } - - fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempRoot { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[test] - fn daemon_runtime_reports_an_unreachable_session_bus() -> TestResult { - let root = TempRoot::new()?; - let config = root.path().join("config.toml"); - fs::write(&config, "config_version = 3\n")?; - let display = "wayland-unixnotis-test"; - let _wayland = UnixListener::bind(root.path().join(display))?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-daemon")) - .args(["--config", config.to_str().ok_or("non-UTF-8 config path")?]) - .args(["--run-seconds", "0"]) - .env("XDG_RUNTIME_DIR", root.path()) - .env("WAYLAND_DISPLAY", display) - .env("XDG_SESSION_TYPE", "wayland") - .env( - "DBUS_SESSION_BUS_ADDRESS", - "unix:path=/nonexistent/unixnotis-test-session-bus", - ) - .output()?; - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("session bus"), "unexpected error: {stderr}"); - Ok(()) - } -} diff --git a/crates/unixnotis-installer/src/tests/support/mod.rs b/crates/unixnotis-installer/src/tests/support/mod.rs index bdecc04cc..f4ab0613d 100644 --- a/crates/unixnotis-installer/src/tests/support/mod.rs +++ b/crates/unixnotis-installer/src/tests/support/mod.rs @@ -2,13 +2,7 @@ pub mod env; pub mod fs; - -impl crate::paths::InstallPaths { - pub(crate) fn discover() -> anyhow::Result { - // Test callers use the same automatic manager selection as the normal CLI - Self::discover_with_service_manager(None) - } -} +mod paths; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/tests/support/paths.rs b/crates/unixnotis-installer/src/tests/support/paths.rs new file mode 100644 index 000000000..615ee9895 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/support/paths.rs @@ -0,0 +1,6 @@ +impl crate::paths::InstallPaths { + pub(crate) fn discover() -> anyhow::Result { + // Test callers use the same automatic manager selection as the normal CLI + Self::discover_with_service_manager(None) + } +} diff --git a/crates/unixnotis-installer/src/tests/support/tests/env.rs b/crates/unixnotis-installer/src/tests/support/tests/env.rs new file mode 100644 index 000000000..520a32ea0 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/support/tests/env.rs @@ -0,0 +1,16 @@ +use super::super::env::{test_env_lock, EnvGuard}; + +#[test] +fn environment_guard_restores_the_original_value() { + const NAME: &str = "UNIXNOTIS_INSTALLER_ENV_GUARD_TEST"; + let _lock = test_env_lock(); + std::env::set_var(NAME, "before"); + + { + let _guard = EnvGuard::set(NAME, "during"); + assert_eq!(std::env::var_os(NAME).as_deref(), Some("during".as_ref())); + } + + assert_eq!(std::env::var_os(NAME).as_deref(), Some("before".as_ref())); + std::env::remove_var(NAME); +} diff --git a/crates/unixnotis-installer/src/tests/support/tests/mod.rs b/crates/unixnotis-installer/src/tests/support/tests/mod.rs index d0e408f56..d3b0dfd34 100644 --- a/crates/unixnotis-installer/src/tests/support/tests/mod.rs +++ b/crates/unixnotis-installer/src/tests/support/tests/mod.rs @@ -1 +1,2 @@ +mod env; mod fs; diff --git a/crates/unixnotis-installer/tests/cli.rs b/crates/unixnotis-installer/tests/cli.rs deleted file mode 100644 index 5cbfd1f8d..000000000 --- a/crates/unixnotis-installer/tests/cli.rs +++ /dev/null @@ -1,30 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::os::unix::process::CommandExt; - use std::process::Command; - - type TestResult = Result<(), Box>; - - fn installer_command_as_non_root() -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_unixnotis-installer")); - - // Root-based CI must exercise the same user-level entrypoint as a desktop session - if rustix::process::geteuid().is_root() { - command.uid(65_534); - } - - command - } - - #[test] - fn installer_help_prints_usage_from_entrypoint() -> TestResult { - let output = installer_command_as_non_root().arg("--help").output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage: unixnotis-installer")); - assert!(stdout.contains("--service-manager")); - Ok(()) - } -} diff --git a/crates/unixnotis-ui/Cargo.toml b/crates/unixnotis-ui/Cargo.toml index 1c5d9e8ed..fed91057f 100644 --- a/crates/unixnotis-ui/Cargo.toml +++ b/crates/unixnotis-ui/Cargo.toml @@ -16,4 +16,3 @@ url.workspace = true [[bin]] name = "unixnotis-css-validate" path = "src/bin/css_validate.rs" -test = false diff --git a/crates/unixnotis-ui/src/bin/css_validate.rs b/crates/unixnotis-ui/src/bin/css_validate.rs index e2a396ad7..315191dce 100644 --- a/crates/unixnotis-ui/src/bin/css_validate.rs +++ b/crates/unixnotis-ui/src/bin/css_validate.rs @@ -47,7 +47,23 @@ fn main() -> ExitCode { fn run_path_protocol(path: &Path) -> ExitCode { // Initialization stays inside this helper so ordinary CLI commands do not load GTK - let report = match gtk::init() { + let report = path_report(path); + + // One JSON document keeps the parent-side protocol simple and deterministic + match serde_json::to_string(&report) { + Ok(encoded) => { + println!("{encoded}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("failed to encode CSS validation report: {error}"); + ExitCode::from(2) + } + } +} + +fn path_report(path: &Path) -> ValidatorReport { + match gtk::init() { Ok(()) => { let (diagnostics, truncated) = parse_path(path); ValidatorReport { @@ -63,18 +79,6 @@ fn run_path_protocol(path: &Path) -> ExitCode { truncated: false, diagnostics: Vec::new(), }, - }; - - // One JSON document keeps the parent-side protocol simple and deterministic - match serde_json::to_string(&report) { - Ok(encoded) => { - println!("{encoded}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("failed to encode CSS validation report: {error}"); - ExitCode::from(2) - } } } @@ -90,6 +94,17 @@ fn run_stdin_protocol() -> ExitCode { return ExitCode::SUCCESS; } + let parse_errors = parse_css_text(&css); + + // Success remains silent for easy use from build scripts + if parse_errors == 0 { + return ExitCode::SUCCESS; + } + eprintln!("gtk css validation found {parse_errors} parse error(s)"); + ExitCode::from(1) +} + +fn parse_css_text(css: &str) -> usize { // Parse errors are counted without retaining unbounded GTK messages let provider = CssProvider::new(); let parse_errors = Rc::new(Cell::new(0usize)); @@ -104,17 +119,8 @@ fn run_stdin_protocol() -> ExitCode { error ); }); - provider.load_from_string(&css); - - // Success remains silent for easy use from build scripts - if parse_errors.get() == 0 { - return ExitCode::SUCCESS; - } - eprintln!( - "gtk css validation found {} parse error(s)", - parse_errors.get() - ); - ExitCode::from(1) + provider.load_from_string(css); + parse_errors.get() } fn parse_path(path: &Path) -> (Vec, bool) { @@ -152,3 +158,7 @@ fn parse_path(path: &Path) -> (Vec, bool) { let parsed = diagnostics.borrow().clone(); (parsed, truncated.get()) } + +#[cfg(test)] +#[path = "tests/css_validate.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/bin/tests/css_validate.rs b/crates/unixnotis-ui/src/bin/tests/css_validate.rs new file mode 100644 index 000000000..f436f5294 --- /dev/null +++ b/crates/unixnotis-ui/src/bin/tests/css_validate.rs @@ -0,0 +1,115 @@ +use std::error::Error; +use std::fmt::Write as _; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::{parse_css_text, path_report}; + +type TestResult = Result<(), Box>; + +static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +#[gtk::test] +fn css_validator_accepts_parseable_css_and_rejects_invalid_css() -> TestResult { + assert_eq!(parse_css_text(".panel { color: #ffffff; }"), 0); + assert!(parse_css_text(".panel { color: ;") > 0); + Ok(()) +} + +#[gtk::test] +fn path_protocol_accepts_percent_encoded_asset_urls() -> TestResult { + let root = temp_root("encoded-imports"); + let assets = root.join("assets"); + std::fs::create_dir_all(&assets)?; + let cases = [ + ("icon%20one.svg", "icon one.svg"), + ("icon%23one.svg", "icon#one.svg"), + ("icon%25one.svg", "icon%one.svg"), + ("icon%29one.svg", "icon)one.svg"), + ("icon%22one.svg", "icon\"one.svg"), + ]; + let mut stylesheet = String::new(); + for (index, (encoded_name, decoded_name)) in cases.into_iter().enumerate() { + std::fs::write( + assets.join(decoded_name), + "", + )?; + writeln!( + stylesheet, + ".encoded-{index}-plain {{ background-image: url(assets/{encoded_name}); }}" + )?; + writeln!( + stylesheet, + ".encoded-{index}-quoted {{ background-image: url(\"assets/{encoded_name}\"); }}" + )?; + } + let stylesheet_path = root.join("base.css"); + std::fs::write(&stylesheet_path, stylesheet)?; + + let report = path_report(&stylesheet_path); + + if report.available { + assert!(report.diagnostics.is_empty()); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[gtk::test] +fn path_protocol_accepts_css_escaped_url_and_import_names() -> TestResult { + let root = temp_root("escaped-reference-tokens"); + let assets = root.join("assets"); + std::fs::create_dir_all(&assets)?; + std::fs::write( + assets.join("icon.svg"), + "", + )?; + std::fs::write(root.join("colors.css"), ".imported { color: red; }")?; + let stylesheet = root.join("base.css"); + std::fs::write( + &stylesheet, + concat!( + "@im\\70ort \"colors.css\";\n", + ".short { background-image: u\\72l(\"assets/icon.svg\"); }\n", + ".six { background-image: U\\000052L(assets/icon.svg); }\n", + ), + )?; + + let report = path_report(&stylesheet); + + if report.available { + assert!(report.diagnostics.is_empty()); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[gtk::test] +fn path_protocol_returns_bounded_structured_diagnostics() -> TestResult { + let root = temp_root("diagnostic-cap"); + std::fs::create_dir_all(&root)?; + let stylesheet = root.join("many-errors.css"); + let mut css = String::new(); + for index in 0..12 { + writeln!(css, ".broken-{index} {{ color: ; }}")?; + } + std::fs::write(&stylesheet, css)?; + + let report = path_report(&stylesheet); + + if report.available { + assert!(!report.diagnostics.is_empty()); + assert!(report.diagnostics.len() <= 4); + assert!(report.truncated); + assert_eq!(report.diagnostics[0].line, 1); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +fn temp_root(name: &str) -> std::path::PathBuf { + let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "unixnotis-css-validator-{name}-{}-{serial}", + std::process::id() + )) +} diff --git a/crates/unixnotis-ui/src/cut_corner/tests/mod.rs b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs index 8509047b5..6b5fb298d 100644 --- a/crates/unixnotis-ui/src/cut_corner/tests/mod.rs +++ b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs @@ -1,3 +1,4 @@ //! Cut-corner geometry regression coverage mod geometry; +mod widget; diff --git a/crates/unixnotis-ui/tests/cut_corner.rs b/crates/unixnotis-ui/src/cut_corner/tests/widget.rs similarity index 98% rename from crates/unixnotis-ui/tests/cut_corner.rs rename to crates/unixnotis-ui/src/cut_corner/tests/widget.rs index 6f3782990..b96429bf5 100644 --- a/crates/unixnotis-ui/tests/cut_corner.rs +++ b/crates/unixnotis-ui/src/cut_corner/tests/widget.rs @@ -1,6 +1,7 @@ use gtk::prelude::*; use unixnotis_core::CutCorners; -use unixnotis_ui::CutCorner; + +use crate::CutCorner; #[gtk::test] fn cut_corner_wraps_one_child_and_retains_configured_geometry() { diff --git a/crates/unixnotis-ui/tests/css_validate.rs b/crates/unixnotis-ui/tests/css_validate.rs deleted file mode 100644 index 336b69a25..000000000 --- a/crates/unixnotis-ui/tests/css_validate.rs +++ /dev/null @@ -1,207 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::fmt::Write as _; - use std::io::{Error as IoError, ErrorKind, Write as _}; - use std::process::{Command, Output, Stdio}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - type TestResult = Result<(), Box>; - - static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - #[test] - fn css_validate_accepts_parseable_css() -> TestResult { - let output = run_validator(".panel { color: #ffffff; }")?; - - // Valid CSS should not emit parser diagnostics or fail the helper process - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr).trim().is_empty(), - "unexpected stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) - } - - #[test] - fn path_protocol_accepts_percent_encoded_asset_urls_in_quoted_and_unquoted_forms() -> TestResult - { - let root = temp_root("encoded-imports"); - let assets = root.join("assets"); - std::fs::create_dir_all(&assets)?; - let cases = [ - ("icon%20one.svg", "icon one.svg"), - ("icon%23one.svg", "icon#one.svg"), - ("icon%25one.svg", "icon%one.svg"), - ("icon%29one.svg", "icon)one.svg"), - ("icon%22one.svg", "icon\"one.svg"), - ]; - let mut stylesheet = String::new(); - for (index, (encoded_name, decoded_name)) in cases.into_iter().enumerate() { - std::fs::write( - assets.join(decoded_name), - "", - )?; - writeln!( - stylesheet, - ".encoded-{index}-plain {{ background-image: url(assets/{encoded_name}); }}" - )?; - writeln!( - stylesheet, - ".encoded-{index}-quoted {{ background-image: url(\"assets/{encoded_name}\"); }}" - )?; - } - let stylesheet_path = root.join("base.css"); - std::fs::write(&stylesheet_path, stylesheet)?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet_path) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - assert_eq!(report["diagnostics"], serde_json::json!([]), "{report}"); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn path_protocol_accepts_css_escaped_url_and_import_token_names() -> TestResult { - let root = temp_root("escaped-reference-tokens"); - let assets = root.join("assets"); - std::fs::create_dir_all(&assets)?; - std::fs::write( - assets.join("icon.svg"), - "", - )?; - std::fs::write(root.join("colors.css"), ".imported { color: red; }")?; - let stylesheet = root.join("base.css"); - std::fs::write( - &stylesheet, - concat!( - "@im\\70ort \"colors.css\";\n", - ".short { background-image: u\\72l(\"assets/icon.svg\"); }\n", - ".six { background-image: U\\000052L(assets/icon.svg); }\n", - ), - )?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - assert_eq!(report["diagnostics"], serde_json::json!([]), "{report}"); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn css_validate_rejects_invalid_css_with_diagnostic() -> TestResult { - let output = run_validator(".panel { color: ;")?; - let stderr = String::from_utf8_lossy(&output.stderr); - - // A real parser error must fail so generated CSS tests catch broken output - assert!(!output.status.success()); - assert!(stderr.contains("gtk css parse error"), "{stderr}"); - assert!(stderr.contains("gtk css validation found"), "{stderr}"); - - Ok(()) - } - - #[test] - fn path_protocol_returns_structured_parser_diagnostics() -> TestResult { - let root = temp_root("path-protocol"); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root)?; - let stylesheet = root.join("broken.css"); - std::fs::write(&stylesheet, ".panel { color: ;")?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - let diagnostics = report["diagnostics"] - .as_array() - .ok_or("diagnostics must be an array")?; - assert!(!diagnostics.is_empty()); - assert_eq!(diagnostics[0]["line"], 1); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn path_protocol_caps_large_diagnostic_sets() -> TestResult { - let root = temp_root("diagnostic-cap"); - std::fs::create_dir_all(&root)?; - let stylesheet = root.join("many-errors.css"); - let mut css = String::new(); - for index in 0..12 { - writeln!(css, ".broken-{index} {{ color: ; }}")?; - } - std::fs::write(&stylesheet, css)?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - let diagnostics = report["diagnostics"] - .as_array() - .ok_or("diagnostics must be an array")?; - assert!(diagnostics.len() <= 4); - assert_eq!(report["truncated"], true); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - fn run_validator(css: &str) -> Result { - let binary = env!("CARGO_BIN_EXE_unixnotis-css-validate"); - let mut child = Command::new(binary) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn()?; - - // The validator contract is stdin, stderr diagnostics, and exit status - let Some(mut stdin) = child.stdin.take() else { - return Err(IoError::new( - ErrorKind::BrokenPipe, - "css validator stdin unavailable", - )); - }; - stdin.write_all(css.as_bytes())?; - drop(stdin); - - child.wait_with_output() - } - - fn temp_root(name: &str) -> std::path::PathBuf { - let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "unixnotis-css-validator-{name}-{}-{serial}", - std::process::id() - )) - } -} From 8a128d9c88024025f4ac0edf049916c45b670a38 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 21:11:00 -0500 Subject: [PATCH 089/275] fix(daemon): harden desktop application association Summary: harden desktop application association. Scope: daemon. --- Cargo.lock | 41 +++ Cargo.toml | 1 + crates/unixnotis-daemon/Cargo.toml | 1 + .../notifications/identity/desktop_index.rs | 323 ------------------ .../identity/desktop_index/index.rs | 121 +++++++ .../identity/desktop_index/mod.rs | 21 ++ .../identity/desktop_index/model.rs | 69 ++++ .../identity/desktop_index/names.rs | 64 ++++ .../identity/desktop_index/program.rs | 25 ++ .../identity/desktop_index/record.rs | 92 +++++ .../identity/desktop_index/scan.rs | 162 +++++++++ .../identity/desktop_index/tests/mod.rs | 3 + .../identity/desktop_index/tests/names.rs | 26 ++ .../tests/parsing.rs} | 23 +- .../identity/desktop_index/tests/scan.rs | 140 ++++++++ .../daemon/notifications/identity/resolver.rs | 40 ++- .../notifications/identity/tests/resolver.rs | 144 +++++++- .../daemon/notifications/server/ingress.rs | 12 + .../notifications/server/tests/ingress.rs | 28 +- 19 files changed, 1001 insertions(+), 335 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/desktop_index.rs => desktop_index/tests/parsing.rs} (61%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs diff --git a/Cargo.lock b/Cargo.lock index 9e263799d..792de7b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3266,6 +3266,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -3483,6 +3498,31 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -3583,6 +3623,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "unicode-security", "unixnotis-core", "zbus", ] diff --git a/Cargo.toml b/Cargo.toml index fdae4e065..1426e62ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ proptest = "1.11.0" crossterm = "0.29" data-url = "0.3" unicode-width = "0.2.2" +unicode-security = "0.1.2" rustix = { version = "1.1", features = ["event", "fs", "process"] } resvg = { version = "0.47.0", default-features = false } semver = "1.0.28" diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index ee5795f78..aae9e0c48 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +unicode-security.workspace = true zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } indexmap.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs deleted file mode 100644 index a37ca6d6e..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Desktop application index preserving system and user entry origins - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; - -use gio::prelude::AppInfoExt; - -use super::executable::{executable_evidence_for_path, FileIdentity}; - -const MAX_DESKTOP_FILES: usize = 8_192; - -#[derive(Debug, Clone)] -pub(super) struct DesktopRecord { - pub(super) id: String, - pub(super) display_name: String, - pub(super) badge_icon: String, - pub(super) executable_path: Option, - pub(super) executable_identity: Option, - pub(super) system_entry: bool, - pub(super) dbus_activatable: bool, - names: HashSet, -} - -impl DesktopRecord { - pub(super) fn claim_matches(&self, claim: &str) -> bool { - // Normalized aliases cover desktop names without trusting free-form display text - self.names.contains(&normalize_name(claim)) - } - - #[cfg(test)] - pub(super) fn fixture( - id: &str, - display_name: &str, - executable_path: &str, - identity: FileIdentity, - system_entry: bool, - dbus_activatable: bool, - ) -> Self { - let mut names = HashSet::new(); - names.insert(normalize_name(display_name)); - Self { - id: id.to_string(), - display_name: display_name.to_string(), - badge_icon: id.to_string(), - executable_path: Some(PathBuf::from(executable_path)), - executable_identity: Some(identity), - system_entry, - dbus_activatable, - names, - } - } -} - -#[derive(Debug, Default)] -pub(in crate::daemon) struct DesktopIdentityIndex { - records: Vec, - by_id: HashMap>, - by_identity: HashMap<(u64, u64), Vec>, - system_names: HashSet, - trusted_relays: Vec, -} - -#[derive(Debug, Clone)] -struct ExecutableIdentity { - path: PathBuf, - identity: FileIdentity, -} - -impl DesktopIdentityIndex { - pub(in crate::daemon) fn shared() -> Arc { - static INDEX: OnceLock> = OnceLock::new(); - // One immutable snapshot serves the daemon lifetime and every notification burst - INDEX.get_or_init(|| Arc::new(Self::new())).clone() - } - - #[must_use] - pub(in crate::daemon) fn new() -> Self { - let mut index = Self::default(); - // User entries are scanned first so local desktop overrides keep normal precedence - for (root, system_entry) in desktop_roots() { - index.scan_root(&root, system_entry); - if index.records.len() >= MAX_DESKTOP_FILES { - break; - } - } - // Relay trust is tied to the installed file identity instead of its basename - index.index_trusted_relay(Path::new("/usr/bin/notify-send")); - index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); - index - } - - pub(super) fn records_for_id(&self, id: &str) -> Vec<&DesktopRecord> { - self.by_id - .get(&normalize_desktop_id(id)) - .into_iter() - .flatten() - .filter_map(|index| self.records.get(*index)) - .collect() - } - - pub(super) fn records_for_executable(&self, identity: FileIdentity) -> Vec<&DesktopRecord> { - self.by_identity - .get(&(identity.device, identity.inode)) - .into_iter() - .flatten() - .filter_map(|index| self.records.get(*index)) - .collect() - } - - pub(super) fn claim_matches_system_app(&self, claim: &str) -> bool { - self.system_names.contains(&normalize_name(claim)) - } - - pub(super) fn trusted_relay_path(&self, identity: FileIdentity) -> Option<&Path> { - self.trusted_relays - .iter() - .find(|relay| relay.identity.same_file(identity)) - .map(|relay| relay.path.as_path()) - } - - fn scan_root(&mut self, root: &Path, system_entry: bool) { - // A bounded iterative walk avoids recursion and unlimited desktop-file growth - let mut pending = vec![root.to_path_buf()]; - while let Some(directory) = pending.pop() { - let Ok(entries) = std::fs::read_dir(&directory) else { - continue; - }; - for entry in entries.flatten() { - if self.records.len() >= MAX_DESKTOP_FILES { - return; - } - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_dir() { - pending.push(entry.path()); - continue; - } - if file_type.is_file() - && entry.path().extension().and_then(|value| value.to_str()) == Some("desktop") - { - self.add_desktop_file(&entry.path(), system_entry); - } - } - } - } - - fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { - // GIO applies desktop-entry parsing rules before any identity is indexed - let Some(desktop) = gio::DesktopAppInfo::from_filename(path) else { - return; - }; - let Some(id) = desktop - .id() - .map(|value| normalize_desktop_id(value.as_str())) - else { - return; - }; - if id.is_empty() { - return; - } - let display_name = desktop.display_name().to_string(); - let executable_path = desktop_executable(&desktop) - .as_deref() - .and_then(resolve_program); - let executable_identity = executable_path - .as_deref() - .and_then(executable_evidence_for_path) - .map(|evidence| evidence.identity); - let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); - // System association requires protected metadata and a protected executable - let system_entry = system_origin - && desktop_identity.is_some_and(FileIdentity::is_system_managed) - && executable_identity.is_some_and(FileIdentity::is_system_managed); - let badge_icon = desktop - .string("Icon") - .map_or_else(|| id.clone(), |value| value.to_string()); - let mut names = HashSet::new(); - // Each alias is only a claim matcher after executable identity already agrees - names.insert(normalize_name(&display_name)); - names.insert(normalize_name(desktop.name().as_str())); - if let Some(generic_name) = desktop.generic_name() { - names.insert(normalize_name(generic_name.as_str())); - } - if let Some(wm_class) = desktop.startup_wm_class() { - names.insert(normalize_name(wm_class.as_str())); - } - names.insert(normalize_name(&id)); - if let Some(executable) = executable_path - .as_deref() - .and_then(Path::file_name) - .and_then(|name| name.to_str()) - { - names.insert(normalize_name(executable)); - } - names.retain(|name| !name.is_empty()); - - let record = DesktopRecord { - id: id.clone(), - display_name, - badge_icon, - executable_path, - executable_identity, - system_entry, - dbus_activatable: desktop.boolean("DBusActivatable"), - names, - }; - let record_index = self.records.len(); - // Protected names help detect spoofing but never establish identity on their own - if system_entry { - self.system_names.extend(record.names.iter().cloned()); - } - self.by_id.entry(id).or_default().push(record_index); - if let Some(identity) = record.executable_identity { - self.by_identity - .entry((identity.device, identity.inode)) - .or_default() - .push(record_index); - } - self.records.push(record); - } - - fn index_trusted_relay(&mut self, path: &Path) { - let Some(evidence) = executable_evidence_for_path(path) else { - return; - }; - // Writable relay binaries stay ordinary unknown senders - if evidence.identity.is_system_managed() { - self.trusted_relays.push(ExecutableIdentity { - path: evidence.canonical_path, - identity: evidence.identity, - }); - } - } - - #[cfg(test)] - pub(super) fn from_records( - records: Vec, - trusted_relays: Vec<(PathBuf, FileIdentity)>, - ) -> Self { - let mut index = Self::default(); - for record in records { - let record_index = index.records.len(); - if record.system_entry { - index.system_names.extend(record.names.iter().cloned()); - } - index - .by_id - .entry(normalize_desktop_id(&record.id)) - .or_default() - .push(record_index); - if let Some(identity) = record.executable_identity { - index - .by_identity - .entry((identity.device, identity.inode)) - .or_default() - .push(record_index); - } - index.records.push(record); - } - index.trusted_relays = trusted_relays - .into_iter() - .map(|(path, identity)| ExecutableIdentity { path, identity }) - .collect(); - index - } -} - -fn desktop_roots() -> Vec<(PathBuf, bool)> { - let mut roots = Vec::new(); - // The user data root remains distinct because its entries are not system evidence - if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) - { - roots.push((data_home.join("applications"), false)); - } - let data_dirs = - std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into()); - roots.extend(std::env::split_paths(&data_dirs).map(|root| (root.join("applications"), true))); - roots -} - -fn resolve_program(program: &Path) -> Option { - // Canonical paths are presentation data while device and inode carry the proof - if program.is_absolute() { - return program.canonicalize().ok(); - } - let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|directory| directory.join(program)) - .find_map(|candidate| candidate.canonicalize().ok()) -} - -fn desktop_executable(desktop: &gio::DesktopAppInfo) -> Option { - // GIO exposes a nullable executable for valid D-Bus-activated entries without Exec - desktop.commandline()?; - let executable = desktop.executable(); - (!executable.as_os_str().is_empty()).then_some(executable) -} - -pub(super) fn normalize_desktop_id(value: &str) -> String { - // Desktop hints commonly include an optional suffix and mixed case - value - .trim() - .strip_suffix(".desktop") - .unwrap_or_else(|| value.trim()) - .to_ascii_lowercase() -} - -pub(super) fn normalize_name(value: &str) -> String { - // Punctuation and case do not create separate branding aliases - value - .chars() - .filter(|character| character.is_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -#[cfg(test)] -#[path = "tests/desktop_index.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs new file mode 100644 index 000000000..8dc069150 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -0,0 +1,121 @@ +//! Desktop record lookup tables and trusted relay matching + +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::model::{DesktopIdentityIndex, DesktopRecord, ExecutableIdentity}; +use super::names::{normalize_brand_name, normalize_desktop_id}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn records_for_id( + &self, + id: &str, + ) -> Vec<&DesktopRecord> { + // Duplicate IDs remain separate so origin can be checked by the resolver + self.by_id + .get(&normalize_desktop_id(id)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn records_for_executable( + &self, + identity: FileIdentity, + ) -> Vec<&DesktopRecord> { + // Device and inode avoid trusting a replaceable executable path + self.by_identity + .get(&(identity.device, identity.inode)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( + &self, + claim: &str, + ) -> bool { + // Confusable spellings share one protected-brand skeleton + let claim = normalize_brand_name(claim); + !claim.is_empty() && self.system_brand_names.contains(&claim) + } + + pub(in crate::daemon::notifications::identity) fn has_system_record_for_id( + &self, + id: &str, + ) -> bool { + self.records_for_id(id) + .iter() + .any(|record| record.system_origin) + } + + pub(in crate::daemon::notifications::identity) fn trusted_relay_path( + &self, + identity: FileIdentity, + ) -> Option<&Path> { + self.trusted_relays + .iter() + .find(|relay| relay.identity.same_file(identity)) + .map(|relay| relay.path.as_path()) + } + + pub(super) fn index_trusted_relay(&mut self, path: &Path) { + let Some(evidence) = executable_evidence_for_path(path) else { + return; + }; + // Writable relay binaries stay ordinary unknown senders + if evidence.identity.is_system_managed() { + self.trusted_relays.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + + #[cfg(test)] + pub(in crate::daemon::notifications::identity) fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self { + let mut index = Self::default(); + for record in records { + index.index_record(record); + } + index.trusted_relays = trusted_relays + .into_iter() + .map(|(path, identity)| ExecutableIdentity { path, identity }) + .collect(); + index + } + + pub(super) fn index_record(&mut self, record: DesktopRecord) { + let record_index = self.records.len(); + if record.system_origin { + // Protected branding excludes generic names and launcher aliases + for brand in [&record.display_name, &record.id] { + let brand = normalize_brand_name(brand); + if !brand.is_empty() { + self.system_brand_names.insert(brand); + } + } + } + self.by_id + .entry(normalize_desktop_id(&record.id)) + .or_default() + .push(record_index); + // Generic launchers are presentation records but never executable evidence + if record.association_eligible { + if let Some(identity) = record.executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + self.records.push(record); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs new file mode 100644 index 000000000..4f07a540d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -0,0 +1,21 @@ +//! Desktop application index preserving system and user entry origins + +mod index; +mod model; +mod names; +mod program; +mod record; +mod scan; + +pub(in crate::daemon) use model::DesktopIdentityIndex; +pub(super) use model::DesktopRecord; +pub(super) use names::{normalize_desktop_id, normalize_name}; + +#[cfg(test)] +pub(super) use names::is_shared_launcher; +#[cfg(test)] +pub(super) use program::desktop_executable; +#[cfg(test)] +pub(super) use scan::{ScanBudget, ScanLimits}; +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs new file mode 100644 index 000000000..c0b5451ec --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -0,0 +1,69 @@ +//! Indexed desktop records and executable evidence + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use super::super::executable::FileIdentity; +use super::names::normalize_name; + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct DesktopRecord { + pub(in crate::daemon::notifications::identity) id: String, + pub(in crate::daemon::notifications::identity) display_name: String, + pub(in crate::daemon::notifications::identity) badge_icon: String, + pub(in crate::daemon::notifications::identity) executable_path: Option, + pub(in crate::daemon::notifications::identity) executable_identity: Option, + pub(in crate::daemon::notifications::identity) desktop_identity: Option, + pub(in crate::daemon::notifications::identity) system_origin: bool, + pub(in crate::daemon::notifications::identity) system_association: bool, + pub(in crate::daemon::notifications::identity) association_eligible: bool, + pub(in crate::daemon::notifications::identity) dbus_activatable: bool, + pub(super) names: HashSet, +} + +impl DesktopRecord { + pub(in crate::daemon::notifications::identity) fn claim_matches(&self, claim: &str) -> bool { + // Normalized aliases cover desktop names without trusting free-form display text + self.names.contains(&normalize_name(claim)) + } + + #[cfg(test)] + pub(in crate::daemon::notifications::identity) fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + dbus_activatable: bool, + ) -> Self { + let names = HashSet::from([normalize_name(display_name)]); + Self { + id: id.to_string(), + display_name: display_name.to_string(), + badge_icon: id.to_string(), + executable_path: Some(PathBuf::from(executable_path)), + executable_identity: Some(identity), + desktop_identity: Some(identity), + system_origin: system_entry, + system_association: system_entry, + association_eligible: true, + dbus_activatable, + names, + } + } +} + +#[derive(Debug, Default)] +pub(in crate::daemon) struct DesktopIdentityIndex { + pub(super) records: Vec, + pub(super) by_id: HashMap>, + pub(super) by_identity: HashMap<(u64, u64), Vec>, + pub(super) system_brand_names: HashSet, + pub(super) trusted_relays: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct ExecutableIdentity { + pub(super) path: PathBuf, + pub(super) identity: FileIdentity, +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs new file mode 100644 index 000000000..bd64d7844 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs @@ -0,0 +1,64 @@ +//! Desktop identifiers, aliases, and protected brand normalization + +use std::path::Path; + +use unicode_security::skeleton; + +pub(in crate::daemon::notifications::identity) fn is_shared_launcher(program: &Path) -> bool { + let Some(name) = program.file_name().and_then(|value| value.to_str()) else { + return true; + }; + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "sh" | "bash" + | "dash" + | "zsh" + | "fish" + | "env" + | "node" + | "nodejs" + | "java" + | "electron" + | "wine" + | "wine64" + | "flatpak" + | "gtk-launch" + | "perl" + | "ruby" + | "php" + | "lua" + | "deno" + | "bun" + ) || name.strip_prefix("python").is_some_and(|suffix| { + suffix + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + }) +} + +pub(in crate::daemon::notifications::identity) fn normalize_desktop_id(value: &str) -> String { + // Desktop hints commonly include an optional suffix and mixed case + value + .trim() + .strip_suffix(".desktop") + .unwrap_or_else(|| value.trim()) + .to_ascii_lowercase() +} + +pub(in crate::daemon::notifications::identity) fn normalize_name(value: &str) -> String { + // Punctuation and case do not create separate branding aliases + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +pub(super) fn normalize_brand_name(value: &str) -> String { + // UTS 39 skeletons collapse common cross-script lookalikes before comparison + skeleton(value) + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_lowercase()) + .collect() +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs new file mode 100644 index 000000000..b2a9459c5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs @@ -0,0 +1,25 @@ +//! Desktop launch-program parsing and path resolution + +use std::path::{Path, PathBuf}; + +use gio::prelude::AppInfoExt; + +pub(super) fn resolve_program(program: &Path) -> Option { + // Canonical paths are presentation data while device and inode carry the proof + if program.is_absolute() { + return program.canonicalize().ok(); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(program)) + .find_map(|candidate| candidate.canonicalize().ok()) +} + +pub(in crate::daemon::notifications::identity) fn desktop_executable( + desktop: &gio::DesktopAppInfo, +) -> Option { + // GIO exposes a nullable executable for valid D-Bus-activated entries without Exec + desktop.commandline()?; + let executable = desktop.executable(); + (!executable.as_os_str().is_empty()).then_some(executable) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs new file mode 100644 index 000000000..da7721f11 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -0,0 +1,92 @@ +//! Desktop-entry parsing and indexed record construction + +use std::collections::HashSet; +use std::path::Path; + +use gio::prelude::AppInfoExt; + +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::names::{is_shared_launcher, normalize_desktop_id, normalize_name}; +use super::program::{desktop_executable, resolve_program}; + +impl DesktopIdentityIndex { + pub(super) fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { + // GIO applies desktop-entry parsing rules before any identity is indexed + let Some(desktop) = gio::DesktopAppInfo::from_filename(path) else { + return; + }; + let Some(id) = desktop + .id() + .map(|value| normalize_desktop_id(value.as_str())) + else { + return; + }; + if id.is_empty() { + return; + } + let display_name = desktop.display_name().to_string(); + let desktop_program = desktop_executable(&desktop); + // Shared runtimes identify the launcher, not the application behind it + let association_eligible = desktop_program + .as_deref() + .is_some_and(|program| !is_shared_launcher(program)); + let executable_path = desktop_program.as_deref().and_then(resolve_program); + let executable_identity = executable_path + .as_deref() + .and_then(executable_evidence_for_path) + .map(|evidence| evidence.identity); + let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); + // System association requires protected metadata and an application-specific executable + let system_association = association_eligible + && system_origin + && desktop_identity.is_some_and(FileIdentity::is_system_managed) + && executable_identity.is_some_and(FileIdentity::is_system_managed); + let badge_icon = desktop + .string("Icon") + .map_or_else(|| id.clone(), |value| value.to_string()); + let names = association_aliases(&desktop, &id, &display_name, executable_path.as_deref()); + + self.index_record(DesktopRecord { + id, + display_name, + badge_icon, + executable_path, + executable_identity, + desktop_identity, + system_origin, + system_association, + association_eligible, + dbus_activatable: desktop.boolean("DBusActivatable"), + names, + }); + } +} + +fn association_aliases( + desktop: &gio::DesktopAppInfo, + id: &str, + display_name: &str, + executable_path: Option<&Path>, +) -> HashSet { + // These aliases are considered only after executable identity already agrees + let mut names = HashSet::from([ + normalize_name(display_name), + normalize_name(desktop.name().as_str()), + normalize_name(id), + ]); + if let Some(generic_name) = desktop.generic_name() { + names.insert(normalize_name(generic_name.as_str())); + } + if let Some(wm_class) = desktop.startup_wm_class() { + names.insert(normalize_name(wm_class.as_str())); + } + if let Some(executable) = executable_path + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + { + names.insert(normalize_name(executable)); + } + names.retain(|name| !name.is_empty()); + names +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs new file mode 100644 index 000000000..58abb504e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs @@ -0,0 +1,162 @@ +//! Bounded desktop-entry discovery and record construction + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use tracing::debug; + +use super::model::DesktopIdentityIndex; + +const MAX_DESKTOP_RECORDS: usize = 8_192; +const MAX_DIRECTORIES_VISITED: usize = 4_096; +const MAX_ENTRIES_VISITED: usize = 65_536; +const MAX_DIRECTORY_DEPTH: usize = 16; +const MAX_DESKTOP_FILE_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Copy, Clone)] +pub(in crate::daemon::notifications::identity) struct ScanLimits { + pub(super) records: usize, + pub(super) directories: usize, + pub(super) entries: usize, + pub(super) depth: usize, + pub(super) file_bytes: u64, +} + +impl Default for ScanLimits { + fn default() -> Self { + Self { + records: MAX_DESKTOP_RECORDS, + directories: MAX_DIRECTORIES_VISITED, + entries: MAX_ENTRIES_VISITED, + depth: MAX_DIRECTORY_DEPTH, + file_bytes: MAX_DESKTOP_FILE_BYTES, + } + } +} + +#[derive(Debug, Default)] +pub(in crate::daemon::notifications::identity) struct ScanBudget { + pub(super) directories: usize, + pub(super) entries: usize, + pub(super) skipped_files: usize, + pub(super) stopped_by: Option<&'static str>, +} + +impl ScanBudget { + fn stop(&mut self, reason: &'static str) { + self.stopped_by.get_or_insert(reason); + } + + const fn exhausted(&self) -> bool { + self.stopped_by.is_some() + } +} + +impl DesktopIdentityIndex { + pub(in crate::daemon) fn shared() -> Arc { + static INDEX: OnceLock> = OnceLock::new(); + // One immutable snapshot serves the daemon lifetime and every notification burst + INDEX.get_or_init(|| Arc::new(Self::new())).clone() + } + + #[must_use] + pub(in crate::daemon) fn new() -> Self { + let mut index = Self::default(); + let limits = ScanLimits::default(); + let mut budget = ScanBudget::default(); + // User entries are scanned first while origin remains part of the security identity + for (root, system_entry) in desktop_roots() { + index.scan_root(&root, system_entry, &limits, &mut budget); + if budget.exhausted() { + break; + } + } + if budget.exhausted() || budget.skipped_files != 0 { + // One summary avoids log floods from attacker-controlled application trees + debug!( + stopped_by = budget.stopped_by.unwrap_or("none"), + directories = budget.directories, + entries = budget.entries, + skipped_files = budget.skipped_files, + "desktop application scan reached a safety limit" + ); + } + // Relay trust is tied to the installed file identity instead of its basename + index.index_trusted_relay(Path::new("/usr/bin/notify-send")); + index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); + index + } + + pub(super) fn scan_root( + &mut self, + root: &Path, + system_entry: bool, + limits: &ScanLimits, + budget: &mut ScanBudget, + ) { + // A bounded iterative walk avoids recursion and unlimited desktop-file growth + let mut pending = vec![(root.to_path_buf(), 0_usize)]; + while let Some((directory, depth)) = pending.pop() { + if budget.directories >= limits.directories { + budget.stop("directory budget"); + return; + } + budget.directories += 1; + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries { + if budget.entries >= limits.entries { + budget.stop("entry budget"); + return; + } + budget.entries += 1; + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + let Ok(metadata) = path.symlink_metadata() else { + continue; + }; + let file_type = metadata.file_type(); + if file_type.is_dir() { + if depth >= limits.depth { + budget.stop("directory depth"); + return; + } + pending.push((path, depth + 1)); + continue; + } + if !file_type.is_file() + || path.extension().and_then(|value| value.to_str()) != Some("desktop") + { + continue; + } + if metadata.len() > limits.file_bytes { + budget.skipped_files += 1; + continue; + } + if self.records.len() >= limits.records { + budget.stop("record budget"); + return; + } + self.add_desktop_file(&path, system_entry); + } + } + } +} + +fn desktop_roots() -> Vec<(PathBuf, bool)> { + let mut roots = Vec::new(); + // The user data root remains distinct because its entries are not system evidence + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) + { + roots.push((data_home.join("applications"), false)); + } + let data_dirs = + std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + roots.extend(std::env::split_paths(&data_dirs).map(|root| (root.join("applications"), true))); + roots +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs new file mode 100644 index 000000000..b83f603fa --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs @@ -0,0 +1,3 @@ +mod names; +mod parsing; +mod scan; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs new file mode 100644 index 000000000..14a5fb6b3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs @@ -0,0 +1,26 @@ +use std::path::Path; + +use super::super::is_shared_launcher; + +#[test] +fn shared_launchers_are_never_application_specific_associations() { + for launcher in [ + "/bin/sh", + "/usr/bin/bash", + "/usr/bin/env", + "/usr/bin/python3", + "/usr/bin/python3.12", + "/usr/bin/node", + "/usr/bin/java", + "/usr/bin/electron", + "/usr/bin/wine", + "/usr/bin/flatpak", + "/usr/bin/gtk-launch", + ] { + assert!( + is_shared_launcher(Path::new(launcher)), + "{launcher} must not establish application identity" + ); + } + assert!(!is_shared_launcher(Path::new("/usr/bin/signal-desktop"))); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs similarity index 61% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index 98dc8dd19..5ecabd2c2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/desktop_index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -1,6 +1,7 @@ use std::fs; +use std::path::Path; -use super::*; +use super::super::{desktop_executable, DesktopIdentityIndex}; use crate::test_support::TempRoot; #[test] @@ -20,7 +21,7 @@ fn dbus_activated_desktop_entry_without_exec_has_no_executable() { index.add_desktop_file(&path, true); assert_eq!(index.records.len(), 1); assert!(index.records[0].executable_path.is_none()); - assert!(!index.records[0].system_entry); + assert!(!index.records[0].system_association); } #[test] @@ -39,3 +40,21 @@ fn desktop_entry_exec_is_reduced_by_gio_to_its_program() { Some(Path::new("/usr/bin/true")) ); } + +#[test] +fn generic_name_is_an_association_alias_but_not_a_protected_brand() { + let root = TempRoot::new("desktop-generic-name"); + let path = root.join("org.example.Browser.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Example Browser\nGenericName=Web Browser\nExec=/usr/bin/true\n", + ) + .expect("desktop entry with generic name"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + + assert!(index.records[0].claim_matches("Web Browser")); + assert!(index.claim_matches_system_app("Example Browser")); + assert!(!index.claim_matches_system_app("Web Browser")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs new file mode 100644 index 000000000..d3c4799f8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -0,0 +1,140 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use super::super::*; +use crate::test_support::TempRoot; + +#[test] +fn scan_rejects_oversized_desktop_files_before_parsing() { + let root = TempRoot::new("desktop-size-budget"); + let path = root.join("large.desktop"); + fs::write(&path, "x".repeat(65)).expect("oversized desktop fixture"); + let limits = ScanLimits { + file_bytes: 64, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert!(index.records.is_empty()); + assert_eq!(budget.skipped_files, 1); +} + +#[test] +fn scan_accepts_a_regular_desktop_file_at_the_exact_size_limit() { + let root = TempRoot::new("desktop-exact-size-budget"); + let contents = "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n"; + fs::write(root.join("exact.desktop"), contents).expect("exact-size desktop fixture"); + let limits = ScanLimits { + file_bytes: u64::try_from(contents.len()).expect("fixture length fits u64"), + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(index.records.len(), 1); + assert_eq!(budget.skipped_files, 0); +} + +#[test] +fn scan_never_follows_a_desktop_file_symlink() { + let root = TempRoot::new("desktop-symlink"); + let target = root.join("target.txt"); + fs::write( + &target, + "[Desktop Entry]\nType=Application\nName=Linked\nExec=/usr/bin/true\n", + ) + .expect("symlink target fixture"); + symlink(&target, root.join("linked.desktop")).expect("desktop symlink fixture"); + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &ScanLimits::default(), &mut budget); + + assert!(index.records.is_empty()); +} + +#[test] +fn scan_stops_when_the_global_entry_budget_is_exhausted() { + let root = TempRoot::new("desktop-entry-budget"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + root.join(name), + "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n", + ) + .expect("desktop fixture"); + } + let limits = ScanLimits { + entries: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.entries, 1); + assert_eq!(budget.stopped_by, Some("entry budget")); + assert!(index.records.len() <= 1); +} + +#[test] +fn scan_stops_before_crossing_the_directory_depth_budget() { + let root = TempRoot::new("desktop-depth-budget"); + fs::create_dir_all(root.join("one/two")).expect("nested application directories"); + let limits = ScanLimits { + depth: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.stopped_by, Some("directory depth")); +} + +#[test] +fn scan_stops_when_the_global_directory_budget_is_exhausted() { + let root = TempRoot::new("desktop-directory-budget"); + fs::create_dir_all(root.join("one")).expect("first application directory"); + fs::create_dir_all(root.join("two")).expect("second application directory"); + let limits = ScanLimits { + directories: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.directories, 1); + assert_eq!(budget.stopped_by, Some("directory budget")); +} + +#[test] +fn scan_stops_when_the_global_record_budget_is_exhausted() { + let root = TempRoot::new("desktop-record-budget"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + root.join(name), + "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n", + ) + .expect("desktop fixture"); + } + let limits = ScanLimits { + records: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(index.records.len(), 1); + assert_eq!(budget.stopped_by, Some("record budget")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index 45aa86af7..b674c4993 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -6,11 +6,11 @@ use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationAttributio use zbus::fdo::DBusProxy; use zbus::Connection; -use super::super::sender::SenderMetadata; use super::desktop_index::{ normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, }; use super::policy::inline_reply_policy; +use super::sender::SenderMetadata; const MAX_DESKTOP_ID_BYTES: usize = 256; @@ -61,7 +61,7 @@ fn resolve_with_evidence( .iter() .find(|record| record_matches_sender(record, sender)) { - return resolution_for_record(record, claim.reported_name, sender); + return resolution_for_record(record, claim.reported_name, sender, index); } if records .iter() @@ -85,9 +85,9 @@ fn resolve_with_evidence( .iter() .find(|record| record.claim_matches(claim.reported_name)) { - return resolution_for_record(record, claim.reported_name, sender); + return resolution_for_record(record, claim.reported_name, sender, index); } - if records.iter().any(|record| record.system_entry) { + if records.iter().any(|record| record.system_association) { // A known executable with a conflicting name must fail closed return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); } @@ -130,12 +130,13 @@ fn resolution_for_record( record: &DesktopRecord, reported_name: &str, sender: &SenderMetadata, + index: &DesktopIdentityIndex, ) -> AttributionResolution { // Display metadata is projected only after the record and sender identities agree if !record.claim_matches(reported_name) { return conflict_resolution(reported_name, sender, "application claim mismatch"); } - let class = if record.system_entry { + let class = if record.system_association { AttributionClass::SystemAssociated } else { AttributionClass::UserAssociated @@ -145,14 +146,36 @@ fn resolution_for_record( .as_deref() .map(|path| path.display().to_string()) .unwrap_or_default(); + let shadows_system_id = !record.system_origin && index.has_system_record_for_id(&record.id); + let source_label = if shadows_system_id { + format!("Shadows a system desktop entry; source {source_label}") + } else { + source_label + }; + let group_prefix = if record.system_association { + "system-desktop" + } else if record.system_origin { + "system-unverified-desktop" + } else { + "user-desktop" + }; + let origin = record.desktop_identity.map_or_else( + || "unknown".to_string(), + super::executable::FileIdentity::group_fragment, + ); + let group_key = if record.system_association { + format!("{group_prefix}:{}", record.id) + } else { + format!("{group_prefix}:{origin}:{}", record.id) + }; let attribution = NotificationAttribution::associated( &record.display_name, &record.id, &record.badge_icon, &source_label, class, - false, - format!("desktop:{}", record.id), + shadows_system_id, + group_key, ); policy_resolution(attribution) } @@ -182,6 +205,9 @@ const fn policy_resolution(attribution: NotificationAttribution) -> AttributionR } const fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { + if !record.association_eligible { + return false; + } match ( record.executable_identity, sender.sender_executable_identity, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index c652f0558..43a58ff71 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -57,12 +57,16 @@ fn system_desktop_identity_allows_legitimate_signal_reply() { AttributionClass::SystemAssociated ); assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!( + resolution.attribution.group_key, + "system-desktop:org.signal.Signal" + ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); assert!(!resolution.attribution.source_label.contains("unverified")); } #[test] -fn user_desktop_identity_requires_confirmation_instead_of_immediate_reply() { +fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { let app_identity = identity(6, 60, 1000); let index = DesktopIdentityIndex::from_records( vec![DesktopRecord::fixture( @@ -90,10 +94,146 @@ fn user_desktop_identity_requires_confirmation_instead_of_immediate_reply() { resolution.attribution.class, AttributionClass::UserAssociated ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Confirm); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert!(!resolution.attribution.has_warning()); } +#[test] +fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { + let python_identity = identity(20, 200, 0); + let mut record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + python_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: Some("org.example.PasswordManager"), + }, + &sender("/usr/bin/python3", python_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn unmediated_flatpak_process_cannot_become_portal_associated() { + let flatpak_identity = identity(21, 210, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Flatpak App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/bin/flatpak", flatpak_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn user_shadow_cannot_join_the_system_desktop_group() { + let system_identity = identity(30, 300, 0); + let user_identity = identity(31, 310, 1000); + let system = system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + system_identity, + ); + let mut user = DesktopRecord::fixture( + "org.signal.Signal", + "Signal", + "/home/user/bin/signal", + user_identity, + false, + false, + ); + user.desktop_identity = Some(identity(32, 320, 1000)); + let index = DesktopIdentityIndex::from_records(vec![user, system], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: Some("org.signal.Signal"), + }, + &sender("/home/user/bin/signal", user_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::UserAssociated + ); + assert!(resolution.attribution.has_warning()); + assert!(resolution + .attribution + .group_key + .starts_with("user-desktop:")); + assert_ne!( + resolution.attribution.group_key, + "system-desktop:org.signal.Signal" + ); +} + +#[test] +fn visually_confusable_system_brand_is_a_conflict() { + let signal_identity = identity(40, 400, 0); + let hostile_identity = identity(41, 410, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + Vec::new(), + ); + + for claim in ["Sіgnal", "Signaⅼ"] { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: claim, + desktop_entry: None, + }, + &sender("/tmp/fake", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + #[test] fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { let signal_identity = identity(1, 10, 0); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs index a5449e236..036c1a372 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -68,6 +68,14 @@ impl Interface for NotificationIngress { message: &'call Message, name: MemberName<'call>, ) -> DispatchResult<'call> { + if notify_has_unix_fds(name.as_str(), message.header().unix_fds()) { + // Notify has no descriptor-bearing fields, so attached descriptors are always invalid + return DispatchResult::new_async(connection, message, async { + Err::<(), _>(zbus::fdo::Error::InvalidArgs( + "Notify does not accept Unix file descriptors".to_string(), + )) + }); + } if notify_body_is_oversized(name.as_str(), message.body().len()) { // Construct the D-Bus error without asking the typed interface to decode the body return DispatchResult::new_async(connection, message, async { @@ -114,6 +122,10 @@ fn notify_body_is_oversized(member: &str, body_len: usize) -> bool { member.as_bytes() == b"Notify" && body_len > MAX_NOTIFY_WIRE_BODY_BYTES } +fn notify_has_unix_fds(member: &str, unix_fds: Option) -> bool { + member.as_bytes() == b"Notify" && unix_fds.is_some_and(|count| count != 0) +} + #[cfg(test)] #[path = "tests/ingress.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index a56c8ec07..f28f84a07 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use std::os::fd::AsFd; use zbus::zvariant::{OwnedValue, Structure, Value}; use zbus::Connection; -use super::{notify_body_is_oversized, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES}; +use super::{ + notify_body_is_oversized, notify_has_unix_fds, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES, +}; use crate::daemon::{NotificationServer, NOTIFICATIONS_OBJECT_PATH}; use crate::expire::ExpirationScheduler; use crate::test_support::daemon_state_for_test; @@ -27,6 +30,29 @@ fn notify_wire_limit_applies_only_to_oversized_notify_calls() { )); } +#[test] +fn unix_file_descriptors_are_rejected_only_for_notify_calls() { + assert!(!notify_has_unix_fds("Notify", None)); + assert!(!notify_has_unix_fds("Notify", Some(0))); + assert!(notify_has_unix_fds("Notify", Some(1))); + assert!(!notify_has_unix_fds("CloseNotification", Some(1))); +} + +#[test] +fn raw_message_header_exposes_attached_unix_file_descriptor_count() { + let file = std::fs::File::open("/dev/null").expect("open descriptor fixture"); + let descriptor = zbus::zvariant::Fd::from(file.as_fd()); + let message = zbus::Message::method(NOTIFICATIONS_OBJECT_PATH, "Notify") + .expect("method builder") + .interface(NOTIFICATIONS_INTERFACE) + .expect("notification interface") + .build(&(descriptor,)) + .expect("descriptor-bearing message"); + + assert_eq!(message.header().unix_fds(), Some(1)); + assert!(notify_has_unix_fds("Notify", message.header().unix_fds())); +} + #[tokio::test] async fn oversized_body_is_rejected_before_notify_deserialization() { let (state, client) = notification_ingress().await; From 43512ceab8d8a13ab245bc7cde419085eba3dde0 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 21:11:18 -0500 Subject: [PATCH 090/275] fix(replies): fail closed on unsupported confirmation Summary: fail closed on unsupported confirmation. Scope: replies. --- .../notifications/row/notification/build.rs | 6 ++- .../notifications/row/notification/state.rs | 6 ++- .../row/notification/update/actions.rs | 9 ++++- .../row/notification/update/tests/actions.rs | 39 +++++++++++++++++++ .../unixnotis-core/src/model/attribution.rs | 2 +- .../daemon/notifications/identity/policy.rs | 13 +++++-- .../notifications/identity/tests/policy.rs | 25 ++++++++++++ .../src/daemon/state/model.rs | 2 +- 8 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 531e61b49..9bf0b4d9e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -221,7 +221,11 @@ pub(in crate::ui::notifications) fn build_notification_row( notify_id, action_cache_id: Cell::new(0), action_cache: RefCell::new(Vec::new()), - reply_cache: RefCell::new((unixnotis_core::InlineReply::default(), false)), + reply_cache: RefCell::new(( + unixnotis_core::InlineReply::default(), + unixnotis_core::InlineReplyPolicy::Deny, + false, + )), icon_sig: RefCell::new(None), }, ) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index afed5bcf1..351378f2c 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -49,7 +49,11 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { // Last rendered action signature for cheap no-op detection pub(super) action_cache: RefCell>, // Reply metadata and live state are cached separately from ordinary actions - pub(super) reply_cache: RefCell<(unixnotis_core::InlineReply, bool)>, + pub(super) reply_cache: RefCell<( + unixnotis_core::InlineReply, + unixnotis_core::InlineReplyPolicy, + bool, + )>, // Last rendered icon signature so decode work only happens on a real change pub(super) icon_sig: RefCell>, } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index cdd72ecc9..b440be8ef 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -43,7 +43,8 @@ pub(super) fn update_actions( .zip(notification.actions.iter()) .all(|((key, label), action)| key == &action.key && label == &action.label) && reply_cached.0 == notification.inline_reply - && reply_cached.1 == is_active + && reply_cached.1 == notification.inline_reply_policy + && reply_cached.2 == is_active { return; } @@ -58,7 +59,11 @@ pub(super) fn update_actions( cached.push((action.key.clone(), action.label.clone())); } row.action_cache_id.set(notification.id); - *row.reply_cache.borrow_mut() = (notification.inline_reply.clone(), is_active); + *row.reply_cache.borrow_mut() = ( + notification.inline_reply.clone(), + notification.inline_reply_policy, + is_active, + ); } // Old buttons leave before rebuilding the current action set diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 4c48de5ea..a86eaa3fb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -91,6 +91,45 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { ); } +#[gtk::test] +fn reply_action_cache_tracks_allow_and_deny_policy_transitions() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Allow; + + let render = |notification: &unixnotis_core::NotificationView| { + update_notification_row( + &row, + &row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + }; + + render(¬ification); + assert_eq!(child_count(&row.actions_box), 1); + + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + render(¬ification); + assert_eq!(child_count(&row.actions_box), 0); + + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Allow; + render(¬ification); + assert_eq!(child_count(&row.actions_box), 1); +} + #[gtk::test] fn update_notification_row_action_button_sends_command_once_per_click_window() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index 668642e23..86378224b 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -25,7 +25,7 @@ pub enum AttributionClass { #[repr(u8)] pub enum InlineReplyPolicy { Allow = 0, - Confirm = 1, + // Value 1 stays unused until confirmation is enforced by the daemon #[default] Deny = 2, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs index 1cd993184..d200b0ff8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs @@ -3,13 +3,18 @@ use unixnotis_core::{AttributionClass, InlineReplyPolicy}; pub(super) const fn inline_reply_policy(class: AttributionClass) -> InlineReplyPolicy { + // Text entry stays disabled unless system or portal evidence identifies the application match class { AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { InlineReplyPolicy::Allow } - AttributionClass::UserAssociated => InlineReplyPolicy::Confirm, - AttributionClass::TrustedRelay | AttributionClass::Unknown | AttributionClass::Conflict => { - InlineReplyPolicy::Deny - } + AttributionClass::UserAssociated + | AttributionClass::TrustedRelay + | AttributionClass::Unknown + | AttributionClass::Conflict => InlineReplyPolicy::Deny, } } + +#[cfg(test)] +#[path = "tests/policy.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs new file mode 100644 index 000000000..6bac3d088 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs @@ -0,0 +1,25 @@ +use unixnotis_core::{AttributionClass, InlineReplyPolicy}; + +use super::inline_reply_policy; + +#[test] +fn only_system_and_portal_associations_allow_inline_replies() { + for class in [ + AttributionClass::SystemAssociated, + AttributionClass::PortalAssociated, + ] { + assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Allow); + } +} + +#[test] +fn every_unconfirmed_attribution_class_denies_inline_replies() { + for class in [ + AttributionClass::UserAssociated, + AttributionClass::TrustedRelay, + AttributionClass::Unknown, + AttributionClass::Conflict, + ] { + assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Deny); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index d930a3db3..a6c2391f6 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -12,8 +12,8 @@ use crate::store::NotificationStore; use crate::daemon::events::DaemonEventPublisher; use crate::daemon::notifications::identity::DesktopIdentityIndex; -use crate::daemon::notifications::sender_cache::SenderMetadataCache; use crate::daemon::notifications::NotificationBurstState; +use crate::daemon::notifications::SenderMetadataCache; /// Shared daemon state guarded behind an async mutex pub struct DaemonState { From 746b3fcccbc8d3adc3b7982fd1e10bc964b31fa3 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 23:43:16 -0500 Subject: [PATCH 091/275] fix(installer): identify bus owners in isolated service environments Summary: identify bus owners in isolated service environments. Scope: installer. --- Cargo.lock | 16 ++++ Cargo.toml | 1 + .../src/session_environment/backends/dinit.rs | 6 +- .../session_environment/backends/envdir.rs | 14 ++-- .../src/session_environment/backends/runit.rs | 2 +- .../src/session_environment/backends/s6.rs | 2 +- .../session_environment/backends/systemd.rs | 13 ++- .../src/session_environment/sync.rs | 3 +- .../tests/backends/envdir.rs | 9 ++- .../tests/backends/systemd.rs | 7 ++ .../session_environment/tests/variables.rs | 42 +++++++++- .../src/session_environment/variables.rs | 33 +++++--- crates/unixnotis-core/Cargo.toml | 1 + crates/unixnotis-core/src/lib.rs | 2 + .../src/notification_daemons.rs | 77 ++++++++++++++++++ .../src/service_manager/environment.rs | 70 ++++++++++++++++ .../unixnotis-core/src/service_manager/mod.rs | 4 + .../src/service_manager/tests/environment.rs | 36 +++++++++ .../src/service_manager/tests/mod.rs | 1 + crates/unixnotis-daemon/Cargo.toml | 4 + .../src/trial_mode/control.rs | 31 ++++--- .../unixnotis-daemon/src/trial_mode/owner.rs | 26 ++++-- .../unixnotis-daemon/src/trial_mode/state.rs | 29 +------ .../src/trial_mode/tests/known_daemons.rs | 21 ++++- .../src/trial_mode/tests/owner.rs | 15 +++- crates/unixnotis-installer/Cargo.toml | 2 + .../unixnotis-installer/src/actions/daemon.rs | 4 + .../src/actions/environment/mod.rs | 1 - .../src/actions/environment/sync.rs | 30 +++---- .../src/actions/hyprland/manage.rs | 9 ++- .../actions/hyprland/tests/config_format.rs | 19 ++++- .../src/actions/tests/daemon.rs | 10 +-- crates/unixnotis-installer/src/detect.rs | 78 +++++++++--------- .../orchestration/environment.rs | 5 ++ .../service_manager/orchestration/model.rs | 16 ++++ .../orchestration/tests/environment.rs | 15 ++++ .../unixnotis-installer/src/tests/detect.rs | 81 +++++++++++++++++-- 37 files changed, 583 insertions(+), 152 deletions(-) create mode 100644 crates/unixnotis-core/src/notification_daemons.rs create mode 100644 crates/unixnotis-core/src/service_manager/environment.rs create mode 100644 crates/unixnotis-core/src/service_manager/tests/environment.rs diff --git a/Cargo.lock b/Cargo.lock index 792de7b65..746648ffd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -3600,6 +3609,7 @@ dependencies = [ "serde_repr", "shell-words", "thiserror 1.0.69", + "tokio", "toml 0.8.23", "tracing", "tracing-subscriber", @@ -3612,19 +3622,23 @@ name = "unixnotis-daemon" version = "1.2.0" dependencies = [ "anyhow", + "arc-swap", "chrono", "clap", "futures-util", "gio", "indexmap", + "notify", "rustix", "serde", "serde_json", + "shell-words", "tokio", "tracing", "tracing-subscriber", "unicode-security", "unixnotis-core", + "url", "zbus", ] @@ -3640,9 +3654,11 @@ dependencies = [ "semver", "serde", "serde_json", + "tokio", "toml 0.8.23", "unicode-width", "unixnotis-core", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1426e62ba..38a317414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ license = "MIT" [workspace.dependencies] anyhow = "1" +arc-swap = "1.9" async-channel = "2" blake3 = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } diff --git a/crates/noticenterctl/src/session_environment/backends/dinit.rs b/crates/noticenterctl/src/session_environment/backends/dinit.rs index 93c6f397b..f68ae2d83 100644 --- a/crates/noticenterctl/src/session_environment/backends/dinit.rs +++ b/crates/noticenterctl/src/session_environment/backends/dinit.rs @@ -1,18 +1,20 @@ //! Dinit user-manager environment import and start flow use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; use unixnotis_core::CommandSpec; use super::super::process::{require_success, run}; -use super::super::variables::IMPORT_VARS; +use super::super::variables::import_variables; pub(in crate::session_environment) fn sync_dinit() -> Result<()> { + let import_variables = import_variables(ServiceManagerKind::Dinit); // Dinit imports named values directly from the current process environment require_success(&CommandSpec::direct( "dinitctl", std::iter::once("--user") .chain(std::iter::once("setenv")) - .chain(IMPORT_VARS), + .chain(import_variables.iter().copied()), ))?; let restart = CommandSpec::direct( "dinitctl", diff --git a/crates/noticenterctl/src/session_environment/backends/envdir.rs b/crates/noticenterctl/src/session_environment/backends/envdir.rs index 3268603e8..094d04e93 100644 --- a/crates/noticenterctl/src/session_environment/backends/envdir.rs +++ b/crates/noticenterctl/src/session_environment/backends/envdir.rs @@ -6,11 +6,15 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use unixnotis_core::filesystem::write_file_atomic; -use unixnotis_core::service_manager::envdir_file_contents; +use unixnotis_core::service_manager::{envdir_file_contents, ServiceManagerKind}; -use super::super::variables::IMPORT_VARS; +use super::super::variables::import_variables; -pub(in crate::session_environment) fn write_envdir(service: &Path, env_dir: &Path) -> Result<()> { +pub(in crate::session_environment) fn write_envdir( + service: &Path, + env_dir: &Path, + kind: ServiceManagerKind, +) -> Result<()> { let metadata = fs::symlink_metadata(service) .with_context(|| format!("inspect installed service directory {}", service.display()))?; // The service anchor must be a real directory before any child path is created @@ -20,8 +24,8 @@ pub(in crate::session_environment) fn write_envdir(service: &Path, env_dir: &Pat service.display() ); } - // PATH remains fixed by the installed run script instead of session input - for name in IMPORT_VARS.into_iter().filter(|name| *name != "PATH") { + // Every published key comes from the backend-specific allowlist + for name in import_variables(kind) { let value = env::var(name).ok(); let contents = envdir_file_contents(value.as_deref()); let target: PathBuf = env_dir.join(name); diff --git a/crates/noticenterctl/src/session_environment/backends/runit.rs b/crates/noticenterctl/src/session_environment/backends/runit.rs index 85e9cf199..5a99e1672 100644 --- a/crates/noticenterctl/src/session_environment/backends/runit.rs +++ b/crates/noticenterctl/src/session_environment/backends/runit.rs @@ -9,7 +9,7 @@ use super::envdir::write_envdir; pub(in crate::session_environment) fn sync_runit(manager: &ServiceManagerPaths) -> Result<()> { let service = manager.artifact_root.join("unixnotis-daemon"); - write_envdir(&service, &service.join("env"))?; + write_envdir(&service, &service.join("env"), manager.kind)?; let restart = CommandSpec::direct("sv", ["restart".into(), service.as_os_str().to_os_string()]); // A successful restart avoids a redundant start request if run(&restart)?.success() { diff --git a/crates/noticenterctl/src/session_environment/backends/s6.rs b/crates/noticenterctl/src/session_environment/backends/s6.rs index 5146c5020..b92e26c09 100644 --- a/crates/noticenterctl/src/session_environment/backends/s6.rs +++ b/crates/noticenterctl/src/session_environment/backends/s6.rs @@ -9,7 +9,7 @@ use super::envdir::write_envdir; pub(in crate::session_environment) fn sync_s6(manager: &ServiceManagerPaths) -> Result<()> { let service = manager.artifact_root.join("sv").join("unixnotis-daemon"); - write_envdir(&service, &service.join("env"))?; + write_envdir(&service, &service.join("env"), manager.kind)?; let live = manager .live_root .as_deref() diff --git a/crates/noticenterctl/src/session_environment/backends/systemd.rs b/crates/noticenterctl/src/session_environment/backends/systemd.rs index b3aceeb5b..02ea0a9b5 100644 --- a/crates/noticenterctl/src/session_environment/backends/systemd.rs +++ b/crates/noticenterctl/src/session_environment/backends/systemd.rs @@ -1,19 +1,26 @@ //! Systemd user-manager environment import and restart flow use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; use unixnotis_core::CommandSpec; use crate::system_tools; use super::super::process::require_success; -use super::super::variables::IMPORT_VARS; +use super::super::variables::import_variables; pub(in crate::session_environment) fn sync_systemd() -> Result<()> { + let import_variables = import_variables(ServiceManagerKind::Systemd); + // Older installer releases may have persisted a transient nested-session address + require_success(&CommandSpec::direct( + "systemctl", + ["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"], + ))?; // D-Bus activation receives the compositor variables when the helper is installed if system_tools::trusted_program_path("dbus-update-activation-environment").is_some() { require_success(&CommandSpec::direct( "dbus-update-activation-environment", - IMPORT_VARS, + import_variables, ))?; } // The user manager must import the same values before restarting the daemon @@ -21,7 +28,7 @@ pub(in crate::session_environment) fn sync_systemd() -> Result<()> { "systemctl", std::iter::once("--user") .chain(std::iter::once("import-environment")) - .chain(IMPORT_VARS), + .chain(import_variables.iter().copied()), ))?; require_success(&CommandSpec::direct( "systemctl", diff --git a/crates/noticenterctl/src/session_environment/sync.rs b/crates/noticenterctl/src/session_environment/sync.rs index 12fcece4c..43f8f81f8 100644 --- a/crates/noticenterctl/src/session_environment/sync.rs +++ b/crates/noticenterctl/src/session_environment/sync.rs @@ -9,12 +9,13 @@ use crate::cli::DoctorServiceManagerArg; use super::backends::{sync_dinit, sync_runit, sync_s6, sync_systemd}; use super::manager::select_manager; -use super::variables::validate_session_environment; +use super::variables::{validate_persisted_bus_address, validate_session_environment}; pub fn sync(requested: DoctorServiceManagerArg) -> Result<()> { // Reject detached launches before resolving or mutating service state validate_session_environment(|name| env::var_os(name))?; let manager = select_manager(requested)?; + validate_persisted_bus_address(manager.kind, env::var_os("DBUS_SESSION_BUS_ADDRESS"))?; // Each backend owns its native restart and environment publication contract match manager.kind { ServiceManagerKind::Systemd => sync_systemd(), diff --git a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs index b82495689..2e416b79b 100644 --- a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs +++ b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs @@ -1,13 +1,18 @@ use super::super::super::backends::envdir::write_envdir; use super::super::support::TempToolDir; +use unixnotis_core::service_manager::ServiceManagerKind; #[test] fn envdir_writer_rejects_a_non_directory_service_anchor() { let root = TempToolDir::new("envdir-anchor"); let service = root.write_file("unixnotis-daemon", "not a directory"); - let error = write_envdir(&service, &root.path().join("env")) - .expect_err("non-directory service must be rejected"); + let error = write_envdir( + &service, + &root.path().join("env"), + ServiceManagerKind::Runit, + ) + .expect_err("non-directory service must be rejected"); assert!(error.to_string().contains("regular service directory")); } diff --git a/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs index ae7e8716f..4055590be 100644 --- a/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs +++ b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs @@ -22,6 +22,13 @@ fn systemd_sync_runs_environment_import_and_restart_commands() { sync_systemd().expect("synchronize systemd environment"); let calls = fs::read_to_string(log).expect("read systemd command log"); + assert!(calls.contains("--user unset-environment DBUS_SESSION_BUS_ADDRESS")); assert!(calls.contains("--user import-environment")); assert!(calls.contains("--user --no-block restart unixnotis-daemon.service")); + let import = calls + .lines() + .find(|line| line.contains("import-environment")) + .expect("systemd import command"); + assert!(!import.contains("DBUS_SESSION_BUS_ADDRESS")); + assert!(!import.contains(" PATH")); } diff --git a/crates/noticenterctl/src/session_environment/tests/variables.rs b/crates/noticenterctl/src/session_environment/tests/variables.rs index e63151c8d..346d2698a 100644 --- a/crates/noticenterctl/src/session_environment/tests/variables.rs +++ b/crates/noticenterctl/src/session_environment/tests/variables.rs @@ -1,6 +1,10 @@ use std::ffi::OsString; -use super::super::variables::{missing_session_variables, validate_session_environment}; +use super::super::variables::{ + import_variables, missing_session_variables, validate_persisted_bus_address, + validate_session_environment, +}; +use unixnotis_core::service_manager::ServiceManagerKind; #[test] fn session_environment_reports_empty_required_values_as_missing() { @@ -37,3 +41,39 @@ fn missing_session_environment_returns_an_actionable_error() { assert!(error.to_string().contains("WAYLAND_DISPLAY")); assert!(error.to_string().contains("XDG_RUNTIME_DIR")); } + +#[test] +fn systemd_repair_environment_omits_shell_bus_and_path_values() { + let variables = import_variables(ServiceManagerKind::Systemd); + + assert!(!variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); +} + +#[test] +fn systemd_does_not_validate_or_persist_the_calling_shell_bus() { + validate_persisted_bus_address( + ServiceManagerKind::Systemd, + Some(OsString::from("unix:path=/tmp/transient")), + ) + .expect("systemd should ignore the shell bus address"); +} + +#[test] +fn non_systemd_managers_reject_a_transient_shell_bus_address() { + for manager in [ + ServiceManagerKind::Dinit, + ServiceManagerKind::Runit, + ServiceManagerKind::S6, + ] { + let error = validate_persisted_bus_address( + manager, + Some(OsString::from("unix:path=/tmp/transient")), + ) + .expect_err("non-systemd managers must reject a transient bus"); + + assert!(error + .to_string() + .contains("nonstandard session bus address")); + } +} diff --git a/crates/noticenterctl/src/session_environment/variables.rs b/crates/noticenterctl/src/session_environment/variables.rs index f033f73a0..881b4092e 100644 --- a/crates/noticenterctl/src/session_environment/variables.rs +++ b/crates/noticenterctl/src/session_environment/variables.rs @@ -3,17 +3,30 @@ use std::ffi::OsString; use anyhow::{bail, Result}; +use unixnotis_core::service_manager::{ + validate_session_bus_address, variables_for_backend, ServiceManagerKind, +}; -pub(super) const IMPORT_VARS: [&str; 8] = [ - "WAYLAND_DISPLAY", - "XDG_CURRENT_DESKTOP", - "XDG_SESSION_TYPE", - "XDG_SESSION_DESKTOP", - "DISPLAY", - "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", - "PATH", -]; +pub(super) const fn import_variables(kind: ServiceManagerKind) -> &'static [&'static str] { + variables_for_backend(kind) +} + +pub(super) fn validate_persisted_bus_address( + kind: ServiceManagerKind, + address: Option, +) -> Result<()> { + if !import_variables(kind).contains(&"DBUS_SESSION_BUS_ADDRESS") { + return Ok(()); + } + let Some(address) = address else { + return Ok(()); + }; + let address = address + .to_str() + .ok_or_else(|| anyhow::anyhow!("session bus address is not valid UTF-8"))?; + // Repair commands use the same stable-bus rule as fresh installations + validate_session_bus_address(address, rustix::process::getuid().as_raw()).map_err(Into::into) +} pub(super) fn validate_session_environment( get_var: impl FnMut(&str) -> Option, diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index 6ec795560..c7129cccc 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -15,6 +15,7 @@ serde_ignored.workspace = true shell-words.workspace = true toml.workspace = true thiserror.workspace = true +tokio.workspace = true tracing.workspace = true unicode-width.workspace = true zbus.workspace = true diff --git a/crates/unixnotis-core/src/lib.rs b/crates/unixnotis-core/src/lib.rs index df58be14e..429ee7790 100644 --- a/crates/unixnotis-core/src/lib.rs +++ b/crates/unixnotis-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod css; pub mod embedded; pub mod filesystem; pub mod model; +pub mod notification_daemons; pub mod process; pub mod reconnect; pub mod service_manager; @@ -35,6 +36,7 @@ pub use control::*; pub use css::*; pub use embedded::*; pub use model::*; +pub use notification_daemons::*; pub use process::*; pub use util::program_in_path; diff --git a/crates/unixnotis-core/src/notification_daemons.rs b/crates/unixnotis-core/src/notification_daemons.rs new file mode 100644 index 000000000..460313650 --- /dev/null +++ b/crates/unixnotis-core/src/notification_daemons.rs @@ -0,0 +1,77 @@ +//! Shared catalog of standalone notification daemons + +/// A process that may own the freedesktop notifications bus name +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct KnownNotificationDaemon { + pub name: &'static str, + // Some daemons use D-Bus activation or desktop startup instead of a user unit + pub systemd_unit: Option<&'static str>, +} + +/// Standalone daemons safe to identify and stop by their exact owner process +pub const KNOWN_NOTIFICATION_DAEMONS: &[KnownNotificationDaemon] = &[ + KnownNotificationDaemon { + name: "unixnotis-daemon", + systemd_unit: Some("unixnotis-daemon.service"), + }, + KnownNotificationDaemon { + name: "fnott", + systemd_unit: Some("fnott.service"), + }, + KnownNotificationDaemon { + name: "mako", + systemd_unit: Some("mako.service"), + }, + KnownNotificationDaemon { + name: "dunst", + systemd_unit: Some("dunst.service"), + }, + KnownNotificationDaemon { + name: "swaync", + systemd_unit: Some("swaync.service"), + }, + KnownNotificationDaemon { + name: "xfce4-notifyd", + systemd_unit: Some("xfce4-notifyd.service"), + }, + KnownNotificationDaemon { + name: "wired", + systemd_unit: Some("wired.service"), + }, + KnownNotificationDaemon { + name: "notify-osd", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "quickshell", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "hyprnotify", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "lxqt-notificationd", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "mate-notification-daemon", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "notification-daemon", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "deadd-notification-center", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "tiramisu", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "runst", + systemd_unit: None, + }, +]; diff --git a/crates/unixnotis-core/src/service_manager/environment.rs b/crates/unixnotis-core/src/service_manager/environment.rs new file mode 100644 index 000000000..7e1e973f0 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/environment.rs @@ -0,0 +1,70 @@ +//! Backend-specific session environment policy + +use std::fmt; + +use super::ServiceManagerKind; + +const GRAPHICAL_SESSION_VARIABLES: [&str; 6] = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", + "XDG_SESSION_DESKTOP", +]; + +const DIRECT_MANAGER_VARIABLES: [&str; 7] = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", + "XDG_SESSION_DESKTOP", + "DBUS_SESSION_BUS_ADDRESS", +]; + +/// Return the narrow environment allowlist for one service manager +#[must_use] +pub const fn variables_for_backend(kind: ServiceManagerKind) -> &'static [&'static str] { + match kind { + // systemd resolves the stable user bus through its own user-manager context + ServiceManagerKind::Systemd => &GRAPHICAL_SESSION_VARIABLES, + // Direct supervisors may need the stable user-bus address persisted explicitly + ServiceManagerKind::Dinit | ServiceManagerKind::Runit | ServiceManagerKind::S6 => { + &DIRECT_MANAGER_VARIABLES + } + } +} + +/// Error returned when an installer shell points at a transient or nonstandard bus +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SessionBusAddressError { + address: String, +} + +impl fmt::Display for SessionBusAddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "refusing to persist nonstandard session bus address: {}", + self.address + ) + } +} + +impl std::error::Error for SessionBusAddressError {} + +/// Require the standard per-user bus before persisting an explicit address +/// +/// # Errors +/// +/// Returns an error when the address does not name `/run/user//bus` +pub fn validate_session_bus_address(address: &str, uid: u32) -> Result<(), SessionBusAddressError> { + let expected = format!("unix:path=/run/user/{uid}/bus"); + if address == expected { + return Ok(()); + } + Err(SessionBusAddressError { + address: address.to_string(), + }) +} diff --git a/crates/unixnotis-core/src/service_manager/mod.rs b/crates/unixnotis-core/src/service_manager/mod.rs index 8773e1511..b3f1f9303 100644 --- a/crates/unixnotis-core/src/service_manager/mod.rs +++ b/crates/unixnotis-core/src/service_manager/mod.rs @@ -1,9 +1,13 @@ //! Shared service-manager identity and user-path resolution mod envdir; +mod environment; mod kind; mod paths; +pub use environment::{ + validate_session_bus_address, variables_for_backend, SessionBusAddressError, +}; pub use kind::ServiceManagerKind; pub use paths::{ dinit_user_dir, resolve_service_manager_paths, runit_user_dir, s6_live_dir, s6_user_dir, diff --git a/crates/unixnotis-core/src/service_manager/tests/environment.rs b/crates/unixnotis-core/src/service_manager/tests/environment.rs new file mode 100644 index 000000000..88ab03f7e --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/tests/environment.rs @@ -0,0 +1,36 @@ +use super::super::{validate_session_bus_address, variables_for_backend, ServiceManagerKind}; + +#[test] +fn systemd_environment_excludes_shell_bus_and_path_values() { + let variables = variables_for_backend(ServiceManagerKind::Systemd); + + assert!(variables.contains(&"WAYLAND_DISPLAY")); + assert!(variables.contains(&"XDG_RUNTIME_DIR")); + assert!(!variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); +} + +#[test] +fn direct_managers_accept_only_an_explicit_stable_bus_variable() { + for kind in [ + ServiceManagerKind::Dinit, + ServiceManagerKind::Runit, + ServiceManagerKind::S6, + ] { + let variables = variables_for_backend(kind); + assert!(variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); + } +} + +#[test] +fn persisted_session_bus_address_must_match_the_standard_user_bus() { + assert!(validate_session_bus_address("unix:path=/run/user/1000/bus", 1000).is_ok()); + + let error = validate_session_bus_address("unix:path=/tmp/transient-bus", 1000) + .expect_err("transient bus must be rejected"); + assert_eq!( + error.to_string(), + "refusing to persist nonstandard session bus address: unix:path=/tmp/transient-bus" + ); +} diff --git a/crates/unixnotis-core/src/service_manager/tests/mod.rs b/crates/unixnotis-core/src/service_manager/tests/mod.rs index 707d93256..6cbb4521b 100644 --- a/crates/unixnotis-core/src/service_manager/tests/mod.rs +++ b/crates/unixnotis-core/src/service_manager/tests/mod.rs @@ -1,2 +1,3 @@ +mod environment; mod kind; mod paths; diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index aae9e0c48..3aa517444 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] anyhow.workspace = true +arc-swap.workspace = true clap.workspace = true chrono.workspace = true futures-util.workspace = true @@ -19,4 +20,7 @@ unicode-security.workspace = true zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } indexmap.workspace = true +notify.workspace = true rustix.workspace = true +shell-words.workspace = true +url.workspace = true diff --git a/crates/unixnotis-daemon/src/trial_mode/control.rs b/crates/unixnotis-daemon/src/trial_mode/control.rs index f5cfffc2d..df611640e 100644 --- a/crates/unixnotis-daemon/src/trial_mode/control.rs +++ b/crates/unixnotis-daemon/src/trial_mode/control.rs @@ -76,16 +76,18 @@ pub(super) async fn stop_active_owner( } RestoreStrategy::Systemd => { // Strict systemd mode errors if the matched unit is not active - if !is_unit_active(known.unit).await { + let unit = known.systemd_unit.ok_or_else(|| { + anyhow!("{} does not publish a known systemd user unit", known.name) + })?; + if !is_unit_active(unit).await { return Err(anyhow!( - "systemd restore requested but {} is not active", - known.unit + "systemd restore requested but {unit} is not active" )); } - stop_via_systemd(known.unit).await?; - debug!(unit = known.unit, "trial mode: restore via systemd"); + stop_via_systemd(unit).await?; + debug!(unit, "trial mode: restore via systemd"); Ok(Some(RestoreAction::Systemd { - unit: known.unit.to_string(), + unit: unit.to_string(), })) } RestoreStrategy::Process => { @@ -103,13 +105,16 @@ pub(super) async fn stop_active_owner( } RestoreStrategy::Auto => { // Auto prefers systemd when unit is active, otherwise process restore - if is_unit_active(known.unit).await { - stop_via_systemd(known.unit).await?; - debug!(unit = known.unit, "trial mode: restore via systemd (auto)"); - Ok(Some(RestoreAction::Systemd { - unit: known.unit.to_string(), - })) - } else { + if let Some(unit) = known.systemd_unit { + if is_unit_active(unit).await { + stop_via_systemd(unit).await?; + debug!(unit, "trial mode: restore via systemd (auto)"); + return Ok(Some(RestoreAction::Systemd { + unit: unit.to_string(), + })); + } + } + { let (program, args) = build_restart_command(owner, comm)?; // Auto mode follows the same prepare-before-stop transaction as strict mode stop_via_process(pid).await?; diff --git a/crates/unixnotis-daemon/src/trial_mode/owner.rs b/crates/unixnotis-daemon/src/trial_mode/owner.rs index 52aee70a2..d6543c5f5 100644 --- a/crates/unixnotis-daemon/src/trial_mode/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/owner.rs @@ -43,14 +43,18 @@ pub(super) async fn detect_owner( } else { None }; - let comm = match pid { - Some(pid) => read_comm(pid).await, - None => None, - }; let args = match pid { Some(pid) => read_args(pid).await, None => None, }; + // Argv keeps long executable names intact while /proc comm truncates after 15 bytes + let comm = args + .as_deref() + .and_then(command_program_name) + .or(match pid { + Some(pid) => read_comm(pid).await, + None => None, + }); Ok(Some(OwnerInfo { pid, comm, args })) } @@ -61,7 +65,10 @@ pub(super) async fn detect_known_daemons(owner: &Option) -> Vec is_unit_active(unit).await, + None => false, + }; let is_owner = owner_name == Some(daemon.name); entries.push(DetectedDaemon { name: daemon.name.to_string(), @@ -73,6 +80,15 @@ pub(super) async fn detect_known_daemons(owner: &Option) -> Vec Option { + let program = args.first()?; + std::path::Path::new(program) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) +} + pub(super) fn print_detected_daemons(daemons: &[DetectedDaemon], owner: &Option) { println!("Detected notification daemons:"); let mut owner_listed = false; diff --git a/crates/unixnotis-daemon/src/trial_mode/state.rs b/crates/unixnotis-daemon/src/trial_mode/state.rs index 4c8dd6db6..38c377d99 100644 --- a/crates/unixnotis-daemon/src/trial_mode/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/state.rs @@ -50,33 +50,8 @@ pub struct DetectedDaemon { pub(super) is_owner: bool, } -pub struct KnownDaemon { - pub(super) name: &'static str, - pub(super) unit: &'static str, -} - -pub const KNOWN_DAEMONS: &[KnownDaemon] = &[ - KnownDaemon { - name: "mako", - unit: "mako.service", - }, - KnownDaemon { - name: "dunst", - unit: "dunst.service", - }, - KnownDaemon { - name: "swaync", - unit: "swaync.service", - }, - KnownDaemon { - name: "notify-osd", - unit: "notify-osd.service", - }, - KnownDaemon { - name: "quickshell", - unit: "quickshell.service", - }, -]; +pub const KNOWN_DAEMONS: &[unixnotis_core::KnownNotificationDaemon] = + unixnotis_core::KNOWN_NOTIFICATION_DAEMONS; pub const TRIAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs b/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs index 591fe60e2..f8672ac40 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs @@ -9,5 +9,24 @@ fn known_daemons_include_quickshell_owner() { .expect("quickshell should be known"); // The unit name lets auto restore prefer systemd when available - assert_eq!(quickshell.unit, "quickshell.service"); + assert_eq!(quickshell.systemd_unit, None); +} + +#[test] +fn known_daemons_include_fnott_owner_and_real_service_unit() { + let fnott = KNOWN_DAEMONS + .iter() + .find(|daemon| daemon.name == "fnott") + .expect("fnott should be known"); + + assert_eq!(fnott.systemd_unit, Some("fnott.service")); +} + +#[test] +fn trial_and_installer_share_the_complete_daemon_catalog() { + assert!(KNOWN_DAEMONS.len() >= 16); + assert!(KNOWN_DAEMONS + .iter() + .any(|daemon| daemon.name == "lxqt-notificationd")); + assert!(KNOWN_DAEMONS.iter().any(|daemon| daemon.name == "runst")); } diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs index d0c4c91db..c3d0cb1fc 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::system_tools::routing::use_fake_tool_bin; -use super::{is_unit_active, pgrep_exact, read_args, read_comm}; +use super::{command_program_name, is_unit_active, pgrep_exact, read_args, read_comm}; struct TempDirGuard { path: std::path::PathBuf, @@ -89,3 +89,16 @@ async fn read_args_uses_trusted_ps_fallback_when_procfs_is_missing() { assert_eq!(args, ["/usr/bin/mako", "--config", "mako.conf"]); } + +#[test] +fn command_program_name_preserves_long_notification_daemon_names() { + let args = vec![ + "/usr/bin/mate-notification-daemon".to_string(), + "--replace".to_string(), + ]; + + assert_eq!( + command_program_name(&args).as_deref(), + Some("mate-notification-daemon") + ); +} diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index bfca880fd..959991197 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -14,5 +14,7 @@ semver.workspace = true serde.workspace = true chrono.workspace = true rustix.workspace = true +tokio.workspace = true unixnotis-core = { path = "../unixnotis-core" } unicode-width.workspace = true +zbus.workspace = true diff --git a/crates/unixnotis-installer/src/actions/daemon.rs b/crates/unixnotis-installer/src/actions/daemon.rs index 2f10c2d80..5a7f855b6 100644 --- a/crates/unixnotis-installer/src/actions/daemon.rs +++ b/crates/unixnotis-installer/src/actions/daemon.rs @@ -176,6 +176,10 @@ fn pid_alive(pid: u32) -> Result { } fn pid_matches_comm(pid: u32, expected: &str) -> Result { + // Argv preserves daemon basenames longer than Linux's 15-byte comm field + if let Some(program) = crate::detect::read_cmdline_program(pid) { + return Ok(program == expected); + } // Validate the process name with ps before sending signals to avoid PID reuse hazards let output = system_tools::command("ps") .context("failed to locate trusted ps")? diff --git a/crates/unixnotis-installer/src/actions/environment/mod.rs b/crates/unixnotis-installer/src/actions/environment/mod.rs index 07ddd2e07..c139f7ea4 100644 --- a/crates/unixnotis-installer/src/actions/environment/mod.rs +++ b/crates/unixnotis-installer/src/actions/environment/mod.rs @@ -5,7 +5,6 @@ mod sync; pub use shell_path::{ensure_shell_path_entry, remove_shell_path_entry}; pub use sync::sync_user_environment; -pub use sync::HYPR_IMPORT_VARS; #[cfg(test)] #[path = "tests/mod.rs"] diff --git a/crates/unixnotis-installer/src/actions/environment/sync.rs b/crates/unixnotis-installer/src/actions/environment/sync.rs index 78255cf36..9ee3e1441 100644 --- a/crates/unixnotis-installer/src/actions/environment/sync.rs +++ b/crates/unixnotis-installer/src/actions/environment/sync.rs @@ -5,23 +5,12 @@ use std::env; use anyhow::{anyhow, Result}; use unixnotis_core::program_in_path; +use unixnotis_core::service_manager::validate_session_bus_address; use super::super::{ install::write_service_artifact, log_line, run_command_without_stdout, ActionContext, }; -pub const HYPR_IMPORT_VARS: [&str; 8] = [ - // Keep this list narrow so debug output and service environments do not inherit full shells - "WAYLAND_DISPLAY", - "XDG_CURRENT_DESKTOP", - "XDG_SESSION_TYPE", - "XDG_SESSION_DESKTOP", - "DISPLAY", - "XDG_RUNTIME_DIR", - // Nonstandard session buses need the explicit address inherited by the login session - "DBUS_SESSION_BUS_ADDRESS", - "PATH", -]; const HYPR_REQUIRED_VARS: [&str; 2] = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]; pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { @@ -55,7 +44,9 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { // Import only the known session variables that are actually present in this process // Missing optional values are left alone so SSH, nested, and unusual sessions still work - let vars = HYPR_IMPORT_VARS + let import_var_names = ctx.paths.service.import_variable_names(); + validate_persisted_bus_address(import_var_names)?; + let vars = import_var_names .iter() .copied() .filter_map(|var| env::var(var).ok().map(|value| (var, value))) @@ -68,7 +59,7 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { let env_artifacts = ctx .paths .service - .environment_sync_artifacts(&HYPR_IMPORT_VARS, &vars); + .environment_sync_artifacts(import_var_names, &vars); for artifact in &env_artifacts { // Artifact-based managers persist a small envdir instead of importing into a daemon write_service_artifact(ctx, artifact)?; @@ -111,3 +102,14 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { // Service start or restart stays owned by the caller so install avoids double boot Ok(()) } + +fn validate_persisted_bus_address(import_var_names: &[&str]) -> Result<()> { + if !import_var_names.contains(&"DBUS_SESSION_BUS_ADDRESS") { + return Ok(()); + } + let Ok(address) = env::var("DBUS_SESSION_BUS_ADDRESS") else { + return Ok(()); + }; + // Direct managers may persist only the stable runtime bus for this uid + validate_session_bus_address(&address, rustix::process::getuid().as_raw()).map_err(Into::into) +} diff --git a/crates/unixnotis-installer/src/actions/hyprland/manage.rs b/crates/unixnotis-installer/src/actions/hyprland/manage.rs index 3444289ed..ef4e2d1f1 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/manage.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/manage.rs @@ -85,12 +85,13 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { // Add only the lines that are still missing from the live config let mut additions = Vec::new(); // User-managed equivalents outside the installer block should not be duplicated + let import_variables = ctx.paths.service.import_variable_names(); for command in ctx .paths .service - .hyprland_startup_commands(&super::super::HYPR_IMPORT_VARS) + .hyprland_startup_commands(import_variables) { - if hyprland_command_present(&stripped, &command) { + if hyprland_command_present(&stripped, &command, import_variables) { continue; } additions.push(hyprland_startup_line(target.syntax, &command)); @@ -147,12 +148,12 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { } } -fn hyprland_command_present(contents: &str, command: &str) -> bool { +fn hyprland_command_present(contents: &str, command: &str, import_variables: &[&str]) -> bool { if command.starts_with("dbus-update-activation-environment") { return has_legacy_dbus_update(contents) || has_startup_command(contents, command); } if command.contains("import-environment") { - return has_import_command_with_vars(contents, &super::super::HYPR_IMPORT_VARS); + return has_import_command_with_vars(contents, import_variables); } has_startup_command(contents, command) } diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs index 8f5b3168a..222ed1a5d 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs @@ -2,7 +2,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use super::super::super::HYPR_IMPORT_VARS; use super::super::block::render_hyprland_bootstrap_block; use super::super::detect::{ has_import_command_with_vars, has_legacy_dbus_update, has_startup_command, @@ -75,7 +74,8 @@ fn existing_hyprland_config_targets_include_both_migration_formats() { #[test] fn rendered_lua_bootstrap_is_detected_as_complete() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - let commands = manager.hyprland_startup_commands(&HYPR_IMPORT_VARS); + let import_variables = manager.import_variable_names(); + let commands = manager.hyprland_startup_commands(import_variables); let lines = commands .iter() .map(|command| hyprland_startup_line(HyprlandConfigSyntax::Lua, command)) @@ -91,7 +91,14 @@ fn rendered_lua_bootstrap_is_detected_as_complete() { assert!(commands .iter() .all(|command| has_startup_command(&block, command) - || has_import_command_with_vars(&block, &HYPR_IMPORT_VARS))); + || has_import_command_with_vars(&block, import_variables))); + assert!(block.contains("systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS")); + let import_line = block + .lines() + .find(|line| line.contains("import-environment")) + .expect("rendered systemd import line"); + assert!(!import_line.contains("DBUS_SESSION_BUS_ADDRESS")); + assert!(!import_line.contains(" PATH")); } #[test] @@ -108,9 +115,13 @@ fn commented_lua_bootstrap_commands_are_ignored() { #[test] fn partial_import_environment_command_is_not_complete() { let contents = "exec-once = systemctl --user import-environment WAYLAND_DISPLAY\n"; + let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); // The installer needs every expected session variable before it can skip rebuilding the line - assert!(!has_import_command_with_vars(contents, &HYPR_IMPORT_VARS)); + assert!(!has_import_command_with_vars( + contents, + manager.import_variable_names() + )); } #[test] diff --git a/crates/unixnotis-installer/src/actions/tests/daemon.rs b/crates/unixnotis-installer/src/actions/tests/daemon.rs index 96c3f6318..d2c1474b0 100644 --- a/crates/unixnotis-installer/src/actions/tests/daemon.rs +++ b/crates/unixnotis-installer/src/actions/tests/daemon.rs @@ -279,16 +279,14 @@ fn pid_matches_comm_rejects_wrong_process_name() { } #[test] -fn pid_matches_comm_accepts_current_process_name_from_ps() { +fn pid_matches_comm_accepts_current_process_argv_basename() { let pid = std::process::id(); - let expected = std::fs::read_to_string(format!("/proc/{pid}/comm")) - .expect("proc should expose the current process name") - .trim() - .to_string(); + let expected = crate::detect::read_cmdline_program(pid) + .expect("proc should expose the current process argv basename"); let matches = pid_matches_comm(pid, &expected).expect("comm probe"); - // A matching process name is the only case where stop logic may signal the PID + // A matching argv basename is the only case where stop logic may signal the PID assert!(matches); } diff --git a/crates/unixnotis-installer/src/detect.rs b/crates/unixnotis-installer/src/detect.rs index a5b52e14c..de639ea38 100644 --- a/crates/unixnotis-installer/src/detect.rs +++ b/crates/unixnotis-installer/src/detect.rs @@ -31,45 +31,7 @@ pub struct Detection { pub daemons: Vec, } -pub struct KnownDaemon { - pub(crate) name: &'static str, - pub(crate) unit: &'static str, -} - -pub const KNOWN_DAEMONS: &[KnownDaemon] = &[ - KnownDaemon { - name: "unixnotis-daemon", - unit: "unixnotis-daemon.service", - }, - KnownDaemon { - name: "mako", - unit: "mako.service", - }, - KnownDaemon { - name: "dunst", - unit: "dunst.service", - }, - KnownDaemon { - name: "swaync", - unit: "swaync.service", - }, - KnownDaemon { - name: "notify-osd", - unit: "notify-osd.service", - }, - KnownDaemon { - name: "quickshell", - unit: "quickshell.service", - }, - KnownDaemon { - name: "hyprnotify", - unit: "hyprnotify.service", - }, - KnownDaemon { - name: "fnott", - unit: "fnott.service", - }, -]; +pub use unixnotis_core::KNOWN_NOTIFICATION_DAEMONS as KNOWN_DAEMONS; pub fn detect() -> Detection { let owner = detect_owner(); @@ -200,10 +162,41 @@ fn read_busctl_owner() -> Option { } } - let status = run_busctl(&["--user", "status", unixnotis_core::NOTIFICATIONS_BUS_NAME])?; + if let Some(status) = run_busctl(&["--user", "status", unixnotis_core::NOTIFICATIONS_BUS_NAME]) + { + if let Some(owner) = parse_busctl_status(&status) { + return Some(owner); + } + } + + // Some busctl versions omit process fields for a well-known name + let reply = run_busctl(&[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ])?; + let unique_name = parse_busctl_string_reply(&reply)?; + if let Some(status) = run_busctl(&["--user", "--json=short", "status", &unique_name]) { + if let Some(owner) = parse_busctl_json(&status) { + return Some(owner); + } + } + let status = run_busctl(&["--user", "status", &unique_name])?; parse_busctl_status(&status) } +fn parse_busctl_string_reply(reply: &str) -> Option { + // Method-call string output is formatted as `s "value"` + let (_, quoted) = reply.trim().split_once('"')?; + let (value, _) = quoted.split_once('"')?; + (!value.is_empty()).then(|| value.to_string()) +} + fn run_busctl(args: &[&str]) -> Option { let output = system_tools::command("busctl") .ok()? @@ -221,10 +214,11 @@ fn detect_known_daemons(owner: &Option) -> Vec { KNOWN_DAEMONS .iter() .map(|daemon| { - let (systemd_active, systemd_error) = is_unit_active(daemon.unit); + let (systemd_active, systemd_error) = + daemon.systemd_unit.map_or((false, None), is_unit_active); DetectedDaemon { name: daemon.name.to_string(), - unit: daemon.unit.to_string(), + unit: daemon.systemd_unit.unwrap_or_default().to_string(), systemd_active, systemd_error, running_pids: pgrep_exact(daemon.name), diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs index 0caa26da2..8f948f25c 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs @@ -5,6 +5,11 @@ use super::super::contract::{CommandSpec, ServiceArtifact}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { + pub fn import_variable_names(&self) -> &'static [&'static str] { + // Backend-specific policy prevents transient shell state from reaching systemd + unixnotis_core::service_manager::variables_for_backend(self.shared_kind()) + } + pub fn hyprland_startup_commands(&self, import_vars: &[&str]) -> Vec { // Startup lines mirror the selected manager instead of assuming systemd match self.kind { diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs index d9a05244c..13d32dffa 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs @@ -72,6 +72,22 @@ impl ServiceManager { self.kind.label() } + pub const fn shared_kind(&self) -> unixnotis_core::service_manager::ServiceManagerKind { + // Environment policy lives in core so installers and repair commands cannot drift + match self.kind { + ServiceManagerKind::Systemd => { + unixnotis_core::service_manager::ServiceManagerKind::Systemd + } + ServiceManagerKind::Dinit => unixnotis_core::service_manager::ServiceManagerKind::Dinit, + ServiceManagerKind::Runit => unixnotis_core::service_manager::ServiceManagerKind::Runit, + ServiceManagerKind::S6 => unixnotis_core::service_manager::ServiceManagerKind::S6, + } + } + + pub const fn is_systemd(&self) -> bool { + matches!(self.kind, ServiceManagerKind::Systemd) + } + pub const fn service_name(&self) -> &'static str { // The backend owns its native service identifier match self.kind { diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs b/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs index d3a7b7c31..8cf291c90 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs @@ -24,3 +24,18 @@ fn artifact_backends_do_not_emit_environment_commands() { assert!(runit.environment_sync_commands(&values, true).is_empty()); assert!(s6.environment_sync_commands(&values, true).is_empty()); } + +#[test] +fn backend_environment_policy_keeps_transient_shell_state_out_of_systemd() { + let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); + let dinit = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")); + + assert!(!systemd + .import_variable_names() + .contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!systemd.import_variable_names().contains(&"PATH")); + assert!(dinit + .import_variable_names() + .contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!dinit.import_variable_names().contains(&"PATH")); +} diff --git a/crates/unixnotis-installer/src/tests/detect.rs b/crates/unixnotis-installer/src/tests/detect.rs index fa143ee2a..84d1cd4f2 100644 --- a/crates/unixnotis-installer/src/tests/detect.rs +++ b/crates/unixnotis-installer/src/tests/detect.rs @@ -3,8 +3,8 @@ use std::fs; use std::io::{Error, ErrorKind}; use crate::detect::{ - parse_busctl_json, parse_busctl_status, read_cmdline_program, read_comm, systemctl_spawn_error, - KNOWN_DAEMONS, + parse_busctl_json, parse_busctl_status, parse_busctl_string_reply, read_cmdline_program, + read_comm, systemctl_spawn_error, KNOWN_DAEMONS, }; #[test] @@ -16,16 +16,13 @@ fn known_daemons_include_quickshell_owner() { .expect("quickshell should be known"); // Unit metadata keeps status output and restore hints consistent - assert_eq!(quickshell.unit, "quickshell.service"); + assert_eq!(quickshell.systemd_unit, None); } #[test] fn known_daemons_include_recent_wayland_notifiers() { // These daemons are common enough to deserve explicit regression coverage - let expected = [ - ("hyprnotify", "hyprnotify.service"), - ("fnott", "fnott.service"), - ]; + let expected = [("hyprnotify", None), ("fnott", Some("fnott.service"))]; for (name, unit) in expected { let daemon = KNOWN_DAEMONS @@ -33,7 +30,26 @@ fn known_daemons_include_recent_wayland_notifiers() { .find(|daemon| daemon.name == name) .expect("daemon should be known"); - assert_eq!(daemon.unit, unit); + assert_eq!(daemon.systemd_unit, unit); + } +} + +#[test] +fn known_daemons_cover_standalone_desktop_and_wayland_owners() { + for name in [ + "xfce4-notifyd", + "lxqt-notificationd", + "mate-notification-daemon", + "notification-daemon", + "wired", + "deadd-notification-center", + "tiramisu", + "runst", + ] { + assert!( + KNOWN_DAEMONS.iter().any(|daemon| daemon.name == name), + "{name} should be recognized" + ); } } @@ -157,6 +173,16 @@ fn parse_busctl_json_rejects_invalid_pid_string() { assert!(owner.is_none()); } +#[test] +fn parse_busctl_string_reply_reads_unique_owner_name() { + assert_eq!( + parse_busctl_string_reply("s \":1.77\"\n").as_deref(), + Some(":1.77") + ); + assert!(parse_busctl_string_reply("s \"\"").is_none()); + assert!(parse_busctl_string_reply("invalid").is_none()); +} + #[test] fn parse_busctl_json_returns_none_for_invalid_json() { let owner = parse_busctl_json("not json"); @@ -301,6 +327,45 @@ fn detect_falls_back_to_text_busctl_status_when_json_status_fails() { let _ = fs::remove_dir_all(root); } +#[test] +fn detect_resolves_unique_owner_when_well_known_status_has_no_process_fields() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("detect-unique-owner-fallback"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\n\ + if [ \"$2\" = '--json=short' ] && [ \"$4\" = ':1.77' ]; then\n\ + printf '{\"Status\":{\"Comm\":\"fnott\"}}\\n'\n\ + exit 0\n\ + fi\n\ + if [ \"$2\" = '--json=short' ]; then printf '{}\\n'; exit 0; fi\n\ + if [ \"$2\" = 'status' ]; then printf 'Name=org.freedesktop.Notifications\\n'; exit 0; fi\n\ + if [ \"$2\" = 'call' ]; then printf 's \":1.77\"\\n'; exit 0; fi\n\ + exit 1\n", + ); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 3\n"); + write_executable(&fake_bin.join("pgrep"), "#!/bin/sh\nexit 1\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let detection = crate::detect::detect(); + + assert_eq!( + detection + .owner + .as_ref() + .and_then(|owner| owner.comm.as_deref()), + Some("fnott") + ); + assert!(detection + .daemons + .iter() + .any(|daemon| daemon.name == "fnott" && daemon.is_owner)); + + let _ = fs::remove_dir_all(root); +} + fn test_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("unixnotis-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&root); From 697ce7f40014634a791d7679174420c6a5f2df65 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 23:44:06 -0500 Subject: [PATCH 092/275] fix: harden daemon bus and notification identity Summary: harden daemon bus and notification identity. Scope: repository. --- crates/unixnotis-core/src/bus_call.rs | 33 ++ crates/unixnotis-core/src/bus_identity.rs | 48 +++ crates/unixnotis-core/src/control/proxy.rs | 4 + crates/unixnotis-core/src/lib.rs | 6 + crates/unixnotis-core/src/notifications.rs | 33 ++ crates/unixnotis-core/src/tests/bus_call.rs | 22 ++ .../src/child_process/process.rs | 35 ++- .../src/child_process/tests/command.rs | 11 +- .../unixnotis-daemon/src/daemon/bus/health.rs | 46 +++ crates/unixnotis-daemon/src/daemon/bus/mod.rs | 4 +- .../src/daemon/bus/ownership.rs | 32 +- .../src/daemon/bus/tests/ownership.rs | 4 +- .../src/daemon/control/query.rs | 5 +- .../src/daemon/control/server.rs | 7 +- crates/unixnotis-daemon/src/daemon/mod.rs | 5 +- .../identity/desktop_index/index.rs | 54 +++- .../identity/desktop_index/launch.rs | 236 +++++++++++++++ .../identity/desktop_index/mod.rs | 26 +- .../identity/desktop_index/model.rs | 68 +++-- .../identity/desktop_index/record.rs | 21 +- .../identity/desktop_index/refresh.rs | 70 +++++ .../identity/desktop_index/scan.rs | 20 +- .../identity/desktop_index/tests/launch.rs | 239 +++++++++++++++ .../identity/desktop_index/tests/names.rs | 2 +- .../identity/desktop_index/tests/parsing.rs | 3 +- .../identity/desktop_index/tests/scan.rs | 3 +- .../notifications/identity/executable.rs | 5 + .../src/daemon/notifications/identity/mod.rs | 4 +- .../daemon/notifications/identity/resolver.rs | 60 +++- .../daemon/notifications/identity/sender.rs | 61 +++- .../identity/tests/executable.rs | 21 ++ .../notifications/identity/tests/resolver.rs | 286 ++++++++++++++++++ .../notifications/identity/tests/sender.rs | 20 +- .../identity/tests/sender_cache.rs | 1 + .../notifications/ingress/tests/payload.rs | 2 + .../src/daemon/notifications/mod.rs | 1 + .../src/daemon/notifications/server/flow.rs | 58 +++- .../daemon/notifications/server/tests/flow.rs | 12 +- .../src/daemon/state/model.rs | 11 +- crates/unixnotis-daemon/src/runtime/daemon.rs | 107 ++++--- crates/unixnotis-daemon/src/runtime/runner.rs | 16 +- .../src/runtime/tests/dbus_lifecycle.rs | 274 +++++++++++++++++ .../src/runtime/tests/runner.rs | 3 + crates/unixnotis-daemon/src/store/dnd/mod.rs | 5 +- .../src/store/test_support.rs | 2 +- crates/unixnotis-daemon/src/tests/support.rs | 11 +- 46 files changed, 1769 insertions(+), 228 deletions(-) create mode 100644 crates/unixnotis-core/src/bus_call.rs create mode 100644 crates/unixnotis-core/src/bus_identity.rs create mode 100644 crates/unixnotis-core/src/notifications.rs create mode 100644 crates/unixnotis-core/src/tests/bus_call.rs create mode 100644 crates/unixnotis-daemon/src/daemon/bus/health.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs create mode 100644 crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs diff --git a/crates/unixnotis-core/src/bus_call.rs b/crates/unixnotis-core/src/bus_call.rs new file mode 100644 index 000000000..36ed7889c --- /dev/null +++ b/crates/unixnotis-core/src/bus_call.rs @@ -0,0 +1,33 @@ +//! Shared hard timeout for internal D-Bus method calls + +use std::future::Future; +use std::time::Duration; + +/// Maximum wait for one internal `UnixNotis` D-Bus method +pub const INTERNAL_DBUS_CALL_TIMEOUT: Duration = Duration::from_secs(2); + +/// Run one D-Bus method with the internal hard timeout +/// +/// # Errors +/// +/// Returns the method error or a timeout error when the call exceeds the limit +pub async fn timed_dbus_call(call: impl Future>) -> zbus::Result { + timed_dbus_call_with_timeout(INTERNAL_DBUS_CALL_TIMEOUT, call).await +} + +async fn timed_dbus_call_with_timeout( + timeout: Duration, + call: impl Future>, +) -> zbus::Result { + match tokio::time::timeout(timeout, call).await { + Ok(result) => result, + Err(_) => Err(zbus::Error::Failure(format!( + "UnixNotis D-Bus call timed out after {} seconds", + timeout.as_secs_f64() + ))), + } +} + +#[cfg(test)] +#[path = "tests/bus_call.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/bus_identity.rs b/crates/unixnotis-core/src/bus_identity.rs new file mode 100644 index 000000000..2f126a800 --- /dev/null +++ b/crates/unixnotis-core/src/bus_identity.rs @@ -0,0 +1,48 @@ +//! Sanitized session-bus identity diagnostics shared by every process + +use tracing::info; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use crate::INTERNAL_DBUS_CALL_TIMEOUT; + +/// Stable identity assigned by one message-bus instance and connection +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionBusIdentity { + pub bus_id: String, + pub unique_name: String, + pub runtime_dir: String, +} + +/// Read and log a sanitized session-bus identity +/// +/// # Errors +/// +/// Returns an error when the bus identity probe fails, times out, or lacks a unique name +pub async fn log_session_bus_identity( + connection: &Connection, + component: &'static str, +) -> zbus::Result { + let dbus = DBusProxy::new(connection).await?; + let bus_id = tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, dbus.get_id()) + .await + .map_err(|_| zbus::Error::Failure("session bus identity probe timed out".to_string()))? + .map_err(zbus::Error::from)?; + let unique_name = connection + .unique_name() + .ok_or_else(|| zbus::Error::Failure("session bus has no unique name".to_string()))?; + let identity = SessionBusIdentity { + bus_id: bus_id.to_string(), + unique_name: unique_name.to_string(), + runtime_dir: std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(), + }; + + info!( + bus_id = %identity.bus_id, + unique_name = %identity.unique_name, + runtime_dir = %identity.runtime_dir, + component, + "connected to session bus" + ); + Ok(identity) +} diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index a93cdda2a..552cdd87b 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -1,5 +1,8 @@ //! Generated D-Bus control proxy contract +// The proxy macro creates signal collections consumed through generated streams +#![allow(clippy::collection_is_never_read)] + use zbus::proxy; use crate::NotificationView; @@ -57,6 +60,7 @@ trait Control { /// Mark the panel UI ready after signal subscriptions are active fn mark_panel_ready(&self) -> zbus::Result<()>; /// Clear panel readiness while the UI reconnects or shuts down + #[zbus(no_autostart)] fn mark_panel_not_ready(&self) -> zbus::Result<()>; #[zbus(signal)] diff --git a/crates/unixnotis-core/src/lib.rs b/crates/unixnotis-core/src/lib.rs index 429ee7790..99c27f9f7 100644 --- a/crates/unixnotis-core/src/lib.rs +++ b/crates/unixnotis-core/src/lib.rs @@ -16,6 +16,8 @@ reason = "reviewed compatibility, wire-format, and bounded numeric conversions that cannot change without breaking public configuration behavior" )] +pub mod bus_call; +pub mod bus_identity; pub mod config; pub mod control; pub mod css; @@ -23,6 +25,7 @@ pub mod embedded; pub mod filesystem; pub mod model; pub mod notification_daemons; +pub mod notifications; pub mod process; pub mod reconnect; pub mod service_manager; @@ -31,12 +34,15 @@ pub mod service_manager; mod test_support; pub mod util; +pub use bus_call::*; +pub use bus_identity::*; pub use config::*; pub use control::*; pub use css::*; pub use embedded::*; pub use model::*; pub use notification_daemons::*; +pub use notifications::*; pub use process::*; pub use util::program_in_path; diff --git a/crates/unixnotis-core/src/notifications.rs b/crates/unixnotis-core/src/notifications.rs new file mode 100644 index 000000000..4a50d8ced --- /dev/null +++ b/crates/unixnotis-core/src/notifications.rs @@ -0,0 +1,33 @@ +//! Freedesktop notification client proxy contract + +use std::collections::HashMap; + +use zbus::proxy; +use zbus::zvariant::OwnedValue; + +#[proxy( + interface = "org.freedesktop.Notifications", + default_service = "org.freedesktop.Notifications", + default_path = "/org/freedesktop/Notifications" +)] +pub trait Notifications { + /// Capabilities advertised by the active notification server + fn get_capabilities(&self) -> zbus::Result>; + + /// Stable server identity and protocol version + fn get_server_information(&self) -> zbus::Result<(String, String, String, String)>; + + /// Submit one notification and return its assigned identifier + #[allow(clippy::too_many_arguments)] + fn notify( + &self, + app_name: &str, + replaces_id: u32, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, + expire_timeout: i32, + ) -> zbus::Result; +} diff --git a/crates/unixnotis-core/src/tests/bus_call.rs b/crates/unixnotis-core/src/tests/bus_call.rs new file mode 100644 index 000000000..cd08d930c --- /dev/null +++ b/crates/unixnotis-core/src/tests/bus_call.rs @@ -0,0 +1,22 @@ +use std::time::Duration; + +use super::{timed_dbus_call, timed_dbus_call_with_timeout}; + +#[tokio::test] +async fn internal_dbus_call_timeout_is_hard_and_bounded() { + let call = std::future::pending::>(); + let error = timed_dbus_call_with_timeout(Duration::from_millis(1), call) + .await + .expect_err("pending method must time out"); + + assert!(error.to_string().contains("timed out")); +} + +#[tokio::test] +async fn internal_dbus_call_returns_success_without_delay() { + let value = timed_dbus_call(std::future::ready(Ok::<_, zbus::Error>(42))) + .await + .expect("ready call should pass"); + + assert_eq!(value, 42); +} diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index 307057d4d..f5bf52e06 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -52,23 +52,22 @@ impl UiProcessKind { } } - pub(super) fn build_command(self, args: &Args) -> Command { - let mut command = match self { - Self::Popups => { - if let Some(path) = resolve_popups_path() { - Command::new(path) - } else { - Command::new("unixnotis-popups") - } - } - Self::Center => { - if let Some(path) = resolve_center_path() { - Command::new(path) - } else { - Command::new("unixnotis-center") - } - } + pub(super) fn build_command(self, args: &Args) -> Result { + let path = match self { + Self::Popups => resolve_popups_path(), + Self::Center => resolve_center_path(), }; + let path = path.ok_or_else(|| { + anyhow!( + "{} is missing beside the daemon executable; refusing a PATH-based child launch", + self.label() + ) + })?; + Ok(Self::build_command_for_path(args, path)) + } + + fn build_command_for_path(args: &Args, path: PathBuf) -> Command { + let mut command = Command::new(path); // Journal should keep child logs tied to the daemon service // Inherited output makes crash lines easier to trace later @@ -90,10 +89,10 @@ impl UiProcessKind { } pub(super) fn start(self, args: &Args) -> Result { - let mut command = self.build_command(args); + let mut command = self.build_command(args)?; let label = self.label(); command.spawn().map_err(|err| { - anyhow!("failed to start {label} ({err}); build it or install it on PATH") + anyhow!("failed to start {label} ({err}); install it beside the daemon executable") }) } } diff --git a/crates/unixnotis-daemon/src/child_process/tests/command.rs b/crates/unixnotis-daemon/src/child_process/tests/command.rs index 4f2a91ab3..9ae2d2388 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/command.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/command.rs @@ -45,7 +45,10 @@ fn child_config_env_path_resolves_relative_paths_against_current_directory() { #[test] fn build_command_sets_config_env_instead_of_forwarding_flag() { let args = Args::parse_from(["unixnotis-daemon", "--config", "fixtures/config.toml"]); - let command = UiProcessKind::Center.build_command(&args); + let command = UiProcessKind::build_command_for_path( + &args, + PathBuf::from("/tmp/unixnotis-test/bin/unixnotis-center"), + ); let std_command = command.as_std(); let args: Vec<_> = std_command.get_args().map(OsString::from).collect(); let envs: Vec<_> = std_command @@ -57,6 +60,7 @@ fn build_command_sets_config_env_instead_of_forwarding_flag() { !args.iter().any(|arg| arg == "--config"), "child argv should stay free of UnixNotis-only flags" ); + assert!(Path::new(std_command.get_program()).is_absolute()); assert!( envs.iter().any(|(key, value)| { key == CONFIG_PATH_ENV @@ -69,7 +73,10 @@ fn build_command_sets_config_env_instead_of_forwarding_flag() { #[test] fn build_command_clears_inherited_config_override_without_custom_path() { let args = Args::parse_from(["unixnotis-daemon"]); - let command = UiProcessKind::Popups.build_command(&args); + let command = UiProcessKind::build_command_for_path( + &args, + PathBuf::from("/tmp/unixnotis-test/bin/unixnotis-popups"), + ); let std_command = command.as_std(); assert!( diff --git a/crates/unixnotis-daemon/src/daemon/bus/health.rs b/crates/unixnotis-daemon/src/daemon/bus/health.rs new file mode 100644 index 000000000..8a6c67489 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/health.rs @@ -0,0 +1,46 @@ +//! Session-bus identity, ownership verification, and runtime health checks + +use std::time::Duration; + +use anyhow::{ensure, Context, Result}; +use unixnotis_core::{CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::Connection; + +const BUS_HEALTH_INTERVAL: Duration = Duration::from_secs(1); +const BUS_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +pub async fn verify_name_owner( + dbus: &DBusProxy<'_>, + connection: &Connection, + name: &'static str, +) -> Result<()> { + let expected = connection + .unique_name() + .context("session bus did not assign a unique name")?; + let bus_name = BusName::try_from(name).context("invalid required D-Bus name")?; + let actual = tokio::time::timeout(BUS_PROBE_TIMEOUT, dbus.get_name_owner(bus_name)) + .await + .with_context(|| format!("D-Bus owner probe timed out for {name}"))? + .with_context(|| format!("D-Bus owner probe failed for {name}"))?; + + ensure!( + actual.as_str() == expected.as_str(), + "{name} owner mismatch: expected {expected}, found {actual}" + ); + Ok(()) +} + +pub async fn monitor_required_bus_names(connection: Connection) -> Result<()> { + let dbus = DBusProxy::new(&connection) + .await + .context("create D-Bus health proxy")?; + + loop { + tokio::time::sleep(BUS_HEALTH_INTERVAL).await; + for required in [NOTIFICATIONS_BUS_NAME, CONTROL_BUS_NAME] { + verify_name_owner(&dbus, &connection, required).await?; + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/mod.rs b/crates/unixnotis-daemon/src/daemon/bus/mod.rs index 44bf4a155..cec984b04 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/mod.rs @@ -1,11 +1,13 @@ //! Bus-name acquisition and client ownership lifecycle mod clients; +mod health; mod names; mod ownership; +pub use health::{monitor_required_bus_names, verify_name_owner}; pub use names::{log_name_reply, request_control_name, request_well_known_name}; -pub use ownership::{log_current_owner, spawn_client_owner_watch, wait_for_owner_state}; +pub use ownership::{spawn_client_owner_watch, wait_for_owner_state}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/bus/ownership.rs b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs index 53169a78f..81466d521 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/ownership.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs @@ -7,9 +7,8 @@ use std::time::Duration; use anyhow::Result; use futures_util::StreamExt; -use tracing::{info, warn}; +use tracing::warn; use zbus::fdo::DBusProxy; -use zbus::Connection; use crate::daemon::DaemonState; @@ -57,41 +56,12 @@ pub async fn wait_for_owner_state( } } -pub async fn log_current_owner( - dbus_proxy: &DBusProxy<'_>, - connection: &Connection, - name: zbus::names::BusName<'_>, -) -> Result { - let unique_name = connection - .unique_name() - .map(std::string::ToString::to_string); - let owner = match dbus_proxy.get_name_owner(name).await { - Ok(owner) => owner.to_string(), - Err(err) => { - info!(?err, "org.freedesktop.Notifications has no owner"); - return Ok(false); - } - }; - let is_self = owner_name_is_self(unique_name.as_deref(), owner.as_str()); - if is_self { - info!(owner, "org.freedesktop.Notifications owner (self)"); - } else { - info!(owner, "org.freedesktop.Notifications owner"); - } - Ok(is_self) -} - pub(super) fn owner_state_matches(new_owner: Option<&str>, expect_owner: bool) -> bool { // D-Bus signals encode release as an empty owner name, not as a missing signal let has_owner = new_owner.is_some_and(|name| !name.is_empty()); has_owner == expect_owner } -pub(super) fn owner_name_is_self(unique_name: Option<&str>, owner: &str) -> bool { - // Unique names come from the live connection and must match the queried owner exactly - unique_name == Some(owner) -} - pub async fn spawn_client_owner_watch(state: Arc) -> zbus::Result<()> { // One owner-loss stream serves sender metadata and every client-owned domain resource let proxy = DBusProxy::new(state.connection()).await?; diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs index d017ae2ed..2f8158f2d 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs @@ -1,4 +1,4 @@ -use super::super::ownership::{owner_name_is_self, owner_state_matches, wait_for_owner_state}; +use super::super::ownership::{owner_state_matches, wait_for_owner_state}; use std::time::Duration; use zbus::fdo::DBusProxy; @@ -18,6 +18,8 @@ fn owner_state_matches_expected_presence_and_release() { #[test] fn owner_name_is_self_requires_exact_unique_name_match() { + let owner_name_is_self = |unique_name: Option<&str>, owner: &str| unique_name == Some(owner); + // D-Bus unique names are exact tokens, so prefix or suffix matches must not pass assert!(owner_name_is_self(Some(":1.7"), ":1.7")); assert!(!owner_name_is_self(Some(":1.70"), ":1.7")); diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index b32e581b7..dd3a23c9e 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -8,9 +8,8 @@ use zbus::message::Header; use super::ControlServer; impl ControlServer { - pub(super) async fn query_state(&self, header: &Header<'_>) -> zbus::fdo::Result { - // State metadata is now treated as privileged control telemetry - self.authorize_control_call(header, "GetState").await?; + pub(super) async fn query_state(&self) -> zbus::fdo::Result { + // Readiness clients receive only aggregate state without notification content // Single lock read keeps state snapshot internally consistent let store = self.state.store.lock().await; Ok(store.control_state()) diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index ce3cb19d9..c93bcaf91 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -69,11 +69,8 @@ impl ControlServer { #[interface(name = "com.unixnotis.Control")] impl ControlServer { - async fn get_state( - &self, - #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result { - self.query_state(&header).await + async fn get_state(&self) -> zbus::fdo::Result { + self.query_state().await } async fn list_active( diff --git a/crates/unixnotis-daemon/src/daemon/mod.rs b/crates/unixnotis-daemon/src/daemon/mod.rs index 9833dc37f..32209cf5c 100644 --- a/crates/unixnotis-daemon/src/daemon/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/mod.rs @@ -9,14 +9,15 @@ mod notifications; mod state; pub use bus::{ - log_current_owner, log_name_reply, request_control_name, request_well_known_name, - spawn_client_owner_watch, wait_for_owner_state, + log_name_reply, monitor_required_bus_names, request_control_name, request_well_known_name, + spawn_client_owner_watch, verify_name_owner, wait_for_owner_state, }; pub use control::ControlServer; pub use errors::to_fdo_error; pub use notifications::NotificationIngress; pub use notifications::NotificationServer; pub(in crate::daemon) use notifications::NotificationSignalMode; +pub use notifications::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub use state::DaemonState; pub const NOTIFICATIONS_OBJECT_PATH: &str = "/org/freedesktop/Notifications"; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index 8dc069150..36463e620 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -1,8 +1,6 @@ //! Desktop record lookup tables and trusted relay matching use std::path::Path; -#[cfg(test)] -use std::path::PathBuf; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::model::{DesktopIdentityIndex, DesktopRecord, ExecutableIdentity}; @@ -63,6 +61,16 @@ impl DesktopIdentityIndex { .map(|relay| relay.path.as_path()) } + pub(in crate::daemon::notifications::identity) fn trusted_portal_path( + &self, + identity: FileIdentity, + ) -> Option<&Path> { + self.trusted_portals + .iter() + .find(|portal| portal.identity.same_file(identity)) + .map(|portal| portal.path.as_path()) + } + pub(super) fn index_trusted_relay(&mut self, path: &Path) { let Some(evidence) = executable_evidence_for_path(path) else { return; @@ -76,23 +84,37 @@ impl DesktopIdentityIndex { } } - #[cfg(test)] - pub(in crate::daemon::notifications::identity) fn from_records( - records: Vec, - trusted_relays: Vec<(PathBuf, FileIdentity)>, - ) -> Self { - let mut index = Self::default(); - for record in records { - index.index_record(record); + pub(super) fn index_trusted_portals_in(&mut self, directory: &Path) { + const MAX_PORTAL_CANDIDATES: usize = 256; + + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.take(MAX_PORTAL_CANDIDATES).flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("xdg-desktop-portal") { + continue; + } + let Some(evidence) = executable_evidence_for_path(&path) else { + continue; + }; + // Portal authority is accepted only from protected system integration binaries + if evidence.identity.is_system_managed() && evidence.identity.is_executable_regular() { + self.trusted_portals.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } } - index.trusted_relays = trusted_relays - .into_iter() - .map(|(path, identity)| ExecutableIdentity { path, identity }) - .collect(); - index } - pub(super) fn index_record(&mut self, record: DesktopRecord) { + pub(in crate::daemon::notifications::identity) fn index_record( + &mut self, + record: DesktopRecord, + ) { let record_index = self.records.len(); if record.system_origin { // Protected branding excludes generic names and launcher aliases diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs new file mode 100644 index 000000000..ccd99d5cd --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs @@ -0,0 +1,236 @@ +//! Desktop `Exec` template parsing and process-command matching + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use gio::prelude::AppInfoExt; + +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; + +const MAX_EXEC_TEMPLATE_BYTES: usize = 16 * 1024; +const MAX_EXEC_TEMPLATE_ARGUMENTS: usize = 128; +const MAX_PROCESS_ARGUMENTS: usize = 256; + +pub(super) fn build_launch_spec( + desktop: &gio::DesktopAppInfo, + desktop_path: &Path, + executable: FileIdentity, +) -> Option { + let template = desktop.string("Exec")?; + if template.len() > MAX_EXEC_TEMPLATE_BYTES { + return None; + } + let words = shell_words::split(template.as_str()).ok()?; + if words.is_empty() || words.len() > MAX_EXEC_TEMPLATE_ARGUMENTS { + return None; + } + + let mut arguments = Vec::with_capacity(words.len().saturating_sub(1)); + let mut protected_literal_files = 0_usize; + let mut literal_files_are_system_managed = true; + for word in words.into_iter().skip(1) { + let argument = match word.as_str() { + "%f" => LaunchArgument::FieldCode(FieldCode::File), + "%F" => LaunchArgument::FieldCode(FieldCode::Files), + "%u" => LaunchArgument::FieldCode(FieldCode::Url), + "%U" => LaunchArgument::FieldCode(FieldCode::Urls), + "%c" => literal_argument(desktop.display_name().as_bytes().to_vec()), + "%k" => literal_argument(desktop_path.as_os_str().as_encoded_bytes().to_vec()), + "%i" => LaunchArgument::OptionalIcon { + name: desktop + .string("Icon") + .map_or_else(String::new, |icon| icon.to_string()), + }, + _ => { + let literal = percent_literal(&word)?; + let literal = literal_argument(literal.into_bytes()); + if let LaunchArgument::Literal(literal) = &literal { + if let Some((_path, identity)) = &literal.file { + if identity.is_system_managed() { + protected_literal_files += 1; + } else { + literal_files_are_system_managed = false; + } + } else if literal_path_candidate(&literal.value) { + // An unresolved application path cannot support system association + literal_files_are_system_managed = false; + } + } + literal + } + }; + arguments.push(argument); + } + + Some(LaunchSpec { + executable, + arguments, + protected_literal_files, + literal_files_are_system_managed, + }) +} + +pub(super) fn launch_spec_matches_sender( + spec: &LaunchSpec, + sender_identity: FileIdentity, + cmdline: &[Vec], +) -> bool { + if !spec.executable.same_file(sender_identity) + || cmdline.is_empty() + || cmdline.len() > MAX_PROCESS_ARGUMENTS + { + return false; + } + if !literal_file_identities_are_current(spec) { + return false; + } + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, &cmdline[1..], 0, 0, &mut visited) +} + +fn literal_argument(value: Vec) -> LaunchArgument { + let file = std::str::from_utf8(&value) + .ok() + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .and_then(|path| { + executable_evidence_for_path(&path).map(|evidence| (path, evidence.identity)) + }); + LaunchArgument::Literal(LiteralArgument { value, file }) +} + +fn literal_path_candidate(value: &[u8]) -> bool { + // Slash-bearing non-option literals are application payload paths even when unresolved + !value.starts_with(b"-") && value.contains(&b'/') +} + +fn percent_literal(word: &str) -> Option { + let mut output = String::with_capacity(word.len()); + let mut characters = word.chars(); + while let Some(character) = characters.next() { + if character != '%' { + output.push(character); + continue; + } + if characters.next()? != '%' { + return None; + } + output.push('%'); + } + Some(output) +} + +fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} + +fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + actual.get(actual_index) == Some(&literal.value) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 1, + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments(template, actual, template_index + 1, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index + 1) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 2, + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let values = actual + .get(actual_index..actual_index + count) + .unwrap_or_default(); + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index + 1, + actual_index + count, + visited, + ) { + return true; + } + } + false +} + +fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} + +#[cfg(test)] +#[path = "tests/launch.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index 4f07a540d..fcd7eaf3f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -1,21 +1,31 @@ //! Desktop application index preserving system and user entry origins mod index; -mod model; +mod launch; +pub(in crate::daemon::notifications::identity) mod model; mod names; mod program; mod record; +mod refresh; mod scan; -pub(in crate::daemon) use model::DesktopIdentityIndex; +pub use model::DesktopIdentityIndex; pub(super) use model::DesktopRecord; pub(super) use names::{normalize_desktop_id, normalize_name}; +pub use refresh::spawn_desktop_index_refresh; + +pub(in crate::daemon::notifications::identity) fn record_launch_matches( + record: &DesktopRecord, + sender_identity: super::FileIdentity, + cmdline: Option<&[Vec]>, +) -> bool { + match &record.launch_spec { + None => true, + Some(spec) => cmdline.is_some_and(|cmdline| { + launch::launch_spec_matches_sender(spec, sender_identity, cmdline) + }), + } +} -#[cfg(test)] -pub(super) use names::is_shared_launcher; -#[cfg(test)] -pub(super) use program::desktop_executable; -#[cfg(test)] -pub(super) use scan::{ScanBudget, ScanLimits}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index c0b5451ec..fc27ec5ca 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -6,6 +6,35 @@ use std::path::PathBuf; use super::super::executable::FileIdentity; use super::names::normalize_name; +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct LaunchSpec { + pub(in crate::daemon::notifications::identity) executable: FileIdentity, + pub(in crate::daemon::notifications::identity) arguments: Vec, + pub(in crate::daemon::notifications::identity) protected_literal_files: usize, + pub(in crate::daemon::notifications::identity) literal_files_are_system_managed: bool, +} + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) enum LaunchArgument { + Literal(LiteralArgument), + FieldCode(FieldCode), + OptionalIcon { name: String }, +} + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct LiteralArgument { + pub(in crate::daemon::notifications::identity) value: Vec, + pub(in crate::daemon::notifications::identity) file: Option<(PathBuf, FileIdentity)>, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(in crate::daemon::notifications::identity) enum FieldCode { + File, + Files, + Url, + Urls, +} + #[derive(Debug, Clone)] pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) id: String, @@ -18,7 +47,8 @@ pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) system_association: bool, pub(in crate::daemon::notifications::identity) association_eligible: bool, pub(in crate::daemon::notifications::identity) dbus_activatable: bool, - pub(super) names: HashSet, + pub(in crate::daemon::notifications::identity) launch_spec: Option, + pub(in crate::daemon::notifications::identity) names: HashSet, } impl DesktopRecord { @@ -26,44 +56,20 @@ impl DesktopRecord { // Normalized aliases cover desktop names without trusting free-form display text self.names.contains(&normalize_name(claim)) } - - #[cfg(test)] - pub(in crate::daemon::notifications::identity) fn fixture( - id: &str, - display_name: &str, - executable_path: &str, - identity: FileIdentity, - system_entry: bool, - dbus_activatable: bool, - ) -> Self { - let names = HashSet::from([normalize_name(display_name)]); - Self { - id: id.to_string(), - display_name: display_name.to_string(), - badge_icon: id.to_string(), - executable_path: Some(PathBuf::from(executable_path)), - executable_identity: Some(identity), - desktop_identity: Some(identity), - system_origin: system_entry, - system_association: system_entry, - association_eligible: true, - dbus_activatable, - names, - } - } } #[derive(Debug, Default)] -pub(in crate::daemon) struct DesktopIdentityIndex { +pub struct DesktopIdentityIndex { pub(super) records: Vec, pub(super) by_id: HashMap>, pub(super) by_identity: HashMap<(u64, u64), Vec>, pub(super) system_brand_names: HashSet, - pub(super) trusted_relays: Vec, + pub(in crate::daemon::notifications::identity) trusted_relays: Vec, + pub(in crate::daemon::notifications::identity) trusted_portals: Vec, } #[derive(Debug, Clone)] -pub(super) struct ExecutableIdentity { - pub(super) path: PathBuf, - pub(super) identity: FileIdentity, +pub(in crate::daemon::notifications::identity) struct ExecutableIdentity { + pub(in crate::daemon::notifications::identity) path: PathBuf, + pub(in crate::daemon::notifications::identity) identity: FileIdentity, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index da7721f11..100abae1d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -6,6 +6,7 @@ use std::path::Path; use gio::prelude::AppInfoExt; use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::launch::build_launch_spec; use super::model::{DesktopIdentityIndex, DesktopRecord}; use super::names::{is_shared_launcher, normalize_desktop_id, normalize_name}; use super::program::{desktop_executable, resolve_program}; @@ -27,21 +28,28 @@ impl DesktopIdentityIndex { } let display_name = desktop.display_name().to_string(); let desktop_program = desktop_executable(&desktop); - // Shared runtimes identify the launcher, not the application behind it - let association_eligible = desktop_program - .as_deref() - .is_some_and(|program| !is_shared_launcher(program)); let executable_path = desktop_program.as_deref().and_then(resolve_program); let executable_identity = executable_path .as_deref() .and_then(executable_evidence_for_path) .map(|evidence| evidence.identity); let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); - // System association requires protected metadata and an application-specific executable + let launch_spec = + executable_identity.and_then(|identity| build_launch_spec(&desktop, path, identity)); + let shared_launcher = desktop_program.as_deref().is_none_or(is_shared_launcher); + // Shared runtimes need one immutable application payload in addition to exact argv matching + let association_eligible = launch_spec.as_ref().is_some_and(|spec| { + !shared_launcher + || (spec.protected_literal_files != 0 && spec.literal_files_are_system_managed) + }); + // System association requires protected metadata and a reproducible launch specification let system_association = association_eligible && system_origin && desktop_identity.is_some_and(FileIdentity::is_system_managed) - && executable_identity.is_some_and(FileIdentity::is_system_managed); + && executable_identity.is_some_and(FileIdentity::is_system_managed) + && launch_spec + .as_ref() + .is_some_and(|spec| spec.literal_files_are_system_managed); let badge_icon = desktop .string("Icon") .map_or_else(|| id.clone(), |value| value.to_string()); @@ -58,6 +66,7 @@ impl DesktopIdentityIndex { system_association, association_eligible, dbus_activatable: desktop.boolean("DBusActivatable"), + launch_spec, names, }); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs new file mode 100644 index 000000000..dd1268667 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs @@ -0,0 +1,70 @@ +//! Debounced desktop-index refresh with atomic snapshot replacement + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use arc_swap::ArcSwap; +use notify::{RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use super::model::DesktopIdentityIndex; +use super::scan::desktop_roots; + +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(500); +const REFRESH_SIGNAL_CAPACITY: usize = 1; + +pub fn spawn_desktop_index_refresh( + index: Arc>, +) -> Result> { + let (refresh_tx, mut refresh_rx) = mpsc::channel(REFRESH_SIGNAL_CAPACITY); + let mut watcher = notify::recommended_watcher(move |event: notify::Result| { + match event { + Ok(_) => { + // A single pending signal coalesces filesystem bursts without blocking the watcher + let _ = refresh_tx.try_send(()); + } + Err(error) => warn!(?error, "desktop application watcher reported an error"), + } + }) + .context("create desktop application watcher")?; + + let mut watched_root = false; + for (root, _) in desktop_roots() { + if !root.is_dir() { + continue; + } + match watcher.watch(Path::new(&root), RecursiveMode::Recursive) { + Ok(()) => watched_root = true, + Err(error) => warn!( + ?error, + root = %root.display(), + "failed to watch desktop application directory" + ), + } + } + if !watched_root { + warn!("no desktop application directory is available for refresh watching"); + } + + Ok(tokio::spawn(async move { + // The watcher must stay owned by this task for kernel watches to remain registered + let _watcher = watcher; + while refresh_rx.recv().await.is_some() { + tokio::time::sleep(REFRESH_DEBOUNCE).await; + // Drain events that arrived during the debounce window before one complete rebuild + while refresh_rx.try_recv().is_ok() {} + match tokio::task::spawn_blocking(DesktopIdentityIndex::new).await { + Ok(rebuilt) => { + index.store(Arc::new(rebuilt)); + debug!("desktop application identity index refreshed"); + } + Err(error) => { + warn!(?error, "desktop application identity index rebuild failed"); + } + } + } + })) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs index 58abb504e..361bb46d8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs @@ -1,7 +1,6 @@ //! Bounded desktop-entry discovery and record construction use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; use tracing::debug; @@ -53,14 +52,8 @@ impl ScanBudget { } impl DesktopIdentityIndex { - pub(in crate::daemon) fn shared() -> Arc { - static INDEX: OnceLock> = OnceLock::new(); - // One immutable snapshot serves the daemon lifetime and every notification burst - INDEX.get_or_init(|| Arc::new(Self::new())).clone() - } - #[must_use] - pub(in crate::daemon) fn new() -> Self { + pub(crate) fn new() -> Self { let mut index = Self::default(); let limits = ScanLimits::default(); let mut budget = ScanBudget::default(); @@ -84,6 +77,15 @@ impl DesktopIdentityIndex { // Relay trust is tied to the installed file identity instead of its basename index.index_trusted_relay(Path::new("/usr/bin/notify-send")); index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); + // Portal backends carry broker-verified application ids into desktop notifications + for directory in [ + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ] { + index.index_trusted_portals_in(Path::new(directory)); + } index } @@ -146,7 +148,7 @@ impl DesktopIdentityIndex { } } -fn desktop_roots() -> Vec<(PathBuf, bool)> { +pub(super) fn desktop_roots() -> Vec<(PathBuf, bool)> { let mut roots = Vec::new(); // The user data root remains distinct because its entries are not system evidence if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs new file mode 100644 index 000000000..e0b40fa33 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -0,0 +1,239 @@ +use std::fs; +use std::path::Path; + +use super::super::launch::{ + build_launch_spec, field_value_matches, launch_spec_matches_sender, + MAX_EXEC_TEMPLATE_ARGUMENTS, MAX_EXEC_TEMPLATE_BYTES, MAX_PROCESS_ARGUMENTS, +}; +use super::super::model::{FieldCode, LaunchArgument, LaunchSpec}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::test_support::TempRoot; + +#[test] +fn shared_launcher_requires_the_fixed_immutable_application_argument() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let immutable_script = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + assert!(immutable_script.identity.is_system_managed()); + + let root = TempRoot::new("launch-spec-shared-runtime"); + let path = root.join("org.example.Script.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Script\nExec=/usr/bin/sh /usr/bin/true %U\n", + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path, shell.identity).expect("build launch spec"); + + assert_eq!(spec.protected_literal_files, 1); + assert!(launch_spec_matches_sender( + &spec, + shell.identity, + &[ + b"/usr/bin/sh".to_vec(), + b"/usr/bin/true".to_vec(), + b"file:///tmp/input".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + shell.identity, + &[ + b"/usr/bin/sh".to_vec(), + b"/tmp/fake-script".to_vec(), + b"file:///tmp/input".to_vec(), + ], + )); +} + +#[test] +fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let root = TempRoot::new("launch-spec-fields"); + let path = root.join("org.example.True.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=True\nExec=/usr/bin/true --fixed %u\n", + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path, executable.identity).expect("build launch spec"); + + assert!(launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--fixed".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--unexpected".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--fixed".to_vec(), + b"--not-a-url".to_vec(), + ], + )); +} + +#[test] +fn launch_spec_enforces_template_size_and_argument_limits_at_the_boundary() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let root = TempRoot::new("launch-spec-limits"); + let executable_prefix = "/usr/bin/true "; + + for (name, template, accepted) in [ + ( + "exact-bytes", + format!( + "{executable_prefix}{}", + "x".repeat(MAX_EXEC_TEMPLATE_BYTES - executable_prefix.len()) + ), + true, + ), + ( + "too-many-bytes", + format!( + "{executable_prefix}{}", + "x".repeat(MAX_EXEC_TEMPLATE_BYTES + 1 - executable_prefix.len()) + ), + false, + ), + ( + "exact-arguments", + std::iter::once("/usr/bin/true") + .chain(std::iter::repeat_n("x", MAX_EXEC_TEMPLATE_ARGUMENTS - 1)) + .collect::>() + .join(" "), + true, + ), + ( + "too-many-arguments", + std::iter::once("/usr/bin/true") + .chain(std::iter::repeat_n("x", MAX_EXEC_TEMPLATE_ARGUMENTS)) + .collect::>() + .join(" "), + false, + ), + ] { + let path = root.join(format!("{name}.desktop")); + fs::write( + &path, + format!("[Desktop Entry]\nType=Application\nName=Limits\nExec={template}\n"), + ) + .expect("write boundary desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path) + .unwrap_or_else(|| panic!("parse {name} desktop entry")); + + assert_eq!( + build_launch_spec(&desktop, &path, executable.identity).is_some(), + accepted, + "{name}" + ); + } +} + +#[test] +fn launch_spec_parses_every_supported_desktop_field_code() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let root = TempRoot::new("launch-spec-field-codes"); + let path = root.join("org.example.Fields.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Fields\nIcon=field-icon\nExec=/usr/bin/true %f %F %u %U %c %k %i\n", + ) + .expect("write field-code desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path, executable.identity).expect("build launch spec"); + + assert!(matches!( + spec.arguments[0], + LaunchArgument::FieldCode(FieldCode::File) + )); + assert!(matches!( + spec.arguments[1], + LaunchArgument::FieldCode(FieldCode::Files) + )); + assert!(matches!( + spec.arguments[2], + LaunchArgument::FieldCode(FieldCode::Url) + )); + assert!(matches!( + spec.arguments[3], + LaunchArgument::FieldCode(FieldCode::Urls) + )); + assert!(matches!( + &spec.arguments[4], + LaunchArgument::Literal(argument) if argument.value == b"Fields" + )); + assert!(matches!( + &spec.arguments[5], + LaunchArgument::Literal(argument) + if argument.value == path.as_os_str().as_encoded_bytes() + )); + assert!(matches!( + &spec.arguments[6], + LaunchArgument::OptionalIcon { name } if name == "field-icon" + )); +} + +#[test] +fn process_matcher_checks_identity_emptiness_and_argument_limits_independently() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let other = executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other fixture"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + protected_literal_files: 0, + literal_files_are_system_managed: true, + }; + let exact_limit = + std::iter::repeat_n(b"input".to_vec(), MAX_PROCESS_ARGUMENTS).collect::>(); + let over_limit = + std::iter::repeat_n(b"input".to_vec(), MAX_PROCESS_ARGUMENTS + 1).collect::>(); + + assert!(launch_spec_matches_sender( + &spec, + executable.identity, + &exact_limit + )); + assert!(!launch_spec_matches_sender( + &spec, + other.identity, + &exact_limit + )); + assert!(!launch_spec_matches_sender(&spec, executable.identity, &[])); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &over_limit + )); +} + +#[test] +fn field_values_reject_empty_and_option_shaped_arguments_independently() { + assert!(!field_value_matches(FieldCode::File, b"")); + assert!(!field_value_matches(FieldCode::Files, b"--option")); + assert!(field_value_matches(FieldCode::File, b"relative-file")); + assert!(field_value_matches( + FieldCode::Url, + b"https://example.invalid/item" + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs index 14a5fb6b3..efde7b9b4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs @@ -1,6 +1,6 @@ use std::path::Path; -use super::super::is_shared_launcher; +use super::super::names::is_shared_launcher; #[test] fn shared_launchers_are_never_application_specific_associations() { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index 5ecabd2c2..97b8bed3d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::Path; -use super::super::{desktop_executable, DesktopIdentityIndex}; +use super::super::program::desktop_executable; +use super::super::DesktopIdentityIndex; use crate::test_support::TempRoot; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs index d3c4799f8..12e8cca4f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -1,7 +1,8 @@ use std::fs; use std::os::unix::fs::symlink; -use super::super::*; +use super::super::scan::{ScanBudget, ScanLimits}; +use super::super::DesktopIdentityIndex; use crate::test_support::TempRoot; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs index 0eec9403f..efe54ed9c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs @@ -32,6 +32,11 @@ impl FileIdentity { self.uid == 0 && self.mode & 0o022 == 0 } + pub(super) const fn is_executable_regular(self) -> bool { + // Authority binaries must be regular files with at least one execute bit + self.mode & 0o170_000 == 0o100_000 && self.mode & 0o111 != 0 + } + pub(super) fn group_fragment(self) -> String { // Group keys expose no path while remaining stable for the running file format!("{}:{}", self.device, self.inode) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 539a8994f..65df3c826 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -7,9 +7,9 @@ mod resolver; mod sender; mod sender_cache; -pub(in crate::daemon) use desktop_index::DesktopIdentityIndex; +pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; -pub(in crate::daemon) use resolver::{resolve_attribution, AppClaim}; +pub(in crate::daemon) use resolver::{resolve_attribution, unknown_reply_denied, AppClaim}; pub(in crate::daemon) use sender::resolve_sender_metadata; pub(super) use sender::SenderMetadata; pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index b674c4993..558885716 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -7,13 +7,15 @@ use zbus::fdo::DBusProxy; use zbus::Connection; use super::desktop_index::{ - normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, + normalize_desktop_id, normalize_name, record_launch_matches, DesktopIdentityIndex, + DesktopRecord, }; use super::policy::inline_reply_policy; use super::sender::SenderMetadata; const MAX_DESKTOP_ID_BYTES: usize = 256; +#[derive(Clone, Copy)] pub(in crate::daemon) struct AppClaim<'a> { pub(in crate::daemon) reported_name: &'a str, pub(in crate::daemon) desktop_entry: Option<&'a str>, @@ -24,6 +26,25 @@ pub(in crate::daemon) struct AttributionResolution { pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, } +pub(in crate::daemon) fn unknown_reply_denied( + claim: AppClaim<'_>, + sender: &SenderMetadata, + reason: &str, +) -> AttributionResolution { + let source = sender.sender_executable.as_deref().map_or_else( + || reason.to_string(), + |path| format!("{reason}; source {path}"), + ); + AttributionResolution { + attribution: NotificationAttribution::unknown( + claim.reported_name, + &source, + unknown_group_key(claim.reported_name, sender), + ), + inline_reply_policy: InlineReplyPolicy::Deny, + } +} + pub(in crate::daemon) async fn resolve_attribution( claim: AppClaim<'_>, sender: &SenderMetadata, @@ -57,6 +78,15 @@ fn resolve_with_evidence( if let Some(desktop_id) = desktop_entry.as_deref() { let records = index.records_for_id(desktop_id); if !records.is_empty() { + if claim.reported_name.trim().is_empty() + && sender + .sender_executable_identity + .and_then(|identity| index.trusted_portal_path(identity)) + .is_some() + { + // Portal backends forward a broker-verified app id as desktop-entry + return resolution_for_portal_record(records[0], sender, index); + } if let Some(record) = records .iter() .find(|record| record_matches_sender(record, sender)) @@ -126,6 +156,31 @@ fn resolve_with_evidence( )) } +fn resolution_for_portal_record( + record: &DesktopRecord, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let portal = sender + .sender_executable_identity + .and_then(|identity| index.trusted_portal_path(identity)) + .map_or_else( + || "desktop portal".to_string(), + |path| path.display().to_string(), + ); + let group_key = format!("portal-desktop:{}", record.id); + let attribution = NotificationAttribution::associated( + &record.display_name, + &record.id, + &record.badge_icon, + &format!("Mediated by {portal}"), + AttributionClass::PortalAssociated, + false, + group_key, + ); + policy_resolution(attribution) +} + fn resolution_for_record( record: &DesktopRecord, reported_name: &str, @@ -204,7 +259,7 @@ const fn policy_resolution(attribution: NotificationAttribution) -> AttributionR } } -const fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { +fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { if !record.association_eligible { return false; } @@ -214,6 +269,7 @@ const fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) ) { (Some(record_identity), Some(sender_identity)) => { record_identity.same_file(sender_identity) + && record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) } _ => false, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index 8c7a15042..686956df5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -3,6 +3,9 @@ //! Sender details are optional and best-effort, so failures here must not reject //! notification delivery +use std::fs::File; +use std::io::Read; + use zbus::fdo::DBusProxy; use zbus::message::Header; use zbus::Connection; @@ -10,6 +13,9 @@ use zbus::Connection; use super::sender_cache::SenderMetadataCache; use super::{executable_evidence_for_pid, FileIdentity}; +const MAX_PROCESS_CMDLINE_BYTES: u64 = 128 * 1024; +const MAX_PROCESS_ARGUMENTS: usize = 256; + #[derive(Debug, Clone, Default)] pub(in crate::daemon) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks @@ -22,6 +28,8 @@ pub(in crate::daemon) struct SenderMetadata { pub(in crate::daemon::notifications) sender_executable: Option, // Device and inode bind policy to the open running executable rather than its basename pub(in crate::daemon::notifications) sender_executable_identity: Option, + // NUL-delimited process arguments prove fixed desktop Exec literals for shared runtimes + pub(in crate::daemon::notifications) sender_cmdline: Option>>, } pub(in crate::daemon) async fn resolve_sender_metadata( @@ -38,6 +46,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, + sender_cmdline: None, }; }; @@ -54,6 +63,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, + sender_cmdline: None, }; }; @@ -64,17 +74,19 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, + sender_cmdline: None, }; }; // PID and executable come from the bus owner, not caller-provided payload fields let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); - let (sender_start_time, executable_evidence) = sender_pid.map_or((None, None), |pid| { + let (sender_start_time, process_evidence) = sender_pid.map_or((None, None), |pid| { let start_before = read_process_start_time(pid); - let evidence = executable_evidence_for_pid(pid); + let evidence = (executable_evidence_for_pid(pid), read_process_cmdline(pid)); let start_after = read_process_start_time(pid); - stable_process_evidence(start_before, evidence, start_after) + stable_process_evidence(start_before, Some(evidence), start_after) }); + let (executable_evidence, sender_cmdline) = process_evidence.unwrap_or((None, None)); let sender_executable = executable_evidence .as_ref() .map(|evidence| evidence.canonical_path.display().to_string()); @@ -86,6 +98,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time, sender_executable, sender_executable_identity, + sender_cmdline, }; // Failed lookups remain retryable instead of becoming persistent unknown identities if metadata.sender_start_time.is_some() && metadata.sender_executable_identity.is_some() { @@ -94,12 +107,6 @@ pub(in crate::daemon) async fn resolve_sender_metadata( metadata } -#[cfg(target_os = "linux")] -#[cfg(test)] -async fn read_process_executable_path(pid: u32) -> Option { - executable_evidence_for_pid(pid).map(|evidence| evidence.canonical_path) -} - #[cfg(target_os = "linux")] fn read_process_start_time(pid: u32) -> Option { // /proc//stat keeps the process lifetime tick count in field 22 @@ -108,11 +115,32 @@ fn read_process_start_time(pid: u32) -> Option { parse_process_start_time(&contents) } -#[cfg(not(target_os = "linux"))] -#[cfg(test)] -async fn read_process_executable_path(_pid: u32) -> Option { - // On other platforms this metadata is optional - None +#[cfg(target_os = "linux")] +fn read_process_cmdline(pid: u32) -> Option>> { + let path = format!("/proc/{pid}/cmdline"); + let mut bytes = Vec::new(); + File::open(path) + .ok()? + .take(MAX_PROCESS_CMDLINE_BYTES + 1) + .read_to_end(&mut bytes) + .ok()?; + parse_process_cmdline(bytes) +} + +#[cfg(target_os = "linux")] +fn parse_process_cmdline(mut bytes: Vec) -> Option>> { + if bytes.is_empty() + || bytes.len() as u64 > MAX_PROCESS_CMDLINE_BYTES + || bytes.last() != Some(&0) + { + return None; + } + bytes.pop(); + let arguments = bytes + .split(|byte| *byte == 0) + .map(<[u8]>::to_vec) + .collect::>(); + (!arguments.is_empty() && arguments.len() <= MAX_PROCESS_ARGUMENTS).then_some(arguments) } #[cfg(not(target_os = "linux"))] @@ -121,6 +149,11 @@ fn read_process_start_time(_pid: u32) -> Option { None } +#[cfg(not(target_os = "linux"))] +fn read_process_cmdline(_pid: u32) -> Option>> { + None +} + fn stable_process_evidence( start_before: Option, evidence: Option, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs index c99519fdd..3ee8ce412 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs @@ -29,6 +29,27 @@ fn system_managed_identity_requires_root_ownership_without_shared_writes() { .is_system_managed()); } +#[test] +fn executable_regular_identity_rejects_directories_and_missing_execute_bits() { + let executable = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + assert!(executable.is_executable_regular()); + assert!(!FileIdentity { + mode: 0o100_644, + ..executable + } + .is_executable_regular()); + assert!(!FileIdentity { + mode: 0o040_755, + ..executable + } + .is_executable_regular()); +} + #[test] fn same_file_uses_device_and_inode_instead_of_mutable_labels() { let first = FileIdentity { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 43a58ff71..7b3d27c4a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -4,9 +4,109 @@ use std::path::PathBuf; use unixnotis_core::{AttributionClass, InlineReplyPolicy}; use super::*; +use crate::daemon::notifications::identity::desktop_index::model::{ + ExecutableIdentity, LaunchArgument, LaunchSpec, LiteralArgument, +}; use crate::daemon::notifications::identity::desktop_index::{DesktopIdentityIndex, DesktopRecord}; use crate::daemon::notifications::identity::FileIdentity; +trait DesktopRecordFixture { + fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + dbus_activatable: bool, + ) -> Self; + + fn with_launch_literals(self, arguments: &[&str]) -> Self; +} + +impl DesktopRecordFixture for DesktopRecord { + fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + dbus_activatable: bool, + ) -> Self { + Self { + id: id.to_string(), + display_name: display_name.to_string(), + badge_icon: id.to_string(), + executable_path: Some(PathBuf::from(executable_path)), + executable_identity: Some(identity), + desktop_identity: Some(identity), + system_origin: system_entry, + system_association: system_entry, + association_eligible: true, + dbus_activatable, + launch_spec: None, + names: HashSet::from([normalize_name(display_name)]), + } + } + + fn with_launch_literals(mut self, arguments: &[&str]) -> Self { + let executable = self + .executable_identity + .expect("launch fixture needs executable identity"); + self.launch_spec = Some(LaunchSpec { + executable, + arguments: arguments + .iter() + .map(|value| { + LaunchArgument::Literal(LiteralArgument { + value: value.as_bytes().to_vec(), + file: None, + }) + }) + .collect(), + protected_literal_files: 1, + literal_files_are_system_managed: true, + }); + self + } +} + +trait DesktopIdentityIndexFixture { + fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self; + + fn with_trusted_portal(self, path: PathBuf, identity: FileIdentity) -> Self; +} + +impl DesktopIdentityIndexFixture for DesktopIdentityIndex { + fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self { + let mut index = Self::default(); + for record in records { + index.index_record(record); + } + index.trusted_relays = trusted_relays + .into_iter() + .map(|(path, identity)| ExecutableIdentity { path, identity }) + .collect(); + index + } + + fn with_trusted_portal(mut self, path: PathBuf, identity: FileIdentity) -> Self { + index_trusted_portal(&mut self, path, identity); + self + } +} + +fn index_trusted_portal(index: &mut DesktopIdentityIndex, path: PathBuf, identity: FileIdentity) { + index + .trusted_portals + .push(ExecutableIdentity { path, identity }); +} + fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { FileIdentity { device, @@ -25,6 +125,17 @@ fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { } } +fn sender_with_arguments(path: &str, identity: FileIdentity, arguments: &[&str]) -> SenderMetadata { + let mut metadata = sender(path, identity); + metadata.sender_cmdline = Some( + std::iter::once(path) + .chain(arguments.iter().copied()) + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + ); + metadata +} + fn system_record(id: &str, name: &str, path: &str, identity: FileIdentity) -> DesktopRecord { DesktopRecord::fixture(id, name, path, identity, true, false) } @@ -128,6 +239,114 @@ fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } +#[test] +fn unlisted_runtimes_cannot_associate_a_different_application_payload() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/pypy3", + "/usr/share/app/main.py", + "/tmp/fake.py", + ), + (2, "/usr/bin/gjs", "/usr/share/app/main.js", "/tmp/fake.js"), + ( + 3, + "/usr/bin/dotnet", + "/usr/share/app/Example.dll", + "/tmp/Fake.dll", + ), + ] { + let runtime_identity = identity(50, 500 + serial, 0); + let record = system_record( + "org.example.RuntimeApp", + "Runtime App", + executable, + runtime_identity, + ) + .with_launch_literals(&[expected]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Runtime App", + desktop_entry: Some("org.example.RuntimeApp"), + }, + &sender_with_arguments(executable, runtime_identity, &[actual]), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "{executable} accepted a different application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn java_cannot_associate_a_different_jar() { + let java_identity = identity(51, 510, 0); + let record = system_record( + "org.example.JavaApp", + "Java App", + "/usr/bin/java", + java_identity, + ) + .with_launch_literals(&["-jar", "/usr/share/java/example.jar"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Java App", + desktop_entry: Some("org.example.JavaApp"), + }, + &sender_with_arguments("/usr/bin/java", java_identity, &["-jar", "/tmp/fake.jar"]), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn matching_fixed_system_application_argument_allows_association() { + let runtime_identity = identity(52, 520, 0); + let record = system_record( + "org.example.ScriptApp", + "Script App", + "/usr/bin/pypy3", + runtime_identity, + ) + .with_launch_literals(&["/usr/share/script-app/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Script App", + desktop_entry: Some("org.example.ScriptApp"), + }, + &sender_with_arguments( + "/usr/bin/pypy3", + runtime_identity, + &["/usr/share/script-app/main.py"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + #[test] fn unmediated_flatpak_process_cannot_become_portal_associated() { let flatpak_identity = identity(21, 210, 0); @@ -158,6 +377,73 @@ fn unmediated_flatpak_process_cannot_become_portal_associated() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } +#[test] +fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { + let flatpak_identity = identity(24, 240, 0); + let relay_identity = identity(25, 250, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/lib/untrusted-relay", relay_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { + let flatpak_identity = identity(22, 220, 0); + let portal_identity = identity(23, 230, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()).with_trusted_portal( + PathBuf::from("/usr/lib/xdg-desktop-portal-gtk"), + portal_identity, + ); + + let resolution = resolve_with_evidence( + AppClaim { + // The GTK portal backend forwards an empty app name and verified desktop-entry hint + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/lib/xdg-desktop-portal-gtk", portal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.attribution.display_name, "Flatpak App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + #[test] fn user_shadow_cannot_join_the_system_desktop_group() { let system_identity = identity(30, 300, 0); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 5b9b7a1c0..0fd7111bc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -17,18 +17,34 @@ fn parse_process_start_time_rejects_missing_or_invalid_fields() { assert!(parse_process_start_time(stat).is_none()); } +#[cfg(target_os = "linux")] +#[test] +fn process_cmdline_parser_preserves_argument_boundaries_and_rejects_truncation() { + assert_eq!( + parse_process_cmdline(b"/usr/bin/python3\0/usr/share/app.py\0".to_vec()), + Some(vec![ + b"/usr/bin/python3".to_vec(), + b"/usr/share/app.py".to_vec(), + ]) + ); + assert!(parse_process_cmdline(b"/usr/bin/python3\0truncated".to_vec()).is_none()); + assert!(parse_process_cmdline(Vec::new()).is_none()); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn process_metadata_helpers_read_current_process_on_linux() { let pid = std::process::id(); - let exe = read_process_executable_path(pid) - .await + let exe = executable_evidence_for_pid(pid) + .map(|evidence| evidence.canonical_path) .expect("current process executable should be readable"); assert!(exe.is_absolute()); let start_time = read_process_start_time(pid).expect("current process start time should exist"); assert!(start_time > 1); + let cmdline = read_process_cmdline(pid).expect("current process cmdline should exist"); + assert!(!cmdline.is_empty()); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index 5e1943ed0..bfef10854 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -8,6 +8,7 @@ fn metadata(sender: &str, pid: u32) -> SenderMetadata { sender_start_time: Some(u64::from(pid)), sender_executable: Some(format!("/usr/bin/app-{pid}")), sender_executable_identity: None, + sender_cmdline: None, } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index fc0aa9bbb..168345e93 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -28,6 +28,7 @@ fn build_notification_clamps_summary_and_body_sizes() { sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), sender_executable_identity: None, + sender_cmdline: None, }, attribution: unixnotis_core::NotificationAttribution::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, @@ -53,6 +54,7 @@ fn build_notification_strips_display_spoofing_controls() { sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), sender_executable_identity: None, + sender_cmdline: None, }, attribution: unixnotis_core::NotificationAttribution::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 8309c210b..11df06e39 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -9,5 +9,6 @@ pub(in crate::daemon) use flow_control::{ notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, }; pub(in crate::daemon) use identity::SenderMetadataCache; +pub use identity::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub use server::NotificationIngress; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 0986c1a38..d3ac6bfe3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,13 +1,16 @@ use std::collections::HashMap; +use std::time::Duration; use std::time::Instant; -use tracing::debug; +use tracing::{debug, warn}; use unixnotis_core::Notification; use zbus::message::Header; use zbus::zvariant::OwnedValue; use crate::daemon::notifications::identity::resolve_sender_metadata; -use crate::daemon::notifications::identity::{resolve_attribution, AppClaim}; +use crate::daemon::notifications::identity::{ + resolve_attribution, unknown_reply_denied, AppClaim, SenderMetadata, +}; use crate::daemon::notifications::ingress::payload::{ build_notification, owned_to_string, resolve_expiration, NotificationInput, }; @@ -31,6 +34,9 @@ struct WireNotification { expire_timeout: i32, } +const SENDER_METADATA_TIMEOUT: Duration = Duration::from_millis(100); +const ATTRIBUTION_TIMEOUT: Duration = Duration::from_millis(100); + impl NotificationServer { #[expect( clippy::too_many_arguments, @@ -108,23 +114,43 @@ impl NotificationServer { header: &Header<'_>, ) -> Notification { // Sender metadata helps with ownership checks and diagnostics - let sender = resolve_sender_metadata( - &self.state.sender_metadata_cache, - self.state.connection(), - header, + let sender = if let Ok(sender) = tokio::time::timeout( + SENDER_METADATA_TIMEOUT, + resolve_sender_metadata( + &self.state.sender_metadata_cache, + self.state.connection(), + header, + ), ) - .await; + .await + { + sender + } else { + warn!("notification sender metadata timed out and failed closed"); + SenderMetadata::default() + }; let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); - let resolution = resolve_attribution( - AppClaim { - reported_name: &input.app_name, - desktop_entry: desktop_entry.as_deref(), - }, - &sender, - &self.state.desktop_identity_index, - self.state.connection(), + let desktop_identity_index = self.state.desktop_identity_index.load_full(); + let claim = AppClaim { + reported_name: &input.app_name, + desktop_entry: desktop_entry.as_deref(), + }; + let resolution = if let Ok(resolution) = tokio::time::timeout( + ATTRIBUTION_TIMEOUT, + resolve_attribution( + claim, + &sender, + &desktop_identity_index, + self.state.connection(), + ), ) - .await; + .await + { + resolution + } else { + warn!("notification attribution timed out and failed closed"); + unknown_reply_denied(claim, &sender, "attribution timed out") + }; if resolution.attribution.has_warning() { debug!( app_name = %input.app_name, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 758b9f194..bace96501 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -71,10 +71,20 @@ fn notify_header_message() -> Message { } async fn daemon_state_with_config(config: Config) -> Arc { + use arc_swap::ArcSwap; + + use crate::daemon::DesktopIdentityIndex; + let connection = Connection::session().await.expect("session bus"); let sound = SoundSettings::from_config(&config, None); let store = NotificationStore::new_with_state_store(config, None); - DaemonState::new_with_store(connection, store, sound, false) + DaemonState::new_with_store( + connection, + store, + sound, + false, + Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + ) } async fn control_signal_stream(state: &DaemonState, member: &str) -> MessageStream { diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index a6c2391f6..ea5f864e4 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -1,6 +1,7 @@ use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use arc_swap::ArcSwap; use tokio::sync::Mutex; use unixnotis_core::Config; use zbus::Connection; @@ -42,8 +43,8 @@ pub struct DaemonState { StdMutex>, // Unique sender identities avoid repeated bus and procfs lookups during bursts pub(in crate::daemon) sender_metadata_cache: SenderMetadataCache, - // Desktop records are indexed once so notification bursts never rescan application files - pub(in crate::daemon) desktop_identity_index: Arc, + // Readers load one immutable snapshot while filesystem refresh swaps the complete index + pub(crate) desktop_identity_index: Arc>, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, } @@ -54,9 +55,10 @@ impl DaemonState { config: Config, sound: SoundSettings, trial_mode: bool, + desktop_identity_index: Arc>, ) -> Arc { let store = NotificationStore::new(config); - Self::new_with_store(connection, store, sound, trial_mode) + Self::new_with_store(connection, store, sound, trial_mode, desktop_identity_index) } pub(crate) fn new_with_store( @@ -64,6 +66,7 @@ impl DaemonState { store: NotificationStore, sound: SoundSettings, trial_mode: bool, + desktop_identity_index: Arc>, ) -> Arc { // One construction path keeps scheduler, signal cache, and popup state in sync Arc::new(Self { @@ -80,7 +83,7 @@ impl DaemonState { events: DaemonEventPublisher::new(connection), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), sender_metadata_cache: SenderMetadataCache::new(), - desktop_identity_index: DesktopIdentityIndex::shared(), + desktop_identity_index, trial_mode, }) } diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index d9a5beb34..64269a9c5 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -1,10 +1,12 @@ //! Live notification service runtime +use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Result}; +use arc_swap::ArcSwap; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use zbus::fdo::DBusProxy; use zbus::Connection; @@ -12,25 +14,35 @@ use super::shutdown::shutdown_signal; use crate::child_process::{spawn_center_supervisor, spawn_popups_supervisor}; use crate::cli::Args; use crate::daemon::{ - log_current_owner, log_name_reply, request_control_name, request_well_known_name, - spawn_client_owner_watch, ControlServer, DaemonState, NotificationIngress, NotificationServer, + log_name_reply, monitor_required_bus_names, request_control_name, request_well_known_name, + spawn_client_owner_watch, spawn_desktop_index_refresh, verify_name_owner, ControlServer, + DaemonState, DesktopIdentityIndex, NotificationIngress, NotificationServer, NOTIFICATIONS_OBJECT_PATH, }; use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; -use unixnotis_core::{Config, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH}; +use unixnotis_core::{Config, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; pub(super) async fn run_daemon( args: &Args, config: Config, connection: &Connection, dbus_proxy: &DBusProxy<'_>, - notifications_name: zbus::names::BusName<'_>, + desktop_identity_index: Arc>, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); - let state = DaemonState::new(connection.clone(), config, sound_settings, args.trial); + let state = DaemonState::new( + connection.clone(), + config, + sound_settings, + args.trial, + desktop_identity_index, + ); + if let Err(error) = spawn_desktop_index_refresh(state.desktop_identity_index.clone()) { + warn!(?error, "desktop application refresh watcher is unavailable"); + } let scheduler = ExpirationScheduler::start(state.clone()); state.set_scheduler(scheduler.clone()); let dnd_scheduler = DndExpirationScheduler::start(state.clone()); @@ -50,30 +62,9 @@ pub(super) async fn run_daemon( .at(CONTROL_OBJECT_PATH, ControlServer::new(state.clone())) .await?; - let control_reply = request_control_name(connection).await?; - match control_reply { - zbus::fdo::RequestNameReply::PrimaryOwner => { - info!(CONTROL_BUS_NAME, "acquired control bus name"); - } - zbus::fdo::RequestNameReply::AlreadyOwner => { - info!(CONTROL_BUS_NAME, "already owns control bus name"); - } - _ => { - return Err(anyhow!( - "control bus name is already owned; another unixnotis instance may be running" - )); - } - } - + // The standard notification name is the first externally visible readiness gate let reply = request_well_known_name(connection, args.trial).await?; log_name_reply(&reply); - let owner_is_self = match log_current_owner(dbus_proxy, connection, notifications_name).await { - Ok(value) => value, - Err(err) => { - warn!(?err, "failed to query current notification owner"); - false - } - }; if !args.trial && !matches!( reply, @@ -84,11 +75,24 @@ pub(super) async fn run_daemon( "org.freedesktop.Notifications is already owned; retry with --trial" )); } - if args.trial && !owner_is_self { - return Err(anyhow!( - "org.freedesktop.Notifications is still owned by another daemon; stop it or use --restore systemd if managed by systemd --user" - )); + verify_name_owner(dbus_proxy, connection, NOTIFICATIONS_BUS_NAME).await?; + + // The private control name is published last and means the daemon is ready + let control_reply = request_control_name(connection).await?; + match control_reply { + zbus::fdo::RequestNameReply::PrimaryOwner => { + info!(CONTROL_BUS_NAME, "acquired control bus name"); + } + zbus::fdo::RequestNameReply::AlreadyOwner => { + info!(CONTROL_BUS_NAME, "already owns control bus name"); + } + zbus::fdo::RequestNameReply::InQueue | zbus::fdo::RequestNameReply::Exists => { + return Err(anyhow!( + "control bus name is already owned; another unixnotis instance may be running" + )); + } } + verify_name_owner(dbus_proxy, connection, CONTROL_BUS_NAME).await?; // A zero-duration run verifies service registration without launching UI processes if skip_ui_for_zero_duration(args.run_seconds) { @@ -106,15 +110,13 @@ pub(super) async fn run_daemon( let center_task = spawn_center_supervisor(args.clone(), state, shutdown_rx); info!("unixnotis-daemon running"); - match args.run_seconds { - Some(seconds) => { - let timeout = tokio::time::sleep(Duration::from_secs(seconds)); - tokio::select! { - () = shutdown_signal() => {}, - () = timeout => info!(seconds, "run-seconds elapsed, shutting down"), - } - } - None => shutdown_signal().await, + let runtime_result = wait_for_runtime_exit(args.run_seconds, connection.clone()).await; + + if let Err(failure) = &runtime_result { + error!( + error = ?failure, + "session bus connection failed; daemon will exit for supervisor restart" + ); } if let Err(err) = shutdown_tx.send(true) { @@ -126,7 +128,28 @@ pub(super) async fn run_daemon( if let Err(err) = center_task.await { warn!(?err, "center supervisor task failed"); } - Ok(()) + runtime_result +} + +async fn wait_for_runtime_exit(run_seconds: Option, connection: Connection) -> Result<()> { + let bus_health = monitor_required_bus_names(connection.clone()); + tokio::pin!(bus_health); + if let Some(seconds) = run_seconds { + let timeout = tokio::time::sleep(Duration::from_secs(seconds)); + tokio::select! { + () = shutdown_signal() => Ok(()), + result = &mut bus_health => result, + () = timeout => { + info!(seconds, "run-seconds elapsed, shutting down"); + Ok(()) + }, + } + } else { + tokio::select! { + () = shutdown_signal() => Ok(()), + result = &mut bus_health => result, + } + } } const fn skip_ui_for_zero_duration(run_seconds: Option) -> bool { diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 373b6d528..8de081f16 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -1,12 +1,16 @@ //! Daemon runtime and trial cleanup coordination +use std::sync::Arc; + use anyhow::{Context, Result}; +use arc_swap::ArcSwap; use zbus::connection::Builder; use zbus::fdo::DBusProxy; use crate::cli::Args; +use crate::daemon::DesktopIdentityIndex; use crate::trial_mode::{prepare_trial, TrialState}; -use unixnotis_core::{Config, NOTIFICATIONS_BUS_NAME}; +use unixnotis_core::{log_session_bus_identity, Config, NOTIFICATIONS_BUS_NAME}; use super::{daemon, trial_cleanup}; @@ -23,6 +27,14 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> .build() .await .context("connect to session bus")?; + log_session_bus_identity(&connection, "daemon") + .await + .context("read daemon session-bus identity")?; + // Finish the bounded filesystem scan before either well-known name can become visible + let desktop_identity_index = tokio::task::spawn_blocking(DesktopIdentityIndex::new) + .await + .context("desktop identity index task failed")?; + let desktop_identity_index = Arc::new(ArcSwap::from_pointee(desktop_identity_index)); let dbus_proxy = DBusProxy::new(&connection).await?; let notifications_name = zbus::names::BusName::try_from(NOTIFICATIONS_BUS_NAME)?; let mut trial_state = if trial_requested(args) { @@ -37,7 +49,7 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> config, &connection, &dbus_proxy, - notifications_name.clone(), + desktop_identity_index, ) .await; let restore_result = trial_cleanup::finish_trial( diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs new file mode 100644 index 000000000..7ccc538f7 --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -0,0 +1,274 @@ +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use clap::Parser; +use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::{Connection, ConnectionBuilder}; + +use super::super::run_with_builder; +use crate::cli::Args; +use unixnotis_core::Config; + +static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); + +struct PrivateBroker { + child: Child, + socket: PathBuf, + address: String, +} + +impl PrivateBroker { + fn start() -> Self { + let socket = broker_socket(); + let listen_address = format!("unix:path={}", socket.display()); + let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") + .expect("find trusted dbus-daemon"); + let mut child = Command::new(daemon) + .args([ + "--session", + "--nofork", + "--nopidfile", + "--print-address=1", + &format!("--address={listen_address}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("start private D-Bus broker"); + let stdout = child.stdout.take().expect("capture broker address"); + let mut address = String::new(); + BufReader::new(stdout) + .read_line(&mut address) + .expect("read broker address"); + assert!( + address.trim().starts_with(&listen_address), + "broker must listen on the isolated test socket" + ); + Self { + child, + socket, + address: address.trim().to_string(), + } + } + + fn terminate(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for PrivateBroker { + fn drop(&mut self) { + self.terminate(); + let _ = std::fs::remove_file(&self.socket); + if let Some(parent) = self.socket.parent() { + let _ = std::fs::remove_dir(parent); + } + } +} + +fn broker_socket() -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after the Unix epoch") + .as_nanos(); + let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-runtime-dbus-{}-{stamp}-{serial}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create private broker directory"); + root.join("bus.sock") +} + +async fn connect(address: &str) -> Connection { + ConnectionBuilder::address(address) + .expect("parse private broker address") + .build() + .await + .expect("connect to private broker") +} + +fn spawn_daemon(address: String, run_seconds: u64) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder(&args, Config::default(), builder)).await + }) +} + +async fn owner(dbus: &DBusProxy<'_>, name: &'static str) -> Option { + let name = BusName::try_from(name).expect("static bus name"); + dbus.get_name_owner(name) + .await + .ok() + .map(|owner| owner.to_string()) +} + +async fn wait_for_both_owners(connection: &Connection) -> (String, String) { + let dbus = DBusProxy::new(connection) + .await + .expect("create broker proxy"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let (Some(notifications), Some(control)) = ( + owner(&dbus, NOTIFICATIONS_BUS_NAME).await, + owner(&dbus, CONTROL_BUS_NAME).await, + ) { + return (notifications, control); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("daemon should acquire both names") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn startup_publishes_both_names_with_one_ready_owner() { + let broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + let daemon = spawn_daemon(broker.address.clone(), 1); + + let (notifications_owner, control_owner) = wait_for_both_owners(&client).await; + assert_eq!( + notifications_owner, control_owner, + "both service names must belong to the ready daemon connection" + ); + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + let capabilities_started = Instant::now(); + tokio::time::timeout(Duration::from_secs(2), notifications.get_capabilities()) + .await + .expect("GetCapabilities must be bounded") + .expect("GetCapabilities must succeed"); + assert!( + capabilities_started.elapsed() < Duration::from_millis(500), + "GetCapabilities exceeded the shared-runner latency budget" + ); + let information_started = Instant::now(); + let server = tokio::time::timeout( + Duration::from_secs(2), + notifications.get_server_information(), + ) + .await + .expect("GetServerInformation must be bounded") + .expect("GetServerInformation must succeed"); + assert_eq!(server.0, "UnixNotis"); + assert!( + information_started.elapsed() < Duration::from_millis(500), + "GetServerInformation exceeded the shared-runner latency budget" + ); + + let cold_started = Instant::now(); + tokio::time::timeout( + Duration::from_secs(2), + notifications.notify( + "Lifecycle test", + 0, + "", + "Cold notification", + "First attribution lookup", + Vec::new(), + HashMap::new(), + 1_000, + ), + ) + .await + .expect("cold Notify must be bounded") + .expect("cold Notify must succeed"); + assert!( + cold_started.elapsed() < Duration::from_secs(1), + "cold Notify exceeded the shared-runner latency budget" + ); + let warm_started = Instant::now(); + tokio::time::timeout( + Duration::from_secs(2), + notifications.notify( + "Lifecycle test", + 0, + "", + "Warm notification", + "Cached sender metadata", + Vec::new(), + HashMap::new(), + 1_000, + ), + ) + .await + .expect("warm Notify must be bounded") + .expect("warm Notify must succeed"); + assert!( + warm_started.elapsed() < Duration::from_millis(500), + "warm Notify exceeded the shared-runner latency budget" + ); + + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + tokio::time::timeout(Duration::from_secs(2), control.get_state()) + .await + .expect("GetState must be bounded") + .expect("GetState must succeed"); + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn broker_loss_makes_the_daemon_exit_with_failure() { + let mut broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + let daemon = spawn_daemon(broker.address.clone(), 30); + let _owners = wait_for_both_owners(&client).await; + + broker.terminate(); + let result = tokio::time::timeout(Duration::from_secs(8), daemon) + .await + .expect("daemon must notice broker loss") + .expect("join daemon task"); + assert!(result.is_err(), "broker loss must return a daemon failure"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn competing_notification_owner_prevents_control_publication() { + let broker = PrivateBroker::start(); + let competitor = connect(&broker.address).await; + competitor + .request_name(NOTIFICATIONS_BUS_NAME) + .await + .expect("competitor owns notification name"); + let observer = connect(&broker.address).await; + let daemon = spawn_daemon(broker.address.clone(), 5); + + let result = tokio::time::timeout(Duration::from_secs(10), daemon) + .await + .expect("competing owner should fail startup promptly") + .expect("join daemon task"); + assert!( + result.is_err(), + "competing notification owner must fail startup" + ); + let dbus = DBusProxy::new(&observer) + .await + .expect("create observer proxy"); + assert!( + owner(&dbus, CONTROL_BUS_NAME).await.is_none(), + "control readiness must never publish after notification ownership fails" + ); +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/runner.rs b/crates/unixnotis-daemon/src/runtime/tests/runner.rs index 1cf797a91..64a6732c0 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/runner.rs @@ -5,6 +5,9 @@ use crate::cli::Args; use unixnotis_core::Config; use zbus::connection::Builder; +#[path = "dbus_lifecycle.rs"] +mod dbus_lifecycle; + #[test] fn trial_preparation_is_enabled_only_by_the_trial_flag() { let normal = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); diff --git a/crates/unixnotis-daemon/src/store/dnd/mod.rs b/crates/unixnotis-daemon/src/store/dnd/mod.rs index c217b12ac..17a0d927a 100644 --- a/crates/unixnotis-daemon/src/store/dnd/mod.rs +++ b/crates/unixnotis-daemon/src/store/dnd/mod.rs @@ -1,12 +1,9 @@ //! Do-not-disturb state changes and persistence -mod persistence; +pub(in crate::store) mod persistence; mod state; pub(in crate::store) use persistence::{DndStateStore, DND_STATE_VERSION}; -#[cfg(test)] -pub(in crate::store) use persistence::{PersistedDndState, DND_STATE_FILE}; - #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs index 112537d21..77fb1568b 100644 --- a/crates/unixnotis-daemon/src/store/test_support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -6,8 +6,8 @@ use chrono::Utc; use unixnotis_core::{Config, Notification, NotificationImage, Urgency}; use zbus::zvariant::OwnedValue; +use super::dnd::persistence::{PersistedDndState, DND_STATE_FILE}; use super::dnd::DndStateStore; -use super::dnd::{PersistedDndState, DND_STATE_FILE}; use super::model::NotificationStore; impl NotificationStore { diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index ae3291c28..f185d875a 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -6,10 +6,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::sync::Arc; +use arc_swap::ArcSwap; use unixnotis_core::Config; use zbus::Connection; -use crate::daemon::DaemonState; +use crate::daemon::{DaemonState, DesktopIdentityIndex}; use crate::sound::SoundSettings; use crate::store::NotificationStore; @@ -29,7 +30,13 @@ pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { let config = Config::default(); let sound = SoundSettings::from_config(&config, None); let store = NotificationStore::new_with_state_store(config, None); - DaemonState::new_with_store(connection, store, sound, trial_mode) + DaemonState::new_with_store( + connection, + store, + sound, + trial_mode, + Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + ) } pub struct EnvVarGuard { From f6b9dbf6410b18d05dc00e674940353d092fdb27 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 23:44:11 -0500 Subject: [PATCH 093/275] fix: make control clients owner-aware Summary: make control clients owner-aware. Scope: repository. --- crates/noticenterctl/src/app/runner.rs | 5 +- crates/noticenterctl/src/dbus/timeout.rs | 2 +- .../noticenterctl/src/doctor/checks/dbus.rs | 116 ++++++++-- .../src/doctor/checks/tests/dbus.rs | 41 ++++ .../unixnotis-center/src/control/commands.rs | 25 +- crates/unixnotis-center/src/control/events.rs | 4 +- crates/unixnotis-center/src/control/model.rs | 2 + .../unixnotis-center/src/control/reconnect.rs | 120 ++++++++-- crates/unixnotis-center/src/control/seed.rs | 107 +++------ .../src/control/subscriptions.rs | 113 +++++++-- .../src/control/tests/seed.rs | 21 +- .../src/control/tests/subscriptions.rs | 18 +- crates/unixnotis-center/src/ui/events.rs | 7 + crates/unixnotis-popups/src/dbus/commands.rs | 8 +- crates/unixnotis-popups/src/dbus/runtime.rs | 214 +++++++++++++++--- crates/unixnotis-popups/src/dbus/seed.rs | 102 +++------ .../unixnotis-popups/src/dbus/tests/seed.rs | 61 +---- crates/unixnotis-popups/src/dbus/types.rs | 2 + .../unixnotis-popups/src/ui/state/events.rs | 5 + 19 files changed, 655 insertions(+), 318 deletions(-) diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index 2e8f0bb65..e2057aac6 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::Parser; -use unixnotis_core::ControlProxy; +use unixnotis_core::{log_session_bus_identity, ControlProxy}; use zbus::Connection; use crate::cli::{Args, Command}; @@ -57,6 +57,9 @@ async fn run_async(command: Command) -> Result<()> { let connection = Connection::session() .await .context("connect to session bus")?; + log_session_bus_identity(&connection, "noticenterctl") + .await + .context("read noticenterctl session-bus identity")?; let proxy = ControlProxy::new(&connection) .await .context("connect to unixnotis control interface")?; diff --git a/crates/noticenterctl/src/dbus/timeout.rs b/crates/noticenterctl/src/dbus/timeout.rs index 8d7f6d8d4..3e6705d27 100644 --- a/crates/noticenterctl/src/dbus/timeout.rs +++ b/crates/noticenterctl/src/dbus/timeout.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; -const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); +const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(2); pub(super) async fn run_control_call(call: impl Future>) -> Result { run_control_call_with_timeout(CONTROL_CALL_TIMEOUT, call).await diff --git a/crates/noticenterctl/src/doctor/checks/dbus.rs b/crates/noticenterctl/src/doctor/checks/dbus.rs index 6b6a6016c..e91c21781 100644 --- a/crates/noticenterctl/src/doctor/checks/dbus.rs +++ b/crates/noticenterctl/src/doctor/checks/dbus.rs @@ -2,7 +2,9 @@ use std::time::Duration; -use unixnotis_core::{ControlProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use unixnotis_core::{ + log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME, +}; use zbus::fdo::DBusProxy; use zbus::names::BusName; use zbus::Connection; @@ -38,6 +40,32 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus DoctorSeverity::Pass, "Session bus connection succeeded", )]; + match log_session_bus_identity(connection, "noticenterctl doctor").await { + Ok(identity) => checks.push( + DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Pass, + "Session bus identity probe succeeded", + ) + .details(format!( + "Bus ID: {}\nUnique name: {}\nRuntime directory: {}", + identity.bus_id, identity.unique_name, identity.runtime_dir + )) + .data("bus_id", identity.bus_id) + .data("unique_name", identity.unique_name) + .data("runtime_dir", identity.runtime_dir), + ), + Err(error) => checks.push( + DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Error, + "Session bus identity probe failed", + ) + .details(safe_doctor_text(&error.to_string())), + ), + } // The daemon proxy is required for ownership checks but not for later service checks let proxy = match tokio::time::timeout(DBUS_CHECK_TIMEOUT, DBusProxy::new(connection)).await { Ok(Ok(proxy)) => proxy, @@ -73,7 +101,7 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus }; // Notification and control names are separate readiness signals - let notifications_owned = check_owner( + let notifications_owner = check_owner( &proxy, NOTIFICATIONS_BUS_NAME, "dbus.notifications-owner", @@ -81,7 +109,7 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus &mut checks, ) .await; - if !notifications_owned { + if notifications_owner.is_none() { // Missing the standard name means desktop applications have no notification target checks.push( DoctorCheck::new( @@ -94,7 +122,7 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus ); } - let control_owned = check_owner( + let control_owner = check_owner( &proxy, CONTROL_BUS_NAME, "dbus.control-owner", @@ -102,7 +130,30 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus &mut checks, ) .await; - if control_owned { + let has_control_owner = control_owner.is_some(); + if let (Some(notifications_owner), Some(control_owner)) = (¬ifications_owner, &control_owner) + { + let owners_match = notifications_owner == control_owner; + checks.push( + DoctorCheck::new( + "dbus.shared-owner", + "UnixNotis D-Bus ownership", + if owners_match { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if owners_match { + "Notification and control names share one owner" + } else { + "Notification and control names have different owners" + }, + ) + .data("notifications_owner", notifications_owner.clone()) + .data("control_owner", control_owner.clone()), + ); + } + if has_control_owner { // Proxy and GetState checks run only after ownership is confirmed inspect_control_proxy(connection, &mut checks).await; } else { @@ -119,7 +170,7 @@ pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBus DoctorBusResult { checks, - control_owned, + control_owned: has_control_owner, connected: true, } } @@ -130,18 +181,47 @@ async fn check_owner( id: &'static str, label: &'static str, checks: &mut Vec, -) -> bool { +) -> Option { // Static names are validated here once before the bounded remote request let bus_name = BusName::try_from(name).expect("static D-Bus name must be valid"); - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name)).await { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name.clone())).await { Ok(Ok(true)) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Pass, - format!("{name} has an owner"), - )); - true + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.get_name_owner(bus_name)).await { + Ok(Ok(owner)) => { + let owner = owner.to_string(); + checks.push( + DoctorCheck::new( + id, + label, + DoctorSeverity::Pass, + format!("{name} has an owner"), + ) + .data("owner", owner.clone()), + ); + Some(owner) + } + Ok(Err(error)) => { + checks.push( + DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Unable to read {name} owner"), + ) + .details(safe_doctor_text(&error.to_string())), + ); + None + } + Err(_) => { + checks.push(DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Owner query for {name} timed out"), + )); + None + } + } } Ok(Ok(false)) => { checks.push(DoctorCheck::new( @@ -150,7 +230,7 @@ async fn check_owner( DoctorSeverity::Warning, format!("{name} has no owner"), )); - false + None } Ok(Err(error)) => { checks.push( @@ -162,7 +242,7 @@ async fn check_owner( ) .details(safe_doctor_text(&error.to_string())), ); - false + None } Err(_) => { checks.push(DoctorCheck::new( @@ -171,7 +251,7 @@ async fn check_owner( DoctorSeverity::Error, format!("Ownership query for {name} timed out"), )); - false + None } } } diff --git a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs index 72775eccd..a6e44ed39 100644 --- a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs +++ b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs @@ -228,6 +228,47 @@ fn owned_control_service_runs_proxy_and_state_checks() { }); } +#[test] +fn different_notification_and_control_owners_fail_the_shared_owner_check() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build D-Bus test runtime"); + runtime.block_on(async { + let broker = PrivateBroker::start(); + let _control = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request control bus name") + .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state: false }) + .expect("register test control interface") + .build() + .await + .expect("connect test control service"); + let _notifications = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(NOTIFICATIONS_BUS_NAME) + .expect("request notification bus name") + .build() + .await + .expect("connect separate notification service"); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + let ownership = result + .checks + .iter() + .find(|check| check.id == "dbus.shared-owner") + .expect("shared owner check"); + + assert_eq!(ownership.severity, DoctorSeverity::Error); + assert_eq!( + ownership.summary, + "Notification and control names have different owners" + ); + }); +} + #[test] fn method_error_access_denial_uses_the_specific_client_guidance() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index 83b56cdbe..13cea69c7 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use tokio::sync::mpsc; use tracing::warn; -use unixnotis_core::{ControlProxy, PanelDebugLevel}; +use unixnotis_core::{timed_dbus_call, ControlProxy, PanelDebugLevel}; use zbus::Result as ZbusResult; use super::model::UiCommand; @@ -20,10 +20,12 @@ pub async fn handle_command( ) -> ZbusResult<()> { match command { // Per-row actions still map straight to the daemon methods - UiCommand::Dismiss(id) => proxy.dismiss(id).await, - UiCommand::InvokeAction { id, action_key } => proxy.invoke_action(id, &action_key).await, + UiCommand::Dismiss(id) => timed_dbus_call(proxy.dismiss(id)).await, + UiCommand::InvokeAction { id, action_key } => { + timed_dbus_call(proxy.invoke_action(id, &action_key)).await + } UiCommand::Reply { id, text, outcome } => { - let result = proxy.reply_notification(id, &text).await; + let result = timed_dbus_call(proxy.reply_notification(id, &text)).await; let reply_result = match &result { Ok(()) => Ok(()), Err(err) => Err(err.to_string()), @@ -33,11 +35,13 @@ pub async fn handle_command( } // Daemon invalidation now drives refresh for every client, not just the caller // Keeping the caller path thin avoids reintroducing one-client-only fixes later - UiCommand::ClearAll => proxy.clear_all().await, + UiCommand::ClearAll => timed_dbus_call(proxy.clear_all()).await, // State and visibility commands remain safe to replay after reconnect - UiCommand::SetDnd(enabled) => proxy.set_dnd(enabled).await, - UiCommand::SetDndUntil(expires_at) => proxy.set_dnd_until(expires_at).await, - UiCommand::ClosePanel => proxy.close_panel().await, + UiCommand::SetDnd(enabled) => timed_dbus_call(proxy.set_dnd(enabled)).await, + UiCommand::SetDndUntil(expires_at) => { + timed_dbus_call(proxy.set_dnd_until(expires_at)).await + } + UiCommand::ClosePanel => timed_dbus_call(proxy.close_panel()).await, } } @@ -61,7 +65,10 @@ pub fn stash_offline_commands( } } -fn enqueue_offline_command(offline: &mut VecDeque, command: UiCommand) -> bool { +pub(super) fn enqueue_offline_command( + offline: &mut VecDeque, + command: UiCommand, +) -> bool { let command = match command { UiCommand::Reply { outcome, .. } => { // Reply text is live-only and must never survive a D-Bus generation change diff --git a/crates/unixnotis-center/src/control/events.rs b/crates/unixnotis-center/src/control/events.rs index cb19bd553..27cf974f4 100644 --- a/crates/unixnotis-center/src/control/events.rs +++ b/crates/unixnotis-center/src/control/events.rs @@ -1,7 +1,7 @@ //! Projection of notification signals into trusted UI payload events use tracing::warn; -use unixnotis_core::{ControlProxy, NotificationView}; +use unixnotis_core::{timed_dbus_call, ControlProxy, NotificationView}; use super::model::UiEvent; @@ -13,7 +13,7 @@ pub(super) async fn push_active_notification_event( is_add: bool, ) { // Trusted UIs fetch current payloads through the authorized control method - match proxy.get_active_notification(id).await { + match timed_dbus_call(proxy.get_active_notification(id)).await { Ok(notifications) => { if let Some(event) = active_notification_event(notifications, show_popup, is_add) { let _ = sender.send(event).await; diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index ac1a8e1db..7c2d4eb72 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -9,6 +9,8 @@ use crate::media::MediaInfo; /// Events delivered to the GTK main loop. #[derive(Debug, Clone)] pub enum UiEvent { + // Owner loss clears snapshots that belong to the previous daemon generation + Disconnected, Seed { state: ControlState, active: Vec, diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index e4b5e330e..b59b95c41 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -3,17 +3,22 @@ use std::collections::VecDeque; use std::time::Duration; +use futures_util::StreamExt; use tokio::sync::mpsc; -use tracing::info; -use unixnotis_core::ControlProxy; +use unixnotis_core::{ + log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, INTERNAL_DBUS_CALL_TIMEOUT, +}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::proxy::OwnerChangedStream; use zbus::Connection; use super::backoff::{ Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, }; -use super::commands::stash_offline_commands; +use super::commands::{enqueue_offline_command, stash_offline_commands}; use super::model::{UiCommand, UiEvent}; -use super::subscriptions::run_control_generation; +use super::subscriptions::{run_control_generation, ControlGenerationContext}; #[cfg(test)] #[path = "tests/reconnect.rs"] @@ -46,6 +51,12 @@ pub(super) async fn run_control_loop( continue; } }; + if let Err(err) = log_session_bus_identity(&connection, "center").await { + connect_log.warn_or_debug(&err, "session bus identity probe failed; retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } // A live bus generation clears only connection-level failure history connect_backoff.reset(); connect_log.reset(); @@ -59,26 +70,107 @@ pub(super) async fn run_control_loop( continue; } }; - info!("connected to unixnotis control interface"); + let mut owner_changes = match proxy.inner().receive_owner_changed().await { + Ok(stream) => stream, + Err(err) => { + connect_log.warn_or_debug(&err, "control owner watch unavailable, retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + }; + let dbus = match DBusProxy::new(&connection).await { + Ok(proxy) => proxy, + Err(err) => { + connect_log.warn_or_debug(&err, "session bus owner proxy unavailable, retrying"); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + }; - // One generation owns every proxy stream tied to this exact connection - let generation = run_control_generation( - &proxy, + let owner = match wait_for_control_owner( + &dbus, + &mut owner_changes, &sender, &mut command_rx, &mut offline_commands, - &mut subscribe_backoff, - &mut subscribe_log, + ) + .await + { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => { + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + OwnerWait::Shutdown => return, + }; + + // One generation owns every proxy stream tied to this exact connection + let generation = run_control_generation( + &proxy, + &owner, + ControlGenerationContext::new( + &mut owner_changes, + &sender, + &mut command_rx, + &mut offline_commands, + &mut subscribe_backoff, + &mut subscribe_log, + ), ) .await; if generation.should_stop() { return; } - if !generation.requires_reconnect_cleanup() { - continue; - } // Preserve safe commands before replacing the failed generation stash_offline_commands(&mut command_rx, &mut offline_commands); - tokio::time::sleep(subscribe_backoff.next_sleep()).await; + if generation.requires_connection_backoff() { + tokio::time::sleep(subscribe_backoff.next_sleep()).await; + } + } +} + +enum OwnerWait { + Ready(String), + Disconnected, + Shutdown, +} + +async fn wait_for_control_owner( + dbus: &DBusProxy<'_>, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + offline_commands: &mut VecDeque, +) -> OwnerWait { + let control_name = BusName::try_from(CONTROL_BUS_NAME) + .expect("static UnixNotis control bus name must be valid"); + if let Ok(Ok(owner)) = tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + return OwnerWait::Ready(owner.to_string()); + } + + // Missing ownership is a stable disconnected state, not a connection failure + let _ = sender.send(UiEvent::Disconnected).await; + loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + return OwnerWait::Shutdown; + }; + enqueue_offline_command(offline_commands, command); + } + update = owner_changes.next() => { + match update { + Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), + Some(None) => {} + None => return OwnerWait::Disconnected, + } + } + } } } diff --git a/crates/unixnotis-center/src/control/seed.rs b/crates/unixnotis-center/src/control/seed.rs index 6674bf238..a9366f6f7 100644 --- a/crates/unixnotis-center/src/control/seed.rs +++ b/crates/unixnotis-center/src/control/seed.rs @@ -1,80 +1,16 @@ -//! Seeding helpers for initial control state sync over D-Bus +//! Bounded seeding helpers for one verified control owner -use std::time::{Duration, Instant}; +use unixnotis_core::{timed_dbus_call, ControlProxy}; -use tokio::time::sleep; -use tracing::{debug, warn}; -use unixnotis_core::ControlProxy; - -use super::backoff::RetryLog; use super::model::UiEvent; -// Seed retries tolerate short startup hiccups without blocking indefinitely -pub const SEED_RETRY_BASE_MS: u64 = 250; -pub const SEED_RETRY_MAX_MS: u64 = 2000; -pub const SEED_RETRY_BUDGET_SECS: u64 = 30; -pub const SEED_RETRY_LOG_INTERVAL_SECS: u64 = 10; - -// Captures seed failures without forcing an immediate reconnect +// Each error identifies the exact stage that prevented one complete snapshot #[derive(Debug)] pub struct SeedError { pub(crate) state_error: Option, pub(crate) active_error: Option, pub(crate) history_error: Option, -} - -pub async fn seed_state_with_retry( - proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, -) { - // Seed retries are bounded to keep startup responsive while tolerating transient failures - let mut backoff = super::backoff::Backoff::new(SEED_RETRY_BASE_MS, SEED_RETRY_MAX_MS); - let deadline = seed_retry_deadline(Instant::now()); - let mut log = RetryLog::new(Duration::from_secs(SEED_RETRY_LOG_INTERVAL_SECS)); - - loop { - // Each attempt fetches a coherent three-part snapshot from one proxy - match seed_state(proxy, sender).await { - Ok(()) => return, - Err(err) => { - if Instant::now() >= deadline { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; giving up until reconnect" - ); - return; - } - // Throttled warnings keep prolonged outages useful without log flooding - log.log_with( - || { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; retrying" - ); - }, - || { - debug!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; retrying" - ); - }, - ); - // Exponential delay stays bounded by the shared seed maximum - sleep(backoff.next_sleep()).await; - } - } - } -} - -fn seed_retry_deadline(now: Instant) -> Instant { - // A fixed deadline prevents repeated seed failures from blocking forever - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) + pub(crate) send_error: Option, } #[cfg(test)] @@ -85,27 +21,44 @@ pub async fn seed_state( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, ) -> Result<(), SeedError> { - // Fetch in parallel so startup waits on the slowest call instead of the sum of all calls - let (state, active, history) = - tokio::join!(proxy.get_state(), proxy.list_active(), proxy.list_history()); + // GetState is the handshake and must succeed before snapshot methods are issued + let state = timed_dbus_call(proxy.get_state()) + .await + .map_err(|error| SeedError { + state_error: Some(error.to_string()), + active_error: None, + history_error: None, + send_error: None, + })?; + let (active, history) = tokio::join!( + timed_dbus_call(proxy.list_active()), + timed_dbus_call(proxy.list_history()) + ); - match (state, active, history) { - (Ok(state), Ok(active), Ok(history)) => { + match (active, history) { + (Ok(active), Ok(history)) => { // Publish only complete snapshots so the UI never mixes generations - let _ = sender + sender .send(UiEvent::Seed { state, active, history, }) - .await; + .await + .map_err(|error| SeedError { + state_error: None, + active_error: None, + history_error: None, + send_error: Some(error.to_string()), + })?; Ok(()) } // Individual errors remain separate for useful diagnostics - (state, active, history) => Err(SeedError { - state_error: state.err().map(|err| err.to_string()), + (active, history) => Err(SeedError { + state_error: None, active_error: active.err().map(|err| err.to_string()), history_error: history.err().map(|err| err.to_string()), + send_error: None, }), } } diff --git a/crates/unixnotis-center/src/control/subscriptions.rs b/crates/unixnotis-center/src/control/subscriptions.rs index 7415b23dd..896e02de0 100644 --- a/crates/unixnotis-center/src/control/subscriptions.rs +++ b/crates/unixnotis-center/src/control/subscriptions.rs @@ -4,14 +4,15 @@ use std::collections::VecDeque; use futures_util::StreamExt; use tokio::sync::mpsc; -use tracing::warn; -use unixnotis_core::ControlProxy; +use tracing::{info, warn}; +use unixnotis_core::{timed_dbus_call, ControlProxy}; +use zbus::proxy::OwnerChangedStream; use super::backoff::{Backoff, RetryLog}; use super::commands::{drop_stale_offline_commands, flush_offline_commands, handle_command}; use super::events::push_active_notification_event; use super::model::{UiCommand, UiEvent}; -use super::seed::seed_state_with_retry; +use super::seed::seed_state; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum ControlGenerationExit { @@ -19,28 +20,71 @@ pub(super) enum ControlGenerationExit { RetryDelayed, // A live stream ended and the reconnect owner must perform cleanup Disconnected, + // A well-known owner transition needs a new handshake without reconnect backoff + OwnerChanged, // The UI dropped every command sender and no longer needs a control task Shutdown, } impl ControlGenerationExit { - pub(super) const fn requires_reconnect_cleanup(self) -> bool { + pub(super) const fn requires_connection_backoff(self) -> bool { matches!(self, Self::Disconnected) } pub(super) const fn should_stop(self) -> bool { matches!(self, Self::Shutdown) } + + pub(super) const fn should_clear_panel_readiness(self) -> bool { + // Owner loss already clears owner-scoped state and must never trigger activation + matches!(self, Self::Shutdown) + } +} + +pub(super) struct ControlGenerationContext<'context, 'stream> { + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + offline_commands: &'context mut VecDeque, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, +} + +impl<'context, 'stream> ControlGenerationContext<'context, 'stream> { + pub(super) const fn new( + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + offline_commands: &'context mut VecDeque, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + ) -> Self { + Self { + owner_changes, + sender, + command_rx, + offline_commands, + subscribe_backoff, + subscribe_log, + } + } } pub(super) async fn run_control_generation( proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, - offline_commands: &mut VecDeque, - subscribe_backoff: &mut Backoff, - subscribe_log: &mut RetryLog, + owner: &str, + context: ControlGenerationContext<'_, '_>, ) -> ControlGenerationExit { + // One context keeps every mutable part tied to this exact owner generation + let ControlGenerationContext { + owner_changes, + sender, + command_rx, + offline_commands, + subscribe_backoff, + subscribe_log, + } = context; + // Every stream below belongs to the same verified proxy generation // Install every match rule before seeding so in-flight signals remain buffered let mut added_stream = match proxy.receive_notification_added().await { @@ -96,11 +140,22 @@ pub(super) async fn run_control_generation( subscribe_log.reset(); // Seed after subscription so events arriving during the fetch wait in their streams - seed_state_with_retry(proxy, sender).await; + if let Err(error) = seed_state(proxy, sender).await { + warn!( + state_error = ?error.state_error, + active_error = ?error.active_error, + history_error = ?error.history_error, + send_error = ?error.send_error, + "control readiness handshake or seed failed" + ); + retry_subscription(subscribe_backoff).await; + return ControlGenerationExit::RetryDelayed; + } + info!(owner, "UnixNotis control service ready"); drop_stale_offline_commands(offline_commands); flush_offline_commands(proxy, sender, offline_commands).await; // Readiness is published only after initial state and buffered commands settle - if let Err(err) = proxy.mark_panel_ready().await { + if let Err(err) = timed_dbus_call(proxy.mark_panel_ready()).await { subscribe_log.warn_or_debug(&err, "failed to mark panel ready"); retry_subscription(subscribe_backoff).await; return ControlGenerationExit::RetryDelayed; @@ -175,7 +230,16 @@ pub(super) async fn run_control_generation( break ControlGenerationExit::Disconnected; }; // A full seed is required because another client may have deleted any row - seed_state_with_retry(proxy, sender).await; + if let Err(error) = seed_state(proxy, sender).await { + warn!( + state_error = ?error.state_error, + active_error = ?error.active_error, + history_error = ?error.history_error, + send_error = ?error.send_error, + "control snapshot refresh failed" + ); + break ControlGenerationExit::Disconnected; + } } signal = panel_stream.next() => { let Some(signal) = signal else { @@ -186,11 +250,32 @@ pub(super) async fn run_control_generation( let _ = sender.send(UiEvent::PanelRequested(*args.request())).await; } } + owner_update = owner_changes.next() => { + match owner_update { + Some(Some(new_owner)) => { + warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::OwnerChanged; + } + Some(None) => { + info!("UnixNotis control service disconnected"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::OwnerChanged; + } + None => { + warn!("control owner stream ended"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::Disconnected; + } + } + } } }; - // Readiness is best effort because a closed transport cannot accept cleanup calls - let _ = proxy.mark_panel_not_ready().await; + if exit.should_clear_panel_readiness() { + // Explicit UI shutdown clears readiness while the current owner is still available + let _ = timed_dbus_call(proxy.mark_panel_not_ready()).await; + } exit } diff --git a/crates/unixnotis-center/src/control/tests/seed.rs b/crates/unixnotis-center/src/control/tests/seed.rs index 1df457837..03ef4de35 100644 --- a/crates/unixnotis-center/src/control/tests/seed.rs +++ b/crates/unixnotis-center/src/control/tests/seed.rs @@ -1,13 +1,16 @@ -use std::time::{Duration, Instant}; - -use super::{seed_retry_deadline, SEED_RETRY_BUDGET_SECS}; +use super::SeedError; #[test] -fn seed_retry_deadline_adds_the_fixed_retry_budget() { - let now = Instant::now(); +fn seed_error_keeps_handshake_snapshot_and_delivery_failures_distinct() { + let error = SeedError { + state_error: Some("state unavailable".to_string()), + active_error: None, + history_error: None, + send_error: None, + }; - assert_eq!( - seed_retry_deadline(now), - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) - ); + assert!(error.state_error.is_some()); + assert!(error.active_error.is_none()); + assert!(error.history_error.is_none()); + assert!(error.send_error.is_none()); } diff --git a/crates/unixnotis-center/src/control/tests/subscriptions.rs b/crates/unixnotis-center/src/control/tests/subscriptions.rs index 8ab53d52d..caf57b44b 100644 --- a/crates/unixnotis-center/src/control/tests/subscriptions.rs +++ b/crates/unixnotis-center/src/control/tests/subscriptions.rs @@ -1,15 +1,25 @@ use super::ControlGenerationExit; #[test] -fn only_a_disconnected_live_generation_requests_reconnect_cleanup() { - assert!(ControlGenerationExit::Disconnected.requires_reconnect_cleanup()); - assert!(!ControlGenerationExit::RetryDelayed.requires_reconnect_cleanup()); - assert!(!ControlGenerationExit::Shutdown.requires_reconnect_cleanup()); +fn only_a_broken_bus_generation_uses_connection_backoff() { + assert!(ControlGenerationExit::Disconnected.requires_connection_backoff()); + assert!(!ControlGenerationExit::OwnerChanged.requires_connection_backoff()); + assert!(!ControlGenerationExit::RetryDelayed.requires_connection_backoff()); + assert!(!ControlGenerationExit::Shutdown.requires_connection_backoff()); } #[test] fn only_a_closed_ui_command_channel_stops_the_control_task() { assert!(ControlGenerationExit::Shutdown.should_stop()); assert!(!ControlGenerationExit::Disconnected.should_stop()); + assert!(!ControlGenerationExit::OwnerChanged.should_stop()); assert!(!ControlGenerationExit::RetryDelayed.should_stop()); } + +#[test] +fn owner_loss_never_calls_the_panel_readiness_cleanup_method() { + assert!(ControlGenerationExit::Shutdown.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::Disconnected.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::OwnerChanged.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::RetryDelayed.should_clear_panel_readiness()); +} diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 46b7c208c..2db235aad 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -13,6 +13,13 @@ use super::{panel, UiState}; impl UiState { pub fn handle_event(&mut self, event: UiEvent) { match event { + UiEvent::Disconnected => { + debug!("UnixNotis control service disconnected"); + // Old rows and state must not survive into a later daemon generation + self.list.seed(Vec::new(), Vec::new()); + self.update_state(unixnotis_core::ControlState::default()); + self.refresh_counts(); + } UiEvent::Seed { state, active, diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index 0697655bd..d74052d43 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -2,15 +2,17 @@ use tokio::sync::mpsc; use tracing::warn; -use unixnotis_core::ControlProxy; +use unixnotis_core::{timed_dbus_call, ControlProxy}; use zbus::Result as ZbusResult; use super::types::UiCommand; pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> ZbusResult<()> { match command { - UiCommand::Dismiss(id) => proxy.dismiss(id).await, - UiCommand::InvokeAction { id, action_key } => proxy.invoke_action(id, &action_key).await, + UiCommand::Dismiss(id) => timed_dbus_call(proxy.dismiss(id)).await, + UiCommand::InvokeAction { id, action_key } => { + timed_dbus_call(proxy.invoke_action(id, &action_key)).await + } } } diff --git a/crates/unixnotis-popups/src/dbus/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime.rs index 2b4ba123b..8266e9485 100644 --- a/crates/unixnotis-popups/src/dbus/runtime.rs +++ b/crates/unixnotis-popups/src/dbus/runtime.rs @@ -6,14 +6,20 @@ use std::time::Duration; use futures_util::StreamExt; use tokio::sync::mpsc; use tracing::{info, warn}; -use unixnotis_core::ControlProxy; +use unixnotis_core::{ + log_session_bus_identity, timed_dbus_call, ControlProxy, CONTROL_BUS_NAME, + INTERNAL_DBUS_CALL_TIMEOUT, +}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::proxy::OwnerChangedStream; use zbus::Connection; use super::backoff::{ Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, }; use super::commands::{drain_offline_commands, handle_command}; -use super::seed::{seed_state_with_retry, PopupSeedSource, SeedError, SeedSnapshot}; +use super::seed::{seed_state, PopupSeedSource, SeedError, SeedSnapshot}; use super::types::{UiCommand, UiEvent}; // Bound UI commands to avoid unbounded memory growth under a stuck UI event loop @@ -25,9 +31,16 @@ struct ControlProxySeedSource<'proxy, 'conn> { impl PopupSeedSource for ControlProxySeedSource<'_, '_> { async fn seed_snapshot(&self) -> Result { - // Both calls run together so startup seed data has the smallest possible skew - // A fully atomic seed would need one daemon method that returns both values - let (state, active) = tokio::join!(self.proxy.get_state(), self.proxy.list_active()); + // GetState is the owner handshake and must finish before snapshot calls begin + let state = timed_dbus_call(self.proxy.get_state()).await; + let state = match state { + Ok(state) => state, + Err(error) => { + return SeedSnapshot::from_fetch_results(Err(error), Ok(Vec::new())); + } + }; + let active = timed_dbus_call(self.proxy.list_active()).await; + let state = Ok(state); SeedSnapshot::from_fetch_results(state, active) } } @@ -75,14 +88,17 @@ async fn run_dbus_loop( loop { let connection = connect_session_bus(&mut connect_backoff, &mut connect_log).await; - let retry_delay = run_connection_once( + let Some(retry_delay) = run_connection_once( &connection, &sender, &mut command_rx, &mut subscribe_backoff, &mut subscribe_log, ) - .await; + .await + else { + return; + }; tokio::time::sleep(retry_delay).await; } } @@ -94,6 +110,12 @@ async fn connect_session_bus( loop { match Connection::session().await { Ok(connection) => { + if let Err(error) = log_session_bus_identity(&connection, "popups").await { + connect_log + .warn_or_debug(&error, "session bus identity probe failed; retrying"); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } connect_backoff.reset(); connect_log.reset(); return connection; @@ -112,78 +134,184 @@ async fn run_connection_once( command_rx: &mut mpsc::Receiver, subscribe_backoff: &mut Backoff, subscribe_log: &mut RetryLog, -) -> Duration { +) -> Option { let proxy = match ControlProxy::new(connection).await { Ok(proxy) => proxy, Err(err) => { subscribe_log.warn_or_debug(&err, "control interface unavailable, retrying"); drain_offline_commands(command_rx); - return subscribe_backoff.next_sleep(); + return Some(subscribe_backoff.next_sleep()); + } + }; + let mut owner_changes = match proxy.inner().receive_owner_changed().await { + Ok(stream) => stream, + Err(error) => { + subscribe_log.warn_or_debug(&error, "control owner watch unavailable, retrying"); + return Some(subscribe_backoff.next_sleep()); + } + }; + let dbus = match DBusProxy::new(connection).await { + Ok(proxy) => proxy, + Err(error) => { + subscribe_log.warn_or_debug(&error, "session bus owner proxy unavailable, retrying"); + return Some(subscribe_backoff.next_sleep()); } }; - subscribe_backoff.reset(); - subscribe_log.reset(); - info!("connected to unixnotis control interface"); + loop { + let owner = + match wait_for_control_owner(&dbus, &mut owner_changes, sender, command_rx).await { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), + OwnerWait::Shutdown => return None, + }; + match run_owner_generation( + &proxy, + &owner, + &mut owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + ) + .await + { + GenerationExit::OwnerChanged => {} + GenerationExit::ConnectionLost => return Some(subscribe_backoff.next_sleep()), + GenerationExit::Shutdown => return None, + GenerationExit::Retry => { + tokio::time::sleep(subscribe_backoff.next_sleep()).await; + } + } + } +} + +enum OwnerWait { + Ready(String), + Disconnected, + Shutdown, +} + +enum GenerationExit { + OwnerChanged, + ConnectionLost, + Shutdown, + Retry, +} + +async fn wait_for_control_owner( + dbus: &DBusProxy<'_>, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, +) -> OwnerWait { + let control_name = BusName::try_from(CONTROL_BUS_NAME) + .expect("static UnixNotis control bus name must be valid"); + if let Ok(Ok(owner)) = tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + return OwnerWait::Ready(owner.to_string()); + } + + // No owner is a quiet disconnected state until the broker announces one + let _ = sender.send(UiEvent::Disconnected).await; + drain_offline_commands(command_rx); + loop { + tokio::select! { + command = command_rx.recv() => { + if command.is_none() { + return OwnerWait::Shutdown; + } + warn!("dropping popup command while control service has no owner"); + } + update = owner_changes.next() => { + match update { + Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), + Some(None) => {} + None => return OwnerWait::Disconnected, + } + } + } + } +} + +async fn run_owner_generation( + proxy: &ControlProxy<'_>, + owner: &str, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + subscribe_backoff: &mut Backoff, + subscribe_log: &mut RetryLog, +) -> GenerationExit { // Popups stay on the shared notification stream, but the trimmed payload keeps // each message smaller now that unused flags were removed from NotificationView let mut added_stream = match proxy.receive_notification_added().await { Ok(stream) => stream, Err(err) => { subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_added"); - return subscribe_backoff.next_sleep(); + return GenerationExit::Retry; } }; let mut updated_stream = match proxy.receive_notification_updated().await { Ok(stream) => stream, Err(err) => { subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_updated"); - return subscribe_backoff.next_sleep(); + return GenerationExit::Retry; } }; let mut closed_stream = match proxy.receive_notification_closed().await { Ok(stream) => stream, Err(err) => { subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_closed"); - return subscribe_backoff.next_sleep(); + return GenerationExit::Retry; } }; let mut popup_gate_stream = match proxy.receive_popup_gate_changed().await { Ok(stream) => stream, Err(err) => { subscribe_log.warn_or_debug(&err, "failed to subscribe to popup_gate_changed"); - return subscribe_backoff.next_sleep(); + return GenerationExit::Retry; } }; let mut invalidated_stream = match proxy.receive_snapshot_invalidated().await { Ok(stream) => stream, Err(err) => { subscribe_log.warn_or_debug(&err, "failed to subscribe to snapshot_invalidated"); - return subscribe_backoff.next_sleep(); + return GenerationExit::Retry; } }; // Seed only after subscriptions are active so startup does not miss in-flight changes - seed_state_with_retry(&ControlProxySeedSource { proxy: &proxy }, sender).await; + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); + return GenerationExit::Retry; + } + subscribe_backoff.reset(); + subscribe_log.reset(); + info!(owner, "UnixNotis control service ready"); - loop { + let exit = loop { tokio::select! { command = command_rx.recv() => { let Some(command) = command else { - break; + break GenerationExit::Shutdown; }; - if let Err(err) = handle_command(&proxy, command).await { + if let Err(err) = handle_command(proxy, command).await { warn!(?err, "control command failed"); } } signal = added_stream.next() => { let Some(signal) = signal else { warn!("notification_added stream ended"); - break; + break GenerationExit::OwnerChanged; }; if let Ok(args) = signal.args() { push_active_notification_event( - &proxy, + proxy, sender, *args.id(), *args.show_popup(), @@ -194,11 +322,11 @@ async fn run_connection_once( signal = updated_stream.next() => { let Some(signal) = signal else { warn!("notification_updated stream ended"); - break; + break GenerationExit::OwnerChanged; }; if let Ok(args) = signal.args() { push_active_notification_event( - &proxy, + proxy, sender, *args.id(), *args.show_popup(), @@ -209,7 +337,7 @@ async fn run_connection_once( signal = closed_stream.next() => { let Some(signal) = signal else { warn!("notification_closed stream ended"); - break; + break GenerationExit::OwnerChanged; }; if let Ok(args) = signal.args() { let _ = sender @@ -223,7 +351,7 @@ async fn run_connection_once( signal = popup_gate_stream.next() => { let Some(signal) = signal else { warn!("popup_gate_changed stream ended"); - break; + break GenerationExit::OwnerChanged; }; if let Ok(args) = signal.args() { let _ = sender @@ -234,16 +362,38 @@ async fn run_connection_once( signal = invalidated_stream.next() => { let Some(_signal) = signal else { warn!("snapshot_invalidated stream ended"); - break; + break GenerationExit::OwnerChanged; }; // A fresh seed clears stale popups after remote clears or daemon restart drift // Seed reconcile also updates same-id payload changes without trusting missed signals - seed_state_with_retry(&ControlProxySeedSource { proxy: &proxy }, sender).await; + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup snapshot refresh failed"); + break GenerationExit::Retry; + } + } + owner_update = owner_changes.next() => { + match owner_update { + Some(Some(new_owner)) => { + warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + Some(None) => { + info!("UnixNotis control service disconnected"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + None => { + warn!("control owner stream ended"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::ConnectionLost; + } + } } } - } + }; - subscribe_backoff.next_sleep() + exit } async fn push_active_notification_event( @@ -254,7 +404,7 @@ async fn push_active_notification_event( is_add: bool, ) { // Full popup payloads now stay on the authorized pull path instead of the shared signal - match proxy.get_active_notification(id).await { + match timed_dbus_call(proxy.get_active_notification(id)).await { Ok(mut notifications) => { // Close fanout can win the race, so a missing row is a normal no-op here let Some(notification) = notifications.pop() else { diff --git a/crates/unixnotis-popups/src/dbus/seed.rs b/crates/unixnotis-popups/src/dbus/seed.rs index aef3bef57..d69a8d860 100644 --- a/crates/unixnotis-popups/src/dbus/seed.rs +++ b/crates/unixnotis-popups/src/dbus/seed.rs @@ -1,20 +1,12 @@ -//! Popup state seeding helpers +//! Popup state seeding for one verified control owner -use std::time::{Duration, Instant}; +use std::fmt; -use tracing::{debug, warn}; use unixnotis_core::{ControlState, NotificationView}; -use super::backoff::{Backoff, RetryLog}; use super::types::UiEvent; -// Seed retries tolerate short startup hiccups without blocking indefinitely -const SEED_RETRY_BASE_MS: u64 = 250; -const SEED_RETRY_MAX_MS: u64 = 2000; -const SEED_RETRY_BUDGET_SECS: u64 = 30; -const SEED_RETRY_LOG_INTERVAL_SECS: u64 = 10; - -// Seed failures are tracked without forcing an immediate reconnect +// Seed failures are returned to the owner state machine #[derive(Debug)] pub struct SeedError { state_error: Option, @@ -22,6 +14,29 @@ pub struct SeedError { send_error: Option, } +impl fmt::Display for SeedError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + // Each available stage preserves the bounded call or channel failure + let failures = [ + self.state_error + .as_deref() + .map(|error| format!("GetState: {error}")), + self.active_error + .as_deref() + .map(|error| format!("ListActive: {error}")), + self.send_error + .as_deref() + .map(|error| format!("UI delivery: {error}")), + ] + .into_iter() + .flatten() + .collect::>(); + write!(formatter, "{}", failures.join("; ")) + } +} + +impl std::error::Error for SeedError {} + #[derive(Debug)] pub struct SeedSnapshot { // State and active rows are sent together so reconnect seeding cannot mix old and new data @@ -50,50 +65,10 @@ pub trait PopupSeedSource { async fn seed_snapshot(&self) -> Result; } -pub async fn seed_state_with_retry(proxy: &S, sender: &async_channel::Sender) -where - S: PopupSeedSource, -{ - // Seed retries stay bounded so startup can recover without hanging forever - let deadline = seed_retry_deadline(Instant::now()); - seed_state_with_retry_until(proxy, sender, deadline).await; -} - -async fn seed_state_with_retry_until( +pub async fn seed_state( proxy: &S, sender: &async_channel::Sender, - deadline: Instant, -) where - S: PopupSeedSource, -{ - // Seed retries stay bounded so startup can recover without hanging forever - let mut backoff = Backoff::new(SEED_RETRY_BASE_MS, SEED_RETRY_MAX_MS); - let mut log = RetryLog::new(Duration::from_secs(SEED_RETRY_LOG_INTERVAL_SECS)); - - loop { - match seed_state(proxy, sender).await { - Ok(()) => return, - Err(err) => { - if Instant::now() >= deadline { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - "failed to seed popup state; giving up until reconnect" - ); - return; - } - log_seed_retry(&mut log, &err, "failed to seed popup state; retrying"); - tokio::time::sleep(backoff.next_sleep()).await; - } - } - } -} - -fn seed_retry_deadline(now: Instant) -> Instant { - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) -} - -async fn seed_state(proxy: &S, sender: &async_channel::Sender) -> Result<(), SeedError> +) -> Result<(), SeedError> where S: PopupSeedSource, { @@ -121,27 +96,6 @@ async fn send_seed_event( }) } -fn log_seed_retry(log: &mut RetryLog, err: &SeedError, message: &str) -> bool { - log.log_with( - || { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - send_error = ?err.send_error, - "{message}" - ); - }, - || { - debug!( - state_error = ?err.state_error, - active_error = ?err.active_error, - send_error = ?err.send_error, - "{message}" - ); - }, - ) -} - #[cfg(test)] #[path = "tests/seed.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/dbus/tests/seed.rs b/crates/unixnotis-popups/src/dbus/tests/seed.rs index 9045cd524..3566fa7d2 100644 --- a/crates/unixnotis-popups/src/dbus/tests/seed.rs +++ b/crates/unixnotis-popups/src/dbus/tests/seed.rs @@ -1,12 +1,7 @@ use async_channel::bounded; -use std::time::{Duration, Instant}; use unixnotis_core::ControlState; -use super::{ - log_seed_retry, seed_retry_deadline, seed_state, seed_state_with_retry, - seed_state_with_retry_until, send_seed_event, PopupSeedSource, SeedError, SeedSnapshot, -}; -use crate::dbus::backoff::RetryLog; +use super::{seed_state, send_seed_event, PopupSeedSource, SeedError, SeedSnapshot}; use crate::dbus::UiEvent; struct FakeSeedSource { @@ -112,57 +107,3 @@ async fn seed_state_reports_active_fetch_failure_without_sending() { assert!(err.active_error.is_some()); assert!(rx.try_recv().is_err()); } - -#[tokio::test] -async fn seed_state_with_retry_returns_after_successful_seed() { - let source = FakeSeedSource::available(); - let (tx, rx) = bounded(1); - - seed_state_with_retry(&source, &tx).await; - - let event = rx.try_recv().expect("seed event should be queued"); - assert!(matches!(event, UiEvent::Seed { .. })); -} - -#[tokio::test] -async fn seed_state_with_retry_stops_immediately_after_expired_budget() { - let source = FakeSeedSource::missing_state(); - let (tx, rx) = bounded(1); - let expired = Instant::now() - .checked_sub(Duration::from_millis(1)) - .expect("test instant should support a one-millisecond offset"); - - tokio::time::timeout( - Duration::from_millis(50), - seed_state_with_retry_until(&source, &tx, expired), - ) - .await - .expect("expired retry budget should not sleep"); - - assert!(rx.try_recv().is_err()); -} - -#[test] -fn seed_retry_deadline_adds_fixed_retry_budget() { - let now = Instant::now(); - - let deadline = seed_retry_deadline(now); - - assert_eq!( - deadline.checked_duration_since(now), - Some(Duration::from_secs(30)) - ); -} - -#[test] -fn log_seed_retry_reports_warning_then_debug_status() { - let mut log = RetryLog::new(Duration::from_mins(1)); - let err = SeedError { - state_error: Some("state unavailable".to_string()), - active_error: None, - send_error: None, - }; - - assert!(log_seed_retry(&mut log, &err, "seed retry")); - assert!(!log_seed_retry(&mut log, &err, "seed retry")); -} diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 287485c57..eaeb59413 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -5,6 +5,8 @@ use unixnotis_core::{CloseReason, ControlState, NotificationView, PopupGateState /// Events delivered to the GTK main loop #[derive(Debug, Clone)] pub enum UiEvent { + // Owner loss clears every popup from the previous daemon generation + Disconnected, Seed { state: ControlState, active: Vec, diff --git a/crates/unixnotis-popups/src/ui/state/events.rs b/crates/unixnotis-popups/src/ui/state/events.rs index f603593e0..ceaf5ee1e 100644 --- a/crates/unixnotis-popups/src/ui/state/events.rs +++ b/crates/unixnotis-popups/src/ui/state/events.rs @@ -12,6 +12,11 @@ use super::model::UiState; impl UiState { pub fn handle_event(&mut self, event: UiEvent) { match event { + UiEvent::Disconnected => { + debug!("UnixNotis control service disconnected"); + self.control_state = ControlState::default(); + self.reconcile_seed(Vec::new()); + } UiEvent::Seed { state, active } => { // Seed is daemon truth, so filtering uses the newest gate state self.control_state = state; From a262b9937725faf624516ee501ed7dfb7bd584fa Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 25 Jul 2026 23:44:18 -0500 Subject: [PATCH 094/275] fix: enforce installation readiness and channel safety Summary: enforce installation readiness and channel safety. Scope: repository. --- .../src/actions/install/service/flow.rs | 12 ++ .../src/actions/install/service/mod.rs | 3 +- .../src/actions/install/service/readiness.rs | 190 ++++++++++++++++++ .../install/service/tests/readiness.rs | 51 +++++ .../src/actions/install/tests/service/flow.rs | 7 +- .../install/tests/service/flow_support.rs | 16 +- .../install/tests/service/lifecycle.rs | 11 +- .../src/actions/installation_channel.rs | 152 ++++++++++++++ crates/unixnotis-installer/src/actions/mod.rs | 5 +- .../unixnotis-installer/src/actions/state.rs | 3 + .../src/actions/tests/installation_channel.rs | 98 +++++++++ .../src/service_manager/backends/systemd.rs | 75 +++++-- .../service_manager/backends/tests/systemd.rs | 58 ++++-- 13 files changed, 633 insertions(+), 48 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/install/service/readiness.rs create mode 100644 crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs create mode 100644 crates/unixnotis-installer/src/actions/installation_channel.rs create mode 100644 crates/unixnotis-installer/src/actions/tests/installation_channel.rs diff --git a/crates/unixnotis-installer/src/actions/install/service/flow.rs b/crates/unixnotis-installer/src/actions/install/service/flow.rs index 545e8886f..b3ab70646 100644 --- a/crates/unixnotis-installer/src/actions/install/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/service/flow.rs @@ -19,6 +19,7 @@ use super::artifacts::{ use super::lifecycle::{ remove_pre_start_artifacts, run_command_spec, run_service_start, warn_pre_start_artifacts_left, }; +use super::readiness::enforce_service_readiness; use super::refresh::refresh_service_artifacts; pub fn install_service(ctx: &mut ActionContext) -> Result<()> { @@ -45,6 +46,16 @@ pub fn install_service(ctx: &mut ActionContext) -> Result<()> { } pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { + enable_service_with_readiness(ctx, enforce_service_readiness) +} + +pub(in crate::actions::install) fn enable_service_with_readiness( + ctx: &mut ActionContext, + readiness: F, +) -> Result<()> +where + F: FnOnce(&mut ActionContext) -> Result<()>, +{ if ctx.service_reload_required.load(Ordering::Acquire) { // Refresh work can be a single reload command or a backend-owned database update refresh_service_artifacts(ctx)?; @@ -66,6 +77,7 @@ pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { } remove_pre_start_artifacts(ctx)?; run_service_start(ctx)?; + readiness(ctx)?; // Shell startup files are updated so new terminals can resolve the installed commands if let Err(err) = ensure_shell_path_entry(ctx) { diff --git a/crates/unixnotis-installer/src/actions/install/service/mod.rs b/crates/unixnotis-installer/src/actions/install/service/mod.rs index 8918c1944..8c7f3e36d 100644 --- a/crates/unixnotis-installer/src/actions/install/service/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/service/mod.rs @@ -3,8 +3,9 @@ pub(in crate::actions::install) mod artifacts; mod dirs; pub(in crate::actions::install) mod files; -mod flow; +pub(in crate::actions::install) mod flow; pub(in crate::actions::install) mod lifecycle; +mod readiness; pub(in crate::actions::install) mod refresh; pub(in crate::actions::install) mod symlinks; diff --git a/crates/unixnotis-installer/src/actions/install/service/readiness.rs b/crates/unixnotis-installer/src/actions/install/service/readiness.rs new file mode 100644 index 000000000..9d4b767b5 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/service/readiness.rs @@ -0,0 +1,190 @@ +//! Post-start D-Bus readiness enforcement and bounded failure diagnostics + +use std::future::Future; +use std::time::Duration; + +use anyhow::{bail, ensure, Context, Result}; +use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::{fdo::DBusProxy, names::BusName, Connection}; + +use super::super::super::{log_line, run_command, ActionContext}; + +const INSTALL_READINESS_TIMEOUT: Duration = Duration::from_secs(20); +const DBUS_METHOD_TIMEOUT: Duration = Duration::from_secs(2); +const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(in crate::actions::install) fn enforce_service_readiness( + ctx: &mut ActionContext, +) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create installer readiness runtime")?; + let address = stable_user_bus_address(); + let result = runtime.block_on(async { + let builder = zbus::connection::Builder::address(address.as_str()) + .context("prepare stable user-bus connection")?; + let connection = tokio::time::timeout(DBUS_METHOD_TIMEOUT, builder.build()) + .await + .context("stable user-bus connection timed out")? + .context("connect to stable user bus")?; + let readiness = + wait_until_ready_with_probe(INSTALL_READINESS_TIMEOUT, || probe_readiness(&connection)) + .await; + if let Err(error) = readiness { + let owners = readiness_owner_diagnostics(&connection).await; + return Err(error.context(owners)); + } + Ok(()) + }); + + if let Err(error) = result { + log_line(ctx, format!("UnixNotis readiness failed: {error:#}")); + if ctx.paths.service.is_systemd() { + log_systemd_failure_diagnostics(ctx); + } + return Err(error.context("UnixNotis did not become ready after service start")); + } + log_line(ctx, "UnixNotis D-Bus readiness verified"); + Ok(()) +} + +async fn wait_until_ready_with_probe(timeout: Duration, mut probe: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let deadline = tokio::time::Instant::now() + timeout; + loop { + let last_failure = match probe().await { + Ok(()) => return Ok(()), + Err(error) => format!("{error:#}"), + }; + let now = tokio::time::Instant::now(); + if now >= deadline { + bail!("UnixNotis readiness timed out; last observation: {last_failure}"); + } + tokio::time::sleep(READINESS_POLL_INTERVAL.min(deadline - now)).await; + } +} + +async fn probe_readiness(connection: &Connection) -> Result<()> { + let dbus = DBusProxy::new(connection) + .await + .context("create readiness D-Bus proxy")?; + let notification_owner = get_owner(&dbus, NOTIFICATIONS_BUS_NAME).await?; + let control_owner = get_owner(&dbus, CONTROL_BUS_NAME).await?; + ensure!( + notification_owner == control_owner, + "D-Bus owners differ: notifications={notification_owner}, control={control_owner}" + ); + + let control = ControlProxy::new(connection) + .await + .context("create control readiness proxy")?; + tokio::time::timeout(DBUS_METHOD_TIMEOUT, control.get_state()) + .await + .context("Control.GetState timed out")? + .context("Control.GetState failed")?; + + let notifications = NotificationsProxy::new(connection) + .await + .context("create notification readiness proxy")?; + tokio::time::timeout(DBUS_METHOD_TIMEOUT, notifications.get_server_information()) + .await + .context("GetServerInformation timed out")? + .context("GetServerInformation failed")?; + Ok(()) +} + +async fn get_owner(dbus: &DBusProxy<'_>, name: &'static str) -> Result { + let name = BusName::try_from(name).context("invalid readiness bus name")?; + let owner = tokio::time::timeout(DBUS_METHOD_TIMEOUT, dbus.get_name_owner(name.clone())) + .await + .context("D-Bus owner lookup timed out")? + .with_context(|| format!("{name} has no owner"))?; + Ok(owner.to_string()) +} + +async fn readiness_owner_diagnostics(connection: &Connection) -> String { + let Ok(dbus) = DBusProxy::new(connection).await else { + return "owner diagnostics unavailable: failed to create D-Bus proxy".to_string(); + }; + let notifications = diagnostic_owner(&dbus, NOTIFICATIONS_BUS_NAME).await; + let control = diagnostic_owner(&dbus, CONTROL_BUS_NAME).await; + format!("D-Bus owners: {NOTIFICATIONS_BUS_NAME}={notifications}; {CONTROL_BUS_NAME}={control}") +} + +async fn diagnostic_owner(dbus: &DBusProxy<'_>, name: &'static str) -> String { + let Ok(name) = BusName::try_from(name) else { + return "".to_string(); + }; + match tokio::time::timeout(DBUS_METHOD_TIMEOUT, dbus.get_name_owner(name)).await { + Ok(Ok(owner)) => owner.to_string(), + Ok(Err(error)) => format!("<{error}>"), + Err(_) => "".to_string(), + } +} + +fn stable_user_bus_address() -> String { + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) +} + +fn log_systemd_failure_diagnostics(ctx: &mut ActionContext) { + let show_args = [ + "--user", + "show", + "unixnotis-daemon.service", + "-p", + "LoadState", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "Result", + "-p", + "ExecMainStatus", + "-p", + "FragmentPath", + "-p", + "ExecStart", + ]; + match crate::system_tools::command("systemctl") { + Ok(mut command) => { + command.args(show_args); + let _ = run_command(ctx, "systemctl readiness diagnostics", command, None); + } + Err(error) => log_line( + ctx, + format!("Warning: systemctl readiness diagnostics unavailable ({error})"), + ), + } + + match crate::system_tools::command("journalctl") { + Ok(mut command) => { + // The line count and shared log reader cap both dimensions of diagnostic output + command.args([ + "--user", + "-u", + "unixnotis-daemon.service", + "-n", + "100", + "--no-pager", + "--output=short-monotonic", + ]); + let _ = run_command(ctx, "journalctl readiness diagnostics", command, None); + } + Err(error) => log_line( + ctx, + format!("Warning: journal readiness diagnostics unavailable ({error})"), + ), + } +} + +#[cfg(test)] +#[path = "tests/readiness.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs b/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs new file mode 100644 index 000000000..a71790ebc --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use anyhow::anyhow; + +use super::{stable_user_bus_address, wait_until_ready_with_probe}; + +#[tokio::test] +async fn installer_rejects_process_start_success_without_dbus_readiness() { + let status = std::process::Command::new("true") + .status() + .expect("fake service start command should run"); + assert!(status.success()); + + let probes = AtomicUsize::new(0); + let error = wait_until_ready_with_probe(Duration::from_millis(15), || { + probes.fetch_add(1, Ordering::Relaxed); + std::future::ready(Err(anyhow!("both required names have no owner"))) + }) + .await + .expect_err("a successful process start must not satisfy D-Bus readiness"); + + assert!(error.to_string().contains("readiness timed out")); + assert!(error.to_string().contains("both required names")); + assert!(probes.load(Ordering::Relaxed) >= 2); +} + +#[tokio::test] +async fn readiness_gate_returns_after_the_first_complete_probe() { + let probes = AtomicUsize::new(0); + + wait_until_ready_with_probe(Duration::from_secs(1), || { + probes.fetch_add(1, Ordering::Relaxed); + std::future::ready(Ok(())) + }) + .await + .expect("complete readiness should pass"); + + assert_eq!(probes.load(Ordering::Relaxed), 1); +} + +#[test] +fn stable_bus_address_uses_the_current_numeric_uid() { + assert_eq!( + stable_user_bus_address(), + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) + ); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs index 2117fd5c8..71be78d90 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs @@ -6,7 +6,7 @@ use crate::service_manager::ServiceManager; use super::flow_support::{ assert_call_order, flow_env, flow_paths, lock_env, read_calls, run_install_and_enable, - service_flow_root, write_fake_tools, FakeToolMode, + service_flow_root, standard_bus_address, write_fake_tools, FakeToolMode, }; #[test] @@ -38,6 +38,7 @@ fn systemd_install_flow_runs_reload_env_import_and_enable() { &calls, &[ "program=systemctl argv=[--user][daemon-reload]", + "program=systemctl argv=[--user][unset-environment][DBUS_SESSION_BUS_ADDRESS]", "program=dbus-update-activation-environment argv=[WAYLAND_DISPLAY]", "program=systemctl argv=[--user][--no-pager][import-environment][WAYLAND_DISPLAY]", "program=systemctl argv=[--user][enable][--now][unixnotis-daemon.service]", @@ -108,7 +109,7 @@ fn runit_install_flow_syncs_envdir_before_removing_down_and_starting() { assert_eq!( fs::read_to_string(service_dir.join("env").join("DBUS_SESSION_BUS_ADDRESS")) .expect("runit D-Bus address should be persisted"), - "unix:path=/tmp/unixnotis-bus\n" + format!("{}\n", standard_bus_address()) ); assert!( fs::symlink_metadata(service_dir.join("down")).is_err(), @@ -160,7 +161,7 @@ fn s6_install_flow_compiles_database_then_changes_service() { .join("DBUS_SESSION_BUS_ADDRESS") ) .expect("s6 D-Bus address should be persisted"), - "unix:path=/tmp/unixnotis-bus\n" + format!("{}\n", standard_bus_address()) ); let calls = read_calls(&log_path); assert_call_order( diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs index 586c1093c..81493a959 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs @@ -10,7 +10,8 @@ use crate::service_manager::contract::command_routing::use_fake_command_bin; use crate::service_manager::ServiceManager; use crate::test_support::fs::write_executable; -use super::super::super::service::{enable_service, install_service, uninstall_service}; +use super::super::super::service::flow::enable_service_with_readiness; +use super::super::super::service::{install_service, uninstall_service}; use super::super::support::{test_context, test_root}; pub(super) fn lock_env() -> MutexGuard<'static, ()> { @@ -70,7 +71,7 @@ pub(super) fn run_install_and_enable(paths: &InstallPaths) -> anyhow::Result<()> let mut ctx = test_context(&detection, paths, ActionMode::Install); // Run the same two public install phases used by the TUI worker install_service(&mut ctx)?; - enable_service(&mut ctx) + enable_service_with_readiness(&mut ctx, |_| Ok(())) } pub(super) fn run_install_only(paths: &InstallPaths) -> anyhow::Result<()> { @@ -88,7 +89,7 @@ pub(super) fn run_enable_only(paths: &InstallPaths) -> anyhow::Result<()> { daemons: Vec::new(), }; let mut ctx = test_context(&detection, paths, ActionMode::Install); - enable_service(&mut ctx) + enable_service_with_readiness(&mut ctx, |_| Ok(())) } pub(super) fn run_uninstall_only(paths: &InstallPaths) -> anyhow::Result<()> { @@ -121,10 +122,17 @@ pub(super) fn flow_env(root: &Path) -> Vec { EnvGuard::set("XDG_SESSION_TYPE", "wayland"), EnvGuard::set("XDG_SESSION_DESKTOP", "Hyprland"), EnvGuard::set("DISPLAY", ":99"), - EnvGuard::set("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/unixnotis-bus"), + EnvGuard::set("DBUS_SESSION_BUS_ADDRESS", standard_bus_address()), ] } +pub(super) fn standard_bus_address() -> String { + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) +} + pub(super) fn write_fake_tools(fake_bin: &Path, log_path: &Path, mode: FakeToolMode) -> impl Drop { fs::create_dir_all(fake_bin).expect("make fake bin"); // All tools listed here are backends or helper commands used by the service install flow diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs index 985c78556..25b9e5a15 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs @@ -21,15 +21,16 @@ use super::flow_support::{flow_env, lock_env, write_fake_tools, FakeToolMode}; fn install_service_skips_rewrite_when_unit_is_already_current() { let root = test_root("install-service-unchanged"); let paths = test_paths(&root); - fs::create_dir_all(paths.service.artifact_root()).expect("make service artifact dir"); - // Seed exactly what the backend would render so the installer should stay quiet - let expected = expected_primary_artifact_contents(&paths); - fs::write(paths.service.primary_artifact_path(), &expected).expect("write current artifact"); - let detection = Detection { owner: None, daemons: Vec::new(), }; + let setup_ctx = test_context(&detection, &paths, ActionMode::Install); + // Seed every artifact because the systemd service and D-Bus activation file form one install + for artifact in paths.service.artifacts(&paths.bin_dir) { + write_service_artifact(&setup_ctx, &artifact).expect("write current service artifact"); + } + let expected = expected_primary_artifact_contents(&paths); let mut ctx = test_context(&detection, &paths, ActionMode::Install); // Start as true so the test proves install_service actively clears stale reload state let reload_required = Arc::new(AtomicBool::new(true)); diff --git a/crates/unixnotis-installer/src/actions/installation_channel.rs b/crates/unixnotis-installer/src/actions/installation_channel.rs new file mode 100644 index 000000000..cef82b195 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/installation_channel.rs @@ -0,0 +1,152 @@ +//! Active systemd unit channel classification for source-install safety + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::{log_line, ActionContext}; + +const SYSTEM_UNIT_ROOT: &str = "/usr/lib/systemd/user"; +const SYSTEM_BINARY_ROOT: &str = "/usr/bin"; +const MAX_SYSTEMCTL_OUTPUT_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallationChannel { + HomeLocal, + SystemPackage, + Mixed, + Unknown, +} + +pub(super) fn reject_conflicting_installation_channel(ctx: &mut ActionContext) -> Result<()> { + if !ctx.paths.service.is_systemd() { + return Ok(()); + } + let Some((fragment, executable)) = active_unit_paths()? else { + return Ok(()); + }; + let channel = classify_installation_channel( + &fragment, + &executable, + ctx.paths.service.artifact_root(), + &ctx.paths.bin_dir, + ); + match channel { + InstallationChannel::HomeLocal => Ok(()), + InstallationChannel::SystemPackage => { + log_channel_conflict(ctx, "system package", &fragment, &executable); + bail!( + "the system-package UnixNotis installation must be removed with its package manager before a home-local install" + ) + } + InstallationChannel::Mixed => { + log_channel_conflict(ctx, "mixed", &fragment, &executable); + bail!( + "mixed UnixNotis installation channels detected; repair the unit and executable paths before installing" + ) + } + InstallationChannel::Unknown => { + log_channel_conflict(ctx, "unrecognized", &fragment, &executable); + bail!( + "the active UnixNotis unit uses an unrecognized installation channel; automatic replacement is unsafe" + ) + } + } +} + +fn active_unit_paths() -> Result> { + let mut command = crate::system_tools::command("systemctl")?; + command.args([ + "--user", + "show", + "unixnotis-daemon.service", + "--property=FragmentPath", + "--property=ExecStart", + "--no-pager", + ]); + let output = command + .output() + .context("inspect active UnixNotis systemd unit")?; + if !output.status.success() { + return Ok(None); + } + if output.stdout.len() > MAX_SYSTEMCTL_OUTPUT_BYTES { + bail!("systemctl unit metadata exceeded the safe output limit"); + } + let text = String::from_utf8(output.stdout).context("systemctl unit metadata was not UTF-8")?; + let fragment = property_value(&text, "FragmentPath").map(PathBuf::from); + let executable = property_value(&text, "ExecStart") + .and_then(parse_exec_start_path) + .map(PathBuf::from); + match (fragment, executable) { + (Some(fragment), Some(executable)) if !fragment.as_os_str().is_empty() => { + Ok(Some((fragment, executable))) + } + (None, None) => Ok(None), + _ => bail!("systemctl returned incomplete UnixNotis unit path metadata"), + } +} + +fn property_value<'a>(text: &'a str, name: &str) -> Option<&'a str> { + text.lines() + .find_map(|line| line.strip_prefix(name)?.strip_prefix('=')) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn parse_exec_start_path(value: &str) -> Option<&str> { + let path = value + .split(';') + .find_map(|field| { + field + .trim() + .trim_start_matches('{') + .trim() + .strip_prefix("path=") + })? + .trim(); + (!path.is_empty()).then_some(path) +} + +fn classify_installation_channel( + fragment: &Path, + executable: &Path, + home_unit_root: &Path, + home_binary_root: &Path, +) -> InstallationChannel { + let unit_channel = path_channel(fragment, home_unit_root, Path::new(SYSTEM_UNIT_ROOT)); + let binary_channel = path_channel(executable, home_binary_root, Path::new(SYSTEM_BINARY_ROOT)); + match (unit_channel, binary_channel) { + (Some(InstallationChannel::HomeLocal), Some(InstallationChannel::HomeLocal)) => { + InstallationChannel::HomeLocal + } + (Some(InstallationChannel::SystemPackage), Some(InstallationChannel::SystemPackage)) => { + InstallationChannel::SystemPackage + } + (Some(_), Some(_)) => InstallationChannel::Mixed, + _ => InstallationChannel::Unknown, + } +} + +fn path_channel(path: &Path, home_root: &Path, system_root: &Path) -> Option { + if path.starts_with(home_root) { + Some(InstallationChannel::HomeLocal) + } else if path.starts_with(system_root) { + Some(InstallationChannel::SystemPackage) + } else { + None + } +} + +fn log_channel_conflict(ctx: &mut ActionContext, label: &str, fragment: &Path, executable: &Path) { + log_line( + ctx, + format!("Error: {label} UnixNotis installation channel"), + ); + log_line(ctx, format!("- unit: {}", fragment.display())); + log_line(ctx, format!("- executable: {}", executable.display())); +} + +#[cfg(test)] +#[path = "tests/installation_channel.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/mod.rs b/crates/unixnotis-installer/src/actions/mod.rs index 3c3c4865a..31f803179 100644 --- a/crates/unixnotis-installer/src/actions/mod.rs +++ b/crates/unixnotis-installer/src/actions/mod.rs @@ -11,6 +11,7 @@ mod format; mod hyprland; mod install; mod install_state; +mod installation_channel; mod plan; mod process; mod state; @@ -30,9 +31,7 @@ pub use build::run_build; pub use config::backup::{list_backup_dirs_for_ui, restore_config}; pub use config::{ensure_config, remove_state, reset_config}; pub use daemon::stop_active_daemon; -pub use environment::{ - ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment, HYPR_IMPORT_VARS, -}; +pub use environment::{ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment}; pub use install::{ enable_service, install_binaries, install_service, remove_binaries, uninstall_service, }; diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index 0e6af8f88..0467e268b 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -8,6 +8,7 @@ use crate::model::ActionMode; use crate::paths::format_with_home; use crate::service_manager::ReadinessIssue; +use super::installation_channel::reject_conflicting_installation_channel; use super::{context::ActionContext, install_state::check_install_state, log_line}; pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { @@ -96,6 +97,8 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { ctx.paths.service.label() )); } + // The source installer must not shadow or combine with package-owned systemd artifacts + reject_conflicting_installation_channel(ctx)?; let mut readiness_errors = Vec::new(); for issue in ctx.paths.service.readiness_issues() { match issue { diff --git a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs new file mode 100644 index 000000000..ab0a7edcf --- /dev/null +++ b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs @@ -0,0 +1,98 @@ +use std::path::Path; + +use super::{ + classify_installation_channel, parse_exec_start_path, property_value, InstallationChannel, +}; + +const HOME_UNITS: &str = "/home/test/.config/systemd/user"; +const HOME_BIN: &str = "/home/test/.local/bin"; + +#[test] +fn matching_home_and_system_paths_select_one_installation_channel() { + assert_eq!( + classify_installation_channel( + Path::new("/home/test/.config/systemd/user/unixnotis-daemon.service"), + Path::new("/home/test/.local/bin/unixnotis-daemon"), + Path::new(HOME_UNITS), + Path::new(HOME_BIN), + ), + InstallationChannel::HomeLocal + ); + assert_eq!( + classify_installation_channel( + Path::new("/usr/lib/systemd/user/unixnotis-daemon.service"), + Path::new("/usr/bin/unixnotis-daemon"), + Path::new(HOME_UNITS), + Path::new(HOME_BIN), + ), + InstallationChannel::SystemPackage + ); +} + +#[test] +fn crossed_unit_and_binary_paths_are_always_mixed() { + for (unit, binary) in [ + ( + "/home/test/.config/systemd/user/unixnotis-daemon.service", + "/usr/bin/unixnotis-daemon", + ), + ( + "/usr/lib/systemd/user/unixnotis-daemon.service", + "/home/test/.local/bin/unixnotis-daemon", + ), + ] { + assert_eq!( + classify_installation_channel( + Path::new(unit), + Path::new(binary), + Path::new(HOME_UNITS), + Path::new(HOME_BIN), + ), + InstallationChannel::Mixed + ); + } +} + +#[test] +fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { + assert_eq!( + classify_installation_channel( + Path::new("/opt/systemd/user/unixnotis-daemon.service"), + Path::new("/opt/unixnotis/bin/unixnotis-daemon"), + Path::new(HOME_UNITS), + Path::new(HOME_BIN), + ), + InstallationChannel::Unknown + ); +} + +#[test] +fn systemd_exec_start_parser_reads_only_the_structured_path_field() { + assert_eq!( + parse_exec_start_path( + "{ path=/home/test/.local/bin/unixnotis-daemon ; argv[]=/home/test/.local/bin/unixnotis-daemon ; ignore_errors=no ; }" + ), + Some("/home/test/.local/bin/unixnotis-daemon") + ); + assert_eq!(parse_exec_start_path("argv[]=/tmp/fake"), None); +} + +#[test] +fn systemd_property_parser_requires_an_exact_nonempty_key() { + let output = "FragmentPath=/home/test/unit\nExecStart={ path=/home/test/bin ; }\n"; + + assert_eq!( + property_value(output, "FragmentPath"), + Some("/home/test/unit") + ); + assert_eq!( + property_value(output, "ExecStart"), + Some("{ path=/home/test/bin ; }") + ); + assert_eq!(property_value(output, "Path"), None); + assert_eq!(property_value("FragmentPath=\n", "FragmentPath"), None); + assert_eq!( + property_value("FragmentPathx=/tmp/wrong\n", "FragmentPath"), + None + ); +} diff --git a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs index be92ddecd..9556f0c98 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs @@ -1,11 +1,10 @@ use std::path::{Path, PathBuf}; -use crate::paths::format_with_home; - use super::super::contract::{CommandSpec, ServiceArtifact}; // Keep the systemd unit name stable for existing installs and migration cleanup pub const SERVICE_NAME: &str = "unixnotis-daemon.service"; +pub const CONTROL_ACTIVATION_SERVICE: &str = "com.unixnotis.Control.service"; pub const fn artifact_label() -> &'static str { "systemd unit" @@ -21,10 +20,13 @@ pub fn primary_artifact_path(artifact_root: &Path) -> PathBuf { } pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { - vec![ServiceArtifact::file( - primary_artifact_path(artifact_root), - render_unit(bin_dir), - )] + vec![ + ServiceArtifact::file(primary_artifact_path(artifact_root), render_unit(bin_dir)), + ServiceArtifact::file( + control_activation_path(bin_dir), + render_control_activation(bin_dir), + ), + ] } pub fn availability_command() -> Option { @@ -102,7 +104,16 @@ pub fn stop_for_reinstall_command() -> Option { } pub fn hyprland_startup_commands(import_vars: &[&str]) -> Vec { + let allowed = unixnotis_core::service_manager::variables_for_backend( + unixnotis_core::service_manager::ServiceManagerKind::Systemd, + ); + let import_vars = import_vars + .iter() + .copied() + .filter(|name| allowed.contains(name)) + .collect::>(); vec![ + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS".to_string(), format!( "dbus-update-activation-environment {}", import_vars.join(" ") @@ -119,10 +130,19 @@ pub fn environment_sync_commands( import_vars: &[(&str, String)], dbus_update_available: bool, ) -> Vec { - let mut commands = Vec::new(); + // Remove a value persisted by older installers before importing safe graphical variables + let mut commands = vec![CommandSpec::new( + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS", + "systemctl", + ["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"], + )]; + let allowed = unixnotis_core::service_manager::variables_for_backend( + unixnotis_core::service_manager::ServiceManagerKind::Systemd, + ); let names = import_vars .iter() .map(|(name, _value)| *name) + .filter(|name| allowed.contains(name)) .collect::>(); if dbus_update_available { // D-Bus activation and systemd imports solve different environment paths @@ -147,12 +167,18 @@ fn render_unit(bin_dir: &Path) -> String { "Description=UnixNotis Notification Daemon".to_string(), // Order after the graphical session without pulling that target into the unit graph "After=graphical-session.target".to_string(), + // Stop this user service when its graphical session is stopped + "PartOf=graphical-session.target".to_string(), String::new(), "[Service]".to_string(), - "Type=simple".to_string(), + // Control ownership is published only after notification readiness is verified + "Type=dbus".to_string(), + "BusName=com.unixnotis.Control".to_string(), format!("ExecStart={exec_start}"), "Restart=on-failure".to_string(), "RestartSec=1".to_string(), + "TimeoutStartSec=20".to_string(), + "TimeoutStopSec=10".to_string(), String::new(), "[Install]".to_string(), "WantedBy=default.target".to_string(), @@ -163,11 +189,30 @@ fn render_unit(bin_dir: &Path) -> String { fn format_exec_start(bin_dir: &Path) -> String { let path = bin_dir.join("unixnotis-daemon"); - let rendered = format_with_home(&path); - if let Some(tail) = rendered.strip_prefix("$HOME") { - // systemd expands %h itself, while $HOME is not shell-expanded in ExecStart - format!("%h{tail}") - } else { - path.display().to_string() - } + // The service manager receives one concrete executable with no shell or PATH lookup + path.display().to_string() +} + +fn control_activation_path(bin_dir: &Path) -> PathBuf { + // Home-local binaries live beside the matching home-local data directory + let local_root = bin_dir + .parent() + .expect("installer binary directory must have a parent"); + local_root + .join("share") + .join("dbus-1") + .join("services") + .join(CONTROL_ACTIVATION_SERVICE) +} + +fn render_control_activation(bin_dir: &Path) -> String { + let executable = format_exec_start(bin_dir); + [ + "[D-BUS Service]".to_string(), + "Name=com.unixnotis.Control".to_string(), + format!("Exec={executable}"), + format!("SystemdService={SERVICE_NAME}"), + String::new(), + ] + .join("\n") } diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs index c493d568a..77928c243 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs @@ -2,15 +2,14 @@ use std::path::PathBuf; use crate::service_manager::{ServiceArtifactKind, ServiceArtifactRefresh, ServiceManager}; -use super::super::systemd::SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE; +use super::super::systemd::{CONTROL_ACTIVATION_SERVICE, SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE}; #[test] fn systemd_backend_renders_exact_unit_artifact() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); let artifacts = manager.artifacts(std::path::Path::new("/tmp/bin")); - // Systemd is the stable default, so this refactor must keep the unit byte-for-byte stable - assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts.len(), 2); assert_eq!( artifacts[0].path, PathBuf::from("/tmp/systemd/user").join(UNIXNOTIS_DAEMON_SERVICE) @@ -24,16 +23,39 @@ fn systemd_backend_renders_exact_unit_artifact() { "[Unit]\n\ Description=UnixNotis Notification Daemon\n\ After=graphical-session.target\n\ + PartOf=graphical-session.target\n\ \n\ [Service]\n\ - Type=simple\n\ + Type=dbus\n\ + BusName=com.unixnotis.Control\n\ ExecStart=/tmp/bin/unixnotis-daemon\n\ Restart=on-failure\n\ RestartSec=1\n\ + TimeoutStartSec=20\n\ + TimeoutStopSec=10\n\ \n\ [Install]\n\ WantedBy=default.target\n" ); + assert_eq!( + artifacts[1].path, + PathBuf::from("/tmp") + .join("share") + .join("dbus-1") + .join("services") + .join(CONTROL_ACTIVATION_SERVICE) + ); + assert_eq!(artifacts[1].kind, ServiceArtifactKind::File); + assert_eq!( + artifacts[1] + .contents + .as_ref() + .expect("activation artifact should render contents"), + "[D-BUS Service]\n\ + Name=com.unixnotis.Control\n\ + Exec=/tmp/bin/unixnotis-daemon\n\ + SystemdService=unixnotis-daemon.service\n" + ); } #[test] @@ -117,6 +139,7 @@ fn hyprland_startup_lines_come_from_selected_backend() { assert_eq!( commands, vec![ + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS".to_string(), "dbus-update-activation-environment WAYLAND_DISPLAY XDG_RUNTIME_DIR".to_string(), "systemctl --user import-environment WAYLAND_DISPLAY XDG_RUNTIME_DIR".to_string(), format!("systemctl --user --no-block restart {UNIXNOTIS_DAEMON_SERVICE}"), @@ -136,43 +159,44 @@ fn environment_sync_commands_come_from_selected_backend() { ), ]; - // D-Bus sync runs first because systemd activation and DBus activation are separate stores + // Legacy bus state is removed before either graphical environment store is updated let with_dbus = manager.environment_sync_commands(&vars, true); - assert_eq!(with_dbus.len(), 2); - assert_eq!(with_dbus[0].program(), "dbus-update-activation-environment"); + assert_eq!(with_dbus.len(), 3); + assert_eq!(with_dbus[0].program(), "systemctl"); assert_eq!( with_dbus[0].args(), - &[ - "WAYLAND_DISPLAY", - "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", - ] + &["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"] ); - assert_eq!(with_dbus[1].program(), "systemctl"); + assert_eq!(with_dbus[1].program(), "dbus-update-activation-environment"); + assert_eq!(with_dbus[1].args(), &["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]); + assert_eq!(with_dbus[2].program(), "systemctl"); assert_eq!( - with_dbus[1].args(), + with_dbus[2].args(), &[ "--user", "--no-pager", "import-environment", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", ] ); let without_dbus = manager.environment_sync_commands(&vars, false); - assert_eq!(without_dbus.len(), 1); + assert_eq!(without_dbus.len(), 2); assert_eq!(without_dbus[0].program(), "systemctl"); assert_eq!( without_dbus[0].args(), + &["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"] + ); + assert_eq!(without_dbus[1].program(), "systemctl"); + assert_eq!( + without_dbus[1].args(), &[ "--user", "--no-pager", "import-environment", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", ] ); } From c85ef427e88a163f49e1b65a54a40a5f6074a377 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:43:51 -0500 Subject: [PATCH 095/275] fix(identity): verify no-hint launch specifications Summary: verify no-hint launch specifications. Scope: identity. --- .../daemon/notifications/identity/resolver.rs | 31 +++- .../notifications/identity/tests/resolver.rs | 132 ++++++++++++++++++ 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index 558885716..0dba69d2d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -26,6 +26,9 @@ pub(in crate::daemon) struct AttributionResolution { pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, } +#[derive(Clone, Copy)] +struct VerifiedDesktopRecord<'record>(&'record DesktopRecord); + pub(in crate::daemon) fn unknown_reply_denied( claim: AppClaim<'_>, sender: &SenderMetadata, @@ -89,7 +92,7 @@ fn resolve_with_evidence( } if let Some(record) = records .iter() - .find(|record| record_matches_sender(record, sender)) + .find_map(|record| verify_record_sender(record, sender)) { return resolution_for_record(record, claim.reported_name, sender, index); } @@ -111,13 +114,18 @@ fn resolve_with_evidence( if let Some(identity) = sender.sender_executable_identity { // Exact file association is stronger than every caller-controlled application name let records = index.records_for_executable(identity); - if let Some(record) = records - .iter() - .find(|record| record.claim_matches(claim.reported_name)) - { + if let Some(record) = records.iter().find_map(|record| { + record + .claim_matches(claim.reported_name) + .then(|| verify_record_sender(record, sender)) + .flatten() + }) { return resolution_for_record(record, claim.reported_name, sender, index); } - if records.iter().any(|record| record.system_association) { + if records + .iter() + .any(|record| record.system_association && record_matches_sender(record, sender)) + { // A known executable with a conflicting name must fail closed return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); } @@ -182,11 +190,12 @@ fn resolution_for_portal_record( } fn resolution_for_record( - record: &DesktopRecord, + verified: VerifiedDesktopRecord<'_>, reported_name: &str, sender: &SenderMetadata, index: &DesktopIdentityIndex, ) -> AttributionResolution { + let record = verified.0; // Display metadata is projected only after the record and sender identities agree if !record.claim_matches(reported_name) { return conflict_resolution(reported_name, sender, "application claim mismatch"); @@ -275,6 +284,14 @@ fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> boo } } +fn verify_record_sender<'record>( + record: &'record DesktopRecord, + sender: &SenderMetadata, +) -> Option> { + // This wrapper makes sender launch verification mandatory at every association call site + record_matches_sender(record, sender).then_some(VerifiedDesktopRecord(record)) +} + fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { // Unknown senders cannot merge into a trusted desktop group by copying its name let claim = normalize_name(reported_name); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 7b3d27c4a..b44d2faff 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -347,6 +347,138 @@ fn matching_fixed_system_application_argument_allows_association() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } +#[test] +fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/python3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 2, + "/usr/bin/pypy3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 3, + "/usr/bin/gjs", + "/usr/share/password-manager/main.js", + "/tmp/fake.js", + ), + ( + 4, + "/usr/bin/dotnet", + "/usr/share/password-manager/PasswordManager.dll", + "/tmp/Fake.dll", + ), + ( + 5, + "/usr/bin/java", + "/usr/share/password-manager/password-manager.jar", + "/tmp/fake.jar", + ), + ] { + let runtime_identity = identity(60, 600 + serial, 0); + let fixed_arguments = if executable == "/usr/bin/java" { + vec!["-jar", expected] + } else { + vec![expected] + }; + let sender_arguments = if executable == "/usr/bin/java" { + vec!["-jar", actual] + } else { + vec![actual] + }; + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + executable, + runtime_identity, + ) + .with_launch_literals(&fixed_arguments); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments(executable, runtime_identity, &sender_arguments), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "{executable} accepted a different no-hint application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { + let runtime_identity = identity(61, 610, 0); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + runtime_identity, + ) + .with_launch_literals(&["/usr/share/password-manager/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments( + "/usr/bin/python3", + runtime_identity, + &["/usr/share/password-manager/main.py"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { + let runtime_identity = identity(62, 620, 0); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + runtime_identity, + ) + .with_launch_literals(&["/usr/share/password-manager/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Unrelated Local Script", + desktop_entry: None, + }, + &sender_with_arguments("/usr/bin/python3", runtime_identity, &["/tmp/local.py"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + #[test] fn unmediated_flatpak_process_cannot_become_portal_associated() { let flatpak_identity = identity(21, 210, 0); From b91914fc3e516f72cfc8fd34347adae7a24e7692 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:45:57 -0500 Subject: [PATCH 096/275] fix(index): bound desktop refresh watching Summary: bound desktop refresh watching. Scope: index. --- crates/unixnotis-daemon/src/daemon/mod.rs | 1 + .../identity/desktop_index/mod.rs | 1 + .../identity/desktop_index/refresh.rs | 146 ++++++++++++++---- .../identity/desktop_index/scan.rs | 80 +++++++--- .../identity/desktop_index/tests/refresh.rs | 81 ++++++++++ .../identity/desktop_index/tests/scan.rs | 37 +++++ .../src/daemon/notifications/identity/mod.rs | 1 + .../src/daemon/notifications/mod.rs | 1 + crates/unixnotis-daemon/src/runtime/daemon.rs | 7 +- crates/unixnotis-daemon/src/runtime/runner.rs | 9 +- 10 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs diff --git a/crates/unixnotis-daemon/src/daemon/mod.rs b/crates/unixnotis-daemon/src/daemon/mod.rs index 32209cf5c..b309fd2d3 100644 --- a/crates/unixnotis-daemon/src/daemon/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/mod.rs @@ -14,6 +14,7 @@ pub use bus::{ }; pub use control::ControlServer; pub use errors::to_fdo_error; +pub use notifications::DesktopIndexSnapshot; pub use notifications::NotificationIngress; pub use notifications::NotificationServer; pub(in crate::daemon) use notifications::NotificationSignalMode; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index fcd7eaf3f..008cf9bf9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -13,6 +13,7 @@ pub use model::DesktopIdentityIndex; pub(super) use model::DesktopRecord; pub(super) use names::{normalize_desktop_id, normalize_name}; pub use refresh::spawn_desktop_index_refresh; +pub use scan::DesktopIndexSnapshot; pub(in crate::daemon::notifications::identity) fn record_launch_matches( record: &DesktopRecord, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs index dd1268667..b73cb17b0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs @@ -1,64 +1,75 @@ //! Debounced desktop-index refresh with atomic snapshot replacement -use std::path::Path; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use arc_swap::ArcSwap; -use notify::{RecursiveMode, Watcher}; +use notify::event::{CreateKind, RemoveKind}; +use notify::{Event, EventKind, RecursiveMode, Watcher}; use tokio::sync::mpsc; use tracing::{debug, warn}; use super::model::DesktopIdentityIndex; -use super::scan::desktop_roots; const REFRESH_DEBOUNCE: Duration = Duration::from_millis(500); +const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(5); const REFRESH_SIGNAL_CAPACITY: usize = 1; +const MAX_WATCHED_DIRECTORIES: usize = 4_096; pub fn spawn_desktop_index_refresh( index: Arc>, + watched_directories: Vec, ) -> Result> { let (refresh_tx, mut refresh_rx) = mpsc::channel(REFRESH_SIGNAL_CAPACITY); - let mut watcher = notify::recommended_watcher(move |event: notify::Result| { - match event { - Ok(_) => { - // A single pending signal coalesces filesystem bursts without blocking the watcher - let _ = refresh_tx.try_send(()); - } - Err(error) => warn!(?error, "desktop application watcher reported an error"), - } - }) - .context("create desktop application watcher")?; + let mut file_monitor = + notify::recommended_watcher(move |event: notify::Result| { + queue_refresh_event(event, &refresh_tx); + }) + .context("create desktop application watcher")?; - let mut watched_root = false; - for (root, _) in desktop_roots() { - if !root.is_dir() { - continue; - } - match watcher.watch(Path::new(&root), RecursiveMode::Recursive) { - Ok(()) => watched_root = true, - Err(error) => warn!( - ?error, - root = %root.display(), - "failed to watch desktop application directory" - ), - } - } - if !watched_root { + let active_watches = add_watch_directories( + &mut file_monitor, + watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES), + ); + if active_watches.is_empty() { warn!("no desktop application directory is available for refresh watching"); } Ok(tokio::spawn(async move { // The watcher must stay owned by this task for kernel watches to remain registered - let _watcher = watcher; + let mut file_monitor = file_monitor; + let mut active_watches = active_watches; + let mut last_rebuild = Instant::now() + .checked_sub(MIN_REBUILD_INTERVAL) + .unwrap_or_else(Instant::now); while refresh_rx.recv().await.is_some() { tokio::time::sleep(REFRESH_DEBOUNCE).await; // Drain events that arrived during the debounce window before one complete rebuild while refresh_rx.try_recv().is_ok() {} - match tokio::task::spawn_blocking(DesktopIdentityIndex::new).await { + + // Sustained user filesystem activity cannot trigger continuous complete rescans + let remaining = rebuild_delay(last_rebuild.elapsed()); + tokio::time::sleep(remaining).await; + match tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot).await { Ok(rebuilt) => { - index.store(Arc::new(rebuilt)); + let requested = rebuilt + .watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES) + .collect::>(); + // Add replacement watches before publishing the new immutable index + let additions = requested.difference(&active_watches).cloned(); + let added = add_watch_directories(&mut file_monitor, additions); + index.store(Arc::new(rebuilt.index)); + remove_stale_watches(&mut file_monitor, &active_watches, &requested); + active_watches.retain(|directory| requested.contains(directory)); + active_watches.extend(added); + last_rebuild = Instant::now(); debug!("desktop application identity index refreshed"); } Err(error) => { @@ -68,3 +79,74 @@ pub fn spawn_desktop_index_refresh( } })) } + +const fn rebuild_delay(elapsed: Duration) -> Duration { + MIN_REBUILD_INTERVAL.saturating_sub(elapsed) +} + +fn queue_refresh_event(event: notify::Result, refresh_tx: &mpsc::Sender<()>) { + match event { + Ok(event) if relevant_desktop_event(&event) => { + // A single pending signal coalesces filesystem bursts without blocking the watcher + let _ = refresh_tx.try_send(()); + } + Ok(_) => {} + Err(error) => warn!(?error, "desktop application watcher reported an error"), + } +} + +fn relevant_desktop_event(event: &Event) -> bool { + // Folder changes alter the bounded nonrecursive watch set + let folder_event = matches!( + event.kind, + EventKind::Create(CreateKind::Folder) | EventKind::Remove(RemoveKind::Folder) + ); + folder_event + || event.paths.iter().any(|path| { + path.extension().and_then(|extension| extension.to_str()) == Some("desktop") + || path.is_dir() + }) +} + +fn add_watch_directories(file_monitor: &mut W, directories: I) -> HashSet +where + W: Watcher, + I: IntoIterator, +{ + let mut registered = HashSet::new(); + let mut failed = 0_usize; + for directory in directories { + if file_monitor + .watch(Path::new(&directory), RecursiveMode::NonRecursive) + .is_ok() + { + registered.insert(directory); + } else { + failed += 1; + } + } + if failed != 0 { + // One bounded summary avoids attacker-controlled path and error log floods + warn!( + failed, + "some desktop application directories could not be watched" + ); + } + registered +} + +fn remove_stale_watches( + file_monitor: &mut W, + active: &HashSet, + requested: &HashSet, +) where + W: Watcher, +{ + for directory in active.difference(requested) { + let _ = file_monitor.unwatch(directory); + } +} + +#[cfg(test)] +#[path = "tests/refresh.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs index 361bb46d8..ce3f8de8e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs @@ -12,6 +12,11 @@ const MAX_ENTRIES_VISITED: usize = 65_536; const MAX_DIRECTORY_DEPTH: usize = 16; const MAX_DESKTOP_FILE_BYTES: u64 = 256 * 1024; +pub struct DesktopIndexSnapshot { + pub index: DesktopIdentityIndex, + pub watched_directories: Vec, +} + #[derive(Debug, Copy, Clone)] pub(in crate::daemon::notifications::identity) struct ScanLimits { pub(super) records: usize, @@ -24,9 +29,10 @@ pub(in crate::daemon::notifications::identity) struct ScanLimits { impl Default for ScanLimits { fn default() -> Self { Self { - records: MAX_DESKTOP_RECORDS, - directories: MAX_DIRECTORIES_VISITED, - entries: MAX_ENTRIES_VISITED, + // Each trust class gets half of every global budget + records: MAX_DESKTOP_RECORDS / 2, + directories: MAX_DIRECTORIES_VISITED / 2, + entries: MAX_ENTRIES_VISITED / 2, depth: MAX_DIRECTORY_DEPTH, file_bytes: MAX_DESKTOP_FILE_BYTES, } @@ -35,10 +41,12 @@ impl Default for ScanLimits { #[derive(Debug, Default)] pub(in crate::daemon::notifications::identity) struct ScanBudget { + pub(super) records: usize, pub(super) directories: usize, pub(super) entries: usize, pub(super) skipped_files: usize, pub(super) stopped_by: Option<&'static str>, + pub(super) visited_directories: Vec, } impl ScanBudget { @@ -53,26 +61,42 @@ impl ScanBudget { impl DesktopIdentityIndex { #[must_use] - pub(crate) fn new() -> Self { + pub(crate) fn build_snapshot() -> DesktopIndexSnapshot { + Self::build_with_roots(desktop_roots(), &ScanLimits::default()) + } + + pub(super) fn build_with_roots( + roots: Vec<(PathBuf, bool)>, + limits: &ScanLimits, + ) -> DesktopIndexSnapshot { let mut index = Self::default(); - let limits = ScanLimits::default(); - let mut budget = ScanBudget::default(); - // User entries are scanned first while origin remains part of the security identity - for (root, system_entry) in desktop_roots() { - index.scan_root(&root, system_entry, &limits, &mut budget); - if budget.exhausted() { - break; + // User-controlled trees and protected trees receive independent resource budgets + let mut user_budget = ScanBudget::default(); + let mut system_budget = ScanBudget::default(); + for (root, system_entry) in roots { + let budget = if system_entry { + &mut system_budget + } else { + &mut user_budget + }; + // Exhausting one trust class must not prevent the other class from being indexed + if !budget.exhausted() { + index.scan_root(&root, system_entry, limits, budget); } } - if budget.exhausted() || budget.skipped_files != 0 { - // One summary avoids log floods from attacker-controlled application trees - debug!( - stopped_by = budget.stopped_by.unwrap_or("none"), - directories = budget.directories, - entries = budget.entries, - skipped_files = budget.skipped_files, - "desktop application scan reached a safety limit" - ); + for (scope, budget) in [("user", &user_budget), ("system", &system_budget)] { + if budget.exhausted() || budget.skipped_files != 0 { + // One summary avoids log floods from attacker-controlled application trees + debug!( + scope, + stopped_by = budget.stopped_by.unwrap_or("none"), + records = budget.records, + directories = budget.directories, + entries = budget.entries, + skipped_files = budget.skipped_files, + "desktop application scan reached a safety limit" + ); + } } // Relay trust is tied to the installed file identity instead of its basename index.index_trusted_relay(Path::new("/usr/bin/notify-send")); @@ -86,7 +110,15 @@ impl DesktopIdentityIndex { ] { index.index_trusted_portals_in(Path::new(directory)); } - index + let watched_directories = user_budget + .visited_directories + .into_iter() + .chain(system_budget.visited_directories) + .collect(); + DesktopIndexSnapshot { + index, + watched_directories, + } } pub(super) fn scan_root( @@ -107,6 +139,8 @@ impl DesktopIdentityIndex { let Ok(entries) = std::fs::read_dir(&directory) else { continue; }; + // Only readable directories can contribute records or useful kernel watches + budget.visited_directories.push(directory); for entry in entries { if budget.entries >= limits.entries { budget.stop("entry budget"); @@ -138,11 +172,13 @@ impl DesktopIdentityIndex { budget.skipped_files += 1; continue; } - if self.records.len() >= limits.records { + if budget.records >= limits.records { budget.stop("record budget"); return; } + let records_before = self.records.len(); self.add_desktop_file(&path, system_entry); + budget.records += self.records.len().saturating_sub(records_before); } } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs new file mode 100644 index 000000000..4b93ad147 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs @@ -0,0 +1,81 @@ +use std::fs; + +use notify::event::{CreateKind, RemoveKind}; +use notify::{Event, EventKind}; + +use std::time::Duration; + +use super::{queue_refresh_event, rebuild_delay, relevant_desktop_event}; +use crate::test_support::TempRoot; + +#[test] +fn desktop_file_changes_request_an_index_refresh() { + let event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn unrelated_regular_file_changes_do_not_request_an_index_refresh() { + let event = Event::new(EventKind::Any).add_path("notes.txt".into()); + + assert!(!relevant_desktop_event(&event)); +} + +#[test] +fn relevant_event_is_queued_for_the_async_refresh_loop() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + queue_refresh_event(Ok(event), &refresh_tx); + + assert_eq!(refresh_rx.try_recv(), Ok(())); +} + +#[test] +fn unrelated_event_is_not_queued_for_the_async_refresh_loop() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let event = Event::new(EventKind::Any).add_path("notes.txt".into()); + + queue_refresh_event(Ok(event), &refresh_tx); + + assert_eq!( + refresh_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); +} + +#[test] +fn existing_directory_changes_request_watch_set_refresh() { + let root = TempRoot::new("desktop-refresh-directory"); + let directory = root.join("nested"); + fs::create_dir(&directory).expect("create watched directory fixture"); + let event = Event::new(EventKind::Any).add_path(directory); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn removed_directory_events_request_watch_set_refresh() { + let event = + Event::new(EventKind::Remove(RemoveKind::Folder)).add_path("removed-directory".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn created_directory_events_request_watch_set_refresh() { + let event = Event::new(EventKind::Create(CreateKind::Folder)).add_path("new-directory".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn rebuild_delay_enforces_the_minimum_interval_without_oversleeping() { + assert_eq!( + rebuild_delay(Duration::from_secs(2)), + Duration::from_secs(3) + ); + assert_eq!(rebuild_delay(Duration::from_secs(5)), Duration::ZERO); + assert_eq!(rebuild_delay(Duration::from_secs(8)), Duration::ZERO); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs index 12e8cca4f..14437b6ea 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -139,3 +139,40 @@ fn scan_stops_when_the_global_record_budget_is_exhausted() { assert_eq!(index.records.len(), 1); assert_eq!(budget.stopped_by, Some("record budget")); } + +#[test] +fn exhausted_user_budget_does_not_block_system_desktop_records() { + let root = TempRoot::new("desktop-separate-budgets"); + let user_root = root.join("user"); + let system_root = root.join("system"); + fs::create_dir_all(&user_root).expect("create user application directory"); + fs::create_dir_all(&system_root).expect("create system application directory"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + user_root.join(name), + "[Desktop Entry]\nType=Application\nName=User App\nExec=/usr/bin/true\n", + ) + .expect("user desktop fixture"); + } + fs::write( + system_root.join("system.desktop"), + "[Desktop Entry]\nType=Application\nName=System App\nExec=/usr/bin/true\n", + ) + .expect("system desktop fixture"); + let limits = ScanLimits { + records: 1, + ..ScanLimits::default() + }; + + let snapshot = DesktopIdentityIndex::build_with_roots( + vec![(user_root, false), (system_root, true)], + &limits, + ); + + assert!(snapshot + .index + .records + .iter() + .any(|record| record.system_origin && record.display_name == "System App")); + assert_eq!(snapshot.watched_directories.len(), 2); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 65df3c826..8c7face72 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -7,6 +7,7 @@ mod resolver; mod sender; mod sender_cache; +pub use desktop_index::DesktopIndexSnapshot; pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; pub(in crate::daemon) use resolver::{resolve_attribution, unknown_reply_denied, AppClaim}; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index 11df06e39..188e0c9d6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -8,6 +8,7 @@ mod server; pub(in crate::daemon) use flow_control::{ notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, }; +pub use identity::DesktopIndexSnapshot; pub(in crate::daemon) use identity::SenderMetadataCache; pub use identity::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub use server::NotificationIngress; diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 64269a9c5..24b5c3cc6 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -1,5 +1,6 @@ //! Live notification service runtime +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -30,6 +31,7 @@ pub(super) async fn run_daemon( connection: &Connection, dbus_proxy: &DBusProxy<'_>, desktop_identity_index: Arc>, + watched_desktop_directories: Vec, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); @@ -40,7 +42,10 @@ pub(super) async fn run_daemon( args.trial, desktop_identity_index, ); - if let Err(error) = spawn_desktop_index_refresh(state.desktop_identity_index.clone()) { + if let Err(error) = spawn_desktop_index_refresh( + state.desktop_identity_index.clone(), + watched_desktop_directories, + ) { warn!(?error, "desktop application refresh watcher is unavailable"); } let scheduler = ExpirationScheduler::start(state.clone()); diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 8de081f16..79b818d13 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -8,7 +8,7 @@ use zbus::connection::Builder; use zbus::fdo::DBusProxy; use crate::cli::Args; -use crate::daemon::DesktopIdentityIndex; +use crate::daemon::{DesktopIdentityIndex, DesktopIndexSnapshot}; use crate::trial_mode::{prepare_trial, TrialState}; use unixnotis_core::{log_session_bus_identity, Config, NOTIFICATIONS_BUS_NAME}; @@ -31,9 +31,13 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> .await .context("read daemon session-bus identity")?; // Finish the bounded filesystem scan before either well-known name can become visible - let desktop_identity_index = tokio::task::spawn_blocking(DesktopIdentityIndex::new) + let desktop_index_snapshot = tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot) .await .context("desktop identity index task failed")?; + let DesktopIndexSnapshot { + index: desktop_identity_index, + watched_directories, + } = desktop_index_snapshot; let desktop_identity_index = Arc::new(ArcSwap::from_pointee(desktop_identity_index)); let dbus_proxy = DBusProxy::new(&connection).await?; let notifications_name = zbus::names::BusName::try_from(NOTIFICATIONS_BUS_NAME)?; @@ -50,6 +54,7 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> &connection, &dbus_proxy, desktop_identity_index, + watched_directories, ) .await; let restore_result = trial_cleanup::finish_trial( From 070ffb333a59532bf93e7e1f5407217f5064b778 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:45:57 -0500 Subject: [PATCH 097/275] fix(dbus): encode wire enums as bytes Summary: encode wire enums as bytes. Scope: dbus. --- .../unixnotis-core/src/model/attribution.rs | 7 ++- .../src/model/tests/attribution.rs | 53 ++++++++++++++++++- .../src/model/tests/notification.rs | 29 +++++++++- .../unixnotis-core/src/model/tests/types.rs | 27 +++++++++- crates/unixnotis-core/src/model/types.rs | 4 +- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index 86378224b..d2b28396e 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -1,6 +1,7 @@ //! Notification application association and interaction policy use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; use crate::util; @@ -8,7 +9,8 @@ use crate::util; const MAX_ATTRIBUTION_TEXT_BYTES: usize = 256; /// Evidence class used to present an application without claiming universal authentication -#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +// Representation-aware Serde keeps the D-Bus body aligned with its one-byte signature +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum AttributionClass { SystemAssociated = 0, @@ -21,7 +23,8 @@ pub enum AttributionClass { } /// Independent policy for credential-like inline text controls -#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +// Ordinary enum Serde writes a wider variant index that strict brokers reject +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum InlineReplyPolicy { Allow = 0, diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 9886918e7..680fc1334 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -1,4 +1,55 @@ -use super::{AttributionClass, NotificationAttribution}; +use super::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +#[test] +fn attribution_wire_enums_use_their_declared_one_byte_signature() { + let context = Context::new_dbus(LE, 0); + + for (class, discriminant) in [ + (AttributionClass::SystemAssociated, 0_u8), + (AttributionClass::PortalAssociated, 1), + (AttributionClass::UserAssociated, 2), + (AttributionClass::TrustedRelay, 3), + (AttributionClass::Unknown, 4), + (AttributionClass::Conflict, 5), + ] { + let encoded = to_bytes(context, &class).expect("serialize attribution class"); + assert_eq!(AttributionClass::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: AttributionClass = encoded + .deserialize() + .expect("deserialize attribution class") + .0; + assert_eq!(decoded, class); + } + + for (policy, discriminant) in [ + (InlineReplyPolicy::Allow, 0_u8), + (InlineReplyPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize inline reply policy"); + assert_eq!(InlineReplyPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: InlineReplyPolicy = encoded + .deserialize() + .expect("deserialize inline reply policy") + .0; + assert_eq!(decoded, policy); + } +} + +#[test] +fn attribution_wire_enums_reject_unknown_discriminants() { + let context = Context::new_dbus(LE, 0); + + // Representation-aware deserialization must not invent policy for unknown wire values + let unknown_class = to_bytes(context, &u8::MAX).expect("serialize unknown class byte"); + assert!(unknown_class.deserialize::().is_err()); + + // The intentionally unused policy value must remain invalid on D-Bus + let unknown_policy = to_bytes(context, &1_u8).expect("serialize unused policy byte"); + assert!(unknown_policy.deserialize::().is_err()); +} #[test] fn associated_identity_keeps_presentation_and_grouping_fields_separate() { diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 8d6b138a2..da68e766e 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use chrono::Utc; -use zbus::zvariant::Value; +use zbus::zvariant::{serialized::Context, to_bytes, Value, LE}; use super::{Notification, NotificationImage}; use crate::{ @@ -90,6 +90,33 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { assert!(view.image.has_image_data); } +#[test] +fn notification_view_round_trips_every_attribution_and_reply_policy_pair() { + let context = Context::new_dbus(LE, 0); + let cases = [ + (AttributionClass::SystemAssociated, InlineReplyPolicy::Allow), + (AttributionClass::PortalAssociated, InlineReplyPolicy::Allow), + (AttributionClass::UserAssociated, InlineReplyPolicy::Deny), + (AttributionClass::TrustedRelay, InlineReplyPolicy::Deny), + (AttributionClass::Unknown, InlineReplyPolicy::Deny), + (AttributionClass::Conflict, InlineReplyPolicy::Deny), + ]; + + for (class, policy) in cases { + let mut view = notification_with_image(image_with_raw_bytes()).to_view(); + view.attribution.class = class; + view.inline_reply_policy = policy; + + // This nested payload matches GetActiveNotification and exercises both wire enums + let encoded = to_bytes(context, &view).expect("serialize notification view"); + let decoded = encoded + .deserialize::() + .expect("deserialize notification view") + .0; + assert_eq!(decoded, view); + } +} + #[test] fn notification_view_keeps_conflict_warning_separate_from_primary_name() { let mut notification = notification_with_image(image_with_raw_bytes()); diff --git a/crates/unixnotis-core/src/model/tests/types.rs b/crates/unixnotis-core/src/model/tests/types.rs index eeb9ecab3..7fcf17af7 100644 --- a/crates/unixnotis-core/src/model/tests/types.rs +++ b/crates/unixnotis-core/src/model/tests/types.rs @@ -1,5 +1,30 @@ use super::Urgency; -use zbus::zvariant::OwnedValue; +use zbus::zvariant::{serialized::Context, to_bytes, OwnedValue, Type, LE}; + +#[test] +fn urgency_wire_values_use_their_declared_one_byte_signature() { + let context = Context::new_dbus(LE, 0); + + for (urgency, discriminant) in [ + (Urgency::Low, 0_u8), + (Urgency::Normal, 1), + (Urgency::Critical, 2), + ] { + let encoded = to_bytes(context, &urgency).expect("serialize urgency"); + assert_eq!(Urgency::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: Urgency = encoded.deserialize().expect("deserialize urgency").0; + assert_eq!(decoded, urgency); + } +} + +#[test] +fn urgency_wire_values_reject_unknown_discriminants() { + let context = Context::new_dbus(LE, 0); + let encoded = to_bytes(context, &u8::MAX).expect("serialize unknown urgency byte"); + + assert!(encoded.deserialize::().is_err()); +} #[test] fn urgency_hint_maps_known_values_to_protocol_urgency() { diff --git a/crates/unixnotis-core/src/model/types.rs b/crates/unixnotis-core/src/model/types.rs index 7472e6d3f..56147069f 100644 --- a/crates/unixnotis-core/src/model/types.rs +++ b/crates/unixnotis-core/src/model/types.rs @@ -1,10 +1,12 @@ //! Core notification enum and action types shared across models use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::{OwnedValue, Type}; /// Notification urgency levels defined by the specification -#[derive(Debug, Copy, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +// The protocol exposes urgency as one byte, including inside notification views +#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum Urgency { Low = 0, From 9468f952b8c8a945f87ba0de8d51dd72986bdb4f Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:46:39 -0500 Subject: [PATCH 098/275] fix(popups): require renderer and bus health readiness Summary: require renderer and bus health readiness. Scope: popups. --- .../noticenterctl/src/doctor/checks/dbus.rs | 63 ++++++ .../src/doctor/checks/tests/dbus.rs | 23 +- crates/unixnotis-core/src/control/proxy.rs | 10 + crates/unixnotis-core/src/control/state.rs | 9 + .../src/child_process/process.rs | 11 +- .../src/child_process/tests/command.rs | 2 +- .../src/daemon/auth/authorization.rs | 25 ++- .../unixnotis-daemon/src/daemon/auth/mod.rs | 4 +- .../src/daemon/auth/policy.rs | 3 + .../src/daemon/auth/tests/authorization.rs | 17 +- .../unixnotis-daemon/src/daemon/bus/health.rs | 112 +++++++++- .../src/daemon/bus/tests/health.rs | 110 ++++++++++ .../src/daemon/control/mod.rs | 1 + .../src/daemon/control/popup.rs | 23 ++ .../src/daemon/control/query.rs | 11 + .../src/daemon/control/server.rs | 26 ++- .../src/daemon/notifications/server/flow.rs | 36 +++- .../daemon/notifications/server/tests/flow.rs | 8 +- .../src/daemon/state/model.rs | 18 +- .../src/daemon/state/status.rs | 78 ++++++- .../src/daemon/state/tests/status.rs | 53 ++++- crates/unixnotis-daemon/src/runtime/daemon.rs | 5 + crates/unixnotis-daemon/src/runtime/runner.rs | 26 +++ .../src/runtime/tests/dbus_lifecycle.rs | 200 ++++++++++++++++-- .../src/store/inhibitors/tests/model.rs | 2 +- crates/unixnotis-daemon/src/store/mod.rs | 5 +- crates/unixnotis-daemon/src/store/model.rs | 24 ++- .../src/store/notifications/insertion.rs | 23 +- .../store/notifications/tests/insertion.rs | 31 ++- crates/unixnotis-daemon/src/store/runtime.rs | 10 + crates/unixnotis-popups/src/app/command.rs | 21 +- crates/unixnotis-popups/src/dbus/commands.rs | 11 +- crates/unixnotis-popups/src/dbus/runtime.rs | 156 ++++++++++++-- .../src/dbus/tests/commands.rs | 20 +- .../src/dbus/tests/runtime.rs | 18 +- .../unixnotis-popups/src/dbus/tests/types.rs | 17 ++ crates/unixnotis-popups/src/dbus/types.rs | 2 + 37 files changed, 1106 insertions(+), 108 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/bus/tests/health.rs create mode 100644 crates/unixnotis-daemon/src/daemon/control/popup.rs diff --git a/crates/noticenterctl/src/doctor/checks/dbus.rs b/crates/noticenterctl/src/doctor/checks/dbus.rs index e91c21781..d19068e70 100644 --- a/crates/noticenterctl/src/doctor/checks/dbus.rs +++ b/crates/noticenterctl/src/doctor/checks/dbus.rs @@ -315,6 +315,69 @@ async fn inspect_control_proxy(connection: &Connection, checks: &mut Vec, checks: &mut Vec) { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_ui_health()).await { + Ok(Ok(health)) => { + let healthy = health.center_process_running + && health.center_ready + && health.popups_process_running + && health.popups_ready; + checks.push( + DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + if healthy { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if healthy { + "Center and popup clients are ready" + } else { + "One or more UI clients are not ready" + }, + ) + .details(format!( + "Center process: {}\nCenter D-Bus client: {}\nPopup process: {}\nPopup D-Bus client: {}\nPopup GTK runtime: {}", + readiness_label(health.center_process_running), + readiness_label(health.center_ready), + readiness_label(health.popups_process_running), + readiness_label(health.popups_ready), + readiness_label(health.popups_ready), + )) + .data("center_process_running", health.center_process_running) + .data("center_ready", health.center_ready) + .data("popups_process_running", health.popups_process_running) + .data("popups_ready", health.popups_ready), + ); + } + Ok(Err(error)) => checks.push( + DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth failed", + ) + .details(safe_doctor_text(&error.to_string())), + ), + Err(_) => checks.push(DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth timed out", + )), + } +} + +const fn readiness_label(ready: bool) -> &'static str { + if ready { + "ready" + } else { + "not ready" + } } pub(super) fn control_state_failure_check(error: &zbus::Error) -> DoctorCheck { diff --git a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs index a6e44ed39..1544f3331 100644 --- a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs +++ b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs @@ -6,7 +6,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::super::dbus::*; use crate::doctor::report::DoctorSeverity; -use unixnotis_core::{ControlState, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; +use unixnotis_core::{ + ControlState, UiHealth, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME, +}; use zbus::ConnectionBuilder; static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); @@ -87,6 +89,15 @@ impl TestControl { inhibitor_count: 2, }) } + + fn get_ui_health(&self) -> zbus::fdo::Result { + Ok(UiHealth { + center_process_running: true, + center_ready: true, + popups_process_running: true, + popups_ready: true, + }) + } } fn broker_socket() -> PathBuf { @@ -225,6 +236,16 @@ fn owned_control_service_runs_proxy_and_state_checks() { .details .as_deref() .is_some_and(|details| details.contains("History entries: 4"))); + let ui_health = result + .checks + .iter() + .find(|check| check.id == "dbus.ui-health") + .expect("UI health check"); + assert_eq!(ui_health.severity, DoctorSeverity::Pass); + assert!(ui_health + .details + .as_deref() + .is_some_and(|details| details.contains("Popup GTK runtime: ready"))); }); } diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 552cdd87b..6f067cc39 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -9,6 +9,7 @@ use crate::NotificationView; use super::{ CloseReason, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, PopupGateState, + UiHealth, }; #[proxy( @@ -19,8 +20,12 @@ use super::{ trait Control { /// Current daemon state fn get_state(&self) -> zbus::Result; + /// Readiness of the daemon-managed center and popup clients + fn get_ui_health(&self) -> zbus::Result; /// Active notifications intended for popups fn list_active(&self) -> zbus::Result>; + /// Active notifications whose persistent rule policy permits popup rendering + fn list_popup_candidates(&self) -> zbus::Result>; /// History notifications for the panel fn list_history(&self) -> zbus::Result>; /// Fetch one currently active notification by identifier @@ -62,6 +67,11 @@ trait Control { /// Clear panel readiness while the UI reconnects or shuts down #[zbus(no_autostart)] fn mark_panel_not_ready(&self) -> zbus::Result<()>; + /// Mark popup rendering ready after subscriptions, seed, and GTK initialization + fn mark_popups_ready(&self) -> zbus::Result<()>; + /// Clear popup readiness during orderly shutdown without activating the daemon + #[zbus(no_autostart)] + fn mark_popups_not_ready(&self) -> zbus::Result<()>; #[zbus(signal)] fn notification_added(&self, id: u32, show_popup: bool) -> zbus::Result<()>; diff --git a/crates/unixnotis-core/src/control/state.rs b/crates/unixnotis-core/src/control/state.rs index 9b573e565..11afb2166 100644 --- a/crates/unixnotis-core/src/control/state.rs +++ b/crates/unixnotis-core/src/control/state.rs @@ -23,5 +23,14 @@ pub struct PopupGateState { pub inhibited: bool, } +/// Process and handshake state for both daemon-managed user interfaces +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +pub struct UiHealth { + pub center_process_running: bool, + pub center_ready: bool, + pub popups_process_running: bool, + pub popups_ready: bool, +} + /// Tuple layout for inhibitor listings: identifier, reason, scope, and owner pub type InhibitorInfo = (u64, String, u32, String); diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index f5bf52e06..20253f60f 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -40,15 +40,8 @@ impl UiProcessKind { pub(super) fn mark_running(self, state: &DaemonState, running: bool) { match self { - Self::Popups => state.set_popups_running(running), - Self::Center => { - let _ = running; - // Center readiness is tied to live subscriptions - // A spawned process alone is not enough to mark it ready - // Spawned is not the same as subscribed and ready - // The center flips this to true once its control streams are active - state.set_panel_ready(false); - } + Self::Popups => state.set_popups_process_running(running), + Self::Center => state.set_center_process_running(running), } } diff --git a/crates/unixnotis-daemon/src/child_process/tests/command.rs b/crates/unixnotis-daemon/src/child_process/tests/command.rs index 9ae2d2388..cfc3b965f 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/command.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/command.rs @@ -19,7 +19,7 @@ async fn mark_running_updates_popup_health_and_resets_center_readiness() { let state = daemon_state_for_test(false).await; UiProcessKind::Popups.mark_running(&state, true); - assert!(state.popups_running()); + assert!(state.popups_process_running()); // Center process spawn is not readiness; readiness only flips after live subscriptions state.set_panel_ready(true); diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index ae89ac927..7ef543adc 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -11,7 +11,10 @@ use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; use super::executable_trust::is_trusted_control_executable_path; -use super::policy::{TRUSTED_CONTROL_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES}; +use super::policy::{ + TRUSTED_CONTROL_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES, + TRUSTED_POPUP_READINESS_EXECUTABLES, +}; #[cfg(not(target_os = "linux"))] use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] @@ -40,6 +43,20 @@ pub(in crate::daemon) async fn authorize_panel_readiness_call( .await } +pub(in crate::daemon) async fn authorize_popup_readiness_call( + state: &Arc, + header: &Header<'_>, + method: &'static str, +) -> zbus::fdo::Result<()> { + authorize_control_call_for_executables( + state, + header, + method, + &TRUSTED_POPUP_READINESS_EXECUTABLES, + ) + .await +} + async fn authorize_control_call_for_executables( state: &Arc, header: &Header<'_>, @@ -52,6 +69,12 @@ async fn authorize_control_call_for_executables( .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; let sender_name = sender.as_str().to_string(); + #[cfg(test)] + if state.is_trusted_test_control_sender(&sender_name) { + // The exact broker-assigned owner is injected only by private-bus integration tests + return Ok(()); + } + let bus_name = zbus::names::BusName::try_from(sender_name.as_str()) .map_err(|_error| zbus::fdo::Error::AccessDenied("invalid sender".to_string()))?; // One bus reply keeps all identity fields tied to the same sender snapshot diff --git a/crates/unixnotis-daemon/src/daemon/auth/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/mod.rs index bd6aaeb5e..9426ff683 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/mod.rs @@ -18,7 +18,9 @@ mod executable_trust; mod policy; mod process_identity; -pub(super) use authorization::{authorize_control_call, authorize_panel_readiness_call}; +pub(super) use authorization::{ + authorize_control_call, authorize_panel_readiness_call, authorize_popup_readiness_call, +}; #[cfg(test)] #[path = "tests/authorization.rs"] diff --git a/crates/unixnotis-daemon/src/daemon/auth/policy.rs b/crates/unixnotis-daemon/src/daemon/auth/policy.rs index 5c9ae6ec9..29a0df074 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/policy.rs @@ -14,6 +14,9 @@ pub(in crate::daemon) const TRUSTED_CONTROL_EXECUTABLES: [&str; 4] = [ // Only the center process may publish panel readiness state pub(in crate::daemon) const TRUSTED_PANEL_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-center"]; +// Only the popup renderer may publish its composite D-Bus and GTK readiness +pub(in crate::daemon) const TRUSTED_POPUP_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-popups"]; + // Small bounded caches avoid unbounded growth from repeated forged callers pub(in crate::daemon) const FINGERPRINT_CACHE_CAPACITY: usize = 32; pub(in crate::daemon) const TRUSTED_SNAPSHOT_CACHE_CAPACITY: usize = 32; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index ac2b66e1c..fef07ef82 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -3,8 +3,8 @@ use zbus::Message; #[cfg(target_os = "linux")] use super::authorization::required_linux_process_fd; use super::authorization::{ - authorize_control_call, authorize_panel_readiness_call, control_executable_error, - control_owner_uid_error, + authorize_control_call, authorize_panel_readiness_call, authorize_popup_readiness_call, + control_executable_error, control_owner_uid_error, }; #[cfg(target_os = "linux")] use super::credentials::CallerCredentials; @@ -46,6 +46,19 @@ async fn panel_readiness_authorization_rejects_header_without_bus_sender() { assert!(err.to_string().contains("missing sender")); } +#[tokio::test] +async fn popup_readiness_authorization_rejects_header_without_bus_sender() { + let state = daemon_state_for_test(false).await; + let message = message_without_bus_sender(); + let header = message.header(); + + let err = authorize_popup_readiness_call(&state, &header, "PopupsReady") + .await + .expect_err("missing sender must be rejected"); + + assert!(err.to_string().contains("missing sender")); +} + #[test] fn control_uid_error_is_none_only_for_matching_uid() { assert!(control_owner_uid_error(1000, 1000).is_none()); diff --git a/crates/unixnotis-daemon/src/daemon/bus/health.rs b/crates/unixnotis-daemon/src/daemon/bus/health.rs index 8a6c67489..2d86eb61d 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/health.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/health.rs @@ -2,7 +2,8 @@ use std::time::Duration; -use anyhow::{ensure, Context, Result}; +use anyhow::{anyhow, ensure, Context, Result}; +use tracing::warn; use unixnotis_core::{CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; use zbus::fdo::DBusProxy; use zbus::names::BusName; @@ -10,6 +11,34 @@ use zbus::Connection; const BUS_HEALTH_INTERVAL: Duration = Duration::from_secs(1); const BUS_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_CONSECUTIVE_TRANSIENT_FAILURES: u8 = 3; + +#[derive(Debug)] +enum BusProbeOutcome { + Healthy, + DefinitiveNameLoss { + name: &'static str, + owner: Option, + }, + DefinitiveTransportFailure(anyhow::Error), + TransientFailure(anyhow::Error), +} + +#[derive(Debug, Default)] +struct TransientFailureCounter { + consecutive: u8, +} + +impl TransientFailureCounter { + const fn observe_healthy(&mut self) { + self.consecutive = 0; + } + + const fn observe_failure(&mut self) -> bool { + self.consecutive = self.consecutive.saturating_add(1); + self.consecutive >= MAX_CONSECUTIVE_TRANSIENT_FAILURES + } +} pub async fn verify_name_owner( dbus: &DBusProxy<'_>, @@ -37,10 +66,87 @@ pub async fn monitor_required_bus_names(connection: Connection) -> Result<()> { .await .context("create D-Bus health proxy")?; + let expected = connection + .unique_name() + .context("session bus did not assign a unique name")? + .to_string(); + let mut transient_failures = TransientFailureCounter::default(); loop { tokio::time::sleep(BUS_HEALTH_INTERVAL).await; - for required in [NOTIFICATIONS_BUS_NAME, CONTROL_BUS_NAME] { - verify_name_owner(&dbus, &connection, required).await?; + match probe_required_names(&dbus, &expected).await { + BusProbeOutcome::Healthy => transient_failures.observe_healthy(), + BusProbeOutcome::DefinitiveNameLoss { name, owner } => { + anyhow::bail!("lost required D-Bus name {name}; owner={owner:?}"); + } + BusProbeOutcome::DefinitiveTransportFailure(error) => { + return Err(error).context("session bus connection is closed"); + } + BusProbeOutcome::TransientFailure(error) => { + let fatal = transient_failures.observe_failure(); + warn!( + ?error, + transient_failures = transient_failures.consecutive, + "transient D-Bus health probe failure" + ); + if fatal { + return Err(error).context("repeated D-Bus health failures"); + } + } } } } + +async fn probe_required_names(dbus: &DBusProxy<'_>, expected: &str) -> BusProbeOutcome { + for required in [NOTIFICATIONS_BUS_NAME, CONTROL_BUS_NAME] { + let bus_name = + BusName::try_from(required).expect("static required D-Bus name must be valid"); + let reply = tokio::time::timeout(BUS_PROBE_TIMEOUT, dbus.get_name_owner(bus_name)).await; + match reply { + Ok(Ok(owner)) if owner.as_str() == expected => {} + Ok(Ok(owner)) => { + return BusProbeOutcome::DefinitiveNameLoss { + name: required, + owner: Some(owner.to_string()), + }; + } + Ok(Err(error)) => return probe_error_outcome(required, error), + Err(error) => { + return BusProbeOutcome::TransientFailure(anyhow!( + "D-Bus owner probe timed out for {required}: {error}" + )); + } + } + } + BusProbeOutcome::Healthy +} + +fn probe_error_outcome(name: &'static str, error: zbus::fdo::Error) -> BusProbeOutcome { + if matches!(error, zbus::fdo::Error::NameHasNoOwner(_)) { + return BusProbeOutcome::DefinitiveNameLoss { name, owner: None }; + } + let message = anyhow!("D-Bus owner probe failed for {name}: {error}"); + if definitive_transport_failure(&error) { + BusProbeOutcome::DefinitiveTransportFailure(message) + } else { + BusProbeOutcome::TransientFailure(message) + } +} + +fn definitive_transport_failure(error: &zbus::fdo::Error) -> bool { + match error { + zbus::fdo::Error::Disconnected(_) => true, + zbus::fdo::Error::ZBus(zbus::Error::InputOutput(error)) => matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof + ), + _ => false, + } +} + +#[cfg(test)] +#[path = "tests/health.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs new file mode 100644 index 000000000..0e2772834 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs @@ -0,0 +1,110 @@ +use std::io; +use std::sync::Arc; + +use super::{ + definitive_transport_failure, probe_error_outcome, BusProbeOutcome, TransientFailureCounter, + MAX_CONSECUTIVE_TRANSIENT_FAILURES, +}; + +#[test] +fn one_transient_probe_timeout_keeps_monitor_policy_alive() { + let mut failures = TransientFailureCounter::default(); + + assert!(!failures.observe_failure()); + assert_eq!(failures.consecutive, 1); +} + +#[test] +fn two_transient_failures_then_success_reset_the_failure_counter() { + let mut failures = TransientFailureCounter::default(); + + assert!(!failures.observe_failure()); + assert!(!failures.observe_failure()); + failures.observe_healthy(); + + assert_eq!(failures.consecutive, 0); + assert!(!failures.observe_failure()); +} + +#[test] +fn repeated_transient_failures_become_fatal_at_the_configured_limit() { + let mut failures = TransientFailureCounter::default(); + + for _ in 1..MAX_CONSECUTIVE_TRANSIENT_FAILURES { + assert!(!failures.observe_failure()); + } + + assert!(failures.observe_failure()); +} + +#[test] +fn name_without_an_owner_is_a_definitive_loss() { + let outcome = BusProbeOutcome::DefinitiveNameLoss { + name: unixnotis_core::CONTROL_BUS_NAME, + owner: None, + }; + + assert!(matches!( + outcome, + BusProbeOutcome::DefinitiveNameLoss { owner: None, .. } + )); +} + +#[test] +fn different_owner_is_a_definitive_loss() { + let outcome = BusProbeOutcome::DefinitiveNameLoss { + name: unixnotis_core::NOTIFICATIONS_BUS_NAME, + owner: Some(":1.99".to_string()), + }; + + assert!(matches!( + outcome, + BusProbeOutcome::DefinitiveNameLoss { + owner: Some(owner), + .. + } if owner == ":1.99" + )); +} + +#[test] +fn concrete_closed_socket_errors_are_definitive_transport_failures() { + let disconnected = zbus::fdo::Error::Disconnected("closed test connection".to_string()); + let closed = zbus::fdo::Error::ZBus(zbus::Error::InputOutput(Arc::new(io::Error::new( + io::ErrorKind::BrokenPipe, + "closed test socket", + )))); + let interrupted = zbus::fdo::Error::ZBus(zbus::Error::InputOutput(Arc::new(io::Error::new( + io::ErrorKind::Interrupted, + "interrupted test operation", + )))); + + assert!(definitive_transport_failure(&disconnected)); + assert!(definitive_transport_failure(&closed)); + assert!(!definitive_transport_failure(&interrupted)); +} + +#[test] +fn probe_error_dispatch_distinguishes_name_loss_transport_loss_and_transient_failure() { + let name_loss = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::NameHasNoOwner("missing test owner".to_string()), + ); + let transport_loss = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::Disconnected("closed test connection".to_string()), + ); + let transient = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::NoReply("temporary test timeout".to_string()), + ); + + assert!(matches!( + name_loss, + BusProbeOutcome::DefinitiveNameLoss { owner: None, .. } + )); + assert!(matches!( + transport_loss, + BusProbeOutcome::DefinitiveTransportFailure(_) + )); + assert!(matches!(transient, BusProbeOutcome::TransientFailure(_))); +} diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 097462342..31156f146 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -3,6 +3,7 @@ mod action; mod inhibit; mod panel; +mod popup; mod query; mod reply; mod sanitize; diff --git a/crates/unixnotis-daemon/src/daemon/control/popup.rs b/crates/unixnotis-daemon/src/daemon/control/popup.rs new file mode 100644 index 000000000..3a46f0d78 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/popup.rs @@ -0,0 +1,23 @@ +//! Popup readiness authorization and owner-generation tracking + +use zbus::message::Header; + +use super::ControlServer; +use crate::daemon::auth; + +impl ControlServer { + pub(super) async fn set_popups_ready_state( + &self, + header: &Header<'_>, + method: &'static str, + ready: bool, + ) -> zbus::fdo::Result<()> { + // Executable verification runs before trusting the broker-supplied unique owner + auth::authorize_popup_readiness_call(&self.state, header, method).await?; + let owner = header + .sender() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; + self.state.set_popups_ready(owner.as_str(), ready); + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index dd3a23c9e..40b150837 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -37,6 +37,17 @@ impl ControlServer { Ok(store.list_history()) } + pub(super) async fn query_popup_candidates( + &self, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Rule-level suppression persists across reconnects and must be applied by the daemon + self.authorize_control_call(header, "ListPopupCandidates") + .await?; + let store = self.state.store.lock().await; + Ok(store.list_popup_candidates()) + } + pub(super) async fn query_active_notification( &self, id: u32, diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index c93bcaf91..66adf96ce 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use unixnotis_core::{ CloseReason, ControlState, InhibitorInfo, NotificationView, PanelDebugLevel, PanelRequest, - PopupGateState, + PopupGateState, UiHealth, }; use zbus::message::Header; use zbus::{interface, SignalContext}; @@ -73,6 +73,10 @@ impl ControlServer { self.query_state().await } + async fn get_ui_health(&self) -> zbus::fdo::Result { + Ok(self.state.ui_health()) + } + async fn list_active( &self, #[zbus(header)] header: Header<'_>, @@ -80,6 +84,13 @@ impl ControlServer { self.query_active(&header).await } + async fn list_popup_candidates( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_popup_candidates(&header).await + } + async fn list_history( &self, #[zbus(header)] header: Header<'_>, @@ -240,6 +251,19 @@ impl ControlServer { .await } + async fn mark_popups_ready(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + self.set_popups_ready_state(&header, "MarkPopupsReady", true) + .await + } + + async fn mark_popups_not_ready( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.set_popups_ready_state(&header, "MarkPopupsNotReady", false) + .await + } + #[zbus(signal)] pub(crate) async fn notification_added( ctx: &SignalContext<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index d3ac6bfe3..7f23e5612 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -237,7 +237,7 @@ impl NotificationServer { mode, outcome.notification.id, outcome.replaced, - outcome.show_popup, + outcome.popup_admission.should_show(), ) .await .map_err(to_fdo_error) @@ -253,15 +253,35 @@ impl NotificationServer { } self.schedule_and_play(&outcome, expiration); - self.emit_notification_change(&outcome).await?; + debug!( + id = outcome.notification.id, + decision = ?outcome.popup_admission, + "notification popup admission decided" + ); + if outcome.popup_admission.should_show() && self.state.should_warn_popups_unready() { + warn!( + id = outcome.notification.id, + "popup admitted while popup renderer is not ready" + ); + } + let id = outcome.notification.id; + if let Err(error) = self.emit_notification_change(&outcome).await { + warn!(?error, id, "notification committed but live fanout failed"); + // Snapshot invalidation gives connected clients one best-effort recovery route + let _ = self.state.publish_snapshot_invalidated().await; + } // Evicted items are announced so UIs can remove stale rows - self.handle_evicted(outcome.evicted).await?; - self.state - .publish_state_changed() - .await - .map_err(to_fdo_error)?; + if let Err(error) = self.handle_evicted(outcome.evicted).await { + warn!( + ?error, + id, "notification committed but eviction fanout failed" + ); + } + if let Err(error) = self.state.publish_state_changed().await { + warn!(?error, id, "notification committed but state fanout failed"); + } - Ok(outcome.notification.id) + Ok(id) } async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index bace96501..8f8fc313b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -17,7 +17,7 @@ use zbus::{Connection, MatchRule, Message, MessageStream}; use crate::daemon::{DaemonState, NotificationServer}; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; -use crate::store::{InsertOutcome, NotificationStore}; +use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; use crate::test_support::daemon_state_for_test; fn notification_with_id(id: u32) -> Arc { @@ -52,7 +52,11 @@ fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { InsertOutcome { notification: notification_with_id(id), replaced: false, - show_popup: !dropped, + popup_admission: if dropped { + PopupAdmission::Suppressed(PopupSuppressionReason::DropAllInhibitor) + } else { + PopupAdmission::Show + }, allow_sound: !dropped, evicted: Vec::new(), dropped, diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index ea5f864e4..a348db322 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -25,7 +25,12 @@ pub struct DaemonState { // Panel control should only succeed once the center has subscribed // This avoids accepting requests that no live listener can receive pub(in crate::daemon::state) panel_ready: AtomicBool, - pub(in crate::daemon::state) popups_running: AtomicBool, + pub(in crate::daemon::state) center_process_running: AtomicBool, + pub(in crate::daemon::state) popups_process_running: AtomicBool, + pub(in crate::daemon::state) popups_ready: AtomicBool, + pub(in crate::daemon::state) popups_unready_warning_emitted: AtomicBool, + // The unique D-Bus owner prevents an older popup generation from clearing a newer one + pub(in crate::daemon::state) popups_ready_owner: StdMutex>, // Scheduler is installed after state startup so close paths can cancel timers pub(in crate::daemon::state) scheduler: OnceLock, // Warn once if scheduler-backed operations happen before install @@ -47,6 +52,9 @@ pub struct DaemonState { pub(crate) desktop_identity_index: Arc>, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, + #[cfg(test)] + // Integration tests can authorize one broker-assigned sender without weakening production + pub(in crate::daemon::state) trusted_test_control_sender: StdMutex>, } impl DaemonState { @@ -74,7 +82,11 @@ impl DaemonState { sound, connection: connection.clone(), panel_ready: AtomicBool::new(false), - popups_running: AtomicBool::new(false), + center_process_running: AtomicBool::new(false), + popups_process_running: AtomicBool::new(false), + popups_ready: AtomicBool::new(false), + popups_unready_warning_emitted: AtomicBool::new(false), + popups_ready_owner: StdMutex::new(None), scheduler: OnceLock::new(), scheduler_missing_warned: AtomicBool::new(false), dnd_scheduler: OnceLock::new(), @@ -85,6 +97,8 @@ impl DaemonState { sender_metadata_cache: SenderMetadataCache::new(), desktop_identity_index, trial_mode, + #[cfg(test)] + trusted_test_control_sender: StdMutex::new(None), }) } diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 86122d46c..28afd8a96 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -1,5 +1,7 @@ use std::sync::atomic::Ordering; +use unixnotis_core::UiHealth; + use crate::daemon::notifications::{notification_signal_mode_for_sender, NotificationSignalMode}; use super::DaemonState; @@ -10,15 +12,70 @@ impl DaemonState { self.panel_ready.store(ready, Ordering::SeqCst); } - pub(crate) fn set_popups_running(&self, running: bool) { + pub(crate) fn set_center_process_running(&self, running: bool) { + self.center_process_running.store(running, Ordering::SeqCst); + // Every process generation must complete its own subscription handshake + self.set_panel_ready(false); + } + + pub(crate) fn set_popups_process_running(&self, running: bool) { // Popup health is tracked for supervision and diagnostics - self.popups_running.store(running, Ordering::SeqCst); + self.popups_process_running.store(running, Ordering::SeqCst); + if !running { + self.clear_popups_ready(); + } + } + + pub(crate) fn set_popups_ready(&self, owner: &str, ready: bool) { + let mut current_owner = self + .popups_ready_owner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ready { + *current_owner = Some(owner.to_string()); + self.popups_ready.store(true, Ordering::SeqCst); + self.popups_unready_warning_emitted + .store(false, Ordering::SeqCst); + } else if current_owner.as_deref() == Some(owner) { + *current_owner = None; + self.popups_ready.store(false, Ordering::SeqCst); + } + } + + fn clear_popups_ready(&self) { + let mut current_owner = self + .popups_ready_owner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *current_owner = None; + self.popups_ready.store(false, Ordering::SeqCst); } pub(crate) fn panel_ready(&self) -> bool { self.panel_ready.load(Ordering::SeqCst) } + pub(crate) fn popups_ready(&self) -> bool { + self.popups_ready.load(Ordering::SeqCst) + } + + pub(crate) fn should_warn_popups_unready(&self) -> bool { + !self.popups_ready() + && self + .popups_unready_warning_emitted + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + } + + pub(crate) fn ui_health(&self) -> UiHealth { + UiHealth { + center_process_running: self.center_process_running.load(Ordering::SeqCst), + center_ready: self.panel_ready(), + popups_process_running: self.popups_process_running.load(Ordering::SeqCst), + popups_ready: self.popups_ready(), + } + } + pub(crate) fn notification_signal_mode( &self, sender_name: Option<&str>, @@ -32,4 +89,21 @@ impl DaemonState { pub(crate) const fn trial_mode(&self) -> bool { self.trial_mode } + + #[cfg(test)] + pub(crate) fn set_trusted_test_control_sender(&self, sender: Option) { + *self + .trusted_test_control_sender + .lock() + .expect("trusted test sender lock poisoned") = sender; + } + + #[cfg(test)] + pub(crate) fn is_trusted_test_control_sender(&self, sender: &str) -> bool { + self.trusted_test_control_sender + .lock() + .expect("trusted test sender lock poisoned") + .as_deref() + == Some(sender) + } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs index b11a2998e..de089af93 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -4,9 +4,8 @@ use std::sync::atomic::Ordering; use super::super::DaemonState; impl DaemonState { - pub(crate) fn popups_running(&self) -> bool { - // Test assertions observe the same sequentially consistent flag used by supervision - self.popups_running.load(Ordering::SeqCst) + pub(crate) fn popups_process_running(&self) -> bool { + self.popups_process_running.load(Ordering::SeqCst) } } @@ -16,14 +15,22 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { assert!(state.trial_mode()); assert!(!state.panel_ready()); - assert!(!state.popups_running()); + assert!(!state.popups_process_running()); // These atomics gate user-visible command handling, so getters must reflect writes exactly + state.set_center_process_running(true); state.set_panel_ready(true); - state.set_popups_running(true); + state.set_popups_process_running(true); assert!(state.panel_ready()); - assert!(state.popups_running()); + assert!(state.popups_process_running()); + state.set_popups_ready(":1.10", true); + + let health = state.ui_health(); + assert!(health.center_process_running); + assert!(health.center_ready); + assert!(health.popups_process_running); + assert!(health.popups_ready); } #[tokio::test] @@ -31,12 +38,40 @@ async fn daemon_state_boolean_flags_can_return_to_false() { let state = daemon_state_for_test(true).await; state.set_panel_ready(true); - state.set_popups_running(true); + state.set_center_process_running(true); + state.set_popups_process_running(true); state.set_panel_ready(false); - state.set_popups_running(false); + state.set_center_process_running(false); + state.set_popups_process_running(false); assert!(!state.panel_ready()); - assert!(!state.popups_running()); + assert!(!state.popups_process_running()); +} + +#[tokio::test] +async fn popup_readiness_can_only_be_cleared_by_its_owner_generation() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.set_popups_ready(":1.11", false); + assert!(state.popups_ready()); + + state.set_popups_ready(":1.10", false); + assert!(!state.popups_ready()); +} + +#[tokio::test] +async fn stopped_popup_process_clears_composite_readiness() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.set_popups_process_running(false); + + let health = state.ui_health(); + assert!(!health.popups_process_running); + assert!(!health.popups_ready); } #[tokio::test] diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 24b5c3cc6..fd659f6d2 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -32,6 +32,7 @@ pub(super) async fn run_daemon( dbus_proxy: &DBusProxy<'_>, desktop_identity_index: Arc>, watched_desktop_directories: Vec, + trusted_test_control_sender: Option, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); @@ -42,6 +43,10 @@ pub(super) async fn run_daemon( args.trial, desktop_identity_index, ); + #[cfg(test)] + state.set_trusted_test_control_sender(trusted_test_control_sender); + #[cfg(not(test))] + let _ = trusted_test_control_sender; if let Err(error) = spawn_desktop_index_refresh( state.desktop_identity_index.clone(), watched_desktop_directories, diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 79b818d13..6f6b59f83 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -22,6 +22,31 @@ pub async fn run(args: &Args, config: Config) -> Result<()> { } async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> Result<()> { + Box::pin(run_with_builder_inner(args, config, builder, None)).await +} + +#[cfg(test)] +async fn run_with_builder_for_test( + args: &Args, + config: Config, + builder: Builder<'_>, + trusted_control_sender: String, +) -> Result<()> { + Box::pin(run_with_builder_inner( + args, + config, + builder, + Some(trusted_control_sender), + )) + .await +} + +async fn run_with_builder_inner( + args: &Args, + config: Config, + builder: Builder<'_>, + trusted_test_control_sender: Option, +) -> Result<()> { let connection = builder .max_queued(DAEMON_DBUS_QUEUE_CAPACITY) .build() @@ -55,6 +80,7 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> &dbus_proxy, desktop_identity_index, watched_directories, + trusted_test_control_sender, ) .await; let restore_result = trial_cleanup::finish_trial( diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs index 7ccc538f7..18420929f 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -1,17 +1,17 @@ use std::collections::HashMap; -use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use clap::Parser; +use futures_util::StreamExt; use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; use zbus::fdo::DBusProxy; use zbus::names::BusName; use zbus::{Connection, ConnectionBuilder}; -use super::super::run_with_builder; +use super::super::{run_with_builder, run_with_builder_for_test}; use crate::cli::Args; use unixnotis_core::Config; @@ -26,35 +26,57 @@ struct PrivateBroker { impl PrivateBroker { fn start() -> Self { let socket = broker_socket(); - let listen_address = format!("unix:path={}", socket.display()); - let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") - .expect("find trusted dbus-daemon"); - let mut child = Command::new(daemon) - .args([ - "--session", - "--nofork", - "--nopidfile", - "--print-address=1", - &format!("--address={listen_address}"), - ]) + let address = format!("unix:path={}", socket.display()); + let socket_activate = + unixnotis_core::util::trusted_system_program_path("systemd-socket-activate") + .expect("find trusted systemd-socket-activate"); + let broker = unixnotis_core::util::trusted_system_program_path("dbus-broker-launch") + .expect("find trusted dbus-broker-launch"); + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .expect("private dbus-broker tests require XDG_RUNTIME_DIR"); + let mut command = Command::new(socket_activate); + command + .arg("--now") + .arg("--setenv") + .arg(format!("XDG_RUNTIME_DIR={runtime_dir}")); + if let Ok(bus_address) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { + // The launcher uses the existing user bus only for systemd activation control + command + .arg("--setenv") + .arg(format!("DBUS_SESSION_BUS_ADDRESS={bus_address}")); + } + let mut child = command + .arg("--listen") + .arg(&socket) + .arg("--fdname=dbus.socket") + .arg(broker) + .args(["--scope", "user"]) .stdin(Stdio::null()) - .stdout(Stdio::piped()) + .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .expect("start private D-Bus broker"); - let stdout = child.stdout.take().expect("capture broker address"); - let mut address = String::new(); - BufReader::new(stdout) - .read_line(&mut address) - .expect("read broker address"); + .expect("start private dbus-broker"); + + // Socket activation creates the isolated listener before clients are allowed to connect + let deadline = Instant::now() + Duration::from_secs(2); + while !socket.exists() && Instant::now() < deadline { + assert!( + child + .try_wait() + .expect("query private broker process") + .is_none(), + "private dbus-broker exited before creating its socket" + ); + std::thread::sleep(Duration::from_millis(10)); + } assert!( - address.trim().starts_with(&listen_address), - "broker must listen on the isolated test socket" + socket.exists(), + "private dbus-broker must create its isolated socket" ); Self { child, socket, - address: address.trim().to_string(), + address, } } @@ -110,6 +132,30 @@ fn spawn_daemon(address: String, run_seconds: u64) -> tokio::task::JoinHandle tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder_for_test( + &args, + Config::default(), + builder, + trusted_sender, + )) + .await + }) +} + async fn owner(dbus: &DBusProxy<'_>, name: &'static str) -> Option { let name = BusName::try_from(name).expect("static bus name"); dbus.get_name_owner(name) @@ -230,6 +276,73 @@ async fn startup_publishes_both_names_with_one_ready_owner() { .expect("bounded daemon run"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn strict_broker_accepts_full_notification_view_after_added_signal() { + let broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + let trusted_sender = client + .unique_name() + .expect("private broker assigns a unique client name") + .to_string(); + let daemon = spawn_daemon_with_trusted_sender(broker.address.clone(), 3, trusted_sender); + let owners_before = wait_for_both_owners(&client).await; + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + let mut added = control + .receive_notification_added() + .await + .expect("subscribe before sending notification"); + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + + let id = notifications + .notify( + "Strict broker wire test", + 0, + "", + "Complete notification view", + "The strict broker must accept the nested enum payload", + Vec::new(), + HashMap::new(), + 2_000, + ) + .await + .expect("Notify should return an assigned id"); + let signal = tokio::time::timeout(Duration::from_secs(2), added.next()) + .await + .expect("NotificationAdded must arrive promptly") + .expect("NotificationAdded stream must remain open"); + let signal_args = signal.args().expect("decode NotificationAdded arguments"); + assert_eq!(*signal_args.id(), id); + + // This is the exact authorized pull that previously made dbus-broker reject the body + let views = control + .get_active_notification(id) + .await + .expect("GetActiveNotification must return a valid D-Bus body"); + assert_eq!(views.len(), 1); + assert_eq!(views[0].id, id); + assert_eq!(views[0].summary, "Complete notification view"); + let popup_candidates = control + .list_popup_candidates() + .await + .expect("ListPopupCandidates must return a valid D-Bus body"); + assert_eq!(popup_candidates.len(), 1); + assert_eq!(popup_candidates[0].id, id); + assert!( + !daemon.is_finished(), + "serializing NotificationView must not disconnect the daemon" + ); + assert_eq!(wait_for_both_owners(&client).await, owners_before); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn broker_loss_makes_the_daemon_exit_with_failure() { let mut broker = PrivateBroker::start(); @@ -245,6 +358,47 @@ async fn broker_loss_makes_the_daemon_exit_with_failure() { assert!(result.is_err(), "broker loss must return a daemon failure"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notification_during_health_probing_keeps_daemon_generation_alive() { + let broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + let daemon = spawn_daemon(broker.address.clone(), 4); + let owners_before = wait_for_both_owners(&client).await; + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + + // Cross the first one-second health interval before committing the notification + tokio::time::sleep(Duration::from_millis(1_100)).await; + let id = notifications + .notify( + "Health overlap test", + 0, + "", + "Notification during probe", + "The daemon generation must remain alive", + Vec::new(), + HashMap::new(), + 3_000, + ) + .await + .expect("Notify should return an assigned id"); + assert_ne!(id, 0); + + tokio::time::sleep(Duration::from_millis(1_100)).await; + assert!( + !daemon.is_finished(), + "one healthy probe interval must not retire the daemon generation" + ); + let owners_after = wait_for_both_owners(&client).await; + assert_eq!(owners_after, owners_before); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn competing_notification_owner_prevents_control_publication() { let broker = PrivateBroker::start(); diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs index f28551785..d5f3b64d7 100644 --- a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs @@ -12,7 +12,7 @@ fn inhibit_no_popups_suppresses_show_popup() { let outcome = store.insert(make_notification("inhibited"), 0); assert!(!outcome.dropped); - assert!(!outcome.show_popup); + assert!(!outcome.popup_admission.should_show()); assert!(!outcome.allow_sound); assert_eq!(store.list_active().len(), 1); } diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index 0269cb104..40486a59f 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -6,7 +6,10 @@ mod model; mod notifications; mod runtime; -pub use model::{DismissOutcome, DndWrite, InsertOutcome, NotificationStore}; +pub use model::{ + DismissOutcome, DndWrite, InsertOutcome, NotificationStore, PopupAdmission, + PopupSuppressionReason, +}; #[cfg(test)] mod test_support; diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index 74c478365..a34760763 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -43,8 +43,8 @@ pub struct InsertOutcome { pub notification: Arc, // True when insertion replaced an existing id pub replaced: bool, - // Whether popup rendering is allowed for this payload - pub show_popup: bool, + // Structured popup policy keeps suppression causes available to diagnostics + pub popup_admission: PopupAdmission, // Whether sound playback is allowed for this payload pub allow_sound: bool, // Active ids evicted because max_active was exceeded @@ -53,6 +53,26 @@ pub struct InsertOutcome { pub dropped: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PopupAdmission { + Show, + Suppressed(PopupSuppressionReason), +} + +impl PopupAdmission { + pub const fn should_show(self) -> bool { + matches!(self, Self::Show) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PopupSuppressionReason { + Rule, + Dnd, + Inhibitor, + DropAllInhibitor, +} + pub struct DndWrite { // True when the in-memory DND value changed pub(crate) changed: bool, diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index fbf1bfbde..bad3265f6 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -5,7 +5,7 @@ use unixnotis_core::{ Notification, Urgency, }; -use crate::store::{InsertOutcome, NotificationStore}; +use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; // Hard ceiling for concurrently active notifications to protect panel/popups stability const ACTIVE_HARD_CAP: usize = 12; @@ -20,7 +20,9 @@ impl NotificationStore { notification.id = assigned_id; let notification = Arc::new(notification); return InsertOutcome { - show_popup: false, + popup_admission: PopupAdmission::Suppressed( + PopupSuppressionReason::DropAllInhibitor, + ), allow_sound: false, notification, replaced: false, @@ -58,7 +60,7 @@ impl NotificationStore { let evicted = self.enforce_active_limit(); InsertOutcome { - show_popup: self.should_show_popup(¬ification), + popup_admission: self.popup_admission(¬ification), allow_sound: self.should_play_sound(¬ification), notification, replaced, @@ -124,13 +126,16 @@ impl NotificationStore { self.history.evict_to_limit(self.config.history.max_entries); } - fn should_show_popup(&self, notification: &Notification) -> bool { + fn popup_admission(&self, notification: &Notification) -> PopupAdmission { // Rule-level popup suppression is highest priority if notification.suppress_popup { - return false; + return PopupAdmission::Suppressed(PopupSuppressionReason::Rule); + } + if self.inhibited { + return PopupAdmission::Suppressed(PopupSuppressionReason::Inhibitor); } // Shared gate keeps daemon admission aligned with popup-side cleanup - popup_allowed_by_state( + if popup_allowed_by_state( notification.urgency as u8, &ControlState { dnd_enabled: self.dnd_enabled, @@ -139,7 +144,11 @@ impl NotificationStore { inhibited: self.inhibited, inhibitor_count: self.inhibitor_count, }, - ) + ) { + PopupAdmission::Show + } else { + PopupAdmission::Suppressed(PopupSuppressionReason::Dnd) + } } fn should_play_sound(&self, notification: &Notification) -> bool { diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs index 2fa5b4b8d..1c28fee44 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs @@ -66,7 +66,7 @@ fn insert_outcome_reflects_popup_and_sound_policy() { config.general.dnd_default = false; let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); let allowed = store.insert(make_notification("normal"), 0); - assert!(allowed.show_popup); + assert!(allowed.popup_admission.should_show()); assert!(allowed.allow_sound); let dnd_state_dir = make_temp_state_dir("insert-outcome-dnd"); @@ -74,13 +74,13 @@ fn insert_outcome_reflects_popup_and_sound_policy() { dnd_config.general.dnd_default = true; let mut dnd_store = NotificationStore::new_with_state_dir(dnd_config, dnd_state_dir.clone()); let normal = dnd_store.insert(make_notification("normal dnd"), 0); - assert!(!normal.show_popup); + assert!(!normal.popup_admission.should_show()); assert!(!normal.allow_sound); let mut critical = make_notification("critical dnd"); critical.urgency = unixnotis_core::Urgency::Critical; let critical = dnd_store.insert(critical, 0); - assert!(critical.show_popup); + assert!(critical.popup_admission.should_show()); assert!(critical.allow_sound); let mut silent = make_notification("silent"); @@ -91,3 +91,28 @@ fn insert_outcome_reflects_popup_and_sound_policy() { cleanup_temp_dir(&state_dir); cleanup_temp_dir(&dnd_state_dir); } + +#[test] +fn popup_candidates_exclude_notifications_suppressed_by_rules() { + let mut store = make_store_with_limits(4, 4); + let mut notification = make_notification("rule-suppressed"); + notification.suppress_popup = true; + + let outcome = store.insert(notification, 0); + + assert!(!outcome.popup_admission.should_show()); + assert_eq!(store.list_active().len(), 1); + assert!(store.list_popup_candidates().is_empty()); +} + +#[test] +fn popup_candidates_include_notifications_allowed_by_rules() { + let mut store = make_store_with_limits(4, 4); + let outcome = store.insert(make_notification("popup-allowed"), 0); + + let candidates = store.list_popup_candidates(); + + assert!(outcome.popup_admission.should_show()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].summary, "popup-allowed"); +} diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 66ba5d4c1..fee1c7f9c 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -113,6 +113,16 @@ impl NotificationStore { self.history.list_views() } + pub fn list_popup_candidates(&self) -> Vec { + // Newest-first ordering matches ListActive while excluding persistent no-popup rules + self.active + .values() + .rev() + .filter(|notification| !notification.suppress_popup) + .map(|notification| notification.to_list_view()) + .collect() + } + pub fn active_notification_view(&self, id: u32) -> Option { // Active rows use the richer popup-oriented view because add/update signals // are consumed by trusted UIs that may need current image payloads diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index 78a9771b8..a4488a321 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -72,7 +72,24 @@ pub fn run(args: Args) -> Result<()> { // Bound the queue so a stalled UI cannot grow memory forever let (event_tx, event_rx) = async_channel::bounded(UI_EVENT_QUEUE_CAPACITY); - let command_tx = dbus::start_dbus_runtime(event_tx.clone()); + let dbus_runtime = dbus::start_dbus_runtime(event_tx.clone()); + let command_tx = dbus_runtime.command_sender(); + let shutdown_tx = command_tx.clone(); + app.connect_shutdown(move |_| { + let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); + if shutdown_tx + .blocking_send(dbus::UiCommand::Shutdown(acknowledgement_tx)) + .is_err() + { + return; + } + if acknowledgement_rx + .recv_timeout(unixnotis_core::INTERNAL_DBUS_CALL_TIMEOUT) + .is_err() + { + warn!("popup readiness cleanup timed out during GTK shutdown"); + } + }); let reload_gate = Arc::new(ReloadGate::new()); // Timer state keeps only one flush source alive at a time let reload_timer = Arc::new(Mutex::new(None::)); @@ -89,6 +106,8 @@ pub fn run(args: Args) -> Result<()> { command_tx, css_manager, ))); + // Composite readiness now means GTK state exists as well as D-Bus seeding succeeding + dbus_runtime.mark_gtk_ready(); let ui_clone = ui; let reload_gate_loop = Arc::clone(&reload_gate); diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index d74052d43..960823395 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -13,14 +13,21 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu UiCommand::InvokeAction { id, action_key } => { timed_dbus_call(proxy.invoke_action(id, &action_key)).await } + UiCommand::Shutdown(_) => Ok(()), } } -pub fn drain_offline_commands(command_rx: &mut mpsc::Receiver) { - while command_rx.try_recv().is_ok() { +pub fn drain_offline_commands( + command_rx: &mut mpsc::Receiver, +) -> Option> { + while let Ok(command) = command_rx.try_recv() { + if let UiCommand::Shutdown(acknowledgement) = command { + return Some(acknowledgement); + } // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); } + None } #[cfg(test)] diff --git a/crates/unixnotis-popups/src/dbus/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime.rs index 8266e9485..2e7d72f9c 100644 --- a/crates/unixnotis-popups/src/dbus/runtime.rs +++ b/crates/unixnotis-popups/src/dbus/runtime.rs @@ -4,7 +4,7 @@ use std::thread; use std::time::Duration; use futures_util::StreamExt; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tracing::{info, warn}; use unixnotis_core::{ log_session_bus_identity, timed_dbus_call, ControlProxy, CONTROL_BUS_NAME, @@ -25,6 +25,22 @@ use super::types::{UiCommand, UiEvent}; // Bound UI commands to avoid unbounded memory growth under a stuck UI event loop const UI_COMMAND_QUEUE_CAPACITY: usize = 64; +pub struct PopupRuntime { + command_tx: mpsc::Sender, + gtk_ready_tx: watch::Sender, +} + +impl PopupRuntime { + pub fn command_sender(&self) -> mpsc::Sender { + self.command_tx.clone() + } + + pub fn mark_gtk_ready(&self) { + // The D-Bus generation cannot publish readiness before UiState construction finishes + let _ = self.gtk_ready_tx.send(true); + } +} + struct ControlProxySeedSource<'proxy, 'conn> { proxy: &'proxy ControlProxy<'conn>, } @@ -39,28 +55,33 @@ impl PopupSeedSource for ControlProxySeedSource<'_, '_> { return SeedSnapshot::from_fetch_results(Err(error), Ok(Vec::new())); } }; - let active = timed_dbus_call(self.proxy.list_active()).await; + let active = timed_dbus_call(self.proxy.list_popup_candidates()).await; let state = Ok(state); SeedSnapshot::from_fetch_results(state, active) } } -pub fn start_dbus_runtime(sender: async_channel::Sender) -> mpsc::Sender { +pub fn start_dbus_runtime(sender: async_channel::Sender) -> PopupRuntime { let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); - spawn_runtime_thread(sender, command_rx); - command_tx + let (gtk_ready_tx, gtk_ready_rx) = watch::channel(false); + spawn_runtime_thread(sender, command_rx, gtk_ready_rx); + PopupRuntime { + command_tx, + gtk_ready_tx, + } } fn spawn_runtime_thread( sender: async_channel::Sender, command_rx: mpsc::Receiver, + gtk_ready_rx: watch::Receiver, ) { thread::spawn(move || { // Dedicated runtime keeps async D-Bus work off the GTK main thread let Some(runtime) = build_runtime() else { return; }; - runtime.block_on(run_dbus_loop(sender, command_rx)); + runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx)); }); } @@ -80,6 +101,7 @@ fn build_runtime() -> Option { async fn run_dbus_loop( sender: async_channel::Sender, mut command_rx: mpsc::Receiver, + mut gtk_ready_rx: watch::Receiver, ) { let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); @@ -94,6 +116,7 @@ async fn run_dbus_loop( &mut command_rx, &mut subscribe_backoff, &mut subscribe_log, + &mut gtk_ready_rx, ) .await else { @@ -134,12 +157,16 @@ async fn run_connection_once( command_rx: &mut mpsc::Receiver, subscribe_backoff: &mut Backoff, subscribe_log: &mut RetryLog, + gtk_ready_rx: &mut watch::Receiver, ) -> Option { let proxy = match ControlProxy::new(connection).await { Ok(proxy) => proxy, Err(err) => { subscribe_log.warn_or_debug(&err, "control interface unavailable, retrying"); - drain_offline_commands(command_rx); + if let Some(acknowledgement) = drain_offline_commands(command_rx) { + let _ = acknowledgement.send(()); + return None; + } return Some(subscribe_backoff.next_sleep()); } }; @@ -168,11 +195,14 @@ async fn run_connection_once( match run_owner_generation( &proxy, &owner, - &mut owner_changes, - sender, - command_rx, - subscribe_backoff, - subscribe_log, + PopupGenerationContext::new( + &mut owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + ), ) .await { @@ -199,6 +229,35 @@ enum GenerationExit { Retry, } +struct PopupGenerationContext<'context, 'stream> { + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, +} + +impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { + const fn new( + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, + ) -> Self { + Self { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + } + } +} + async fn wait_for_control_owner( dbus: &DBusProxy<'_>, owner_changes: &mut OwnerChangedStream<'_>, @@ -218,14 +277,21 @@ async fn wait_for_control_owner( // No owner is a quiet disconnected state until the broker announces one let _ = sender.send(UiEvent::Disconnected).await; - drain_offline_commands(command_rx); + if let Some(acknowledgement) = drain_offline_commands(command_rx) { + let _ = acknowledgement.send(()); + return OwnerWait::Shutdown; + } loop { tokio::select! { command = command_rx.recv() => { - if command.is_none() { - return OwnerWait::Shutdown; + match command { + Some(UiCommand::Shutdown(acknowledgement)) => { + let _ = acknowledgement.send(()); + return OwnerWait::Shutdown; + } + Some(_) => warn!("dropping popup command while control service has no owner"), + None => return OwnerWait::Shutdown, } - warn!("dropping popup command while control service has no owner"); } update = owner_changes.next() => { match update { @@ -241,12 +307,16 @@ async fn wait_for_control_owner( async fn run_owner_generation( proxy: &ControlProxy<'_>, owner: &str, - owner_changes: &mut OwnerChangedStream<'_>, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, - subscribe_backoff: &mut Backoff, - subscribe_log: &mut RetryLog, + context: PopupGenerationContext<'_, '_>, ) -> GenerationExit { + let PopupGenerationContext { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + } = context; // Popups stay on the shared notification stream, but the trimmed payload keeps // each message smaller now that unused flags were removed from NotificationView let mut added_stream = match proxy.receive_notification_added().await { @@ -290,18 +360,35 @@ async fn run_owner_generation( subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); return GenerationExit::Retry; } + if !wait_for_gtk_runtime(gtk_ready_rx).await { + warn!("popup GTK runtime did not become ready"); + return GenerationExit::Retry; + } + if let Err(error) = timed_dbus_call(proxy.mark_popups_ready()).await { + subscribe_log.warn_or_debug(&error, "failed to mark popup renderer ready"); + return GenerationExit::Retry; + } subscribe_backoff.reset(); subscribe_log.reset(); info!(owner, "UnixNotis control service ready"); + let mut shutdown_acknowledgement = None; let exit = loop { tokio::select! { command = command_rx.recv() => { let Some(command) = command else { break GenerationExit::Shutdown; }; - if let Err(err) = handle_command(proxy, command).await { - warn!(?err, "control command failed"); + match command { + UiCommand::Shutdown(acknowledgement) => { + shutdown_acknowledgement = Some(acknowledgement); + break GenerationExit::Shutdown; + } + command => { + if let Err(err) = handle_command(proxy, command).await { + warn!(?err, "control command failed"); + } + } } } signal = added_stream.next() => { @@ -393,9 +480,32 @@ async fn run_owner_generation( } }; + // No-autostart prevents orderly cleanup from reviving a stopped daemon + let _ = timed_dbus_call(proxy.mark_popups_not_ready()).await; + if let Some(acknowledgement) = shutdown_acknowledgement { + let _ = acknowledgement.send(()); + } exit } +async fn wait_for_gtk_runtime(gtk_ready_rx: &mut watch::Receiver) -> bool { + if *gtk_ready_rx.borrow() { + return true; + } + tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, async { + loop { + if gtk_ready_rx.changed().await.is_err() { + return *gtk_ready_rx.borrow(); + } + if *gtk_ready_rx.borrow() { + return true; + } + } + }) + .await + .unwrap_or(false) +} + async fn push_active_notification_event( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index 02ec43ca9..c813f84ec 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -14,7 +14,7 @@ fn drain_offline_commands_removes_all_queued_commands() { }) .expect("action command should queue"); - drain_offline_commands(&mut rx); + assert!(drain_offline_commands(&mut rx).is_none()); // Stale commands are intentionally discarded while popups are offline assert!(rx.try_recv().is_err()); @@ -24,7 +24,23 @@ fn drain_offline_commands_removes_all_queued_commands() { fn drain_offline_commands_accepts_empty_queue() { let (_tx, mut rx) = mpsc::channel(1); - drain_offline_commands(&mut rx); + assert!(drain_offline_commands(&mut rx).is_none()); assert!(rx.try_recv().is_err()); } + +#[test] +fn drain_offline_commands_returns_shutdown_acknowledgement() { + let (tx, mut rx) = mpsc::channel(1); + let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); + tx.try_send(UiCommand::Shutdown(acknowledgement_tx)) + .expect("shutdown command should queue"); + + let acknowledgement = + drain_offline_commands(&mut rx).expect("shutdown acknowledgement should be preserved"); + acknowledgement.send(()).expect("acknowledge shutdown"); + + acknowledgement_rx + .recv() + .expect("receive shutdown acknowledgement"); +} diff --git a/crates/unixnotis-popups/src/dbus/tests/runtime.rs b/crates/unixnotis-popups/src/dbus/tests/runtime.rs index da0a67190..83a326944 100644 --- a/crates/unixnotis-popups/src/dbus/tests/runtime.rs +++ b/crates/unixnotis-popups/src/dbus/tests/runtime.rs @@ -1,7 +1,23 @@ -use super::{build_runtime, UI_COMMAND_QUEUE_CAPACITY}; +use super::{build_runtime, wait_for_gtk_runtime, UI_COMMAND_QUEUE_CAPACITY}; #[test] fn popup_runtime_builds_with_a_bounded_command_queue() { assert!(build_runtime().is_some()); assert_eq!(UI_COMMAND_QUEUE_CAPACITY, 64); } + +#[tokio::test] +async fn gtk_readiness_wait_completes_after_ui_state_is_published() { + let (ready_tx, mut ready_rx) = tokio::sync::watch::channel(false); + ready_tx.send(true).expect("publish GTK readiness"); + + assert!(wait_for_gtk_runtime(&mut ready_rx).await); +} + +#[tokio::test] +async fn gtk_readiness_wait_rejects_a_closed_unready_startup_channel() { + let (ready_tx, mut ready_rx) = tokio::sync::watch::channel(false); + drop(ready_tx); + + assert!(!wait_for_gtk_runtime(&mut ready_rx).await); +} diff --git a/crates/unixnotis-popups/src/dbus/tests/types.rs b/crates/unixnotis-popups/src/dbus/tests/types.rs index b7a0d8544..b40e82f94 100644 --- a/crates/unixnotis-popups/src/dbus/tests/types.rs +++ b/crates/unixnotis-popups/src/dbus/tests/types.rs @@ -5,6 +5,23 @@ fn dismiss_command_preserves_notification_id() { assert!(matches!(UiCommand::Dismiss(17), UiCommand::Dismiss(17))); } +#[test] +fn shutdown_command_preserves_the_cleanup_acknowledgement() { + let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); + let command = UiCommand::Shutdown(acknowledgement_tx); + + if let UiCommand::Shutdown(acknowledgement) = command { + acknowledgement + .send(()) + .expect("send shutdown acknowledgement"); + } else { + panic!("shutdown command variant should remain intact"); + } + acknowledgement_rx + .recv() + .expect("receive shutdown acknowledgement"); +} + #[test] fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index eaeb59413..f37c73c78 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -26,6 +26,8 @@ pub enum UiEvent { pub enum UiCommand { Dismiss(u32), InvokeAction { id: u32, action_key: String }, + // A synchronous acknowledgement lets GTK wait for MarkPopupsNotReady before process exit + Shutdown(std::sync::mpsc::SyncSender<()>), } #[cfg(test)] From ead797348b0bf843c1ec3f6cc1f535a8ede539a7 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:46:46 -0500 Subject: [PATCH 099/275] test(installer): use neutral home fixtures Summary: use neutral home fixtures. Scope: installer. --- .../src/actions/tests/installation_channel.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs index ab0a7edcf..88e94da48 100644 --- a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs +++ b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs @@ -4,15 +4,15 @@ use super::{ classify_installation_channel, parse_exec_start_path, property_value, InstallationChannel, }; -const HOME_UNITS: &str = "/home/test/.config/systemd/user"; -const HOME_BIN: &str = "/home/test/.local/bin"; +const HOME_UNITS: &str = "/home/user/.config/systemd/user"; +const HOME_BIN: &str = "/home/user/.local/bin"; #[test] fn matching_home_and_system_paths_select_one_installation_channel() { assert_eq!( classify_installation_channel( - Path::new("/home/test/.config/systemd/user/unixnotis-daemon.service"), - Path::new("/home/test/.local/bin/unixnotis-daemon"), + Path::new("/home/user/.config/systemd/user/unixnotis-daemon.service"), + Path::new("/home/user/.local/bin/unixnotis-daemon"), Path::new(HOME_UNITS), Path::new(HOME_BIN), ), @@ -33,12 +33,12 @@ fn matching_home_and_system_paths_select_one_installation_channel() { fn crossed_unit_and_binary_paths_are_always_mixed() { for (unit, binary) in [ ( - "/home/test/.config/systemd/user/unixnotis-daemon.service", + "/home/user/.config/systemd/user/unixnotis-daemon.service", "/usr/bin/unixnotis-daemon", ), ( "/usr/lib/systemd/user/unixnotis-daemon.service", - "/home/test/.local/bin/unixnotis-daemon", + "/home/user/.local/bin/unixnotis-daemon", ), ] { assert_eq!( @@ -70,24 +70,24 @@ fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { fn systemd_exec_start_parser_reads_only_the_structured_path_field() { assert_eq!( parse_exec_start_path( - "{ path=/home/test/.local/bin/unixnotis-daemon ; argv[]=/home/test/.local/bin/unixnotis-daemon ; ignore_errors=no ; }" + "{ path=/home/user/.local/bin/unixnotis-daemon ; argv[]=/home/user/.local/bin/unixnotis-daemon ; ignore_errors=no ; }" ), - Some("/home/test/.local/bin/unixnotis-daemon") + Some("/home/user/.local/bin/unixnotis-daemon") ); assert_eq!(parse_exec_start_path("argv[]=/tmp/fake"), None); } #[test] fn systemd_property_parser_requires_an_exact_nonempty_key() { - let output = "FragmentPath=/home/test/unit\nExecStart={ path=/home/test/bin ; }\n"; + let output = "FragmentPath=/home/user/unit\nExecStart={ path=/home/user/bin ; }\n"; assert_eq!( property_value(output, "FragmentPath"), - Some("/home/test/unit") + Some("/home/user/unit") ); assert_eq!( property_value(output, "ExecStart"), - Some("{ path=/home/test/bin ; }") + Some("{ path=/home/user/bin ; }") ); assert_eq!(property_value(output, "Path"), None); assert_eq!(property_value("FragmentPath=\n", "FragmentPath"), None); From 9d17fdf76f6c15ecab8d52ffe5735245ab114676 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 26 Jul 2026 01:46:51 -0500 Subject: [PATCH 100/275] fix(css): scope intentional stock overrides Summary: scope intentional stock overrides. Scope: css. --- .../src/css_check/lint/directives.rs | 87 ++++++++++++ .../noticenterctl/src/css_check/lint/mod.rs | 1 + .../noticenterctl/src/css_check/lint/scan.rs | 50 ++++--- .../src/css_check/lint/tests/directives.rs | 49 +++++++ .../src/css_check/lint/tests/scan.rs | 129 ++++++++++++++++++ crates/unixnotis-core/assets/media.css | 2 + crates/unixnotis-core/assets/panel.css | 2 + crates/unixnotis-core/assets/widgets.css | 2 + 8 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 crates/noticenterctl/src/css_check/lint/directives.rs create mode 100644 crates/noticenterctl/src/css_check/lint/tests/directives.rs diff --git a/crates/noticenterctl/src/css_check/lint/directives.rs b/crates/noticenterctl/src/css_check/lint/directives.rs new file mode 100644 index 000000000..776d1e6d9 --- /dev/null +++ b/crates/noticenterctl/src/css_check/lint/directives.rs @@ -0,0 +1,87 @@ +//! Narrow source directives for intentional CSS cascade overrides + +use std::ops::Range; + +const ALLOW_DUPLICATE_SELECTORS_START: &str = + "/* unixnotis-css-check allow-duplicate-selectors:start */"; +const ALLOW_DUPLICATE_SELECTORS_END: &str = + "/* unixnotis-css-check allow-duplicate-selectors:end */"; + +#[derive(Debug, Default)] +pub(super) struct DuplicateSelectorAllowlist { + ranges: Vec>, +} + +impl DuplicateSelectorAllowlist { + pub(super) fn from_source(source: &str) -> Self { + let mut ranges = Vec::new(); + let mut remaining = source; + + while let Some((_before_start, after_start)) = + remaining.split_once(ALLOW_DUPLICATE_SELECTORS_START) + { + let Some((allowed_source, after_end)) = + after_start.split_once(ALLOW_DUPLICATE_SELECTORS_END) + else { + // An incomplete directive must not hide the rest of a user stylesheet + break; + }; + // Slice lengths provide absolute offsets without letting malformed input overflow + let start = source + .len() + .checked_sub(after_start.len()) + .expect("directive slice belongs to source"); + let end = start + .checked_add(allowed_source.len()) + .expect("allowed directive range fits source"); + ranges.push(start..end); + // Splitting consumes one complete section and guarantees forward progress + remaining = after_end; + } + + if ranges.is_empty() { + // Existing untouched installs predate directives but retain known stock bytes + if let Some(start) = legacy_stock_override_start(source) { + ranges.push(start..source.len()); + } + } + + Self { ranges } + } + + pub(super) fn contains(&self, offset: usize) -> bool { + self.ranges.iter().any(|range| range.contains(&offset)) + } +} + +fn legacy_stock_override_start(source: &str) -> Option { + [ + ( + unixnotis_core::DEFAULT_PANEL_CSS, + "/* Restrained default composition", + ), + ( + unixnotis_core::DEFAULT_WIDGETS_CSS, + "/* Restrained widget composition", + ), + ( + unixnotis_core::DEFAULT_MEDIA_CSS, + "/* Restrained media transport */", + ), + ] + .into_iter() + .find_map(|(current, override_header)| { + let legacy = current + .replace(&format!("{ALLOW_DUPLICATE_SELECTORS_START}\n"), "") + .replace(&format!("{ALLOW_DUPLICATE_SELECTORS_END}\n"), ""); + (source == legacy).then(|| { + source + .find(override_header) + .expect("stock override header remains present") + }) + }) +} + +#[cfg(test)] +#[path = "tests/directives.rs"] +mod tests; diff --git a/crates/noticenterctl/src/css_check/lint/mod.rs b/crates/noticenterctl/src/css_check/lint/mod.rs index 34a8f9458..6023c91c8 100644 --- a/crates/noticenterctl/src/css_check/lint/mod.rs +++ b/crates/noticenterctl/src/css_check/lint/mod.rs @@ -1,5 +1,6 @@ //! CSS declaration, selector, and compatibility lint rules +mod directives; mod runner; mod scan; mod values; diff --git a/crates/noticenterctl/src/css_check/lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs index 01ddc85b2..823a5b751 100644 --- a/crates/noticenterctl/src/css_check/lint/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/scan.rs @@ -5,17 +5,26 @@ use super::super::parse::{ next_css_block_with_offsets, normalize_selector, parse_css_declarations_with_offsets, should_recurse_at_rule, split_selectors, strip_css_comments, }; +use super::directives::DuplicateSelectorAllowlist; use super::values::{ line_column_for_offset, should_suppress_duplicate_property_warning, web_length_value_warning, }; use super::CssCheckLintFinding; +struct CssLintContext<'a> { + // Shared source data stays together while recursive at-rules adjust only their offsets + source_contents: &'a str, + custom_properties: &'a CssCustomPropertyScopes, + duplicate_selector_allowlist: &'a DuplicateSelectorAllowlist, +} + pub(super) fn lint_css_contents_with_properties( contents: &str, custom_properties: &CssCustomPropertyScopes, ) -> Vec { // One collection keeps source order stable across color and rule diagnostics let mut warnings = Vec::new(); + let duplicate_selector_allowlist = DuplicateSelectorAllowlist::from_source(contents); // Strip comments first so block scanning stays honest let stripped = strip_css_comments(contents); @@ -52,12 +61,16 @@ pub(super) fn lint_css_contents_with_properties( // Selector repeats matter across the whole file let mut selector_seen: HashMap = HashMap::new(); + let lint_context = CssLintContext { + source_contents: &stripped, + custom_properties, + duplicate_selector_allowlist: &duplicate_selector_allowlist, + }; lint_css_block( - &stripped, &stripped, 0, None, - custom_properties, + &lint_context, &mut selector_seen, &mut warnings, ); @@ -70,10 +83,9 @@ mod tests; fn lint_css_block( contents: &str, - source_contents: &str, base_offset: usize, - context: Option, - custom_properties: &CssCustomPropertyScopes, + at_rule_context: Option, + lint_context: &CssLintContext<'_>, selector_seen: &mut HashMap, warnings: &mut Vec, ) { @@ -93,17 +105,16 @@ fn lint_css_block( if should_recurse_at_rule(&selector) { // At-rules still matter because duplicate selectors and bad layout values can // hide inside the nested block - let nested_context = match context.as_ref() { + let nested_context = match at_rule_context.as_ref() { Some(parent) => format!("{parent} {selector}"), None => selector.clone(), }; // Keep the at-rule in the warning so the scope still makes sense lint_css_block( &css_block.block, - source_contents, base_offset + css_block.block_start, Some(nested_context), - custom_properties, + lint_context, selector_seen, warnings, ); @@ -116,23 +127,26 @@ fn lint_css_block( if selector_part.is_empty() { continue; } - let key = match context.as_ref() { + let key = match at_rule_context.as_ref() { // At-rule scope is part of identity so media variants are not false duplicates Some(prefix) => format!("{prefix}::{selector_part}"), None => selector_part.clone(), }; let count = selector_seen.entry(key).or_insert(0); *count += 1; - if *count > 1 { + let selector_source_offset = base_offset + css_block.selector_start + selector_offset; + if *count > 1 + && !lint_context + .duplicate_selector_allowlist + .contains(selector_source_offset) + { // Point to the repeated selector rather than the opening block delimiter - let context_note = context + let context_note = at_rule_context .as_ref() .map(|ctx| format!(" within {ctx}")) .unwrap_or_default(); - let (lint_line, lint_column) = line_column_for_offset( - source_contents, - base_offset + css_block.selector_start + selector_offset, - ); + let (lint_line, lint_column) = + line_column_for_offset(lint_context.source_contents, selector_source_offset); warnings.push(CssCheckLintFinding { line: Some(lint_line), column: Some(lint_column), @@ -144,12 +158,12 @@ fn lint_css_block( } warnings.extend(lint_css_properties( - source_contents, + lint_context.source_contents, &selector, &css_block.block, base_offset + css_block.block_start, - context.as_deref(), - custom_properties, + at_rule_context.as_deref(), + lint_context.custom_properties, )); } } diff --git a/crates/noticenterctl/src/css_check/lint/tests/directives.rs b/crates/noticenterctl/src/css_check/lint/tests/directives.rs new file mode 100644 index 000000000..ff097fc53 --- /dev/null +++ b/crates/noticenterctl/src/css_check/lint/tests/directives.rs @@ -0,0 +1,49 @@ +use super::{ + DuplicateSelectorAllowlist, ALLOW_DUPLICATE_SELECTORS_END, ALLOW_DUPLICATE_SELECTORS_START, +}; + +#[test] +fn closed_duplicate_selector_directive_only_allows_its_own_range() { + let long_prefix = "x".repeat(ALLOW_DUPLICATE_SELECTORS_START.len() + 16); + let source = format!( + "{long_prefix}\n.before-marker {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_START}\n.inside {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}\n.after {{}}" + ); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(!allowlist.contains( + source + .find(".before-marker") + .expect("selector before marker") + )); + assert!(allowlist.contains(source.find(".inside").expect("inside selector"))); + assert!(!allowlist.contains(source.find(".after").expect("after selector"))); +} + +#[test] +fn multiple_closed_directives_allow_each_section_without_hiding_the_gap() { + let source = format!( + "{ALLOW_DUPLICATE_SELECTORS_START}\n.first {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}\n.between {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_START}\n.second {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}" + ); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(allowlist.contains(source.find(".first").expect("first allowed selector"))); + assert!(!allowlist.contains(source.find(".between").expect("selector between sections"))); + assert!(allowlist.contains(source.find(".second").expect("second allowed selector"))); +} + +#[test] +fn unclosed_duplicate_selector_directive_does_not_hide_later_rules() { + let source = format!(".outside {{}}\n{ALLOW_DUPLICATE_SELECTORS_START}\n.still-checked {{}}"); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(!allowlist.contains( + source + .find(".still-checked") + .expect("selector after incomplete directive") + )); +} diff --git a/crates/noticenterctl/src/css_check/lint/tests/scan.rs b/crates/noticenterctl/src/css_check/lint/tests/scan.rs index 2897c4b9e..f1dac180b 100644 --- a/crates/noticenterctl/src/css_check/lint/tests/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/tests/scan.rs @@ -15,3 +15,132 @@ fn scanner_reports_duplicate_selectors_with_source_location() { assert_eq!(duplicate.line, Some(2)); assert!(duplicate.column.is_some()); } + +#[test] +fn nested_duplicate_selector_reports_its_absolute_source_location() { + let css = "@media (min-width: 1px) {\n .item { color: red; }\n .item { color: blue; }\n}"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate selector")) + .expect("nested duplicate selector should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(3), Some(3))); +} + +#[test] +fn grouped_duplicate_selector_reports_the_repeated_member_location() { + let css = ".a, .b { color: red; }\n.x, .b { color: blue; }"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate selector '.b'")) + .expect("grouped duplicate selector should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(2), Some(5))); +} + +#[test] +fn nested_duplicate_property_reports_its_absolute_source_location() { + let css = "@media (min-width: 1px) {\n .item {\n color: red;\n color: blue;\n }\n}"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate property 'color'")) + .expect("nested duplicate property should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(4), Some(5))); +} + +#[test] +fn scanner_suppresses_only_duplicates_inside_a_closed_override_section() { + let css = " + .item { color: red; } + /* unixnotis-css-check allow-duplicate-selectors:start */ + .item { color: blue; } + /* unixnotis-css-check allow-duplicate-selectors:end */ + .item { color: green; } + "; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicates = findings + .iter() + .filter(|finding| finding.message.contains("duplicate selector")) + .collect::>(); + + assert_eq!(duplicates.len(), 1); + assert_eq!(duplicates[0].line, Some(6)); +} + +#[test] +fn shipped_css_assets_are_lint_clean() { + let assets = [ + unixnotis_core::DEFAULT_BASE_CSS, + unixnotis_core::DEFAULT_PANEL_CSS, + unixnotis_core::DEFAULT_POPUP_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_MEDIA_CSS, + ]; + let config = unixnotis_core::Config::default(); + let generated = unixnotis_core::build_modern_theme_custom_properties( + &config.theme, + unixnotis_core::gtk_css_features_for_version(4, 16), + ); + let combined = std::iter::once(generated.as_str()) + .chain(assets) + .collect::>() + .join("\n"); + let properties = collect_custom_property_scopes(&combined); + + for css in assets { + let findings = lint_css_contents_with_properties(css, &properties); + + assert!(findings.is_empty(), "{findings:?}"); + } +} + +#[test] +fn untouched_legacy_stock_assets_are_lint_clean_without_rewriting_user_files() { + let marker_lines = [ + "/* unixnotis-css-check allow-duplicate-selectors:start */\n", + "/* unixnotis-css-check allow-duplicate-selectors:end */\n", + ]; + let assets = [ + unixnotis_core::DEFAULT_PANEL_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_MEDIA_CSS, + ]; + let config = unixnotis_core::Config::default(); + let generated = unixnotis_core::build_modern_theme_custom_properties( + &config.theme, + unixnotis_core::gtk_css_features_for_version(4, 16), + ); + let current_assets = [ + unixnotis_core::DEFAULT_BASE_CSS, + unixnotis_core::DEFAULT_PANEL_CSS, + unixnotis_core::DEFAULT_POPUP_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_MEDIA_CSS, + ]; + let combined = std::iter::once(generated.as_str()) + .chain(current_assets) + .collect::>() + .join("\n"); + let properties = collect_custom_property_scopes(&combined); + + for current in assets { + let legacy = marker_lines + .iter() + .fold(current.to_string(), |css, marker| css.replace(marker, "")); + let findings = lint_css_contents_with_properties(&legacy, &properties); + + assert!(findings.is_empty(), "{findings:?}"); + } +} diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 4e2981e31..aed9e4a51 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -233,6 +233,7 @@ border-color: alpha(@unixnotis-accent, 0.75); } +/* unixnotis-css-check allow-duplicate-selectors:start */ /* Restrained media transport */ .unixnotis-media-card { background: alpha(#ffffff, 0.035); @@ -323,3 +324,4 @@ .unixnotis-media-nav-next { min-width: 18px; } +/* unixnotis-css-check allow-duplicate-selectors:end */ diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 6bf436539..1c9614a22 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -621,6 +621,7 @@ entry selection { box-shadow: 0 0 16px -12px @unixnotis-glow-cyan; } +/* unixnotis-css-check allow-duplicate-selectors:start */ /* Restrained default composition * * Navy remains the visual identity while flat surfaces and spacing carry hierarchy @@ -844,3 +845,4 @@ scrollbar slider:active { background: #52d9da; box-shadow: 0 0 12px alpha(#52d9da, 0.80); } +/* unixnotis-css-check allow-duplicate-selectors:end */ diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index 0b99399be..ca063ee11 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -719,6 +719,7 @@ padding: 8px 10px; } +/* unixnotis-css-check allow-duplicate-selectors:start */ /* Restrained widget composition * * Repeated widget types share one surface treatment and reserve color for state @@ -1159,3 +1160,4 @@ box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05); border-radius: 8px; } +/* unixnotis-css-check allow-duplicate-selectors:end */ From d0f2e999604c6ec75e9a4e0003043fa55aae43cc Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:06:51 -0500 Subject: [PATCH 101/275] refactor(doctor): split D-Bus checks by responsibility Summary: split D-Bus checks by responsibility. Scope: doctor. --- .../noticenterctl/src/doctor/checks/dbus.rs | 439 ------------------ .../src/doctor/checks/dbus/classify.rs | 37 ++ .../src/doctor/checks/dbus/control.rs | 140 ++++++ .../src/doctor/checks/dbus/mod.rs | 101 ++++ .../src/doctor/checks/dbus/owners.rs | 164 +++++++ .../src/doctor/checks/dbus/session.rs | 100 ++++ .../src/doctor/checks/dbus/tests/classify.rs | 39 ++ .../src/doctor/checks/dbus/tests/control.rs | 117 +++++ .../src/doctor/checks/dbus/tests/mod.rs | 5 + .../src/doctor/checks/dbus/tests/owners.rs | 127 +++++ .../src/doctor/checks/dbus/tests/session.rs | 20 + .../src/doctor/checks/dbus/tests/support.rs | 158 +++++++ .../src/doctor/checks/tests/dbus.rs | 319 ------------- .../src/doctor/checks/tests/mod.rs | 1 - 14 files changed, 1008 insertions(+), 759 deletions(-) delete mode 100644 crates/noticenterctl/src/doctor/checks/dbus.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/classify.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/control.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/mod.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/owners.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/session.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs create mode 100644 crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs delete mode 100644 crates/noticenterctl/src/doctor/checks/tests/dbus.rs diff --git a/crates/noticenterctl/src/doctor/checks/dbus.rs b/crates/noticenterctl/src/doctor/checks/dbus.rs deleted file mode 100644 index d19068e70..000000000 --- a/crates/noticenterctl/src/doctor/checks/dbus.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! Bounded session-bus and `UnixNotis` control checks - -use std::time::Duration; - -use unixnotis_core::{ - log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME, -}; -use zbus::fdo::DBusProxy; -use zbus::names::BusName; -use zbus::Connection; - -use super::super::report::safe_doctor_text; -use super::super::report::{DoctorCheck, DoctorSeverity}; - -const DBUS_CHECK_TIMEOUT: Duration = Duration::from_secs(3); - -pub(in crate::doctor) struct DoctorBusResult { - pub checks: Vec, - pub control_owned: bool, - pub connected: bool, -} - -pub(in crate::doctor) async fn inspect_bus() -> DoctorBusResult { - // Every bus operation is bounded so doctor cannot hang on a broken session - let connection = match tokio::time::timeout(DBUS_CHECK_TIMEOUT, Connection::session()).await { - Ok(Ok(connection)) => connection, - Ok(Err(error)) => { - return unavailable_bus_result(format!("Session bus connection failed: {error}")); - } - Err(_) => return unavailable_bus_result("Session bus connection timed out".to_string()), - }; - - inspect_bus_connection(&connection).await -} - -pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBusResult { - let mut checks = vec![DoctorCheck::new( - "dbus.session", - "Session bus", - DoctorSeverity::Pass, - "Session bus connection succeeded", - )]; - match log_session_bus_identity(connection, "noticenterctl doctor").await { - Ok(identity) => checks.push( - DoctorCheck::new( - "dbus.identity", - "Session bus identity", - DoctorSeverity::Pass, - "Session bus identity probe succeeded", - ) - .details(format!( - "Bus ID: {}\nUnique name: {}\nRuntime directory: {}", - identity.bus_id, identity.unique_name, identity.runtime_dir - )) - .data("bus_id", identity.bus_id) - .data("unique_name", identity.unique_name) - .data("runtime_dir", identity.runtime_dir), - ), - Err(error) => checks.push( - DoctorCheck::new( - "dbus.identity", - "Session bus identity", - DoctorSeverity::Error, - "Session bus identity probe failed", - ) - .details(safe_doctor_text(&error.to_string())), - ), - } - // The daemon proxy is required for ownership checks but not for later service checks - let proxy = match tokio::time::timeout(DBUS_CHECK_TIMEOUT, DBusProxy::new(connection)).await { - Ok(Ok(proxy)) => proxy, - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - "dbus.proxy", - "Session bus proxy", - DoctorSeverity::Error, - "D-Bus daemon proxy construction failed", - ) - .details(safe_doctor_text(&error.to_string())), - ); - return DoctorBusResult { - checks, - control_owned: false, - connected: true, - }; - } - Err(_) => { - checks.push(DoctorCheck::new( - "dbus.proxy", - "Session bus proxy", - DoctorSeverity::Error, - "D-Bus daemon proxy construction timed out", - )); - return DoctorBusResult { - checks, - control_owned: false, - connected: true, - }; - } - }; - - // Notification and control names are separate readiness signals - let notifications_owner = check_owner( - &proxy, - NOTIFICATIONS_BUS_NAME, - "dbus.notifications-owner", - "Notification service", - &mut checks, - ) - .await; - if notifications_owner.is_none() { - // Missing the standard name means desktop applications have no notification target - checks.push( - DoctorCheck::new( - "dbus.notifications-readiness", - "Notification readiness", - DoctorSeverity::Error, - "No notification service owns org.freedesktop.Notifications", - ) - .hint("Start unixnotis-daemon and run doctor again"), - ); - } - - let control_owner = check_owner( - &proxy, - CONTROL_BUS_NAME, - "dbus.control-owner", - "UnixNotis control service", - &mut checks, - ) - .await; - let has_control_owner = control_owner.is_some(); - if let (Some(notifications_owner), Some(control_owner)) = (¬ifications_owner, &control_owner) - { - let owners_match = notifications_owner == control_owner; - checks.push( - DoctorCheck::new( - "dbus.shared-owner", - "UnixNotis D-Bus ownership", - if owners_match { - DoctorSeverity::Pass - } else { - DoctorSeverity::Error - }, - if owners_match { - "Notification and control names share one owner" - } else { - "Notification and control names have different owners" - }, - ) - .data("notifications_owner", notifications_owner.clone()) - .data("control_owner", control_owner.clone()), - ); - } - if has_control_owner { - // Proxy and GetState checks run only after ownership is confirmed - inspect_control_proxy(connection, &mut checks).await; - } else { - checks.push( - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "UnixNotis control service has no owner", - ) - .hint("Check the selected service manager status below"), - ); - } - - DoctorBusResult { - checks, - control_owned: has_control_owner, - connected: true, - } -} - -async fn check_owner( - proxy: &DBusProxy<'_>, - name: &'static str, - id: &'static str, - label: &'static str, - checks: &mut Vec, -) -> Option { - // Static names are validated here once before the bounded remote request - let bus_name = BusName::try_from(name).expect("static D-Bus name must be valid"); - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name.clone())).await { - Ok(Ok(true)) => { - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.get_name_owner(bus_name)).await { - Ok(Ok(owner)) => { - let owner = owner.to_string(); - checks.push( - DoctorCheck::new( - id, - label, - DoctorSeverity::Pass, - format!("{name} has an owner"), - ) - .data("owner", owner.clone()), - ); - Some(owner) - } - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Unable to read {name} owner"), - ) - .details(safe_doctor_text(&error.to_string())), - ); - None - } - Err(_) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Owner query for {name} timed out"), - )); - None - } - } - } - Ok(Ok(false)) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Warning, - format!("{name} has no owner"), - )); - None - } - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Unable to inspect {name} ownership"), - ) - .details(safe_doctor_text(&error.to_string())), - ); - None - } - Err(_) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Ownership query for {name} timed out"), - )); - None - } - } -} - -async fn inspect_control_proxy(connection: &Connection, checks: &mut Vec) { - // Keep proxy construction distinct from GetState for precise failure reports - let control = - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, ControlProxy::new(connection)).await { - Ok(Ok(proxy)) => proxy, - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Error, - "Control proxy construction failed", - ) - .details(safe_doctor_text(&error.to_string())), - ); - return; - } - Err(_) => { - checks.push(DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Error, - "Control proxy construction timed out", - )); - return; - } - }; - checks.push(DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Pass, - "Control proxy construction succeeded", - )); - - // GetState proves that the owner can serve the real control interface - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_state()).await { - Ok(Ok(state)) => checks.push( - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Pass, - "GetState completed", - ) - .details(format!( - "DND: {}\nHistory entries: {}\nInhibitors: {}", - state.dnd_enabled, state.history_count, state.inhibitor_count - )) - .data("dnd_enabled", state.dnd_enabled) - .data("history_count", state.history_count) - .data("inhibitor_count", state.inhibitor_count), - ), - Ok(Err(error)) => checks.push(control_state_failure_check(&error)), - Err(_) => checks.push(DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "GetState timed out", - )), - } - inspect_ui_health(&control, checks).await; -} - -async fn inspect_ui_health(control: &ControlProxy<'_>, checks: &mut Vec) { - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_ui_health()).await { - Ok(Ok(health)) => { - let healthy = health.center_process_running - && health.center_ready - && health.popups_process_running - && health.popups_ready; - checks.push( - DoctorCheck::new( - "dbus.ui-health", - "UnixNotis UI readiness", - if healthy { - DoctorSeverity::Pass - } else { - DoctorSeverity::Error - }, - if healthy { - "Center and popup clients are ready" - } else { - "One or more UI clients are not ready" - }, - ) - .details(format!( - "Center process: {}\nCenter D-Bus client: {}\nPopup process: {}\nPopup D-Bus client: {}\nPopup GTK runtime: {}", - readiness_label(health.center_process_running), - readiness_label(health.center_ready), - readiness_label(health.popups_process_running), - readiness_label(health.popups_ready), - readiness_label(health.popups_ready), - )) - .data("center_process_running", health.center_process_running) - .data("center_ready", health.center_ready) - .data("popups_process_running", health.popups_process_running) - .data("popups_ready", health.popups_ready), - ); - } - Ok(Err(error)) => checks.push( - DoctorCheck::new( - "dbus.ui-health", - "UnixNotis UI readiness", - DoctorSeverity::Error, - "GetUiHealth failed", - ) - .details(safe_doctor_text(&error.to_string())), - ), - Err(_) => checks.push(DoctorCheck::new( - "dbus.ui-health", - "UnixNotis UI readiness", - DoctorSeverity::Error, - "GetUiHealth timed out", - )), - } -} - -const fn readiness_label(ready: bool) -> &'static str { - if ready { - "ready" - } else { - "not ready" - } -} - -pub(super) fn control_state_failure_check(error: &zbus::Error) -> DoctorCheck { - // Access denial is expected when a development binary calls a strict installed daemon - if control_access_was_denied(error) { - return DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "UnixNotis control access denied", - ) - .details("The running daemon rejected this client") - .hint( - "Use the installed noticenterctl from the same installation as the daemon; uninstalled development binaries are intentionally rejected", - ); - } - - // Other failures retain the broker detail because they need different troubleshooting - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "GetState failed", - ) - .details(safe_doctor_text(&error.to_string())) -} - -fn control_access_was_denied(error: &zbus::Error) -> bool { - match error { - zbus::Error::MethodError(name, _, _) => { - name.as_str() == "org.freedesktop.DBus.Error.AccessDenied" - } - zbus::Error::FDO(error) => matches!(error.as_ref(), zbus::fdo::Error::AccessDenied(_)), - _ => false, - } -} - -pub(super) fn unavailable_bus_result(details: String) -> DoctorBusResult { - // Dependent checks become one note instead of a chain of misleading errors - DoctorBusResult { - checks: vec![ - DoctorCheck::new( - "dbus.session", - "Session bus", - DoctorSeverity::Error, - "Session bus is unavailable", - ) - .details(safe_doctor_text(&details)), - DoctorCheck::new( - "dbus.dependent-checks", - "D-Bus dependent checks", - DoctorSeverity::Note, - "Owner, proxy, and GetState checks could not run", - ), - ], - control_owned: false, - connected: false, - } -} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/classify.rs b/crates/noticenterctl/src/doctor/checks/dbus/classify.rs new file mode 100644 index 000000000..85d422203 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/classify.rs @@ -0,0 +1,37 @@ +//! Stable user-facing classification for control-call failures + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; + +pub(super) fn control_state_failure_check(error: &zbus::Error) -> DoctorCheck { + if control_access_was_denied(error) { + return DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "UnixNotis control access denied", + ) + .details("The running daemon rejected this client") + .hint( + "Use the installed noticenterctl from the same installation as the daemon; uninstalled development binaries are intentionally rejected", + ); + } + + DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "GetState failed", + ) + .details(safe_doctor_text(&error.to_string())) +} + +fn control_access_was_denied(error: &zbus::Error) -> bool { + match error { + zbus::Error::MethodError(name, _, _) => { + name.as_str() == "org.freedesktop.DBus.Error.AccessDenied" + } + zbus::Error::FDO(error) => matches!(error.as_ref(), zbus::fdo::Error::AccessDenied(_)), + _ => false, + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/control.rs b/crates/noticenterctl/src/doctor/checks/dbus/control.rs new file mode 100644 index 000000000..26602ea7c --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/control.rs @@ -0,0 +1,140 @@ +//! Control proxy, state, and composite UI readiness checks + +use unixnotis_core::ControlProxy; +use zbus::Connection; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::classify::control_state_failure_check; +use super::DBUS_CHECK_TIMEOUT; + +pub(super) async fn inspect_control(connection: &Connection) -> Vec { + let mut checks = Vec::new(); + let control = + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, ControlProxy::new(connection)).await { + Ok(Ok(proxy)) => proxy, + Ok(Err(error)) => { + checks.push( + DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Error, + "Control proxy construction failed", + ) + .details(safe_doctor_text(&error.to_string())), + ); + return checks; + } + Err(_) => { + checks.push(DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Error, + "Control proxy construction timed out", + )); + return checks; + } + }; + checks.push(DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Pass, + "Control proxy construction succeeded", + )); + + checks.push(inspect_control_state(&control).await); + checks.push(inspect_ui_health(&control).await); + checks +} + +pub(super) fn unavailable_control_check() -> DoctorCheck { + DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "UnixNotis control service has no owner", + ) + .hint("Check the selected service manager status below") +} + +async fn inspect_control_state(control: &ControlProxy<'_>) -> DoctorCheck { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_state()).await { + Ok(Ok(state)) => DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Pass, + "GetState completed", + ) + .details(format!( + "DND: {}\nHistory entries: {}\nInhibitors: {}", + state.dnd_enabled, state.history_count, state.inhibitor_count + )) + .data("dnd_enabled", state.dnd_enabled) + .data("history_count", state.history_count) + .data("inhibitor_count", state.inhibitor_count), + Ok(Err(error)) => control_state_failure_check(&error), + Err(_) => DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "GetState timed out", + ), + } +} + +async fn inspect_ui_health(control: &ControlProxy<'_>) -> DoctorCheck { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_ui_health()).await { + Ok(Ok(health)) => { + let healthy = health.center_process_running + && health.center_ready + && health.popups_process_running + && health.popups_ready; + DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + if healthy { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if healthy { + "Center and popup clients are ready" + } else { + "One or more UI clients are not ready" + }, + ) + .details(format!( + "Center process: {}\nCenter D-Bus client: {}\nPopup process: {}\nPopup D-Bus/GTK client: {}", + readiness_label(health.center_process_running), + readiness_label(health.center_ready), + readiness_label(health.popups_process_running), + readiness_label(health.popups_ready), + )) + .data("center_process_running", health.center_process_running) + .data("center_ready", health.center_ready) + .data("popups_process_running", health.popups_process_running) + .data("popups_ready", health.popups_ready) + } + Ok(Err(error)) => DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth failed", + ) + .details(safe_doctor_text(&error.to_string())), + Err(_) => DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth timed out", + ), + } +} + +const fn readiness_label(ready: bool) -> &'static str { + if ready { + "ready" + } else { + "not ready" + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/mod.rs b/crates/noticenterctl/src/doctor/checks/dbus/mod.rs new file mode 100644 index 000000000..60be68a33 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/mod.rs @@ -0,0 +1,101 @@ +//! Bounded session-bus and `UnixNotis` control inspection + +mod classify; +mod control; +mod owners; +mod session; + +use std::time::Duration; + +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::report::DoctorCheck; + +pub(super) const DBUS_CHECK_TIMEOUT: Duration = Duration::from_secs(3); + +pub(in crate::doctor) struct DoctorBusResult { + pub checks: Vec, + pub control_owned: bool, + pub connected: bool, +} + +pub(in crate::doctor) async fn inspect_bus() -> DoctorBusResult { + let session::SessionProbe { connection, checks } = session::probe_session().await; + let Some(connection) = connection else { + return DoctorBusResult { + checks, + control_owned: false, + connected: false, + }; + }; + debug_assert!( + checks.is_empty(), + "session probing must not report checks when a connection is available" + ); + inspect_bus_connection(&connection).await +} + +pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBusResult { + let checks = session::connected_checks(connection).await; + inspect_connected_bus(connection, checks).await +} + +async fn inspect_connected_bus( + connection: &Connection, + mut checks: Vec, +) -> DoctorBusResult { + let proxy = match session::build_bus_proxy(connection).await { + Ok(proxy) => proxy, + Err(check) => { + checks.push(check); + return DoctorBusResult { + checks, + control_owned: false, + connected: true, + }; + } + }; + inspect_owners_and_control(connection, &proxy, &mut checks).await +} + +async fn inspect_owners_and_control( + connection: &Connection, + proxy: &DBusProxy<'_>, + checks: &mut Vec, +) -> DoctorBusResult { + let notifications = owners::probe_notifications_owner(proxy).await; + let notification_owner = notifications.owner().map(ToOwned::to_owned); + checks.push(notifications.check); + if notification_owner.is_none() { + checks.push(owners::notification_readiness_failure()); + } + + let control_probe = owners::probe_control_owner(proxy).await; + let control_owned = control_probe.owner().is_some(); + let control_owner_name = control_probe.owner().map(ToOwned::to_owned); + checks.push(control_probe.check); + if let (Some(notification_owner), Some(control_owner_name)) = + (¬ification_owner, &control_owner_name) + { + checks.push(owners::shared_owner_check( + notification_owner, + control_owner_name, + )); + } + + if control_owned { + checks.extend(control::inspect_control(connection).await); + } else { + checks.push(control::unavailable_control_check()); + } + + DoctorBusResult { + checks: std::mem::take(checks), + control_owned, + connected: true, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/doctor/checks/dbus/owners.rs b/crates/noticenterctl/src/doctor/checks/dbus/owners.rs new file mode 100644 index 000000000..6ea440b85 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/owners.rs @@ -0,0 +1,164 @@ +//! Notification and control name ownership probes + +use zbus::fdo::DBusProxy; +use zbus::names::BusName; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::DBUS_CHECK_TIMEOUT; +use unixnotis_core::{CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; + +#[derive(Debug)] +pub(super) enum OwnerState { + Owned(String), + Unowned, + QueryFailed, + TimedOut, +} + +pub(super) struct OwnerProbe { + pub(super) state: OwnerState, + pub(super) check: DoctorCheck, +} + +impl OwnerProbe { + pub(super) fn owner(&self) -> Option<&str> { + match &self.state { + OwnerState::Owned(owner) => Some(owner), + OwnerState::Unowned | OwnerState::QueryFailed | OwnerState::TimedOut => None, + } + } +} + +pub(super) async fn probe_notifications_owner(proxy: &DBusProxy<'_>) -> OwnerProbe { + probe_owner( + proxy, + NOTIFICATIONS_BUS_NAME, + "dbus.notifications-owner", + "Notification service", + ) + .await +} + +pub(super) async fn probe_control_owner(proxy: &DBusProxy<'_>) -> OwnerProbe { + probe_owner( + proxy, + CONTROL_BUS_NAME, + "dbus.control-owner", + "UnixNotis control service", + ) + .await +} + +async fn probe_owner( + proxy: &DBusProxy<'_>, + name: &'static str, + id: &'static str, + label: &'static str, +) -> OwnerProbe { + let bus_name = BusName::try_from(name).expect("static D-Bus name must be valid"); + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name.clone())).await { + Ok(Ok(true)) => read_owner(proxy, bus_name, name, id, label).await, + Ok(Ok(false)) => OwnerProbe { + state: OwnerState::Unowned, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Warning, + format!("{name} has no owner"), + ), + }, + Ok(Err(error)) => OwnerProbe { + state: OwnerState::QueryFailed, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Unable to inspect {name} ownership"), + ) + .details(safe_doctor_text(&error.to_string())), + }, + Err(_) => OwnerProbe { + state: OwnerState::TimedOut, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Ownership query for {name} timed out"), + ), + }, + } +} + +async fn read_owner( + proxy: &DBusProxy<'_>, + bus_name: BusName<'_>, + name: &'static str, + id: &'static str, + label: &'static str, +) -> OwnerProbe { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.get_name_owner(bus_name)).await { + Ok(Ok(owner)) => { + let owner = owner.to_string(); + OwnerProbe { + state: OwnerState::Owned(owner.clone()), + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Pass, + format!("{name} has an owner"), + ) + .data("owner", owner), + } + } + Ok(Err(error)) => OwnerProbe { + state: OwnerState::QueryFailed, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Unable to read {name} owner"), + ) + .details(safe_doctor_text(&error.to_string())), + }, + Err(_) => OwnerProbe { + state: OwnerState::TimedOut, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Owner query for {name} timed out"), + ), + }, + } +} + +pub(super) fn notification_readiness_failure() -> DoctorCheck { + DoctorCheck::new( + "dbus.notifications-readiness", + "Notification readiness", + DoctorSeverity::Error, + "No notification service owns org.freedesktop.Notifications", + ) + .hint("Start unixnotis-daemon and run doctor again") +} + +pub(super) fn shared_owner_check(notification_owner: &str, control_owner: &str) -> DoctorCheck { + let owners_match = notification_owner == control_owner; + DoctorCheck::new( + "dbus.shared-owner", + "UnixNotis D-Bus ownership", + if owners_match { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if owners_match { + "Notification and control names share one owner" + } else { + "Notification and control names have different owners" + }, + ) + .data("notifications_owner", notification_owner) + .data("control_owner", control_owner) +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/session.rs b/crates/noticenterctl/src/doctor/checks/dbus/session.rs new file mode 100644 index 000000000..e0079d2f9 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/session.rs @@ -0,0 +1,100 @@ +//! Session connection, identity, and daemon-proxy probes + +use unixnotis_core::log_session_bus_identity; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::DBUS_CHECK_TIMEOUT; + +pub(super) struct SessionProbe { + pub(super) connection: Option, + pub(super) checks: Vec, +} + +pub(super) async fn probe_session() -> SessionProbe { + // A broken session environment must never make doctor hang + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, Connection::session()).await { + Ok(Ok(connection)) => SessionProbe { + connection: Some(connection), + checks: Vec::new(), + }, + Ok(Err(error)) => unavailable_probe(format!("Session bus connection failed: {error}")), + Err(_) => unavailable_probe("Session bus connection timed out".to_string()), + } +} + +pub(super) async fn connected_checks(connection: &Connection) -> Vec { + let mut checks = vec![DoctorCheck::new( + "dbus.session", + "Session bus", + DoctorSeverity::Pass, + "Session bus connection succeeded", + )]; + let identity_check = match log_session_bus_identity(connection, "noticenterctl doctor").await { + Ok(identity) => DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Pass, + "Session bus identity probe succeeded", + ) + .details(format!( + "Bus ID: {}\nUnique name: {}\nRuntime directory: {}", + identity.bus_id, identity.unique_name, identity.runtime_dir + )) + .data("bus_id", identity.bus_id) + .data("unique_name", identity.unique_name) + .data("runtime_dir", identity.runtime_dir), + Err(error) => DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Error, + "Session bus identity probe failed", + ) + .details(safe_doctor_text(&error.to_string())), + }; + checks.push(identity_check); + checks +} + +pub(super) async fn build_bus_proxy(connection: &Connection) -> Result, DoctorCheck> { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, DBusProxy::new(connection)).await { + Ok(Ok(proxy)) => Ok(proxy), + Ok(Err(error)) => Err(DoctorCheck::new( + "dbus.proxy", + "Session bus proxy", + DoctorSeverity::Error, + "D-Bus daemon proxy construction failed", + ) + .details(safe_doctor_text(&error.to_string()))), + Err(_) => Err(DoctorCheck::new( + "dbus.proxy", + "Session bus proxy", + DoctorSeverity::Error, + "D-Bus daemon proxy construction timed out", + )), + } +} + +pub(super) fn unavailable_probe(details: String) -> SessionProbe { + // Dependent checks collapse into one note instead of cascading misleading failures + SessionProbe { + connection: None, + checks: vec![ + DoctorCheck::new( + "dbus.session", + "Session bus", + DoctorSeverity::Error, + "Session bus is unavailable", + ) + .details(safe_doctor_text(&details)), + DoctorCheck::new( + "dbus.dependent-checks", + "D-Bus dependent checks", + DoctorSeverity::Note, + "Owner, proxy, and GetState checks could not run", + ), + ], + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs new file mode 100644 index 000000000..946915fc8 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs @@ -0,0 +1,39 @@ +use crate::doctor::report::DoctorSeverity; + +use super::super::classify::control_state_failure_check; + +#[test] +fn access_denied_state_failure_explains_installed_client_requirements() { + let error = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "caller is not authorized for control operation".to_string(), + ))); + + let check = control_state_failure_check(&error); + + assert_eq!(check.id, "dbus.control-state"); + assert_eq!(check.severity, DoctorSeverity::Error); + assert_eq!(check.summary, "UnixNotis control access denied"); + assert_eq!( + check.details.as_deref(), + Some("The running daemon rejected this client") + ); + assert!(check + .hint + .as_deref() + .is_some_and(|hint| hint.contains("installed noticenterctl"))); + assert!(!check + .details + .as_deref() + .is_some_and(|details| details.contains("caller is not authorized"))); +} + +#[test] +fn non_authorization_state_failure_preserves_the_original_error() { + let error = zbus::Error::Failure("state unavailable".to_string()); + + let check = control_state_failure_check(&error); + + assert_eq!(check.summary, "GetState failed"); + assert_eq!(check.details.as_deref(), Some("state unavailable")); + assert!(check.hint.is_none()); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs new file mode 100644 index 000000000..748b1f622 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs @@ -0,0 +1,117 @@ +use crate::doctor::report::DoctorSeverity; +use unixnotis_core::CONTROL_BUS_NAME; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::ConnectionBuilder; + +use super::super::{control, inspect_bus_connection, owners}; +use super::support::{check_ids, connect, control_server, run_async, PrivateBroker}; + +#[test] +fn same_owner_and_healthy_ui_preserve_the_complete_check_sequence() { + run_async(async { + let broker = PrivateBroker::start(); + let _server = control_server(&broker.address, false, true).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert!(result.control_owned); + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.shared-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + let health = result + .checks + .iter() + .find(|check| check.id == "dbus.ui-health") + .expect("UI health check"); + assert_eq!(health.severity, DoctorSeverity::Pass); + assert!(health + .details + .as_deref() + .is_some_and(|details| details.contains("Popup D-Bus/GTK client: ready"))); + assert!(!health + .details + .as_deref() + .is_some_and(|details| details.contains("Popup GTK runtime:"))); + }); +} + +#[test] +fn access_denied_control_state_reports_mismatched_installation_guidance() { + run_async(async { + let broker = PrivateBroker::start(); + let _server = control_server(&broker.address, true, true).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + let state = result + .checks + .iter() + .find(|check| check.id == "dbus.control-state") + .expect("control state check"); + + assert_eq!(state.severity, DoctorSeverity::Error); + assert_eq!(state.summary, "UnixNotis control access denied"); + }); +} + +#[test] +fn control_owner_loss_between_probe_and_get_state_is_reported() { + run_async(async { + let broker = PrivateBroker::start(); + let server = control_server(&broker.address, false, true).await; + let client = connect(&broker.address).await; + let dbus = DBusProxy::new(&client).await.expect("create daemon proxy"); + let owner = owners::probe_control_owner(&dbus).await; + assert!(owner.owner().is_some()); + server + .release_name(CONTROL_BUS_NAME) + .await + .expect("release control name"); + drop(server); + let control_name = BusName::try_from(CONTROL_BUS_NAME).expect("static control name"); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while dbus + .name_has_owner(control_name.clone()) + .await + .expect("query control owner") + { + tokio::task::yield_now().await; + } + }) + .await + .expect("control owner should disappear"); + // A replacement without the UnixNotis interface prevents host activation from masking + // the owner-generation race on systems with the control service installed + let _replacement = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request replacement control name") + .build() + .await + .expect("connect replacement owner"); + + let checks = control::inspect_control(&client).await; + + assert_eq!( + checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + ["dbus.control-proxy", "dbus.control-state", "dbus.ui-health"] + ); + assert_eq!(checks[1].severity, DoctorSeverity::Error); + assert_eq!(checks[2].severity, DoctorSeverity::Error); + }); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs new file mode 100644 index 000000000..09e826793 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs @@ -0,0 +1,5 @@ +mod classify; +mod control; +mod owners; +mod session; +mod support; diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs new file mode 100644 index 000000000..c6f485f75 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs @@ -0,0 +1,127 @@ +use crate::doctor::report::DoctorSeverity; +use unixnotis_core::{CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; +use zbus::ConnectionBuilder; + +use super::super::inspect_bus_connection; +use super::support::{check_ids, connect, control_server, run_async, PrivateBroker, TestControl}; + +#[test] +fn no_bus_owners_preserve_the_complete_readiness_failure_sequence() { + run_async(async { + let broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert!(!result.control_owned); + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.notifications-readiness", + "dbus.control-owner", + "dbus.control-state", + ] + ); + }); +} + +#[test] +fn notification_owner_without_control_owner_keeps_control_failure_last() { + run_async(async { + let broker = PrivateBroker::start(); + let _notifications = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(NOTIFICATIONS_BUS_NAME) + .expect("request notification bus name") + .build() + .await + .expect("connect notification service"); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.control-state", + ] + ); + }); +} + +#[test] +fn notification_gap_does_not_hide_healthy_control_checks() { + run_async(async { + let broker = PrivateBroker::start(); + let _control = control_server(&broker.address, false, false).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.notifications-readiness", + "dbus.control-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + }); +} + +#[test] +fn different_owners_preserve_the_shared_owner_error_and_check_order() { + run_async(async { + let broker = PrivateBroker::start(); + let _control = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request control bus name") + .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state: false }) + .expect("register test control interface") + .build() + .await + .expect("connect control service"); + let _notifications = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(NOTIFICATIONS_BUS_NAME) + .expect("request notification bus name") + .build() + .await + .expect("connect separate notification service"); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.shared-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + let ownership = result + .checks + .iter() + .find(|check| check.id == "dbus.shared-owner") + .expect("shared owner check"); + assert_eq!(ownership.severity, DoctorSeverity::Error); + }); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs new file mode 100644 index 000000000..c2b6bfaa4 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs @@ -0,0 +1,20 @@ +use crate::doctor::report::DoctorSeverity; + +use super::super::session::unavailable_probe; + +#[test] +fn unavailable_bus_preserves_the_complete_dependent_check_sequence() { + let result = unavailable_probe("connection refused".to_string()); + + assert!(result.connection.is_none()); + assert_eq!( + result + .checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + ["dbus.session", "dbus.dependent-checks"] + ); + assert_eq!(result.checks[0].severity, DoctorSeverity::Error); + assert_eq!(result.checks[1].severity, DoctorSeverity::Note); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs new file mode 100644 index 000000000..9b70ac06c --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs @@ -0,0 +1,158 @@ +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{ + ControlState, UiHealth, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME, +}; +use zbus::ConnectionBuilder; + +static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); + +pub(super) struct PrivateBroker { + child: Child, + socket: PathBuf, + pub(super) address: String, +} + +impl PrivateBroker { + pub(super) fn start() -> Self { + let socket = broker_socket(); + let listen_address = format!("unix:path={}", socket.display()); + // Resolve from protected roots because other tests may temporarily replace PATH + let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") + .expect("find dbus-daemon in a trusted system directory"); + let mut child = Command::new(daemon) + .args([ + "--session", + "--nofork", + "--nopidfile", + "--print-address=1", + &format!("--address={listen_address}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("start private D-Bus broker"); + let stdout = child.stdout.take().expect("capture private broker address"); + let mut address = String::new(); + BufReader::new(stdout) + .read_line(&mut address) + .expect("read private broker address"); + assert!( + address.trim().starts_with(&listen_address), + "private broker must listen on the requested socket" + ); + Self { + child, + socket, + address: address.trim().to_string(), + } + } +} + +impl Drop for PrivateBroker { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.socket); + if let Some(parent) = self.socket.parent() { + let _ = std::fs::remove_dir(parent); + } + } +} + +pub(super) struct TestControl { + pub(super) deny_state: bool, +} + +#[zbus::interface(name = "com.unixnotis.Control")] +impl TestControl { + fn get_state(&self) -> zbus::fdo::Result { + if self.deny_state { + return Err(zbus::fdo::Error::AccessDenied( + "test client denied".to_string(), + )); + } + Ok(ControlState { + dnd_enabled: true, + dnd_expires_at: 0, + history_count: 4, + inhibited: false, + inhibitor_count: 2, + }) + } + + fn get_ui_health(&self) -> zbus::fdo::Result { + Ok(UiHealth { + center_process_running: true, + center_ready: true, + popups_process_running: true, + popups_ready: true, + }) + } +} + +pub(super) fn run_async(future: impl std::future::Future) { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build D-Bus test runtime") + .block_on(future); +} + +pub(super) async fn connect(address: &str) -> zbus::Connection { + ConnectionBuilder::address(address) + .expect("parse private broker address") + .build() + .await + .expect("connect to private broker") +} + +pub(super) async fn control_server( + address: &str, + deny_state: bool, + own_notifications: bool, +) -> zbus::Connection { + let connection = ConnectionBuilder::address(address) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request control bus name") + .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state }) + .expect("register test control interface") + .build() + .await + .expect("connect test control service"); + if own_notifications { + connection + .request_name(NOTIFICATIONS_BUS_NAME) + .await + .expect("request notification bus name"); + } + connection +} + +pub(super) fn check_ids(result: &super::super::DoctorBusResult) -> Vec<&str> { + result + .checks + .iter() + .map(|check| check.id.as_str()) + .collect() +} + +fn broker_socket() -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after the Unix epoch") + .as_nanos(); + let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-doctor-dbus-{}-{stamp}-{serial}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create private broker directory"); + root.join("bus.sock") +} diff --git a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs deleted file mode 100644 index 1544f3331..000000000 --- a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::super::dbus::*; -use crate::doctor::report::DoctorSeverity; -use unixnotis_core::{ - ControlState, UiHealth, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME, -}; -use zbus::ConnectionBuilder; - -static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); - -struct PrivateBroker { - child: Child, - socket: PathBuf, - address: String, -} - -impl PrivateBroker { - fn start() -> Self { - let socket = broker_socket(); - let listen_address = format!("unix:path={}", socket.display()); - // Other tests may temporarily replace PATH, so resolve the broker from fixed system roots - let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") - .expect("find dbus-daemon in a trusted system directory"); - let mut child = Command::new(daemon) - .args([ - "--session", - "--nofork", - "--nopidfile", - "--print-address=1", - &format!("--address={listen_address}"), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("start private D-Bus broker"); - let stdout = child.stdout.take().expect("capture private broker address"); - let mut address = String::new(); - BufReader::new(stdout) - .read_line(&mut address) - .expect("read private broker address"); - assert!( - address.trim().starts_with(&listen_address), - "private broker must listen on the requested socket" - ); - - Self { - child, - socket, - address: address.trim().to_string(), - } - } -} - -impl Drop for PrivateBroker { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_file(&self.socket); - if let Some(parent) = self.socket.parent() { - let _ = std::fs::remove_dir(parent); - } - } -} - -struct TestControl { - deny_state: bool, -} - -#[zbus::interface(name = "com.unixnotis.Control")] -impl TestControl { - fn get_state(&self) -> zbus::fdo::Result { - if self.deny_state { - return Err(zbus::fdo::Error::AccessDenied( - "test client denied".to_string(), - )); - } - - Ok(ControlState { - dnd_enabled: true, - dnd_expires_at: 0, - history_count: 4, - inhibited: false, - inhibitor_count: 2, - }) - } - - fn get_ui_health(&self) -> zbus::fdo::Result { - Ok(UiHealth { - center_process_running: true, - center_ready: true, - popups_process_running: true, - popups_ready: true, - }) - } -} - -fn broker_socket() -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock must be after the Unix epoch") - .as_nanos(); - let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "unixnotis-doctor-dbus-{}-{stamp}-{serial}", - std::process::id() - )); - std::fs::create_dir_all(&root).expect("create private broker directory"); - root.join("bus.sock") -} - -async fn connect(address: &str) -> zbus::Connection { - ConnectionBuilder::address(address) - .expect("parse private broker address") - .build() - .await - .expect("connect to private broker") -} - -async fn control_server(address: &str, deny_state: bool) -> zbus::Connection { - let connection = ConnectionBuilder::address(address) - .expect("parse private broker address") - .name(CONTROL_BUS_NAME) - .expect("request control bus name") - .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state }) - .expect("register test control interface") - .build() - .await - .expect("connect test control service"); - connection - .request_name(NOTIFICATIONS_BUS_NAME) - .await - .expect("request notification bus name"); - connection -} - -#[test] -fn unavailable_bus_preserves_an_error_and_dependent_check_context() { - let result = unavailable_bus_result("connection refused".to_string()); - - assert!(!result.control_owned); - assert_eq!(result.checks.len(), 2); - assert_eq!(result.checks[0].severity, DoctorSeverity::Error); - assert_eq!(result.checks[1].severity, DoctorSeverity::Note); -} - -#[test] -fn access_denied_state_failure_explains_installed_client_requirements() { - let error = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( - "caller is not authorized for control operation".to_string(), - ))); - - let check = control_state_failure_check(&error); - - assert_eq!(check.id, "dbus.control-state"); - assert_eq!(check.severity, DoctorSeverity::Error); - assert_eq!(check.summary, "UnixNotis control access denied"); - assert_eq!( - check.details.as_deref(), - Some("The running daemon rejected this client") - ); - assert!(check - .hint - .as_deref() - .is_some_and(|hint| hint.contains("installed noticenterctl"))); - assert!(!check - .details - .as_deref() - .is_some_and(|details| details.contains("caller is not authorized"))); -} - -#[test] -fn non_authorization_state_failure_preserves_the_original_error() { - let error = zbus::Error::Failure("state unavailable".to_string()); - - let check = control_state_failure_check(&error); - - assert_eq!(check.summary, "GetState failed"); - assert_eq!(check.details.as_deref(), Some("state unavailable")); - assert!(check.hint.is_none()); -} - -#[test] -fn missing_bus_owners_report_both_readiness_failures() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(!result.control_owned); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.notifications-readiness" && check.severity == DoctorSeverity::Error - })); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.control-state" - && check.summary == "UnixNotis control service has no owner" - })); - }); -} - -#[test] -fn owned_control_service_runs_proxy_and_state_checks() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let _server = control_server(&broker.address, false).await; - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(result.control_owned); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.control-proxy" && check.severity == DoctorSeverity::Pass - })); - let state = result - .checks - .iter() - .find(|check| check.id == "dbus.control-state") - .expect("control state check"); - assert_eq!(state.severity, DoctorSeverity::Pass); - assert_eq!(state.summary, "GetState completed"); - assert!(state - .details - .as_deref() - .is_some_and(|details| details.contains("History entries: 4"))); - let ui_health = result - .checks - .iter() - .find(|check| check.id == "dbus.ui-health") - .expect("UI health check"); - assert_eq!(ui_health.severity, DoctorSeverity::Pass); - assert!(ui_health - .details - .as_deref() - .is_some_and(|details| details.contains("Popup GTK runtime: ready"))); - }); -} - -#[test] -fn different_notification_and_control_owners_fail_the_shared_owner_check() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let _control = ConnectionBuilder::address(broker.address.as_str()) - .expect("parse private broker address") - .name(CONTROL_BUS_NAME) - .expect("request control bus name") - .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state: false }) - .expect("register test control interface") - .build() - .await - .expect("connect test control service"); - let _notifications = ConnectionBuilder::address(broker.address.as_str()) - .expect("parse private broker address") - .name(NOTIFICATIONS_BUS_NAME) - .expect("request notification bus name") - .build() - .await - .expect("connect separate notification service"); - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - let ownership = result - .checks - .iter() - .find(|check| check.id == "dbus.shared-owner") - .expect("shared owner check"); - - assert_eq!(ownership.severity, DoctorSeverity::Error); - assert_eq!( - ownership.summary, - "Notification and control names have different owners" - ); - }); -} - -#[test] -fn method_error_access_denial_uses_the_specific_client_guidance() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let _server = control_server(&broker.address, true).await; - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(result.control_owned); - let state = result - .checks - .iter() - .find(|check| check.id == "dbus.control-state") - .expect("control state check"); - assert_eq!(state.severity, DoctorSeverity::Error); - assert_eq!(state.summary, "UnixNotis control access denied"); - assert_eq!( - state.details.as_deref(), - Some("The running daemon rejected this client") - ); - }); -} diff --git a/crates/noticenterctl/src/doctor/checks/tests/mod.rs b/crates/noticenterctl/src/doctor/checks/tests/mod.rs index ca74e29fb..e515969e5 100644 --- a/crates/noticenterctl/src/doctor/checks/tests/mod.rs +++ b/crates/noticenterctl/src/doctor/checks/tests/mod.rs @@ -1,4 +1,3 @@ mod config; mod css; -mod dbus; mod environment; From 69aa3b3bf5ffacab57cfd9d298853a5f8afcadf3 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:06:55 -0500 Subject: [PATCH 102/275] fix(popups): bind readiness to renderer generations Summary: bind readiness to renderer generations. Scope: popups. --- .../src/daemon/bus/clients.rs | 4 +- crates/unixnotis-popups/src/dbus/runtime.rs | 538 ------------------ .../src/dbus/runtime/bootstrap.rs | 47 ++ .../src/dbus/runtime/connection.rs | 193 +++++++ .../src/dbus/runtime/delivery.rs | 37 ++ .../src/dbus/runtime/generation.rs | 286 ++++++++++ .../unixnotis-popups/src/dbus/runtime/mod.rs | 37 ++ .../src/dbus/runtime/readiness.rs | 51 ++ .../src/dbus/runtime/tests/bootstrap.rs | 8 + .../src/dbus/runtime/tests/connection.rs | 11 + .../src/dbus/runtime/tests/generation.rs | 15 + .../src/dbus/runtime/tests/mod.rs | 4 + .../runtime.rs => runtime/tests/readiness.rs} | 8 +- 13 files changed, 693 insertions(+), 546 deletions(-) delete mode 100644 crates/unixnotis-popups/src/dbus/runtime.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/connection.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/delivery.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/generation.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/mod.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/readiness.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs create mode 100644 crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs rename crates/unixnotis-popups/src/dbus/{tests/runtime.rs => runtime/tests/readiness.rs} (68%) diff --git a/crates/unixnotis-daemon/src/daemon/bus/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/clients.rs index 5b67e87a9..28acbb800 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/clients.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/clients.rs @@ -3,9 +3,11 @@ use crate::daemon::DaemonState; impl DaemonState { - pub(in crate::daemon::bus) async fn remove_disconnected_client(&self, owner: &str) { + pub(in crate::daemon) async fn remove_disconnected_client(&self, owner: &str) { // Sender metadata is keyed by unique names and cannot survive owner loss self.sender_metadata_cache.remove(owner); + // Only the owner that published the active popup generation can clear it + self.set_popups_ready(owner, false); let inhibitors_removed = { let mut store = self.store.lock().await; diff --git a/crates/unixnotis-popups/src/dbus/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime.rs deleted file mode 100644 index 2e7d72f9c..000000000 --- a/crates/unixnotis-popups/src/dbus/runtime.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! Popup D-Bus runtime bootstrap and stream loop - -use std::thread; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio::sync::{mpsc, watch}; -use tracing::{info, warn}; -use unixnotis_core::{ - log_session_bus_identity, timed_dbus_call, ControlProxy, CONTROL_BUS_NAME, - INTERNAL_DBUS_CALL_TIMEOUT, -}; -use zbus::fdo::DBusProxy; -use zbus::names::BusName; -use zbus::proxy::OwnerChangedStream; -use zbus::Connection; - -use super::backoff::{ - Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, -}; -use super::commands::{drain_offline_commands, handle_command}; -use super::seed::{seed_state, PopupSeedSource, SeedError, SeedSnapshot}; -use super::types::{UiCommand, UiEvent}; - -// Bound UI commands to avoid unbounded memory growth under a stuck UI event loop -const UI_COMMAND_QUEUE_CAPACITY: usize = 64; - -pub struct PopupRuntime { - command_tx: mpsc::Sender, - gtk_ready_tx: watch::Sender, -} - -impl PopupRuntime { - pub fn command_sender(&self) -> mpsc::Sender { - self.command_tx.clone() - } - - pub fn mark_gtk_ready(&self) { - // The D-Bus generation cannot publish readiness before UiState construction finishes - let _ = self.gtk_ready_tx.send(true); - } -} - -struct ControlProxySeedSource<'proxy, 'conn> { - proxy: &'proxy ControlProxy<'conn>, -} - -impl PopupSeedSource for ControlProxySeedSource<'_, '_> { - async fn seed_snapshot(&self) -> Result { - // GetState is the owner handshake and must finish before snapshot calls begin - let state = timed_dbus_call(self.proxy.get_state()).await; - let state = match state { - Ok(state) => state, - Err(error) => { - return SeedSnapshot::from_fetch_results(Err(error), Ok(Vec::new())); - } - }; - let active = timed_dbus_call(self.proxy.list_popup_candidates()).await; - let state = Ok(state); - SeedSnapshot::from_fetch_results(state, active) - } -} - -pub fn start_dbus_runtime(sender: async_channel::Sender) -> PopupRuntime { - let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); - let (gtk_ready_tx, gtk_ready_rx) = watch::channel(false); - spawn_runtime_thread(sender, command_rx, gtk_ready_rx); - PopupRuntime { - command_tx, - gtk_ready_tx, - } -} - -fn spawn_runtime_thread( - sender: async_channel::Sender, - command_rx: mpsc::Receiver, - gtk_ready_rx: watch::Receiver, -) { - thread::spawn(move || { - // Dedicated runtime keeps async D-Bus work off the GTK main thread - let Some(runtime) = build_runtime() else { - return; - }; - runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx)); - }); -} - -fn build_runtime() -> Option { - tokio::runtime::Builder::new_multi_thread() - // Small worker pool keeps background popups responsive without excess threads - .worker_threads(2) - .enable_all() - .build() - .map_err(|err| { - warn!(?err, "failed to initialize tokio runtime"); - err - }) - .ok() -} - -async fn run_dbus_loop( - sender: async_channel::Sender, - mut command_rx: mpsc::Receiver, - mut gtk_ready_rx: watch::Receiver, -) { - let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); - let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); - let mut connect_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); - let mut subscribe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); - - loop { - let connection = connect_session_bus(&mut connect_backoff, &mut connect_log).await; - let Some(retry_delay) = run_connection_once( - &connection, - &sender, - &mut command_rx, - &mut subscribe_backoff, - &mut subscribe_log, - &mut gtk_ready_rx, - ) - .await - else { - return; - }; - tokio::time::sleep(retry_delay).await; - } -} - -async fn connect_session_bus( - connect_backoff: &mut Backoff, - connect_log: &mut RetryLog, -) -> Connection { - loop { - match Connection::session().await { - Ok(connection) => { - if let Err(error) = log_session_bus_identity(&connection, "popups").await { - connect_log - .warn_or_debug(&error, "session bus identity probe failed; retrying"); - tokio::time::sleep(connect_backoff.next_sleep()).await; - continue; - } - connect_backoff.reset(); - connect_log.reset(); - return connection; - } - Err(err) => { - connect_log.warn_or_debug(&err, "failed to connect to session bus; retrying"); - tokio::time::sleep(connect_backoff.next_sleep()).await; - } - } - } -} - -async fn run_connection_once( - connection: &Connection, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, - subscribe_backoff: &mut Backoff, - subscribe_log: &mut RetryLog, - gtk_ready_rx: &mut watch::Receiver, -) -> Option { - let proxy = match ControlProxy::new(connection).await { - Ok(proxy) => proxy, - Err(err) => { - subscribe_log.warn_or_debug(&err, "control interface unavailable, retrying"); - if let Some(acknowledgement) = drain_offline_commands(command_rx) { - let _ = acknowledgement.send(()); - return None; - } - return Some(subscribe_backoff.next_sleep()); - } - }; - let mut owner_changes = match proxy.inner().receive_owner_changed().await { - Ok(stream) => stream, - Err(error) => { - subscribe_log.warn_or_debug(&error, "control owner watch unavailable, retrying"); - return Some(subscribe_backoff.next_sleep()); - } - }; - let dbus = match DBusProxy::new(connection).await { - Ok(proxy) => proxy, - Err(error) => { - subscribe_log.warn_or_debug(&error, "session bus owner proxy unavailable, retrying"); - return Some(subscribe_backoff.next_sleep()); - } - }; - - loop { - let owner = - match wait_for_control_owner(&dbus, &mut owner_changes, sender, command_rx).await { - OwnerWait::Ready(owner) => owner, - OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), - OwnerWait::Shutdown => return None, - }; - match run_owner_generation( - &proxy, - &owner, - PopupGenerationContext::new( - &mut owner_changes, - sender, - command_rx, - subscribe_backoff, - subscribe_log, - gtk_ready_rx, - ), - ) - .await - { - GenerationExit::OwnerChanged => {} - GenerationExit::ConnectionLost => return Some(subscribe_backoff.next_sleep()), - GenerationExit::Shutdown => return None, - GenerationExit::Retry => { - tokio::time::sleep(subscribe_backoff.next_sleep()).await; - } - } - } -} - -enum OwnerWait { - Ready(String), - Disconnected, - Shutdown, -} - -enum GenerationExit { - OwnerChanged, - ConnectionLost, - Shutdown, - Retry, -} - -struct PopupGenerationContext<'context, 'stream> { - owner_changes: &'context mut OwnerChangedStream<'stream>, - sender: &'context async_channel::Sender, - command_rx: &'context mut mpsc::Receiver, - subscribe_backoff: &'context mut Backoff, - subscribe_log: &'context mut RetryLog, - gtk_ready_rx: &'context mut watch::Receiver, -} - -impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { - const fn new( - owner_changes: &'context mut OwnerChangedStream<'stream>, - sender: &'context async_channel::Sender, - command_rx: &'context mut mpsc::Receiver, - subscribe_backoff: &'context mut Backoff, - subscribe_log: &'context mut RetryLog, - gtk_ready_rx: &'context mut watch::Receiver, - ) -> Self { - Self { - owner_changes, - sender, - command_rx, - subscribe_backoff, - subscribe_log, - gtk_ready_rx, - } - } -} - -async fn wait_for_control_owner( - dbus: &DBusProxy<'_>, - owner_changes: &mut OwnerChangedStream<'_>, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, -) -> OwnerWait { - let control_name = BusName::try_from(CONTROL_BUS_NAME) - .expect("static UnixNotis control bus name must be valid"); - if let Ok(Ok(owner)) = tokio::time::timeout( - INTERNAL_DBUS_CALL_TIMEOUT, - dbus.get_name_owner(control_name), - ) - .await - { - return OwnerWait::Ready(owner.to_string()); - } - - // No owner is a quiet disconnected state until the broker announces one - let _ = sender.send(UiEvent::Disconnected).await; - if let Some(acknowledgement) = drain_offline_commands(command_rx) { - let _ = acknowledgement.send(()); - return OwnerWait::Shutdown; - } - loop { - tokio::select! { - command = command_rx.recv() => { - match command { - Some(UiCommand::Shutdown(acknowledgement)) => { - let _ = acknowledgement.send(()); - return OwnerWait::Shutdown; - } - Some(_) => warn!("dropping popup command while control service has no owner"), - None => return OwnerWait::Shutdown, - } - } - update = owner_changes.next() => { - match update { - Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), - Some(None) => {} - None => return OwnerWait::Disconnected, - } - } - } - } -} - -async fn run_owner_generation( - proxy: &ControlProxy<'_>, - owner: &str, - context: PopupGenerationContext<'_, '_>, -) -> GenerationExit { - let PopupGenerationContext { - owner_changes, - sender, - command_rx, - subscribe_backoff, - subscribe_log, - gtk_ready_rx, - } = context; - // Popups stay on the shared notification stream, but the trimmed payload keeps - // each message smaller now that unused flags were removed from NotificationView - let mut added_stream = match proxy.receive_notification_added().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_added"); - return GenerationExit::Retry; - } - }; - let mut updated_stream = match proxy.receive_notification_updated().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_updated"); - return GenerationExit::Retry; - } - }; - let mut closed_stream = match proxy.receive_notification_closed().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_closed"); - return GenerationExit::Retry; - } - }; - let mut popup_gate_stream = match proxy.receive_popup_gate_changed().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to popup_gate_changed"); - return GenerationExit::Retry; - } - }; - let mut invalidated_stream = match proxy.receive_snapshot_invalidated().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to snapshot_invalidated"); - return GenerationExit::Retry; - } - }; - - // Seed only after subscriptions are active so startup does not miss in-flight changes - if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { - subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); - return GenerationExit::Retry; - } - if !wait_for_gtk_runtime(gtk_ready_rx).await { - warn!("popup GTK runtime did not become ready"); - return GenerationExit::Retry; - } - if let Err(error) = timed_dbus_call(proxy.mark_popups_ready()).await { - subscribe_log.warn_or_debug(&error, "failed to mark popup renderer ready"); - return GenerationExit::Retry; - } - subscribe_backoff.reset(); - subscribe_log.reset(); - info!(owner, "UnixNotis control service ready"); - - let mut shutdown_acknowledgement = None; - let exit = loop { - tokio::select! { - command = command_rx.recv() => { - let Some(command) = command else { - break GenerationExit::Shutdown; - }; - match command { - UiCommand::Shutdown(acknowledgement) => { - shutdown_acknowledgement = Some(acknowledgement); - break GenerationExit::Shutdown; - } - command => { - if let Err(err) = handle_command(proxy, command).await { - warn!(?err, "control command failed"); - } - } - } - } - signal = added_stream.next() => { - let Some(signal) = signal else { - warn!("notification_added stream ended"); - break GenerationExit::OwnerChanged; - }; - if let Ok(args) = signal.args() { - push_active_notification_event( - proxy, - sender, - *args.id(), - *args.show_popup(), - true, - ).await; - } - } - signal = updated_stream.next() => { - let Some(signal) = signal else { - warn!("notification_updated stream ended"); - break GenerationExit::OwnerChanged; - }; - if let Ok(args) = signal.args() { - push_active_notification_event( - proxy, - sender, - *args.id(), - *args.show_popup(), - false, - ).await; - } - } - signal = closed_stream.next() => { - let Some(signal) = signal else { - warn!("notification_closed stream ended"); - break GenerationExit::OwnerChanged; - }; - if let Ok(args) = signal.args() { - let _ = sender - .send(UiEvent::NotificationClosed( - *args.id(), - *args.reason(), - )) - .await; - } - } - signal = popup_gate_stream.next() => { - let Some(signal) = signal else { - warn!("popup_gate_changed stream ended"); - break GenerationExit::OwnerChanged; - }; - if let Ok(args) = signal.args() { - let _ = sender - .send(UiEvent::PopupGateChanged(args.gate().clone())) - .await; - } - } - signal = invalidated_stream.next() => { - let Some(_signal) = signal else { - warn!("snapshot_invalidated stream ended"); - break GenerationExit::OwnerChanged; - }; - // A fresh seed clears stale popups after remote clears or daemon restart drift - // Seed reconcile also updates same-id payload changes without trusting missed signals - if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { - subscribe_log.warn_or_debug(&error, "popup snapshot refresh failed"); - break GenerationExit::Retry; - } - } - owner_update = owner_changes.next() => { - match owner_update { - Some(Some(new_owner)) => { - warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); - let _ = sender.send(UiEvent::Disconnected).await; - break GenerationExit::OwnerChanged; - } - Some(None) => { - info!("UnixNotis control service disconnected"); - let _ = sender.send(UiEvent::Disconnected).await; - break GenerationExit::OwnerChanged; - } - None => { - warn!("control owner stream ended"); - let _ = sender.send(UiEvent::Disconnected).await; - break GenerationExit::ConnectionLost; - } - } - } - } - }; - - // No-autostart prevents orderly cleanup from reviving a stopped daemon - let _ = timed_dbus_call(proxy.mark_popups_not_ready()).await; - if let Some(acknowledgement) = shutdown_acknowledgement { - let _ = acknowledgement.send(()); - } - exit -} - -async fn wait_for_gtk_runtime(gtk_ready_rx: &mut watch::Receiver) -> bool { - if *gtk_ready_rx.borrow() { - return true; - } - tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, async { - loop { - if gtk_ready_rx.changed().await.is_err() { - return *gtk_ready_rx.borrow(); - } - if *gtk_ready_rx.borrow() { - return true; - } - } - }) - .await - .unwrap_or(false) -} - -async fn push_active_notification_event( - proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, - id: u32, - show_popup: bool, - is_add: bool, -) { - // Full popup payloads now stay on the authorized pull path instead of the shared signal - match timed_dbus_call(proxy.get_active_notification(id)).await { - Ok(mut notifications) => { - // Close fanout can win the race, so a missing row is a normal no-op here - let Some(notification) = notifications.pop() else { - return; - }; - let event = if is_add { - UiEvent::NotificationAdded(notification, show_popup) - } else { - UiEvent::NotificationUpdated(notification, show_popup) - }; - let _ = sender.send(event).await; - } - Err(err) => { - warn!(?err, id, "failed to fetch popup notification after signal"); - } - } -} - -#[cfg(test)] -#[path = "tests/runtime.rs"] -mod tests; diff --git a/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs new file mode 100644 index 000000000..b9f6480df --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs @@ -0,0 +1,47 @@ +//! Dedicated Tokio runtime construction and queue wiring + +use std::thread; + +use tokio::sync::{mpsc, watch}; +use tracing::warn; + +use super::connection::run_dbus_loop; +use super::{PopupRuntime, UI_COMMAND_QUEUE_CAPACITY}; +use crate::dbus::{UiCommand, UiEvent}; + +pub(super) fn start_runtime(sender: async_channel::Sender) -> PopupRuntime { + let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); + let (gtk_ready_tx, gtk_ready_rx) = watch::channel(false); + spawn_runtime_thread(sender, command_rx, gtk_ready_rx); + PopupRuntime { + command_tx, + gtk_ready_tx, + } +} + +fn spawn_runtime_thread( + sender: async_channel::Sender, + command_rx: mpsc::Receiver, + gtk_ready_rx: watch::Receiver, +) { + thread::spawn(move || { + // The GTK main thread never blocks on bus calls or retry delays + let Some(runtime) = build_runtime() else { + return; + }; + runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx)); + }); +} + +pub(super) fn build_runtime() -> Option { + tokio::runtime::Builder::new_multi_thread() + // Two workers keep signal delivery moving while one bounded call is pending + .worker_threads(2) + .enable_all() + .build() + .map_err(|error| { + warn!(?error, "failed to initialize popup Tokio runtime"); + error + }) + .ok() +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/connection.rs new file mode 100644 index 000000000..6b179d356 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/connection.rs @@ -0,0 +1,193 @@ +//! Session-bus recovery and control-owner discovery + +use std::time::Duration; + +use futures_util::StreamExt; +use tokio::sync::{mpsc, watch}; +use tracing::warn; +use unixnotis_core::{ + log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, INTERNAL_DBUS_CALL_TIMEOUT, +}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::proxy::OwnerChangedStream; +use zbus::Connection; + +use super::generation::{run_owner_generation, GenerationExit, PopupGenerationContext}; +use crate::dbus::backoff::{ + Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, +}; +use crate::dbus::commands::drain_offline_commands; +use crate::dbus::{UiCommand, UiEvent}; + +pub(super) async fn run_dbus_loop( + sender: async_channel::Sender, + mut command_rx: mpsc::Receiver, + mut gtk_ready_rx: watch::Receiver, +) { + let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); + let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); + let mut connect_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + let mut subscribe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + + loop { + let connection = connect_session_bus(&mut connect_backoff, &mut connect_log).await; + let Some(retry_delay) = run_connection_once( + &connection, + &sender, + &mut command_rx, + &mut subscribe_backoff, + &mut subscribe_log, + &mut gtk_ready_rx, + ) + .await + else { + return; + }; + tokio::time::sleep(retry_delay).await; + } +} + +async fn connect_session_bus( + connect_backoff: &mut Backoff, + connect_log: &mut RetryLog, +) -> Connection { + loop { + match Connection::session().await { + Ok(connection) => { + if let Err(error) = log_session_bus_identity(&connection, "popups").await { + connect_log + .warn_or_debug(&error, "session bus identity probe failed; retrying"); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + connect_backoff.reset(); + connect_log.reset(); + return connection; + } + Err(error) => { + connect_log.warn_or_debug(&error, "failed to connect to the session bus; retrying"); + tokio::time::sleep(connect_backoff.next_sleep()).await; + } + } + } +} + +async fn run_connection_once( + connection: &Connection, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + subscribe_backoff: &mut Backoff, + subscribe_log: &mut RetryLog, + gtk_ready_rx: &mut watch::Receiver, +) -> Option { + let proxy = match ControlProxy::new(connection).await { + Ok(proxy) => proxy, + Err(error) => { + subscribe_log.warn_or_debug(&error, "control interface unavailable; retrying"); + if acknowledge_offline_shutdown(command_rx) { + return None; + } + return Some(subscribe_backoff.next_sleep()); + } + }; + let mut owner_changes = match proxy.inner().receive_owner_changed().await { + Ok(stream) => stream, + Err(error) => { + subscribe_log.warn_or_debug(&error, "control owner watch unavailable; retrying"); + return Some(subscribe_backoff.next_sleep()); + } + }; + let dbus = match DBusProxy::new(connection).await { + Ok(proxy) => proxy, + Err(error) => { + subscribe_log.warn_or_debug(&error, "session owner proxy unavailable; retrying"); + return Some(subscribe_backoff.next_sleep()); + } + }; + + loop { + let owner = + match wait_for_control_owner(&dbus, &mut owner_changes, sender, command_rx).await { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), + OwnerWait::Shutdown => return None, + }; + let context = PopupGenerationContext::new( + &mut owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + ); + match run_owner_generation(&proxy, &owner, context).await { + GenerationExit::OwnerChanged => {} + GenerationExit::ConnectionLost => return Some(subscribe_backoff.next_sleep()), + GenerationExit::Shutdown => return None, + GenerationExit::Retry => { + tokio::time::sleep(subscribe_backoff.next_sleep()).await; + } + } + } +} + +pub(super) enum OwnerWait { + Ready(String), + Disconnected, + Shutdown, +} + +async fn wait_for_control_owner( + dbus: &DBusProxy<'_>, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, +) -> OwnerWait { + let control_name = + BusName::try_from(CONTROL_BUS_NAME).expect("static control bus name must be valid"); + if let Ok(Ok(owner)) = tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + return OwnerWait::Ready(owner.to_string()); + } + + // An unowned name is a quiet state and must not trigger seed or readiness calls + let _ = sender.send(UiEvent::Disconnected).await; + if acknowledge_offline_shutdown(command_rx) { + return OwnerWait::Shutdown; + } + loop { + tokio::select! { + command = command_rx.recv() => { + match command { + Some(UiCommand::Shutdown(acknowledgement)) => { + let _ = acknowledgement.send(()); + return OwnerWait::Shutdown; + } + Some(_) => warn!("dropping popup command while control has no owner"), + None => return OwnerWait::Shutdown, + } + } + update = owner_changes.next() => { + match update { + Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), + Some(None) => {} + None => return OwnerWait::Disconnected, + } + } + } + } +} + +fn acknowledge_offline_shutdown(command_rx: &mut mpsc::Receiver) -> bool { + if let Some(acknowledgement) = drain_offline_commands(command_rx) { + let _ = acknowledgement.send(()); + true + } else { + false + } +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs new file mode 100644 index 000000000..935f78350 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs @@ -0,0 +1,37 @@ +//! Authenticated notification pulls after lightweight signal delivery + +use tracing::warn; +use unixnotis_core::{timed_dbus_call, ControlProxy}; + +use crate::dbus::UiEvent; + +pub(super) async fn push_active_notification_event( + proxy: &ControlProxy<'_>, + sender: &async_channel::Sender, + id: u32, + show_popup: bool, + is_add: bool, +) { + // The daemon remains the authority for the complete notification payload + match timed_dbus_call(proxy.get_active_notification(id)).await { + Ok(mut notifications) => { + // A close signal may win this fetch race, making an empty result normal + let Some(notification) = notifications.pop() else { + return; + }; + let event = if is_add { + UiEvent::NotificationAdded(notification, show_popup) + } else { + UiEvent::NotificationUpdated(notification, show_popup) + }; + let _ = sender.send(event).await; + } + Err(error) => { + // One failed pull must not tear down an otherwise healthy generation + warn!( + ?error, + id, "failed to fetch popup notification after signal" + ); + } + } +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/generation.rs new file mode 100644 index 000000000..097f607c9 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/generation.rs @@ -0,0 +1,286 @@ +//! One control-owner generation from subscription through orderly cleanup + +use futures_util::StreamExt; +use tokio::sync::{mpsc, watch}; +use tracing::{info, warn}; +use unixnotis_core::{ + timed_dbus_call, ControlProxy, NotificationAddedStream, NotificationClosedStream, + NotificationUpdatedStream, PopupGateChangedStream, SnapshotInvalidatedStream, +}; +use zbus::proxy::OwnerChangedStream; + +use super::delivery::push_active_notification_event; +use super::readiness::{wait_for_gtk_runtime, PopupReadinessLease}; +use crate::dbus::backoff::{Backoff, RetryLog}; +use crate::dbus::commands::handle_command; +use crate::dbus::seed::{seed_state, PopupSeedSource, SeedError, SeedSnapshot}; +use crate::dbus::{UiCommand, UiEvent}; + +struct ControlProxySeedSource<'proxy, 'connection> { + proxy: &'proxy ControlProxy<'connection>, +} + +impl PopupSeedSource for ControlProxySeedSource<'_, '_> { + async fn seed_snapshot(&self) -> Result { + // GetState proves the owner can serve the expected control interface + let state = match timed_dbus_call(self.proxy.get_state()).await { + Ok(state) => state, + Err(error) => { + return SeedSnapshot::from_fetch_results(Err(error), Ok(Vec::new())); + } + }; + let active = timed_dbus_call(self.proxy.list_popup_candidates()).await; + SeedSnapshot::from_fetch_results(Ok(state), active) + } +} + +pub(super) enum GenerationExit { + OwnerChanged, + ConnectionLost, + Shutdown, + Retry, +} + +pub(super) struct PopupGenerationContext<'context, 'stream> { + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, +} + +impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { + pub(super) const fn new( + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, + ) -> Self { + Self { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + } + } +} + +struct GenerationStreams<'proxy> { + added: NotificationAddedStream<'proxy>, + updated: NotificationUpdatedStream<'proxy>, + closed: NotificationClosedStream<'proxy>, + gate: PopupGateChangedStream<'proxy>, + invalidated: SnapshotInvalidatedStream<'proxy>, +} + +struct SubscribeError { + signal: &'static str, + source: zbus::Error, +} + +impl GenerationStreams<'_> { + async fn subscribe<'proxy>( + proxy: &'proxy ControlProxy<'_>, + ) -> Result, SubscribeError> { + let added = proxy + .receive_notification_added() + .await + .map_err(|source| SubscribeError { + signal: "notification_added", + source, + })?; + let updated = proxy + .receive_notification_updated() + .await + .map_err(|source| SubscribeError { + signal: "notification_updated", + source, + })?; + let closed = proxy + .receive_notification_closed() + .await + .map_err(|source| SubscribeError { + signal: "notification_closed", + source, + })?; + let gate = proxy + .receive_popup_gate_changed() + .await + .map_err(|source| SubscribeError { + signal: "popup_gate_changed", + source, + })?; + let invalidated = proxy + .receive_snapshot_invalidated() + .await + .map_err(|source| SubscribeError { + signal: "snapshot_invalidated", + source, + })?; + Ok(GenerationStreams { + added, + updated, + closed, + gate, + invalidated, + }) + } +} + +pub(super) async fn run_owner_generation( + proxy: &ControlProxy<'_>, + owner: &str, + context: PopupGenerationContext<'_, '_>, +) -> GenerationExit { + let PopupGenerationContext { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + } = context; + let mut streams = match GenerationStreams::subscribe(proxy).await { + Ok(streams) => streams, + Err(error) => { + subscribe_log.warn_or_debug( + &error.source, + &format!("failed to subscribe to {}", error.signal), + ); + return GenerationExit::Retry; + } + }; + + // Subscription precedes the seed so no change can fall between both phases + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); + return GenerationExit::Retry; + } + if !wait_for_gtk_runtime(gtk_ready_rx).await { + warn!("popup GTK runtime did not become ready"); + return GenerationExit::Retry; + } + let mut readiness = PopupReadinessLease::new(proxy); + if let Err(error) = readiness.publish().await { + subscribe_log.warn_or_debug(&error, "failed to mark popup renderer ready"); + return GenerationExit::Retry; + } + subscribe_backoff.reset(); + subscribe_log.reset(); + info!(owner, "UnixNotis control service ready"); + + let mut shutdown_acknowledgement = None; + let exit = loop { + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + break GenerationExit::Shutdown; + }; + match command { + UiCommand::Shutdown(acknowledgement) => { + shutdown_acknowledgement = Some(acknowledgement); + break GenerationExit::Shutdown; + } + command => { + if let Err(error) = handle_command(proxy, command).await { + warn!(?error, "popup control command failed"); + } + } + } + } + signal = streams.added.next() => { + let Some(signal) = signal else { + warn!("notification_added stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + push_active_notification_event( + proxy, + sender, + *args.id(), + *args.show_popup(), + true, + ).await; + } + } + signal = streams.updated.next() => { + let Some(signal) = signal else { + warn!("notification_updated stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + push_active_notification_event( + proxy, + sender, + *args.id(), + *args.show_popup(), + false, + ).await; + } + } + signal = streams.closed.next() => { + let Some(signal) = signal else { + warn!("notification_closed stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + let _ = sender + .send(UiEvent::NotificationClosed(*args.id(), *args.reason())) + .await; + } + } + signal = streams.gate.next() => { + let Some(signal) = signal else { + warn!("popup_gate_changed stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + let _ = sender + .send(UiEvent::PopupGateChanged(args.gate().clone())) + .await; + } + } + signal = streams.invalidated.next() => { + let Some(_signal) = signal else { + warn!("snapshot_invalidated stream ended"); + break GenerationExit::OwnerChanged; + }; + // Re-seeding reconciles missed updates without publishing a second readiness lease + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup snapshot refresh failed"); + break GenerationExit::Retry; + } + } + owner_update = owner_changes.next() => { + match owner_update { + Some(Some(new_owner)) => { + warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + Some(None) => { + info!("UnixNotis control service disconnected"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + None => { + warn!("control owner stream ended"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::ConnectionLost; + } + } + } + } + }; + + readiness.clear().await; + if let Some(acknowledgement) = shutdown_acknowledgement { + let _ = acknowledgement.send(()); + } + exit +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/mod.rs new file mode 100644 index 000000000..d51139da4 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/mod.rs @@ -0,0 +1,37 @@ +//! Popup D-Bus runtime public surface + +mod bootstrap; +mod connection; +mod delivery; +mod generation; +mod readiness; + +use tokio::sync::{mpsc, watch}; + +use super::types::{UiCommand, UiEvent}; + +// A bounded queue prevents a stalled D-Bus connection from growing memory without limit +pub(super) const UI_COMMAND_QUEUE_CAPACITY: usize = 64; + +pub struct PopupRuntime { + command_tx: mpsc::Sender, + gtk_ready_tx: watch::Sender, +} + +impl PopupRuntime { + pub fn command_sender(&self) -> mpsc::Sender { + self.command_tx.clone() + } + + pub fn mark_gtk_ready(&self) { + // Readiness is published only after the GTK state owns its complete widget tree + let _ = self.gtk_ready_tx.send(true); + } +} + +pub fn start_dbus_runtime(sender: async_channel::Sender) -> PopupRuntime { + bootstrap::start_runtime(sender) +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-popups/src/dbus/runtime/readiness.rs b/crates/unixnotis-popups/src/dbus/runtime/readiness.rs new file mode 100644 index 000000000..658a8ae51 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/readiness.rs @@ -0,0 +1,51 @@ +//! GTK readiness wait and owner-generation readiness lease + +use tokio::sync::watch; +use unixnotis_core::{timed_dbus_call, ControlProxy, INTERNAL_DBUS_CALL_TIMEOUT}; + +pub(super) struct PopupReadinessLease<'proxy, 'connection> { + proxy: &'proxy ControlProxy<'connection>, + published: bool, +} + +impl<'proxy, 'connection> PopupReadinessLease<'proxy, 'connection> { + pub(super) const fn new(proxy: &'proxy ControlProxy<'connection>) -> Self { + Self { + proxy, + published: false, + } + } + + pub(super) async fn publish(&mut self) -> zbus::Result<()> { + timed_dbus_call(self.proxy.mark_popups_ready()).await?; + self.published = true; + Ok(()) + } + + pub(super) async fn clear(&mut self) { + if !self.published { + return; + } + // No-autostart cleanup cannot revive a daemon that is already stopping + let _ = timed_dbus_call(self.proxy.mark_popups_not_ready()).await; + self.published = false; + } +} + +pub(super) async fn wait_for_gtk_runtime(gtk_ready_rx: &mut watch::Receiver) -> bool { + if *gtk_ready_rx.borrow() { + return true; + } + tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, async { + loop { + if gtk_ready_rx.changed().await.is_err() { + return *gtk_ready_rx.borrow(); + } + if *gtk_ready_rx.borrow() { + return true; + } + } + }) + .await + .unwrap_or(false) +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs new file mode 100644 index 000000000..92a41727c --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs @@ -0,0 +1,8 @@ +use super::super::bootstrap::build_runtime; +use super::super::UI_COMMAND_QUEUE_CAPACITY; + +#[test] +fn popup_runtime_builds_with_a_bounded_command_queue() { + assert!(build_runtime().is_some()); + assert_eq!(UI_COMMAND_QUEUE_CAPACITY, 64); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs new file mode 100644 index 000000000..fea59aa52 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs @@ -0,0 +1,11 @@ +use super::super::connection::OwnerWait; + +#[test] +fn owner_wait_states_keep_shutdown_distinct_from_recovery() { + assert!(matches!( + OwnerWait::Ready(String::from(":1.20")), + OwnerWait::Ready(_) + )); + assert!(matches!(OwnerWait::Disconnected, OwnerWait::Disconnected)); + assert!(matches!(OwnerWait::Shutdown, OwnerWait::Shutdown)); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs new file mode 100644 index 000000000..01c65b82f --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs @@ -0,0 +1,15 @@ +use super::super::generation::GenerationExit; + +#[test] +fn generation_exit_keeps_owner_change_connection_loss_and_shutdown_distinct() { + assert!(matches!( + GenerationExit::OwnerChanged, + GenerationExit::OwnerChanged + )); + assert!(matches!( + GenerationExit::ConnectionLost, + GenerationExit::ConnectionLost + )); + assert!(matches!(GenerationExit::Shutdown, GenerationExit::Shutdown)); + assert!(matches!(GenerationExit::Retry, GenerationExit::Retry)); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs new file mode 100644 index 000000000..b58bd3635 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs @@ -0,0 +1,4 @@ +mod bootstrap; +mod connection; +mod generation; +mod readiness; diff --git a/crates/unixnotis-popups/src/dbus/tests/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs similarity index 68% rename from crates/unixnotis-popups/src/dbus/tests/runtime.rs rename to crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs index 83a326944..0f811ad93 100644 --- a/crates/unixnotis-popups/src/dbus/tests/runtime.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs @@ -1,10 +1,4 @@ -use super::{build_runtime, wait_for_gtk_runtime, UI_COMMAND_QUEUE_CAPACITY}; - -#[test] -fn popup_runtime_builds_with_a_bounded_command_queue() { - assert!(build_runtime().is_some()); - assert_eq!(UI_COMMAND_QUEUE_CAPACITY, 64); -} +use super::super::readiness::wait_for_gtk_runtime; #[tokio::test] async fn gtk_readiness_wait_completes_after_ui_state_is_published() { From 569fb190662b071ef80b3021115311d3409ff1bf Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:06:58 -0500 Subject: [PATCH 103/275] refactor(test): inject preauthorized control owners explicitly Summary: inject preauthorized control owners explicitly. Scope: test. --- .../src/daemon/auth/authorization.rs | 5 ++--- .../daemon/notifications/server/tests/flow.rs | 1 + .../src/daemon/state/model.rs | 19 ++++++++++++------ .../src/daemon/state/status.rs | 17 ++-------------- .../src/daemon/state/tests/status.rs | 11 ++++++++++ crates/unixnotis-daemon/src/runtime/daemon.rs | 7 ++----- crates/unixnotis-daemon/src/runtime/runner.rs | 20 ++----------------- .../src/runtime/tests/dbus_lifecycle.rs | 10 ++++++---- crates/unixnotis-daemon/src/tests/support.rs | 1 + 9 files changed, 40 insertions(+), 51 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index 7ef543adc..a5b8adc70 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -69,9 +69,8 @@ async fn authorize_control_call_for_executables( .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; let sender_name = sender.as_str().to_string(); - #[cfg(test)] - if state.is_trusted_test_control_sender(&sender_name) { - // The exact broker-assigned owner is injected only by private-bus integration tests + if state.control_owner_is_preauthorized(&sender_name) { + // The policy is internal and production startup always selects strict verification return Ok(()); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 8f8fc313b..e1dab2ea8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -88,6 +88,7 @@ async fn daemon_state_with_config(config: Config) -> Arc { sound, false, Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + None, ) } diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index a348db322..4b3483414 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -52,9 +52,8 @@ pub struct DaemonState { pub(crate) desktop_identity_index: Arc>, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, - #[cfg(test)] - // Integration tests can authorize one broker-assigned sender without weakening production - pub(in crate::daemon::state) trusted_test_control_sender: StdMutex>, + // Normal startup supplies None; private-bus protocol tests can inject one unique owner + pub(in crate::daemon::state) preauthorized_control_owner: Option, } impl DaemonState { @@ -64,9 +63,17 @@ impl DaemonState { sound: SoundSettings, trial_mode: bool, desktop_identity_index: Arc>, + preauthorized_control_owner: Option, ) -> Arc { let store = NotificationStore::new(config); - Self::new_with_store(connection, store, sound, trial_mode, desktop_identity_index) + Self::new_with_store( + connection, + store, + sound, + trial_mode, + desktop_identity_index, + preauthorized_control_owner, + ) } pub(crate) fn new_with_store( @@ -75,6 +82,7 @@ impl DaemonState { sound: SoundSettings, trial_mode: bool, desktop_identity_index: Arc>, + preauthorized_control_owner: Option, ) -> Arc { // One construction path keeps scheduler, signal cache, and popup state in sync Arc::new(Self { @@ -97,8 +105,7 @@ impl DaemonState { sender_metadata_cache: SenderMetadataCache::new(), desktop_identity_index, trial_mode, - #[cfg(test)] - trusted_test_control_sender: StdMutex::new(None), + preauthorized_control_owner, }) } diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 28afd8a96..2a3e6ac87 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -90,20 +90,7 @@ impl DaemonState { self.trial_mode } - #[cfg(test)] - pub(crate) fn set_trusted_test_control_sender(&self, sender: Option) { - *self - .trusted_test_control_sender - .lock() - .expect("trusted test sender lock poisoned") = sender; - } - - #[cfg(test)] - pub(crate) fn is_trusted_test_control_sender(&self, sender: &str) -> bool { - self.trusted_test_control_sender - .lock() - .expect("trusted test sender lock poisoned") - .as_deref() - == Some(sender) + pub(in crate::daemon) fn control_owner_is_preauthorized(&self, owner: &str) -> bool { + self.preauthorized_control_owner.as_deref() == Some(owner) } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs index de089af93..c2a6f83dc 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -61,6 +61,17 @@ async fn popup_readiness_can_only_be_cleared_by_its_owner_generation() { assert!(!state.popups_ready()); } +#[tokio::test] +async fn popup_owner_loss_clears_readiness_for_the_matching_generation() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.remove_disconnected_client(":1.10").await; + + assert!(!state.popups_ready()); +} + #[tokio::test] async fn stopped_popup_process_clears_composite_readiness() { let state = daemon_state_for_test(true).await; diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index fd659f6d2..cead46d64 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -32,7 +32,7 @@ pub(super) async fn run_daemon( dbus_proxy: &DBusProxy<'_>, desktop_identity_index: Arc>, watched_desktop_directories: Vec, - trusted_test_control_sender: Option, + preauthorized_control_owner: Option, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); @@ -42,11 +42,8 @@ pub(super) async fn run_daemon( sound_settings, args.trial, desktop_identity_index, + preauthorized_control_owner, ); - #[cfg(test)] - state.set_trusted_test_control_sender(trusted_test_control_sender); - #[cfg(not(test))] - let _ = trusted_test_control_sender; if let Err(error) = spawn_desktop_index_refresh( state.desktop_identity_index.clone(), watched_desktop_directories, diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 6f6b59f83..2faa7cf2d 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -25,27 +25,11 @@ async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> Box::pin(run_with_builder_inner(args, config, builder, None)).await } -#[cfg(test)] -async fn run_with_builder_for_test( - args: &Args, - config: Config, - builder: Builder<'_>, - trusted_control_sender: String, -) -> Result<()> { - Box::pin(run_with_builder_inner( - args, - config, - builder, - Some(trusted_control_sender), - )) - .await -} - async fn run_with_builder_inner( args: &Args, config: Config, builder: Builder<'_>, - trusted_test_control_sender: Option, + preauthorized_control_owner: Option, ) -> Result<()> { let connection = builder .max_queued(DAEMON_DBUS_QUEUE_CAPACITY) @@ -80,7 +64,7 @@ async fn run_with_builder_inner( &dbus_proxy, desktop_identity_index, watched_directories, - trusted_test_control_sender, + preauthorized_control_owner, ) .await; let restore_result = trial_cleanup::finish_trial( diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs index 18420929f..bf528c548 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -9,9 +9,10 @@ use futures_util::StreamExt; use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; use zbus::fdo::DBusProxy; use zbus::names::BusName; +use zbus::zvariant::OwnedValue; use zbus::{Connection, ConnectionBuilder}; -use super::super::{run_with_builder, run_with_builder_for_test}; +use super::super::{run_with_builder, run_with_builder_inner}; use crate::cli::Args; use unixnotis_core::Config; @@ -146,11 +147,11 @@ fn spawn_daemon_with_trusted_sender( .expect("parse bounded daemon command"); let builder = zbus::connection::Builder::address(address.as_str()) .expect("parse daemon broker address"); - Box::pin(run_with_builder_for_test( + Box::pin(run_with_builder_inner( &args, Config::default(), builder, - trusted_sender, + Some(trusted_sender), )) .await }) @@ -305,7 +306,7 @@ async fn strict_broker_accepts_full_notification_view_after_added_signal() { "Complete notification view", "The strict broker must accept the nested enum payload", Vec::new(), - HashMap::new(), + HashMap::from([("urgency".to_string(), OwnedValue::from(2_u8))]), 2_000, ) .await @@ -325,6 +326,7 @@ async fn strict_broker_accepts_full_notification_view_after_added_signal() { assert_eq!(views.len(), 1); assert_eq!(views[0].id, id); assert_eq!(views[0].summary, "Complete notification view"); + assert_eq!(views[0].urgency, 2); let popup_candidates = control .list_popup_candidates() .await diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index f185d875a..1e426cc2a 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -36,6 +36,7 @@ pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { sound, trial_mode, Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + None, ) } From 0c84ef9432a974867f4b37c7d1b6551bcecf187e Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:09 -0500 Subject: [PATCH 104/275] fix(theme): flatten stock styles and preserve compact geometry Summary: flatten stock styles and preserve compact geometry. Scope: theme. --- Cargo.lock | 1 + .../src/css_check/lint/directives.rs | 35 - .../noticenterctl/src/css_check/lint/scan.rs | 27 +- .../src/css_check/lint/tests/scan.rs | 47 +- crates/unixnotis-core/Cargo.toml | 1 + crates/unixnotis-core/assets/base.css | 18 + crates/unixnotis-core/assets/media.css | 143 ++-- crates/unixnotis-core/assets/panel.css | 402 ++++------ crates/unixnotis-core/assets/popup.css | 37 +- crates/unixnotis-core/assets/widgets.css | 688 ++++-------------- .../src/config/loading/io/mod.rs | 1 + .../src/config/loading/io/tests/mod.rs | 1 + .../config/loading/io/tests/theme_stock.rs | 131 ++++ .../src/config/loading/io/theme_files.rs | 2 + .../src/config/loading/io/theme_stock.rs | 111 +++ .../unixnotis-core/src/embedded/tests/css.rs | 54 ++ 16 files changed, 728 insertions(+), 971 deletions(-) create mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock.rs diff --git a/Cargo.lock b/Cargo.lock index 746648ffd..e7e7d1a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3599,6 +3599,7 @@ dependencies = [ name = "unixnotis-core" version = "1.2.0" dependencies = [ + "blake3", "chrono", "image", "proptest", diff --git a/crates/noticenterctl/src/css_check/lint/directives.rs b/crates/noticenterctl/src/css_check/lint/directives.rs index 776d1e6d9..fff5950a4 100644 --- a/crates/noticenterctl/src/css_check/lint/directives.rs +++ b/crates/noticenterctl/src/css_check/lint/directives.rs @@ -39,13 +39,6 @@ impl DuplicateSelectorAllowlist { remaining = after_end; } - if ranges.is_empty() { - // Existing untouched installs predate directives but retain known stock bytes - if let Some(start) = legacy_stock_override_start(source) { - ranges.push(start..source.len()); - } - } - Self { ranges } } @@ -54,34 +47,6 @@ impl DuplicateSelectorAllowlist { } } -fn legacy_stock_override_start(source: &str) -> Option { - [ - ( - unixnotis_core::DEFAULT_PANEL_CSS, - "/* Restrained default composition", - ), - ( - unixnotis_core::DEFAULT_WIDGETS_CSS, - "/* Restrained widget composition", - ), - ( - unixnotis_core::DEFAULT_MEDIA_CSS, - "/* Restrained media transport */", - ), - ] - .into_iter() - .find_map(|(current, override_header)| { - let legacy = current - .replace(&format!("{ALLOW_DUPLICATE_SELECTORS_START}\n"), "") - .replace(&format!("{ALLOW_DUPLICATE_SELECTORS_END}\n"), ""); - (source == legacy).then(|| { - source - .find(override_header) - .expect("stock override header remains present") - }) - }) -} - #[cfg(test)] #[path = "tests/directives.rs"] mod tests; diff --git a/crates/noticenterctl/src/css_check/lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs index 823a5b751..6bc45050b 100644 --- a/crates/noticenterctl/src/css_check/lint/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/scan.rs @@ -18,13 +18,38 @@ struct CssLintContext<'a> { duplicate_selector_allowlist: &'a DuplicateSelectorAllowlist, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct LintOptions { + pub(super) honor_suppressions: bool, +} + +impl Default for LintOptions { + fn default() -> Self { + Self { + honor_suppressions: true, + } + } +} + pub(super) fn lint_css_contents_with_properties( contents: &str, custom_properties: &CssCustomPropertyScopes, +) -> Vec { + lint_css_contents_with_options(contents, custom_properties, LintOptions::default()) +} + +pub(super) fn lint_css_contents_with_options( + contents: &str, + custom_properties: &CssCustomPropertyScopes, + options: LintOptions, ) -> Vec { // One collection keeps source order stable across color and rule diagnostics let mut warnings = Vec::new(); - let duplicate_selector_allowlist = DuplicateSelectorAllowlist::from_source(contents); + let duplicate_selector_allowlist = if options.honor_suppressions { + DuplicateSelectorAllowlist::from_source(contents) + } else { + DuplicateSelectorAllowlist::default() + }; // Strip comments first so block scanning stays honest let stripped = strip_css_comments(contents); diff --git a/crates/noticenterctl/src/css_check/lint/tests/scan.rs b/crates/noticenterctl/src/css_check/lint/tests/scan.rs index f1dac180b..b6adb464a 100644 --- a/crates/noticenterctl/src/css_check/lint/tests/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/tests/scan.rs @@ -1,4 +1,4 @@ -use super::lint_css_contents_with_properties; +use super::{lint_css_contents_with_options, lint_css_contents_with_properties, LintOptions}; use crate::css_check::geometry::collect_custom_property_scopes; #[test] @@ -100,47 +100,30 @@ fn shipped_css_assets_are_lint_clean() { let properties = collect_custom_property_scopes(&combined); for css in assets { - let findings = lint_css_contents_with_properties(css, &properties); + let findings = lint_css_contents_with_options( + css, + &properties, + LintOptions { + honor_suppressions: false, + }, + ); assert!(findings.is_empty(), "{findings:?}"); } } #[test] -fn untouched_legacy_stock_assets_are_lint_clean_without_rewriting_user_files() { - let marker_lines = [ - "/* unixnotis-css-check allow-duplicate-selectors:start */\n", - "/* unixnotis-css-check allow-duplicate-selectors:end */\n", - ]; - let assets = [ - unixnotis_core::DEFAULT_PANEL_CSS, - unixnotis_core::DEFAULT_WIDGETS_CSS, - unixnotis_core::DEFAULT_MEDIA_CSS, - ]; - let config = unixnotis_core::Config::default(); - let generated = unixnotis_core::build_modern_theme_custom_properties( - &config.theme, - unixnotis_core::gtk_css_features_for_version(4, 16), - ); - let current_assets = [ +fn current_stock_assets_contain_no_lint_suppressions() { + for css in [ unixnotis_core::DEFAULT_BASE_CSS, unixnotis_core::DEFAULT_PANEL_CSS, unixnotis_core::DEFAULT_POPUP_CSS, unixnotis_core::DEFAULT_WIDGETS_CSS, unixnotis_core::DEFAULT_MEDIA_CSS, - ]; - let combined = std::iter::once(generated.as_str()) - .chain(current_assets) - .collect::>() - .join("\n"); - let properties = collect_custom_property_scopes(&combined); - - for current in assets { - let legacy = marker_lines - .iter() - .fold(current.to_string(), |css, marker| css.replace(marker, "")); - let findings = lint_css_contents_with_properties(&legacy, &properties); - - assert!(findings.is_empty(), "{findings:?}"); + ] { + assert!( + !css.contains("unixnotis-css-check allow-duplicate-selectors"), + "current stock CSS must not suppress repository lint findings" + ); } } diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index c7129cccc..adcd086b0 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +blake3.workspace = true chrono.workspace = true image.workspace = true resvg.workspace = true diff --git a/crates/unixnotis-core/assets/base.css b/crates/unixnotis-core/assets/base.css index 113abb257..bd8d8398d 100644 --- a/crates/unixnotis-core/assets/base.css +++ b/crates/unixnotis-core/assets/base.css @@ -38,6 +38,11 @@ @define-color unixnotis-accent #66f2e6; @define-color unixnotis-accent-2 #ff4fb7; @define-color unixnotis-urgent #ff5a78; +@define-color unixnotis-critical-surface #29151d; +@define-color unixnotis-critical-surface-strong #351923; +@define-color unixnotis-critical-border #fb7185; +@define-color unixnotis-critical-text #fecdd3; +@define-color unixnotis-critical-icon #fda4af; /* Per-toggle accents * @@ -103,4 +108,17 @@ font-family: "Manrope", "SF Pro Text", "CaskaydiaCove Nerd Font Propo", "Noto Sans", sans-serif; font-family: var(--unixnotis-ui-font-family); } + +/* Shared urgency badge stays compact enough for long application names */ +.unixnotis-urgency-badge { + background: alpha(@unixnotis-critical-border, 0.12); + border: 1px solid alpha(@unixnotis-critical-border, 0.42); + border-radius: 999px; + color: @unixnotis-critical-text; + font-size: 9px; + font-weight: 750; + letter-spacing: 0.08em; + padding: 2px 7px; + text-transform: uppercase; +} /* End of base theme. */ diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index aed9e4a51..42e26d6e3 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -60,27 +60,36 @@ } .unixnotis-media-nav { - background-image: linear-gradient(150deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.9)); - border-radius: 12px; - padding: calc((var(--unixnotis-media-nav-size) - 14px) / 2); - border: 1px solid alpha(@unixnotis-accent, 0.2); + background: alpha(#ffffff, 0.04); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: var(--unixnotis-media-button-radius); + padding: 3px; + margin: 0; font-weight: 700; font-size: 12px; - min-width: var(--unixnotis-media-nav-size); - color: @unixnotis-text; - box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - 0 0 16px -14px @unixnotis-glow-cyan; + min-width: 18px; + min-height: 18px; + color: alpha(#ffffff, 0.85); + box-shadow: none; + transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } .unixnotis-media-nav:hover { - background-image: linear-gradient(150deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.16)); - border-color: alpha(@unixnotis-accent, 0.5); + background: alpha(#ffffff, 0.09); + border-top-color: alpha(#ffffff, 0.16); + border-left-color: alpha(#ffffff, 0.12); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.03); + box-shadow: 0 4px 10px -5px alpha(#000000, 0.4), inset 0 1px 0 alpha(#ffffff, 0.05); + color: #ffffff; } .unixnotis-media-nav-prev, .unixnotis-media-nav-next { - min-width: var(--unixnotis-media-nav-size); + min-width: 18px; } .unixnotis-media-position { @@ -90,15 +99,16 @@ } .unixnotis-media-card { - background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, alpha(#0f1828, 0.94)); - border-radius: 18px; - border: 1px solid @unixnotis-card-border; + background: alpha(#ffffff, 0.035); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: var(--unixnotis-media-card-radius); padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); min-height: 68px; - box-shadow: - 0 14px 26px -20px @unixnotis-shadow-strong, - 0 0 22px -20px alpha(@unixnotis-accent-2, 0.16), - inset 0 0 0 1px alpha(#ffffff, 0.04); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-media-card-carousel { @@ -126,7 +136,14 @@ } .unixnotis-media-card.playing { - border-left: 1px solid @unixnotis-card-border; + background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); + border-top: 1px solid alpha(#ffffff, 0.16); + border-left: 1px solid alpha(#ffffff, 0.12); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.03); + box-shadow: + 0 16px 36px -20px alpha(#000000, 0.8), + inset 0 1px 0 alpha(#ffffff, 0.12); } /* @@ -214,75 +231,19 @@ } .unixnotis-media-button { - background-image: linear-gradient(160deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.95)); - border-radius: 10px; - border: 1px solid alpha(@unixnotis-accent, 0.2); - padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); - box-shadow: - 0 6px 14px -12px @unixnotis-shadow-soft, - 0 0 14px -14px @unixnotis-glow-cyan; -} - -.unixnotis-media-button:hover { - background-image: linear-gradient(160deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.2)); - border-color: alpha(@unixnotis-accent, 0.5); -} - -.unixnotis-media-button.primary { - background-image: linear-gradient(160deg, @unixnotis-action-bg-active, alpha(@unixnotis-accent-2, 0.28)); - border-color: alpha(@unixnotis-accent, 0.75); -} - -/* unixnotis-css-check allow-duplicate-selectors:start */ -/* Restrained media transport */ -.unixnotis-media-card { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - border-radius: var(--unixnotis-media-card-radius); - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-media-card:hover { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); - border-top-color: alpha(#ffffff, 0.14); - border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.06); - border-bottom-color: alpha(#ffffff, 0.03); - box-shadow: 0 8px 20px -10px alpha(#000000, 0.7), inset 0 1px 0 alpha(#ffffff, 0.05); -} - -.unixnotis-media-card.playing { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); - border-top: 1px solid alpha(#ffffff, 0.16); - border-left: 1px solid alpha(#ffffff, 0.12); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: - 0 16px 36px -20px alpha(#000000, 0.8), - inset 0 1px 0 alpha(#ffffff, 0.12); -} - -.unixnotis-media-button, -.unixnotis-media-nav { background: alpha(#ffffff, 0.04); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: var(--unixnotis-media-button-radius); + padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); box-shadow: none; color: alpha(#ffffff, 0.85); - border-radius: 999px; - border-radius: var(--unixnotis-media-button-radius); transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } -.unixnotis-media-button:hover, -.unixnotis-media-nav:hover { +.unixnotis-media-button:hover { background: alpha(#ffffff, 0.09); border-top-color: alpha(#ffffff, 0.16); border-left-color: alpha(#ffffff, 0.12); @@ -299,6 +260,16 @@ box-shadow: 0 4px 10px -3px alpha(#000000, 0.3); } +/* Restrained media transport */ +.unixnotis-media-card:hover { + background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.03); + box-shadow: 0 8px 20px -10px alpha(#000000, 0.7), inset 0 1px 0 alpha(#ffffff, 0.05); +} + .unixnotis-media-button.primary:hover { background: alpha(#ffffff, 0.90); border-color: alpha(#ffffff, 0.90); @@ -311,17 +282,3 @@ /* Remove default blue focus rings from GTK button selections */ outline: none; } - -/* Compact carousel navigation to protect panel width budget */ -.unixnotis-media-nav { - min-width: 18px; - min-height: 18px; - padding: 3px; - margin: 0; -} - -.unixnotis-media-nav-prev, -.unixnotis-media-nav-next { - min-width: 18px; -} -/* unixnotis-css-check allow-duplicate-selectors:end */ diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 1c9614a22..fd0ff1729 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -4,21 +4,16 @@ */ .unixnotis-panel { min-width: 420px; - /* Primary surface gradient. - * The last stop (panel-grad-3) is intentionally hotpink-leaning to create a - * bottom-right glow without changing any layout logic. */ - background-image: linear-gradient(155deg, @unixnotis-panel-grad-1 0%, @unixnotis-panel-grad-2 55%, @unixnotis-panel-grad-3 100%); + background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); color: @unixnotis-text; - border-radius: 30px; - border-radius: var(--unixnotis-panel-radius); + border-radius: 20px; padding: 16px; padding: var(--unixnotis-panel-padding); - border: 1px solid @unixnotis-outline; + border: 1px solid alpha(#9bb8e8, 0.16); + font-family: "Inter", "Manrope", "Noto Sans", sans-serif; box-shadow: - 0 28px 70px -38px @unixnotis-glow-cyan, - 0 22px 60px -42px @unixnotis-glow-pink, - 0 12px 28px -20px @unixnotis-shadow-soft, - inset 0 0 0 1px alpha(#ffffff, 0.04); + 0 26px 64px -34px alpha(#000000, 0.88), + inset 0 1px 0 alpha(#ffffff, 0.035); } @@ -26,14 +21,14 @@ margin-bottom: 12px; padding: 12px; padding: var(--unixnotis-panel-header-padding); - border-radius: 18px; - border-radius: var(--unixnotis-panel-header-radius); - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.7), alpha(@unixnotis-surface, 0.9)); - border: 1px solid alpha(@unixnotis-accent, 0.16); - box-shadow: - 0 10px 24px -18px @unixnotis-shadow-soft, - 0 0 22px -18px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.03); + padding-left: 2px; + padding-right: 2px; + padding-bottom: 12px; + border: 0; + border-bottom: 1px solid alpha(#9bb8e8, 0.12); + border-radius: 0; + background: transparent; + box-shadow: none; } .unixnotis-panel-header-top { @@ -79,21 +74,20 @@ .unixnotis-panel-title { font-weight: 700; font-size: 16px; - letter-spacing: 0.3px; + letter-spacing: -0.01em; } .unixnotis-panel-count { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.22), alpha(@unixnotis-accent-2, 0.18)); - color: @unixnotis-text; + background: alpha(@unixnotis-accent, 0.12); + color: #bffaf5; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; border-radius: 999px; padding: 2px 8px; - border: 1px solid alpha(@unixnotis-accent, 0.35); + border: 1px solid alpha(@unixnotis-accent, 0.28); min-width: 26px; - /* Slight bloom improves readability over complex wallpapers. */ - box-shadow: 0 0 12px -10px @unixnotis-glow-cyan; + box-shadow: none; } .unixnotis-panel-actions { @@ -107,18 +101,19 @@ } .unixnotis-panel-action { - background-image: linear-gradient(160deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.9)); - color: @unixnotis-text; - border-radius: 999px; + background: alpha(#ffffff, 0.045); + color: alpha(#ffffff, 0.75); + border-radius: 10px; padding: 6px 10px; padding: var(--unixnotis-panel-action-gap) calc(var(--unixnotis-panel-action-gap) + 4px); - border: 1px solid alpha(@unixnotis-accent, 0.18); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 28px; - box-shadow: - 0 8px 16px -12px @unixnotis-shadow-soft, - 0 0 18px -16px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.04); + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); font-size: 11px; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action-focus, @@ -160,8 +155,12 @@ } .unixnotis-panel-action:hover { - background-image: linear-gradient(160deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.16)); - border-color: alpha(@unixnotis-accent, 0.5); + background: alpha(#ffffff, 0.08); + border-top: 1px solid alpha(#ffffff, 0.14); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + color: #ffffff; box-shadow: 0 10px 18px -14px @unixnotis-shadow-soft, 0 0 22px -18px @unixnotis-glow-cyan, @@ -170,13 +169,14 @@ } .unixnotis-panel-action:checked { - background-image: linear-gradient(140deg, @unixnotis-action-bg-active, alpha(@unixnotis-accent-2, 0.3)); - border-color: alpha(@unixnotis-accent, 0.75); + background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); + border: 1px solid alpha(#00a2ff, 0.50); + box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } .unixnotis-panel-action:checked .unixnotis-panel-action-glyph, .unixnotis-panel-action:checked .unixnotis-panel-action-label { - color: @unixnotis-text; + color: #ffffff; } .unixnotis-panel-action-icon { @@ -188,17 +188,20 @@ .unixnotis-panel-action-close { /* Close action is visually isolated from destructive list actions. */ margin-left: 6px; - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-strong, 0.88), alpha(@unixnotis-surface-soft, 0.84)); - border-color: alpha(@unixnotis-accent, 0.26); - box-shadow: - 0 8px 14px -12px @unixnotis-shadow-soft, - 0 0 14px -14px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); + background: alpha(#ffffff, 0.045); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 10px; + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action-close:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg-hover, 0.88), alpha(@unixnotis-accent-2, 0.18)); - border-color: alpha(@unixnotis-accent, 0.58); + background: alpha(#fb7185, 0.16); + border-color: alpha(#fb7185, 0.45); + color: #fb7185; } /* The DND menu is a compact action list rather than a stack of stock buttons */ @@ -315,7 +318,7 @@ entry selection { .unixnotis-panel-close { background: alpha(#ffffff, 0.045); - border-radius: 999px; + border-radius: 10px; border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); @@ -355,17 +358,25 @@ entry selection { } .unixnotis-group-header { - background-image: linear-gradient(160deg, @unixnotis-pill-bg, alpha(@unixnotis-surface-soft, 0.86)); + background: alpha(#ffffff, 0.025); color: @unixnotis-text; border-radius: 999px; padding: 6px 12px; - border: 1px solid @unixnotis-pill-border; - box-shadow: 0 10px 18px -16px @unixnotis-shadow-soft; + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.03); + border-bottom: 1px solid alpha(#ffffff, 0.01); + box-shadow: none; outline: none; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-group-header:hover { - background-image: linear-gradient(160deg, @unixnotis-pill-hover, alpha(@unixnotis-accent-2, 0.18)); + background: alpha(#ffffff, 0.05); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.05); + border-bottom-color: alpha(#ffffff, 0.02); } .unixnotis-group-header:focus, @@ -391,15 +402,16 @@ entry selection { } .unixnotis-group-count { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.22), alpha(@unixnotis-accent-2, 0.2)); - color: @unixnotis-text; + background: alpha(@unixnotis-accent, 0.12); + color: #bffaf5; border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; - border: 1px solid alpha(@unixnotis-accent, 0.35); + border: 1px solid alpha(@unixnotis-accent, 0.28); min-width: 22px; + box-shadow: none; } .unixnotis-group-chevron { @@ -427,24 +439,22 @@ entry selection { } .unixnotis-panel-card { - background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); - border: 1px solid @unixnotis-card-border; - border-radius: 20px; - border-radius: var(--unixnotis-notification-card-radius); + background-image: linear-gradient(135deg, alpha(#17253f, 0.90), alpha(#12182c, 0.96)); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 16px; padding: 10px 12px; padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); margin-bottom: 8px; - box-shadow: - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 22px -18px alpha(@unixnotis-accent, 0.16), - inset 0 0 0 1px alpha(#ffffff, 0.05); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); + transition: border-color 0.15s ease-out; } .unixnotis-panel-card.stacked { /* One focused shadow keeps the foreground content visually above the rear layers */ - box-shadow: - 0 12px 24px -20px @unixnotis-shadow-strong, - inset 0 0 0 1px alpha(#ffffff, 0.04); + box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); } .unixnotis-panel-card-group-collapsed { @@ -459,8 +469,8 @@ entry selection { .unixnotis-stack-ghost { /* Full card silhouettes preserve the stack shape if card colors are customized */ - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.10); - border-radius: 18px; + background: #172238; + border-radius: 16px; padding: 0; min-height: 68px; opacity: 1; @@ -468,62 +478,81 @@ entry selection { margin-right: 10px; margin-top: -58px; margin-bottom: 0; - border: 1px solid alpha(@unixnotis-card-border, 0.62); - box-shadow: - 0 -2px 10px -8px alpha(@unixnotis-accent, 0.22), - 0 8px 14px -14px @unixnotis-shadow-soft; + border: 1px solid alpha(#9bb8e8, 0.18); + box-shadow: none; } .unixnotis-stack-ghost-2 { - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.06); + background: #121c2f; min-height: 68px; opacity: 1; margin-left: 20px; margin-right: 20px; margin-top: 0; margin-bottom: 0; - border-color: alpha(@unixnotis-card-border, 0.42); - box-shadow: - 0 -2px 10px -9px alpha(@unixnotis-accent, 0.14), - 0 10px 16px -15px @unixnotis-shadow-soft; + border-color: alpha(#9bb8e8, 0.14); + border-radius: 16px; } .unixnotis-panel-card.active { - box-shadow: - 0 0 0 1px alpha(@unixnotis-accent, 0.28), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); -} - -.unixnotis-panel-card.critical { - box-shadow: - 0 0 0 1px alpha(@unixnotis-urgent, 0.35), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px alpha(@unixnotis-urgent, 0.35), - inset 0 0 0 1px alpha(#ffffff, 0.05); + background-image: linear-gradient(135deg, alpha(#1a2e50, 0.93), alpha(#111627, 0.96)); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.09); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.03); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.03), 0 6px 16px -8px alpha(#000000, 0.8); } .unixnotis-panel-card.stacked.active { /* Active state should not erase the collapsed-stack shadow */ + box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); +} + +/* Critical state composes after the ordinary active and stack rules */ +.unixnotis-panel-card.critical, +.unixnotis-panel-card.active.critical { + background-image: linear-gradient( + 145deg, + alpha(@unixnotis-critical-surface, 0.82), + alpha(#12182c, 0.97) + ); + border-color: alpha(@unixnotis-critical-border, 0.48); box-shadow: - 0 8px 0 -4px alpha(@unixnotis-accent, 0.18), - 0 16px 0 -8px alpha(@unixnotis-accent, 0.14), - 0 0 0 1px alpha(@unixnotis-accent, 0.28), 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); + 0 0 20px -16px alpha(@unixnotis-critical-border, 0.32), + inset 0 1px 0 alpha(#ffffff, 0.05); +} + +.unixnotis-panel-card.critical .unixnotis-panel-app { + color: @unixnotis-critical-text; +} + +.unixnotis-panel-card.critical .unixnotis-panel-icon { + background: alpha(@unixnotis-critical-border, 0.12); + border: 1px solid alpha(@unixnotis-critical-border, 0.28); + border-radius: 8px; + color: @unixnotis-critical-icon; + padding: 4px; +} + +.unixnotis-panel-card.critical .unixnotis-panel-summary { + color: #ffffff; } .unixnotis-panel-card.stacked.critical, .unixnotis-panel-card.stacked.active.critical { - /* Urgent stacks keep the same depth while using the urgent border color */ + /* Urgent stacks keep the same depth while using the critical border color */ + background-image: linear-gradient( + 145deg, + alpha(@unixnotis-critical-surface, 0.82), + alpha(#12182c, 0.97) + ); + border-color: alpha(@unixnotis-critical-border, 0.48); box-shadow: - 0 8px 0 -4px alpha(@unixnotis-urgent, 0.20), - 0 16px 0 -8px alpha(@unixnotis-urgent, 0.14), - 0 0 0 1px alpha(@unixnotis-urgent, 0.35), + 0 8px 0 -4px alpha(@unixnotis-critical-border, 0.18), + 0 16px 0 -8px alpha(@unixnotis-critical-border, 0.12), 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px alpha(@unixnotis-urgent, 0.35), + 0 0 20px -16px alpha(@unixnotis-critical-border, 0.32), inset 0 0 0 1px alpha(#ffffff, 0.05); } @@ -567,15 +596,20 @@ entry selection { .unixnotis-panel-app { font-weight: 700; - font-size: 14px; + color: #a3b3cc; + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; } .unixnotis-panel-summary { font-size: 13px; + color: #ffffff; + font-weight: 700; } .unixnotis-panel-body { - color: @unixnotis-muted; + color: #cbd5e1; font-size: 12px; } @@ -621,131 +655,13 @@ entry selection { box-shadow: 0 0 16px -12px @unixnotis-glow-cyan; } -/* unixnotis-css-check allow-duplicate-selectors:start */ -/* Restrained default composition - * - * Navy remains the visual identity while flat surfaces and spacing carry hierarchy - */ -.unixnotis-panel { - background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); - border: 1px solid alpha(#9bb8e8, 0.16); - border-radius: 20px; - font-family: "Inter", "Manrope", "Noto Sans", sans-serif; - box-shadow: - 0 26px 64px -34px alpha(#000000, 0.88), - inset 0 1px 0 alpha(#ffffff, 0.035); -} - -.unixnotis-panel-header { - background: transparent; - border: 0; - border-bottom: 1px solid alpha(#9bb8e8, 0.12); - border-radius: 0; - box-shadow: none; - padding-left: 2px; - padding-right: 2px; - padding-bottom: 12px; -} - -.unixnotis-panel-title { - font-size: 16px; - letter-spacing: -0.01em; -} - -.unixnotis-panel-count, -.unixnotis-group-count { - background: alpha(@unixnotis-accent, 0.12); - color: #bffaf5; - border-color: alpha(@unixnotis-accent, 0.28); - box-shadow: none; -} - -.unixnotis-panel-action { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 10px; - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.75); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; -} - -.unixnotis-panel-action:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - color: #ffffff; -} - -.unixnotis-panel-action:checked { - background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); - border: 1px solid alpha(#00a2ff, 0.50); - box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); -} - -.unixnotis-panel-action:checked .unixnotis-panel-action-glyph, -.unixnotis-panel-action:checked .unixnotis-panel-action-label { - color: #ffffff; -} - -.unixnotis-panel-action-close, -.unixnotis-panel-close { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 10px; - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; -} - -.unixnotis-panel-action-close:hover, -.unixnotis-panel-close:hover { - background: alpha(#fb7185, 0.16); - border-color: alpha(#fb7185, 0.45); - color: #fb7185; -} - -.unixnotis-group-header { - background: alpha(#ffffff, 0.025); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.03); - border-bottom: 1px solid alpha(#ffffff, 0.01); - box-shadow: none; - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-group-header:hover { - background: alpha(#ffffff, 0.05); - border-top-color: alpha(#ffffff, 0.14); - border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.05); - border-bottom-color: alpha(#ffffff, 0.02); -} - +/* Interactive refinements follow the canonical base rules */ .unixnotis-group-header:hover .unixnotis-group-count { background: alpha(@unixnotis-accent, 0.22); color: #ffffff; border-color: alpha(@unixnotis-accent, 0.45); } -.unixnotis-panel-card { - background-image: linear-gradient(135deg, alpha(#17253f, 0.90), alpha(#12182c, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); - transition: border-color 0.15s ease-out; -} - .unixnotis-panel-card:hover { background-image: linear-gradient(135deg, alpha(#21355a, 0.92), alpha(#161e38, 0.97)); border-top: 1px solid alpha(#ffffff, 0.14); @@ -755,15 +671,6 @@ entry selection { box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04), 0 10px 24px -16px alpha(#000000, 0.85); } -.unixnotis-panel-card.active { - background-image: linear-gradient(135deg, alpha(#1a2e50, 0.93), alpha(#111627, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.09); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.03), 0 6px 16px -8px alpha(#000000, 0.8); -} - .unixnotis-panel-card.active:hover { background-image: linear-gradient(135deg, alpha(#264375, 0.95), alpha(#151b32, 0.97)); border-top: 1px solid alpha(#ffffff, 0.18); @@ -773,40 +680,6 @@ entry selection { box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05), 0 12px 28px -14px alpha(#000000, 0.9); } -.unixnotis-panel-card.stacked, -.unixnotis-panel-card.stacked.active { - box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); -} - -.unixnotis-stack-ghost { - background: #172238; - border-color: alpha(#9bb8e8, 0.18); - border-radius: 16px; - box-shadow: none; -} - -.unixnotis-stack-ghost-2 { - background: #121c2f; - border-color: alpha(#9bb8e8, 0.14); - border-radius: 16px; -} - -.unixnotis-panel-app { - color: #a3b3cc; - font-size: 10px; - letter-spacing: 0.07em; - text-transform: uppercase; -} - -.unixnotis-panel-summary { - color: #ffffff; - font-weight: 700; -} - -.unixnotis-panel-body { - color: #cbd5e1; -} - /* * Premium glowing scrollbars */ @@ -839,10 +712,11 @@ scrollbar slider { scrollbar slider:hover { background: alpha(#52d9da, 0.85); box-shadow: 0 0 8px alpha(#52d9da, 0.50); + min-width: 6px; } scrollbar slider:active { background: #52d9da; box-shadow: 0 0 12px alpha(#52d9da, 0.80); + min-width: 6px; } -/* unixnotis-css-check allow-duplicate-selectors:end */ diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 9dab8dec6..23cd8895d 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -56,13 +56,6 @@ inset 0 1px 0 alpha(#ffffff, 0.08); } -.unixnotis-popup-card.critical { - border-left: 3px solid @unixnotis-urgent; - border-top: 1px solid alpha(#ffffff, 0.16); - border-right: 1px solid alpha(#ffffff, 0.08); - border-bottom: 1px solid alpha(#ffffff, 0.04); -} - .unixnotis-popup-header-row { margin-bottom: 8px; padding-bottom: 6px; @@ -149,4 +142,34 @@ border: 1px solid alpha(#00a2ff, 0.50); box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } + +/* Critical state composes after the ordinary card and interaction rules */ +.unixnotis-popup-card.critical { + background-image: linear-gradient( + 145deg, + alpha(@unixnotis-critical-surface-strong, 0.96), + alpha(#111522, 0.98) + ); + border: 1px solid alpha(@unixnotis-critical-border, 0.58); + box-shadow: + 0 18px 38px -22px alpha(#000000, 0.92), + 0 0 20px -16px alpha(@unixnotis-critical-border, 0.38), + inset 0 1px 0 alpha(#ffffff, 0.06); +} + +.unixnotis-popup-card.critical .unixnotis-popup-header { + color: @unixnotis-critical-text; +} + +.unixnotis-popup-card.critical .unixnotis-popup-icon { + background: alpha(@unixnotis-critical-border, 0.12); + border: 1px solid alpha(@unixnotis-critical-border, 0.30); + border-radius: 8px; + color: @unixnotis-critical-icon; + padding: 4px; +} + +.unixnotis-popup-card.critical .unixnotis-popup-summary { + color: #ffffff; +} /* End of popup theme. */ diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index ca063ee11..c59b07a8c 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -3,63 +3,49 @@ * Quick controls, toggles, stats, info cards, and media carousel */ .unixnotis-quick-controls { - background-image: linear-gradient(155deg, alpha(#11182a, 0.98), alpha(#17162b, 0.96)); - border: 1px solid alpha(#a9b8d4, 0.16); - border-radius: 14px; - padding: 4px 10px; + background: alpha(#ffffff, 0.02); + border-top: 1px solid alpha(#ffffff, 0.06); + border-left: 1px solid alpha(#ffffff, 0.05); + border-right: 1px solid alpha(#ffffff, 0.03); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 16px; + padding: 8px 14px; margin-bottom: 10px; - box-shadow: - 0 12px 28px -24px alpha(#000000, 0.9), - inset 0 1px 0 alpha(#ffffff, 0.045); + box-shadow: 0 4px 16px -8px alpha(#000000, 0.5); } -.unixnotis-quick-slider { - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.97)); +.unixnotis-quick-slider, +.unixnotis-quick-slider-volume, +.unixnotis-quick-slider-brightness { + background: transparent; border-radius: 18px; border-radius: var(--unixnotis-quick-slider-radius); - padding: 8px 12px; - padding: var(--unixnotis-quick-slider-padding-y) var(--unixnotis-quick-slider-padding-x); - border: 1px solid alpha(@unixnotis-accent, 0.26); - box-shadow: - 0 12px 24px -20px @unixnotis-shadow-soft, - 0 0 18px -14px @unixnotis-glow-cyan, - 0 0 18px -16px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.06), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 0 0 1px alpha(#ffffff, 0.06); -} - -.unixnotis-quick-slider:hover { - border-color: alpha(@unixnotis-accent, 0.45); - box-shadow: - 0 16px 26px -20px @unixnotis-shadow-soft, - 0 0 22px -14px @unixnotis-glow-cyan, - 0 0 22px -16px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.26), - inset 0 0 0 1px alpha(#ffffff, 0.08); -} - -.unixnotis-quick-slider-volume { - border-color: alpha(@unixnotis-accent, 0.35); + padding: 4px 6px; + border: none; + box-shadow: none; + margin: 0; } -.unixnotis-quick-slider-brightness { - border-color: alpha(@unixnotis-accent-2, 0.35); +.unixnotis-quick-slider:hover, +.unixnotis-quick-slider-volume:hover, +.unixnotis-quick-slider-brightness:hover { + background: transparent; + border: none; + box-shadow: none; } .unixnotis-quick-slider-icon { - background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.65)); + background: transparent; border-radius: 999px; - border: 1px solid alpha(@unixnotis-accent, 0.3); + border: 0; padding: 4px; min-width: 32px; min-width: var(--unixnotis-quick-slider-icon-size); min-height: 32px; min-height: var(--unixnotis-quick-slider-icon-size); - box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - inset 0 0 0 1px alpha(#ffffff, 0.05); + box-shadow: none; + color: alpha(#ffffff, 0.65); + transition: color 0.15s ease-out; } .unixnotis-quick-slider-icon:hover { @@ -68,20 +54,23 @@ } .unixnotis-quick-slider-value { - color: @unixnotis-muted; - font-size: 12px; - letter-spacing: 0.06em; + color: alpha(#ffffff, 0.7); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; min-width: 42px; /* Alignment is controlled by the widget code (set_xalign) for GTK compatibility. */ font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; + transition: color 0.15s ease-out; } .unixnotis-quick-slider-scale trough { - background-image: linear-gradient(90deg, alpha(#000000, 0.3), alpha(#000000, 0.15)); + background: alpha(#000000, 0.4); border-radius: 999px; - min-height: 6px; - border: 1px solid alpha(#ffffff, 0.05); + min-height: 4px; + border: none; + box-shadow: none; } .unixnotis-quick-slider-scale highlight { @@ -91,19 +80,15 @@ } .unixnotis-quick-slider-scale slider { - /* Knob keeps the "neon" look by mixing both accents. */ - background-image: linear-gradient(140deg, alpha(@unixnotis-accent-2, 0.92), alpha(@unixnotis-accent, 0.66)); + background-image: linear-gradient(135deg, #ffffff 30%, #e2e8f0 100%); border-radius: 999px; - min-width: 16px; - min-width: var(--unixnotis-quick-slider-knob-size); - min-height: 16px; - min-height: var(--unixnotis-quick-slider-knob-size); - border: 1px solid alpha(@unixnotis-accent, 0.6); - margin: 0; + min-width: 12px; + min-height: 12px; + border: 1px solid alpha(#000000, 0.15); + margin: -4px 0; padding: 0; - box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - 0 0 10px -6px @unixnotis-glow-pink; + box-shadow: 0 1.5px 3.5px alpha(#000000, 0.40); + transition: border-color 0.15s ease-out; } /* @@ -117,129 +102,39 @@ padding: 0; } -.unixnotis-toggle { - /* Base toggle: "glass pill" with a consistent outline and subtle 3D depth. */ - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-radius: 18px; - border-radius: calc(var(--unixnotis-quick-slider-radius)); +.unixnotis-toggle, +.unixnotis-toggle.unixnotis-toggle-kind-wifi, +.unixnotis-toggle.unixnotis-toggle-kind-bluetooth, +.unixnotis-toggle.unixnotis-toggle-kind-airplane, +.unixnotis-toggle.unixnotis-toggle-kind-night { + background: alpha(#ffffff, 0.045); + border-radius: 14px; padding: 10px 12px; padding: var(--unixnotis-toggle-padding-y) var(--unixnotis-toggle-padding-x); - border: 1px solid alpha(@unixnotis-outline, 0.9); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 56px; min-height: var(--unixnotis-toggle-min-height); min-width: 104px; min-width: var(--unixnotis-toggle-min-width); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent, 0.10), - 0 0 20px -18px alpha(@unixnotis-accent, 0.12), - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.45); -} - -/* Kind-specific toggle accents - * - * The center UI assigns a stable class: `.unixnotis-toggle-kind-` - * These overrides provide a distinct accent per control while preserving the - * same layout and interaction behavior. */ -.unixnotis-toggle.unixnotis-toggle-kind-wifi { - border-color: alpha(@unixnotis-accent-wifi, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-wifi, 0.14), - 0 0 20px -18px @unixnotis-glow-wifi, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth { - border-color: alpha(@unixnotis-accent-bluetooth, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-bluetooth, 0.14), - 0 0 20px -18px @unixnotis-glow-bluetooth, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-airplane { - border-color: alpha(@unixnotis-accent-airplane, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-airplane, 0.14), - 0 0 20px -18px @unixnotis-glow-airplane, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-night { - border-color: alpha(@unixnotis-accent-night, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-night, 0.14), - 0 0 20px -18px @unixnotis-glow-night, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.45); -} - -.unixnotis-toggle:hover { - border-color: alpha(@unixnotis-accent, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 22px -18px alpha(@unixnotis-accent, 0.14), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); + color: alpha(#ffffff, 0.7); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } -.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover { - border-color: alpha(@unixnotis-accent-wifi, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-wifi, - 0 0 0 1px alpha(@unixnotis-accent-wifi, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover { - border-color: alpha(@unixnotis-accent-bluetooth, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-bluetooth, - 0 0 0 1px alpha(@unixnotis-accent-bluetooth, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} -.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover { - border-color: alpha(@unixnotis-accent-airplane, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-airplane, - 0 0 0 1px alpha(@unixnotis-accent-airplane, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} +.unixnotis-toggle:hover, +.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover, +.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover, +.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover, .unixnotis-toggle.unixnotis-toggle-kind-night:hover { - border-color: alpha(@unixnotis-accent-night, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-night, - 0 0 0 1px alpha(@unixnotis-accent-night, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); + background: alpha(#ffffff, 0.08); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + color: #ffffff; } .unixnotis-toggle:checked { @@ -257,35 +152,7 @@ inset 0 1px 0 alpha(#ffffff, 0.12), inset 0 2px 4px -3px alpha(#ffffff, 0.18), inset 0 -2px 6px -5px alpha(#000000, 0.38); -} - -.unixnotis-toggle.unixnotis-toggle-kind-wifi:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-wifi, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-wifi, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-bluetooth, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-bluetooth, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-airplane:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-airplane, 0.18), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-airplane, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-night:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-night, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-night, 0.75); + color: #ffffff; } /* Keep outlines and depth visible when the panel is unfocused (GTK backdrop). */ @@ -320,8 +187,8 @@ -gtk-icon-palette: success @unixnotis-accent, warning @unixnotis-accent, error @unixnotis-accent; min-width: 24px; min-height: 24px; - /* Icons inherit widget state; base color uses the cyan accent for clarity. */ - color: @unixnotis-accent; + color: alpha(#ffffff, 0.65); + transition: color 0.15s ease-out; } .unixnotis-toggle-label { @@ -346,24 +213,25 @@ .unixnotis-stat-icon { background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.7)); - border-radius: 10px; - padding: 4px; + border-radius: 8px; + padding: 5px; border: 1px solid alpha(@unixnotis-outline, 0.5); - box-shadow: - 0 8px 16px -14px alpha(#000000, 0.45), - inset 0 0 0 1px alpha(#ffffff, 0.06); + min-width: 24px; + min-height: 24px; + box-shadow: none; color: @unixnotis-accent; + transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; } -.unixnotis-info-icon { - background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.7)); - border-radius: 12px; +.unixnotis-info-icon, +.unixnotis-info-icon-weather { + background: alpha(#ffffff, 0.04); + border-radius: 7px; padding: 6px; - border: 1px solid alpha(@unixnotis-outline, 0.5); - box-shadow: - 0 8px 16px -14px alpha(#000000, 0.45), - inset 0 0 0 1px alpha(#ffffff, 0.06); - color: @unixnotis-accent-2; + border: 1px solid alpha(#ffffff, 0.04); + box-shadow: none; + color: alpha(#ffffff, 0.6); + transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; } /* @@ -378,37 +246,27 @@ } .unixnotis-stat-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.95), alpha(@unixnotis-surface, 0.98)); - border-radius: 18px; - border-radius: var(--unixnotis-stat-card-radius); + background: alpha(#ffffff, 0.035); + border-radius: 14px; padding: 10px 12px; padding: var(--unixnotis-stat-card-padding-y) var(--unixnotis-stat-card-padding-x); - /* Keep the outline always visible so the stat grid reads as "cards" - * even when not hovered. */ - border: 1px solid alpha(@unixnotis-outline, 0.88); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 56px; min-height: var(--unixnotis-stat-card-min-height); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.35), - 0 0 0 1px alpha(@unixnotis-accent, 0.1), - 0 0 22px -18px alpha(@unixnotis-accent, 0.16), - inset 0 1px 0 alpha(#ffffff, 0.06), - inset 0 2px 4px -3px alpha(#ffffff, 0.12), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.38), - inset 0 0 0 1px alpha(#ffffff, 0.05); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-stat-card:hover { - border-color: alpha(@unixnotis-accent, 0.45); - box-shadow: - 0 20px 32px -24px alpha(#000000, 0.4), - 0 0 24px -20px alpha(@unixnotis-accent, 0.18), - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.42), - inset 0 0 0 1px alpha(#ffffff, 0.06); + background: alpha(#ffffff, 0.06); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + box-shadow: 0 8px 20px -12px alpha(#000000, 0.6); } .unixnotis-stat-card:backdrop { @@ -422,10 +280,11 @@ } .unixnotis-stat-title { - font-size: 12px; + font-size: 9px; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; - color: @unixnotis-muted; + letter-spacing: 0.10em; + color: alpha(#ffffff, 0.45); } .unixnotis-stat-card-plugin .unixnotis-stat-title { @@ -437,8 +296,9 @@ } .unixnotis-stat-value { - font-size: 15px; - font-weight: 600; + color: #ffffff; + font-size: 13px; + font-weight: 700; } /* @@ -452,30 +312,29 @@ padding: 0; } -.unixnotis-info-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.98)); - border-radius: 22px; - border-radius: var(--unixnotis-info-card-radius); +.unixnotis-info-card, +.unixnotis-info-card-weather { + background: alpha(#ffffff, 0.035); + border-radius: 16px; padding: 12px; padding: var(--unixnotis-info-card-padding); - border: 1px solid alpha(@unixnotis-outline, 0.7); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 56px; min-height: var(--unixnotis-info-card-min-height); - box-shadow: - 0 20px 32px -24px alpha(#000000, 0.4), - 0 0 0 1px alpha(@unixnotis-accent-2, 0.1), - inset 0 0 0 1px alpha(#ffffff, 0.06), - inset 0 2px 4px -3px alpha(#ffffff, 0.12), - inset 0 -3px 6px -5px alpha(#000000, 0.35); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-info-card:hover { - border-color: alpha(@unixnotis-accent-2, 0.45); - box-shadow: - 0 22px 36px -24px alpha(#000000, 0.4), - inset 0 0 0 1px alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -3px 6px -5px alpha(#000000, 0.38); + background: alpha(#ffffff, 0.06); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + box-shadow: 0 6px 14px -6px alpha(#000000, 0.6); } .unixnotis-info-card:backdrop { @@ -488,9 +347,11 @@ } .unixnotis-info-title { - font-size: 13px; + color: alpha(#ffffff, 0.45); + font-size: 9px; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.10em; + text-transform: uppercase; } .unixnotis-info-body { @@ -504,7 +365,7 @@ } .unixnotis-info-card-calendar .unixnotis-info-title { - color: alpha(@unixnotis-accent, 0.88); + color: alpha(#ffffff, 0.6); } /* @@ -513,27 +374,20 @@ * Purpose: make the calendar read as a premium card while keeping retro cues */ .unixnotis-calendar { - background-image: - radial-gradient(circle at 14% 18%, alpha(@unixnotis-accent, 0.16), transparent 55%), - linear-gradient(160deg, alpha(@unixnotis-card, 0.96), alpha(@unixnotis-surface, 0.98)); - border: 1px solid alpha(@unixnotis-outline, 0.6); - border-radius: 18px; - border-radius: var(--unixnotis-calendar-radius); + background: alpha(#ffffff, 0.02); + border: 1px solid alpha(#ffffff, 0.06); + border-radius: 12px; padding: 10px 12px; padding: var(--unixnotis-info-card-padding); color: @unixnotis-text; - box-shadow: - 0 18px 28px -22px alpha(#000000, 0.6), - 0 0 18px -12px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05), - inset 0 -8px 16px -14px alpha(#000000, 0.5); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); } .unixnotis-calendar button { /* Calendar nav controls are custom-styled so the arrow icons remain visible. */ - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg, 0.8), alpha(@unixnotis-surface-strong, 0.9)); + background: transparent; border-radius: 999px; - border: 1px solid alpha(@unixnotis-outline, 0.7); + border: 0; padding: 3px; min-width: 22px; min-height: 22px; @@ -542,15 +396,14 @@ -gtk-icon-style: symbolic; -gtk-icon-shadow: 0 0 6px alpha(@unixnotis-accent, 0.45); -gtk-icon-palette: success @unixnotis-accent, warning @unixnotis-accent, error @unixnotis-accent; - box-shadow: - 0 8px 14px -12px alpha(#000000, 0.6), - inset 0 0 0 1px alpha(#ffffff, 0.06); + box-shadow: none; } .unixnotis-calendar button:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg-hover, 0.78), alpha(@unixnotis-accent, 0.28)); - border-color: alpha(@unixnotis-accent, 0.7); + background: alpha(#ffffff, 0.08); + border: 0; color: @unixnotis-accent; + box-shadow: none; } .unixnotis-calendar button:active { @@ -655,37 +508,26 @@ .unixnotis-calendar:selected, .unixnotis-calendar .day-number:selected { - background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.85), alpha(@unixnotis-accent-2, 0.75)); - color: #0b1020; + background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.25), alpha(@unixnotis-accent, 0.15)); + border: 1px solid alpha(@unixnotis-accent, 0.60); + color: #ffffff; border-radius: 8px; - box-shadow: - 0 0 0 1px alpha(@unixnotis-accent, 0.5), - 0 10px 16px -12px alpha(@unixnotis-accent, 0.7); + box-shadow: 0 0 12px -2px alpha(@unixnotis-accent, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } .unixnotis-calendar .day-number.today { - box-shadow: - inset 0 0 0 1px alpha(@unixnotis-accent-2, 0.45), - 0 0 12px -10px @unixnotis-glow-pink; + background: alpha(#ffffff, 0.08); + color: @unixnotis-text; + border: 1px solid alpha(#ffffff, 0.20); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05); border-radius: 8px; } -.unixnotis-info-card-weather { - background-image: - radial-gradient(circle at 20% 20%, alpha(@unixnotis-accent-2, 0.18), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-2, 0.45); - box-shadow: - 0 22px 36px -24px alpha(#000000, 0.5), - 0 0 0 1px alpha(@unixnotis-accent-2, 0.12), - inset 0 0 0 1px alpha(#ffffff, 0.07); -} - .unixnotis-info-card-weather .unixnotis-info-title { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; - color: @unixnotis-muted; + color: alpha(#ffffff, 0.6); } .unixnotis-info-card-weather .unixnotis-info-body { @@ -697,14 +539,6 @@ min-height: 18px; } -.unixnotis-info-icon-weather { - background-image: linear-gradient(150deg, alpha(@unixnotis-accent-2, 0.35), alpha(@unixnotis-accent, 0.2)); - border-color: alpha(@unixnotis-accent-2, 0.6); - color: @unixnotis-accent-2; - box-shadow: - 0 12px 18px -14px alpha(@unixnotis-accent-2, 0.6), - inset 0 0 0 1px alpha(#ffffff, 0.1); -} /* Compact density trims shell padding without shrinking interactive targets below 36px */ .unixnotis-widget-density-compact .unixnotis-quick-slider { padding: 8px 10px; @@ -719,51 +553,10 @@ padding: 8px 10px; } -/* unixnotis-css-check allow-duplicate-selectors:start */ /* Restrained widget composition * * Repeated widget types share one surface treatment and reserve color for state */ -.unixnotis-quick-controls { - background: alpha(#ffffff, 0.02); - border-top: 1px solid alpha(#ffffff, 0.06); - border-left: 1px solid alpha(#ffffff, 0.05); - border-right: 1px solid alpha(#ffffff, 0.03); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - padding: 8px 14px; - margin-bottom: 10px; - box-shadow: 0 4px 16px -8px alpha(#000000, 0.5); -} - -.unixnotis-quick-slider, -.unixnotis-quick-slider-volume, -.unixnotis-quick-slider-brightness { - background: transparent; - border: none; - border-color: transparent; - box-shadow: none; - padding: 4px 6px; - margin: 0; -} - -.unixnotis-quick-slider:hover, -.unixnotis-quick-slider-volume:hover, -.unixnotis-quick-slider-brightness:hover { - background: transparent; - border: none; - border-color: transparent; - box-shadow: none; -} - -.unixnotis-quick-slider-icon { - background: transparent; - border: 0; - box-shadow: none; - color: alpha(#ffffff, 0.65); - transition: color 0.15s ease-out; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-icon { color: alpha(#ffffff, 0.65); } @@ -780,14 +573,6 @@ color: #ffb86b; } -.unixnotis-quick-slider-value { - color: alpha(#ffffff, 0.7); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - transition: color 0.15s ease-out; -} - .unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-value { color: #ffffff; } @@ -796,15 +581,6 @@ color: #ffffff; } -.unixnotis-quick-slider-scale trough { - background: alpha(#000000, 0.4); - border-top: 1px solid alpha(#ffffff, 0.05); - border-radius: 999px; - min-height: 4px; - border: none; - box-shadow: none; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale highlight { background-image: linear-gradient(90deg, #3b82f6, #00a2ff); } @@ -813,18 +589,6 @@ background-image: linear-gradient(90deg, #d97706, #ff9f0a); } -.unixnotis-quick-slider-scale slider { - background-image: linear-gradient(135deg, #ffffff 30%, #e2e8f0 100%); - border: 1px solid alpha(#000000, 0.15); - border-radius: 999px; - min-width: 12px; - min-height: 12px; - margin-top: -4px; /* Center 12px knob over 4px trough */ - margin-bottom: -4px; - box-shadow: 0 1.5px 3.5px alpha(#000000, 0.40); - transition: border-color 0.15s ease-out; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider { border-color: alpha(#00a2ff, 0.6); } @@ -907,47 +671,11 @@ color: alpha(#ff9f0a, 0.7); } -.unixnotis-toggle, -.unixnotis-toggle.unixnotis-toggle-kind-wifi, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth, -.unixnotis-toggle.unixnotis-toggle-kind-airplane, -.unixnotis-toggle.unixnotis-toggle-kind-night { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 14px; - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.7); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; -} - -.unixnotis-toggle:hover, -.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover, -.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover, -.unixnotis-toggle.unixnotis-toggle-kind-night:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - color: #ffffff; -} - -.unixnotis-toggle:checked, -.unixnotis-toggle.unixnotis-toggle-kind-wifi:checked, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked, -.unixnotis-toggle.unixnotis-toggle-kind-airplane:checked, -.unixnotis-toggle.unixnotis-toggle-kind-night:checked { - color: #ffffff; -} - .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked { background-image: linear-gradient(135deg, #00b4db, #0083b0); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#0083b0, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked:hover { @@ -959,6 +687,7 @@ background-image: linear-gradient(135deg, #3b82f6, #1d4ed8); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#1d4ed8, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked:hover { @@ -970,6 +699,7 @@ background-image: linear-gradient(135deg, #f59e0b, #d97706); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#d97706, 0.5), inset 0 1px 0 alpha(#ffffff, 0.15); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-airplane:checked:hover { @@ -981,6 +711,7 @@ background-image: linear-gradient(135deg, #8b5cf6, #6d28d9); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#6d28d9, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-night:checked:hover { @@ -988,11 +719,6 @@ box-shadow: 0 8px 20px -6px alpha(#5b21b6, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); } -.unixnotis-toggle-icon { - color: alpha(#ffffff, 0.65); - transition: color 0.15s ease-out; -} - .unixnotis-toggle:hover .unixnotis-toggle-icon { color: #ffffff; } @@ -1001,35 +727,6 @@ color: #ffffff; } -.unixnotis-stat-card { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 14px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-stat-card:hover { - background: alpha(#ffffff, 0.06); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: 0 8px 20px -12px alpha(#000000, 0.6); -} - -.unixnotis-stat-icon { - min-width: 24px; - min-height: 24px; - padding: 5px; - border-radius: 8px; - box-shadow: none; - transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; -} - /* CPU Stat Style (Emerald Green) */ .unixnotis-stat-kind-cpu .unixnotis-stat-icon { background: alpha(#10b981, 0.08); @@ -1069,95 +766,8 @@ color: #fbbf24; } -.unixnotis-info-icon, -.unixnotis-info-icon-weather { - background: alpha(#ffffff, 0.04); - border: 1px solid alpha(#ffffff, 0.04); - border-radius: 7px; - box-shadow: none; - color: alpha(#ffffff, 0.6); - transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-stat-title, -.unixnotis-info-title { - color: alpha(#ffffff, 0.45); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.10em; - text-transform: uppercase; -} - -.unixnotis-stat-value { - color: #ffffff; - font-size: 13px; - font-weight: 700; -} - -.unixnotis-info-card, -.unixnotis-info-card-weather { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-info-card:hover { - background: alpha(#ffffff, 0.06); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: 0 6px 14px -6px alpha(#000000, 0.6); -} - .unixnotis-info-card:hover .unixnotis-info-icon { color: #ffffff; background: alpha(#ffffff, 0.12); border-color: alpha(#ffffff, 0.12); } - -.unixnotis-info-card-calendar .unixnotis-info-title, -.unixnotis-info-card-weather .unixnotis-info-title { - color: alpha(#ffffff, 0.6); -} - -.unixnotis-calendar { - background: alpha(#ffffff, 0.02); - border: 1px solid alpha(#ffffff, 0.06); - border-radius: 12px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); -} - -.unixnotis-calendar button { - background: transparent; - border: 0; - box-shadow: none; -} - -.unixnotis-calendar button:hover { - background: alpha(#ffffff, 0.08); - border: 0; - box-shadow: none; -} - -.unixnotis-calendar .day-number:selected { - background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.25), alpha(@unixnotis-accent, 0.15)); - border: 1px solid alpha(@unixnotis-accent, 0.60); - color: #ffffff; - box-shadow: 0 0 12px -2px alpha(@unixnotis-accent, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); - border-radius: 8px; -} - -.unixnotis-calendar .day-number.today { - background: alpha(#ffffff, 0.08); - color: @unixnotis-text; - border: 1px solid alpha(#ffffff, 0.20); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05); - border-radius: 8px; -} -/* unixnotis-css-check allow-duplicate-selectors:end */ diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs index 86d509ed4..1a1088a72 100644 --- a/crates/unixnotis-core/src/config/loading/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -6,6 +6,7 @@ mod paths; mod script_migrations; mod scripts; mod theme_files; +mod theme_stock; mod write; pub use error::ConfigError; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index 740db0af7..15e50fb9c 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -7,4 +7,5 @@ mod script_migrations; mod scripts; mod support; mod theme_files; +mod theme_stock; mod write; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs new file mode 100644 index 000000000..202c4012e --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs @@ -0,0 +1,131 @@ +//! Regression tests for exact-byte stock theme migration + +use std::fs; +use std::io; + +use super::super::theme_stock::{ + migrate_known_stock_file, migrate_stock_file_with_writer, stock_backup_path, +}; +use super::support::test_root; + +const OLD_STOCK: &[u8] = b"/* exact previous stock */\n.card { color: red; }\n"; +const CURRENT_STOCK: &[u8] = b"/* current flattened stock */\n.card { color: blue; }\n"; +const BACKUP_TAG: &str = "unixnotis-stock-test"; + +#[test] +fn exact_legacy_stock_file_is_backed_up_and_atomically_migrated() { + let root = test_root("exact-stock-migration"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + fs::write(&target, OLD_STOCK).expect("legacy stock"); + + let migrated = migrate(&target, OLD_STOCK).expect("stock migration"); + + assert!(migrated); + assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn one_byte_stock_modification_prevents_automatic_replacement() { + let root = test_root("modified-stock-preserved"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("widgets.css"); + let mut customized = OLD_STOCK.to_vec(); + customized.push(b' '); + fs::write(&target, &customized).expect("customized stock"); + + let migrated = migrate(&target, OLD_STOCK).expect("migration check"); + + assert!(!migrated); + assert_eq!(fs::read(&target).expect("customized stock"), customized); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + assert!(!backup.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn current_stock_file_remains_unchanged() { + let root = test_root("current-stock-preserved"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("media.css"); + fs::write(&target, CURRENT_STOCK).expect("current stock"); + + let migrated = migrate(&target, OLD_STOCK).expect("migration check"); + + assert!(!migrated); + assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn interrupted_replacement_keeps_complete_legacy_file_and_backup() { + let root = test_root("interrupted-stock-migration"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + fs::write(&target, OLD_STOCK).expect("legacy stock"); + let digest = blake3::hash(OLD_STOCK).to_hex().to_string(); + + let result = migrate_stock_file_with_writer( + &target, + CURRENT_STOCK, + &digest, + BACKUP_TAG, + |_path, _contents| { + Err(io::Error::new( + io::ErrorKind::Interrupted, + "test interruption", + )) + }, + ); + + assert!(result.is_err()); + assert_eq!(fs::read(&target).expect("legacy stock"), OLD_STOCK); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn matching_existing_backup_allows_a_retried_migration() { + let root = test_root("stock-migration-retry"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + fs::write(&target, OLD_STOCK).expect("legacy stock"); + fs::write(&backup, OLD_STOCK).expect("matching stock backup"); + + let migrated = migrate(&target, OLD_STOCK).expect("retried stock migration"); + + assert!(migrated); + assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); + assert_eq!(fs::read(&backup).expect("stock backup"), OLD_STOCK); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn conflicting_existing_backup_prevents_replacement() { + let root = test_root("stock-migration-backup-conflict"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + fs::write(&target, OLD_STOCK).expect("legacy stock"); + fs::write(&backup, b"custom backup").expect("conflicting stock backup"); + + let result = migrate(&target, OLD_STOCK); + + assert!(result.is_err()); + assert_eq!(fs::read(&target).expect("legacy stock"), OLD_STOCK); + assert_eq!( + fs::read(&backup).expect("conflicting stock backup"), + b"custom backup" + ); + let _ = fs::remove_dir_all(root); +} + +fn migrate(target: &std::path::Path, legacy: &[u8]) -> Result { + let digest = blake3::hash(legacy).to_hex().to_string(); + migrate_known_stock_file(target, CURRENT_STOCK, &digest, BACKUP_TAG) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs index 2b3d94cba..1d8974199 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_files.rs @@ -10,6 +10,7 @@ use crate::{ DEFAULT_WIDGETS_CSS, }; +use super::theme_stock::migrate_known_stock_themes; use super::write::write_if_missing; use super::{ConfigError, ThemePaths}; @@ -38,6 +39,7 @@ impl Config { write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; + migrate_known_stock_themes(theme_paths)?; if legacy_contents.is_some() { let backup = legacy.with_extension("css.bak"); diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs new file mode 100644 index 000000000..45cc1cbc1 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs @@ -0,0 +1,111 @@ +//! Exact-byte migration for stock theme assets that shipped with older releases + +use std::io; +use std::path::{Path, PathBuf}; + +use crate::filesystem::{ + read_regular_file_bounded, regular_file_contents_equal, write_file_atomic_preserving_mode, + write_file_if_missing, +}; +use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; + +use super::{ConfigError, ThemePaths}; + +const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; +const LEGACY_BACKUP_TAG: &str = "unixnotis-stock-9ca42584"; +const LEGACY_PANEL_DIGEST: &str = + "bd2342e4ff91dab10dbdece082d1c58e9352b3b8167e046697dd921b6de4ceb3"; +const LEGACY_WIDGETS_DIGEST: &str = + "72c0ab3c38557ea10adfee7e2b11a18b94317b9100579101c04beeb47092e5d2"; +const LEGACY_MEDIA_DIGEST: &str = + "f3618bdaf411d4b018cb9aa1688c9be0880a5bdc0016fdb5e35d8ec798ae6b36"; + +pub(super) fn migrate_known_stock_themes(paths: &ThemePaths) -> Result<(), ConfigError> { + // Each file migrates independently so one customized layer never changes another layer + migrate_known_stock_file( + &paths.panel_css, + DEFAULT_PANEL_CSS.as_bytes(), + LEGACY_PANEL_DIGEST, + LEGACY_BACKUP_TAG, + )?; + migrate_known_stock_file( + &paths.widgets_css, + DEFAULT_WIDGETS_CSS.as_bytes(), + LEGACY_WIDGETS_DIGEST, + LEGACY_BACKUP_TAG, + )?; + migrate_known_stock_file( + &paths.media_css, + DEFAULT_MEDIA_CSS.as_bytes(), + LEGACY_MEDIA_DIGEST, + LEGACY_BACKUP_TAG, + )?; + Ok(()) +} + +pub(super) fn migrate_known_stock_file( + path: &Path, + current_stock: &[u8], + legacy_digest: &str, + backup_tag: &str, +) -> Result { + migrate_stock_file_with_writer( + path, + current_stock, + legacy_digest, + backup_tag, + |target, contents| write_file_atomic_preserving_mode(target, contents, 0o644), + ) +} + +pub(super) fn migrate_stock_file_with_writer( + path: &Path, + current_stock: &[u8], + legacy_digest: &str, + backup_tag: &str, + replace_file: impl FnOnce(&Path, &[u8]) -> io::Result<()>, +) -> Result { + // Unknown, unreadable, and oversized files remain user-owned and untouched + let Ok(existing) = read_regular_file_bounded(path, MAX_STOCK_THEME_BYTES) else { + return Ok(false); + }; + if blake3::hash(&existing).to_hex().as_str() != legacy_digest { + return Ok(false); + } + + // The exact previous bytes are recoverable before the current stock file is published + let backup = stock_backup_path(path, backup_tag)?; + let backup_created = write_file_if_missing(&backup, &existing, 0o644) + .map_err(|error| migration_error(path, &error))?; + if !backup_created + && !regular_file_contents_equal(&backup, &existing, MAX_STOCK_THEME_BYTES) + .map_err(|error| migration_error(path, &error))? + { + return Err(ConfigError::ReadFailed(format!( + "refusing to replace stock theme because backup differs: {}", + backup.display() + ))); + } + + // Atomic replacement keeps the prior complete file visible if publication is interrupted + replace_file(path, current_stock).map_err(|error| migration_error(path, &error))?; + Ok(true) +} + +pub(super) fn stock_backup_path(path: &Path, backup_tag: &str) -> Result { + let file_name = path.file_name().ok_or_else(|| { + ConfigError::ReadFailed(format!("theme path has no file name: {}", path.display())) + })?; + let mut backup_name = file_name.to_os_string(); + backup_name.push("."); + backup_name.push(backup_tag); + backup_name.push(".bak"); + Ok(path.with_file_name(backup_name)) +} + +fn migration_error(path: &Path, error: &io::Error) -> ConfigError { + ConfigError::ReadFailed(format!( + "failed to migrate exact stock theme {}: {error}", + path.display() + )) +} diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 3e514a852..f9d1566a0 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -77,3 +77,57 @@ fn stock_panel_hover_styles_avoid_transform_and_geometry_animation() { assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-width")); assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-height")); } + +#[test] +fn stock_scrollbar_keeps_master_sizing_without_geometry_animation() { + assert!(DEFAULT_PANEL_CSS.contains( + "scrollbar slider {\n background: alpha(#ffffff, 0.16);\n border-radius: 999px;\n border: none;\n min-width: 4px;" + )); + for selector in ["scrollbar slider:hover", "scrollbar slider:active"] { + let rule = DEFAULT_PANEL_CSS + .split(selector) + .nth(1) + .and_then(|suffix| suffix.split('}').next()) + .expect("stock scrollbar state rule"); + assert!( + rule.contains("min-width: 6px"), + "{selector} should retain the master width" + ); + } + assert!(!DEFAULT_PANEL_CSS.contains("transition: background-color 0.15s ease-out, min-width")); +} + +#[test] +fn critical_alert_assets_define_composed_popup_and_panel_states() { + for token in [ + "unixnotis-critical-surface", + "unixnotis-critical-surface-strong", + "unixnotis-critical-border", + "unixnotis-critical-text", + "unixnotis-critical-icon", + ] { + assert!( + DEFAULT_BASE_CSS.contains(token), + "base CSS should define {token}" + ); + } + + for selector in [ + ".unixnotis-popup-card.critical", + ".unixnotis-popup-card.critical .unixnotis-popup-icon", + ".unixnotis-panel-card.critical,\n.unixnotis-panel-card.active.critical", + ".unixnotis-panel-card.stacked.critical,\n.unixnotis-panel-card.stacked.active.critical", + ".unixnotis-panel-card.critical .unixnotis-panel-icon", + ] { + let css = if selector.contains("popup") { + DEFAULT_POPUP_CSS + } else { + DEFAULT_PANEL_CSS + }; + assert!(css.contains(selector), "stock CSS should retain {selector}"); + } + + assert!(DEFAULT_BASE_CSS.contains(".unixnotis-urgency-badge")); + assert!(!DEFAULT_PANEL_CSS.contains("animation:")); + assert!(!DEFAULT_POPUP_CSS.contains("animation:")); +} From b94f119b415921945cda577a5e828536e746d3ec Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:14 -0500 Subject: [PATCH 105/275] feat(ui): surface critical notification urgency Summary: surface critical notification urgency. Scope: ui. --- .../notifications/row/notification/build.rs | 8 +++ .../notifications/row/notification/state.rs | 2 + .../row/notification/update/tests/state.rs | 19 ++++++ .../row/notification/update/visual.rs | 8 +-- .../unixnotis-core/src/css/hooks/classes.rs | 5 ++ crates/unixnotis-core/src/css/hooks/mod.rs | 2 +- .../src/css/hooks/tests/hooks.rs | 3 +- crates/unixnotis-popups/src/ui/entry/build.rs | 13 +++- .../src/ui/entry/tests/build.rs | 15 ++++- .../src/ui/state/tests/constructor.rs | 63 ++++++++++++++++++- 10 files changed, 128 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 9bf0b4d9e..7aee5a5f7 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -78,6 +78,12 @@ pub(in crate::ui::notifications) fn build_notification_row( app_label.set_max_width_chars(40); app_label.add_css_class("unixnotis-panel-app"); + let urgency_badge = gtk::Label::new(Some("Critical")); + // Reused rows toggle this widget instead of rebuilding the header tree + urgency_badge.add_css_class(hooks::urgency::BADGE); + urgency_badge.set_single_line_mode(true); + urgency_badge.set_visible(false); + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); // Spacer pushes close button to the far edge spacer.set_hexpand(true); @@ -88,6 +94,7 @@ pub(in crate::ui::notifications) fn build_notification_row( header.append(&icon); header.append(&app_label); + header.append(&urgency_badge); header.append(&spacer); header.append(&close_button); @@ -207,6 +214,7 @@ pub(in crate::ui::notifications) fn build_notification_row( stack_ghost_2, icon, app_label, + urgency_badge, meta_top, meta_label, time_badge, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 351378f2c..acae61b9d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -22,6 +22,8 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) icon: gtk::Image, // App name text shown beside the icon pub(super) app_label: gtk::Label, + // Critical badge remains allocated so urgency changes only toggle visibility + pub(super) urgency_badge: gtk::Label, // Optional metadata rows are present for themes but hidden unless config enables them pub(super) meta_top: gtk::Box, // Optional top metadata label for category/urgency styling diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 75e78f8aa..008e73810 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -38,6 +38,8 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); assert!(row.stack_ghost_1.get_visible()); assert!(row.stack_ghost_2.get_visible()); + assert!(row.urgency_badge.get_visible()); + assert_eq!(row.urgency_badge.text().as_str(), "Critical"); assert_eq!(row.app_label.text().as_str(), "demo"); assert_eq!(row.summary_label.text().as_str(), "summary"); assert_eq!(row.body_label.text().as_str(), "body"); @@ -45,6 +47,23 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row.icon_sig.borrow().is_some()); } +#[gtk::test] +fn recycled_panel_row_hides_critical_badge_after_urgency_returns_to_normal() { + let (_root, row) = notification_row(); + let mut critical = sample_notification(); + critical.urgency = Urgency::Critical as u8; + let critical = row_data(Rc::new(critical), RowFlags::default()); + let normal = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &critical, &IconResolver::new(), &command_tx); + assert!(row.urgency_badge.get_visible()); + + update_notification_row(&row, &normal, &IconResolver::new(), &command_tx); + assert!(!row.card.has_css_class(hooks::shared_state::CRITICAL)); + assert!(!row.urgency_badge.get_visible()); +} + #[gtk::test] fn update_notification_row_shows_metadata_lanes_and_footer_state() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 1afc617eb..d4afbba2b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -29,14 +29,12 @@ pub(super) fn apply_visual_state( has_thumbnail: bool, ) { let card = &row.card; + let is_critical = notification.urgency == Urgency::Critical as u8; // Theme changes update recycled rows without rebuilding the GTK child tree row.card_plate.set_corners(data.presentation.card_corners); // Explicit state updates prevent recycled rows from retaining stale classes - set_class_state( - card, - hooks::shared_state::CRITICAL, - notification.urgency == Urgency::Critical as u8, - ); + set_class_state(card, hooks::shared_state::CRITICAL, is_critical); + set_widget_visible_if_changed(&row.urgency_badge, is_critical); set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); set_class_state(card, hooks::shared_state::STACKED, data.stacked); set_class_state(card, hooks::panel_card::GROUPED, true); diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 0cf90bff2..7243dd207 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -9,6 +9,11 @@ pub mod shared_state { pub const STACKED: &str = "stacked"; } +pub mod urgency { + // One badge class keeps popup and panel urgency labels visually aligned + pub const BADGE: &str = "unixnotis-urgency-badge"; +} + pub mod cut_corner { // The wrapper hook lets themes adjust the primitive without using its custom CSS node name pub const ROOT: &str = "unixnotis-cut-corner"; diff --git a/crates/unixnotis-core/src/css/hooks/mod.rs b/crates/unixnotis-core/src/css/hooks/mod.rs index 18850e3fe..1186386f1 100644 --- a/crates/unixnotis-core/src/css/hooks/mod.rs +++ b/crates/unixnotis-core/src/css/hooks/mod.rs @@ -5,7 +5,7 @@ mod classes; pub use self::classes::{ cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, - toggle_card, + toggle_card, urgency, }; #[cfg(test)] diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 11651ae9a..a66e80d27 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use super::{ cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, - toggle_card, + toggle_card, urgency, }; #[test] @@ -28,6 +28,7 @@ fn hook_names_stay_unique() { shared_state::EMPTY, shared_state::PLAYING, shared_state::STACKED, + urgency::BADGE, panel_action::FOCUS, panel_action::PRIMARY, panel_action::MUTED, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 836f877ca..9d8285386 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -73,7 +73,8 @@ impl UiState { root.set_hexpand(false); // New roots stay hidden until visibility logic decides otherwise root.set_visible(false); - if notification.urgency == Urgency::Critical as u8 { + let is_critical = notification.urgency == Urgency::Critical as u8; + if is_critical { // Critical rows keep the shared urgency class at the root root.add_css_class(hooks::shared_state::CRITICAL); } @@ -128,6 +129,7 @@ impl UiState { // Close stays on the right edge even when the title text shrinks header.append(&app); + header.append(&build_urgency_badge(is_critical)); header.append(&build_popup_header_spacer()); header.append(&close); @@ -274,6 +276,15 @@ fn build_popup_header_spacer() -> gtk::Box { spacer } +fn build_urgency_badge(is_critical: bool) -> gtk::Label { + let badge = gtk::Label::new(Some("Critical")); + // The widget stays in the tree so header composition remains stable across payload variants + badge.add_css_class(hooks::urgency::BADGE); + badge.set_single_line_mode(true); + badge.set_visible(is_critical); + badge +} + pub(super) const fn popup_header_spacer_expands() -> bool { // Keep the alignment rule easy to test without constructing full GTK rows true diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index e981e8d94..c5e83ea80 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -1,7 +1,9 @@ use super::{ - popup_action_is_visible, popup_header_spacer_expands, widget_type_blocks_default_action, + build_urgency_badge, popup_action_is_visible, popup_header_spacer_expands, + widget_type_blocks_default_action, }; use gtk::glib::prelude::StaticType; +use gtk::prelude::*; use unixnotis_core::Action; #[test] @@ -10,6 +12,17 @@ fn popup_header_spacer_expands_to_hold_close_alignment() { assert!(popup_header_spacer_expands()); } +#[gtk::test] +fn popup_critical_badge_uses_shared_hook_and_visibility() { + let critical = build_urgency_badge(true); + let normal = build_urgency_badge(false); + + assert!(critical.has_css_class(unixnotis_core::hooks::urgency::BADGE)); + assert_eq!(critical.text().as_str(), "Critical"); + assert!(critical.get_visible()); + assert!(!normal.get_visible()); +} + #[gtk::test] fn default_card_action_is_blocked_for_button_widgets() { // Button clicks must remain owned by the button action diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 24e926f94..0b765bfbd 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -1,7 +1,9 @@ use std::path::Path; use gtk::prelude::*; -use unixnotis_core::{Config, CutCorners, NotificationImage, NotificationView, ThemePaths}; +use unixnotis_core::{ + hooks, Config, CutCorners, NotificationImage, NotificationView, ThemePaths, Urgency, +}; use unixnotis_ui::{css::CssManager, CutCorner}; use super::super::UiState; @@ -90,3 +92,62 @@ fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { assert!(state.popup_order.is_empty()); assert!(state.visible_popups.is_empty()); } + +#[gtk::test] +fn critical_popup_probe_builds_the_root_class_and_badge() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupCriticalProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup critical probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-critical-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 2, + app_name: "Critical probe".to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "Critical probe".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + summary: "Critical popup".to_string(), + body: "The composed critical state must be visible".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Critical as u8, + is_transient: false, + image: NotificationImage::default(), + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class(hooks::shared_state::CRITICAL)); + assert!(visible_descendant_has_class( + root.upcast_ref(), + hooks::urgency::BADGE + )); +} + +fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.get_visible() && current.has_css_class(class_name) { + return true; + } + if visible_descendant_has_class(¤t, class_name) { + return true; + } + child = current.next_sibling(); + } + false +} From 987c994812438b68303f02fc14d3eae3126c97e9 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:17 -0500 Subject: [PATCH 106/275] fix(identity): resolve dedicated Electron application binaries Summary: resolve dedicated Electron application binaries. Scope: identity. --- .../identity/desktop_index/index.rs | 24 +- .../daemon/notifications/identity/resolver.rs | 111 +++++++-- .../notifications/identity/tests/resolver.rs | 234 +++++++++++++++++- 3 files changed, 336 insertions(+), 33 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index 36463e620..91d1b620a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -4,7 +4,7 @@ use std::path::Path; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::model::{DesktopIdentityIndex, DesktopRecord, ExecutableIdentity}; -use super::names::{normalize_brand_name, normalize_desktop_id}; +use super::names::{is_shared_launcher, normalize_brand_name, normalize_desktop_id}; impl DesktopIdentityIndex { pub(in crate::daemon::notifications::identity) fn records_for_id( @@ -33,6 +33,28 @@ impl DesktopIdentityIndex { .collect() } + pub(in crate::daemon::notifications::identity) fn requires_launch_arguments( + &self, + record: &DesktopRecord, + ) -> bool { + let Some(identity) = record.executable_identity else { + return true; + }; + let Some(path) = record.executable_path.as_deref() else { + return true; + }; + // Generic runtimes need their fixed payload because the binary is not the application + if is_shared_launcher(path) { + return true; + } + + let record_id = normalize_desktop_id(&record.id); + // One binary serving distinct desktop applications needs argv to select the right record + self.records_for_executable(identity) + .iter() + .any(|candidate| normalize_desktop_id(&candidate.id) != record_id) + } + pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( &self, claim: &str, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index 0dba69d2d..8e2c1dbc9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -10,6 +10,7 @@ use super::desktop_index::{ normalize_desktop_id, normalize_name, record_launch_matches, DesktopIdentityIndex, DesktopRecord, }; +use super::executable::{executable_evidence_for_path, FileIdentity}; use super::policy::inline_reply_policy; use super::sender::SenderMetadata; @@ -78,6 +79,7 @@ fn resolve_with_evidence( ) -> AttributionResolution { // An explicit desktop hint is accepted only when its executable is the sender file let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); + let mut desktop_hint_conflict = None; if let Some(desktop_id) = desktop_entry.as_deref() { let records = index.records_for_id(desktop_id); if !records.is_empty() { @@ -92,7 +94,7 @@ fn resolve_with_evidence( } if let Some(record) = records .iter() - .find_map(|record| verify_record_sender(record, sender)) + .find_map(|record| verify_record_sender(record, sender, index)) { return resolution_for_record(record, claim.reported_name, sender, index); } @@ -101,30 +103,25 @@ fn resolve_with_evidence( .any(|record| owned_desktop_ids.contains(&normalize_desktop_id(&record.id))) { // Session applications can request names, so ownership is context rather than proof - return conflict_resolution( - claim.reported_name, - sender, - "bus name ownership lacks executable association", - ); + desktop_hint_conflict = Some("bus name ownership lacks executable association"); + } else { + // Packaging aliases may be stale, so exact executable evidence still gets a chance + desktop_hint_conflict = Some("desktop identity mismatch"); } - return conflict_resolution(claim.reported_name, sender, "desktop identity mismatch"); } } if let Some(identity) = sender.sender_executable_identity { // Exact file association is stronger than every caller-controlled application name let records = index.records_for_executable(identity); - if let Some(record) = records.iter().find_map(|record| { - record - .claim_matches(claim.reported_name) - .then(|| verify_record_sender(record, sender)) - .flatten() - }) { + if let Some(record) = + verified_executable_record(&records, claim.reported_name, sender, index) + { return resolution_for_record(record, claim.reported_name, sender, index); } if records .iter() - .any(|record| record.system_association && record_matches_sender(record, sender)) + .any(|record| record.system_association && record_matches_sender(record, sender, index)) { // A known executable with a conflicting name must fail closed return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); @@ -146,6 +143,10 @@ fn resolve_with_evidence( } } + if let Some(reason) = desktop_hint_conflict { + return conflict_resolution(claim.reported_name, sender, reason); + } + if index.claim_matches_system_app(claim.reported_name) { // Protected branding without the matching executable is an explicit conflict return conflict_resolution(claim.reported_name, sender, "executable identity mismatch"); @@ -197,7 +198,7 @@ fn resolution_for_record( ) -> AttributionResolution { let record = verified.0; // Display metadata is projected only after the record and sender identities agree - if !record.claim_matches(reported_name) { + if !reported_name.trim().is_empty() && !record.claim_matches(reported_name) { return conflict_resolution(reported_name, sender, "application claim mismatch"); } let class = if record.system_association { @@ -268,28 +269,92 @@ const fn policy_resolution(attribution: NotificationAttribution) -> AttributionR } } -fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { +fn verified_executable_record<'record>( + records: &[&'record DesktopRecord], + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option> { + let missing_name = reported_name.trim().is_empty(); + let mut matches = records.iter().filter_map(|record| { + (missing_name || record.claim_matches(reported_name)) + .then(|| verify_record_sender(record, sender, index)) + .flatten() + }); + let first = matches.next()?; + let first_id = normalize_desktop_id(&first.0.id); + let mut preferred = first; + + for candidate in matches { + // One executable cannot prove which of two distinct desktop applications sent the message + if normalize_desktop_id(&candidate.0.id) != first_id { + return None; + } + // Protected records win over duplicate user metadata for the same desktop id + if candidate.0.system_association && !preferred.0.system_association { + preferred = candidate; + } + } + Some(preferred) +} + +fn record_matches_sender( + record: &DesktopRecord, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> bool { if !record.association_eligible { return false; } - match ( + let (Some(record_identity), Some(sender_identity)) = ( record.executable_identity, sender.sender_executable_identity, - ) { - (Some(record_identity), Some(sender_identity)) => { - record_identity.same_file(sender_identity) - && record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) + ) else { + return false; + }; + if !record_identity.same_file(sender_identity) { + return false; + } + + if record.system_association { + // Cached inode equality cannot carry root ownership across inode reuse + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return false; + } + let Some(path) = record.executable_path.as_deref() else { + return false; + }; + // Reopen the installed path so stale index authority cannot outlive replacement + let Some(current) = executable_evidence_for_path(path) else { + return false; + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return false; } - _ => false, } + + // Dedicated application binaries may add safe runtime flags after desktop activation + !index.requires_launch_arguments(record) + || record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) +} + +const fn current_system_identity_matches_sender( + current: FileIdentity, + sender_identity: FileIdentity, +) -> bool { + // Every property is checked again because the cached inode may have changed in place + current.same_file(sender_identity) + && current.is_system_managed() + && current.is_executable_regular() } fn verify_record_sender<'record>( record: &'record DesktopRecord, sender: &SenderMetadata, + index: &DesktopIdentityIndex, ) -> Option> { // This wrapper makes sender launch verification mandatory at every association call site - record_matches_sender(record, sender).then_some(VerifiedDesktopRecord(record)) + record_matches_sender(record, sender, index).then_some(VerifiedDesktopRecord(record)) } fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index b44d2faff..0c43d1436 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -140,14 +140,23 @@ fn system_record(id: &str, name: &str, path: &str, identity: FileIdentity) -> De DesktopRecord::fixture(id, name, path, identity, true, false) } +fn installed_system_executable() -> (String, FileIdentity) { + let path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find a protected system executable"); + let evidence = executable_evidence_for_path(&path).expect("read system executable evidence"); + assert!(evidence.identity.is_system_managed()); + assert!(evidence.identity.is_executable_regular()); + (path.display().to_string(), evidence.identity) +} + #[test] fn system_desktop_identity_allows_legitimate_signal_reply() { - let signal_identity = identity(1, 10, 0); + let (signal_path, signal_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( "org.signal.Signal", "Signal", - "/usr/bin/signal-desktop", + &signal_path, signal_identity, )], Vec::new(), @@ -158,7 +167,7 @@ fn system_desktop_identity_allows_legitimate_signal_reply() { reported_name: "Signal", desktop_entry: Some("org.signal.Signal.desktop"), }, - &sender("/usr/bin/signal-desktop", signal_identity), + &sender(&signal_path, signal_identity), &index, &HashSet::new(), ); @@ -176,6 +185,213 @@ fn system_desktop_identity_allows_legitimate_signal_reply() { assert!(!resolution.attribution.source_label.contains("unverified")); } +#[test] +fn dedicated_system_binary_with_empty_claim_accepts_runtime_added_flags() { + let (signal_path, signal_identity) = installed_system_executable(); + let record = system_record("signal", "Signal", &signal_path, signal_identity) + .with_launch_literals(&["--", "sgnl://expected"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + // Signal sends an empty app name and adds Electron flags after desktop activation + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments( + &signal_path, + signal_identity, + &["--password-store=kwallet6", "--ozone-platform=x11", "--"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn verified_executable_recovers_from_stale_desktop_hint() { + let (signal_path, signal_identity) = installed_system_executable(); + let mut stale_user_entry = DesktopRecord::fixture( + "signal-desktop", + "Signal", + "/usr/bin/env", + identity(90, 900, 0), + false, + false, + ); + // An env wrapper cannot associate the user entry with the dedicated Signal process + stale_user_entry.association_eligible = false; + stale_user_entry.system_association = false; + let system_entry = system_record("signal", "Signal", &signal_path, signal_identity); + let index = + DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + // Electron derives this hint from a differently named local desktop file + desktop_entry: Some("signal-desktop"), + }, + &sender_with_arguments( + &signal_path, + signal_identity, + &["--password-store=kwallet6", "--"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!(resolution.attribution.desktop_id, "signal"); +} + +#[test] +fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let first = system_record( + "org.example.First", + "First App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=first"]); + let second = system_record( + "org.example.Second", + "Second App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=second"]); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn duplicate_desktop_id_prefers_the_protected_record() { + let (app_path, app_identity) = installed_system_executable(); + let user_record = + DesktopRecord::fixture("signal", "Signal", &app_path, app_identity, false, false); + let mut system_record = system_record("signal", "Signal", &app_path, app_identity); + system_record.badge_icon = "protected-signal".to_string(); + let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate desktop id should keep one verified record"); + + assert!(verified.0.system_association); + assert_eq!(verified.0.badge_icon, "protected-signal"); +} + +#[test] +fn duplicate_protected_desktop_id_keeps_stable_index_order() { + let (app_path, app_identity) = installed_system_executable(); + let mut first = system_record("signal", "Signal", &app_path, app_identity); + first.badge_icon = "first-signal".to_string(); + let mut second = system_record("signal", "Signal", &app_path, app_identity); + second.badge_icon = "second-signal".to_string(); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate protected records should keep one verified record"); + + assert_eq!(verified.0.badge_icon, "first-signal"); +} + +#[test] +fn reopened_system_identity_must_remain_protected_and_executable() { + let (_, trusted) = installed_system_executable(); + let unprotected = FileIdentity { + uid: 1_000, + ..trusted + }; + let non_executable = FileIdentity { + mode: 0o100_644, + ..trusted + }; + + assert!(current_system_identity_matches_sender(trusted, trusted)); + assert!(!current_system_identity_matches_sender( + unprotected, + trusted + )); + assert!(!current_system_identity_matches_sender( + non_executable, + trusted + )); +} + +#[test] +fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { + let (system_path, cached_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected App", + &system_path, + cached_identity, + )], + Vec::new(), + ); + let untrusted_identities = [ + FileIdentity { + uid: 1_000, + ..cached_identity + }, + FileIdentity { + mode: 0o100_777, + ..cached_identity + }, + ]; + + for desktop_entry in [Some("org.example.Protected"), None] { + for sender_identity in untrusted_identities { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry, + }, + &sender(&system_path, sender_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "stale system identity accepted for hint {desktop_entry:?}" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } + } +} + #[test] fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { let app_identity = identity(6, 60, 1000); @@ -316,11 +532,11 @@ fn java_cannot_associate_a_different_jar() { #[test] fn matching_fixed_system_application_argument_allows_association() { - let runtime_identity = identity(52, 520, 0); + let (runtime_path, runtime_identity) = installed_system_executable(); let record = system_record( "org.example.ScriptApp", "Script App", - "/usr/bin/pypy3", + &runtime_path, runtime_identity, ) .with_launch_literals(&["/usr/share/script-app/main.py"]); @@ -332,7 +548,7 @@ fn matching_fixed_system_application_argument_allows_association() { desktop_entry: Some("org.example.ScriptApp"), }, &sender_with_arguments( - "/usr/bin/pypy3", + &runtime_path, runtime_identity, &["/usr/share/script-app/main.py"], ), @@ -422,11 +638,11 @@ fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { #[test] fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { - let runtime_identity = identity(61, 610, 0); + let (runtime_path, runtime_identity) = installed_system_executable(); let record = system_record( "org.example.PasswordManager", "Example Password Manager", - "/usr/bin/python3", + &runtime_path, runtime_identity, ) .with_launch_literals(&["/usr/share/password-manager/main.py"]); @@ -438,7 +654,7 @@ fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { desktop_entry: None, }, &sender_with_arguments( - "/usr/bin/python3", + &runtime_path, runtime_identity, &["/usr/share/password-manager/main.py"], ), From ec7d4f97259ded20b92dff518f95591dbf5c4699 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:23 -0500 Subject: [PATCH 107/275] fix(popups): invalidate every trailing burst commit Summary: invalidate every trailing burst commit. Scope: popups. --- .../src/daemon/events/notifications.rs | 1 - .../src/daemon/notifications/flow_control.rs | 27 +++++------ .../notifications/tests/flow_control.rs | 45 ++++++++++++++----- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/events/notifications.rs b/crates/unixnotis-daemon/src/daemon/events/notifications.rs index 96195f981..bb5e35d28 100644 --- a/crates/unixnotis-daemon/src/daemon/events/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/events/notifications.rs @@ -155,7 +155,6 @@ impl DaemonEventPublisher { } } NotificationSignalMode::SnapshotOnly => self.snapshot_invalidated().await, - NotificationSignalMode::Suppress => Ok(()), } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs index a58be8992..45882dfd6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs @@ -8,10 +8,8 @@ use std::time::{Duration, Instant}; pub enum NotificationSignalMode { // Normal path: send the precise notification signal Direct, - // Burst path: send one invalidation so clients can rebuild from state + // Burst path: send invalidations so clients can rebuild from committed state SnapshotOnly, - // Extra burst events are skipped until the window resets - Suppress, } #[derive(Clone, Debug)] @@ -19,7 +17,6 @@ pub(in crate::daemon) struct NotificationBurstState { window_started: Instant, last_seen: Instant, count: u16, - snapshot_emitted: bool, } const NOTIFICATION_SIGNAL_WINDOW: Duration = Duration::from_secs(1); @@ -32,7 +29,14 @@ pub(in crate::daemon) fn notification_signal_mode_for_sender( cache: &StdMutex>, sender: &str, ) -> NotificationSignalMode { - let now = Instant::now(); + notification_signal_mode_for_sender_at(cache, sender, Instant::now()) +} + +fn notification_signal_mode_for_sender_at( + cache: &StdMutex>, + sender: &str, + now: Instant, +) -> NotificationSignalMode { let mut cache = match cache.lock() { Ok(cache) => cache, Err(poisoned) => poisoned.into_inner(), @@ -51,14 +55,12 @@ pub(in crate::daemon) fn notification_signal_mode_for_sender( window_started: now, last_seen: now, count: 0, - snapshot_emitted: false, }); // A fresh window resets the direct-signal allowance for that sender - if now.duration_since(state.window_started) > NOTIFICATION_SIGNAL_WINDOW { + if now.duration_since(state.window_started) >= NOTIFICATION_SIGNAL_WINDOW { state.window_started = now; state.count = 0; - state.snapshot_emitted = false; } state.last_seen = now; state.count = state.count.saturating_add(1); @@ -66,13 +68,8 @@ pub(in crate::daemon) fn notification_signal_mode_for_sender( if state.count <= NOTIFICATION_DIRECT_SIGNAL_LIMIT { return NotificationSignalMode::Direct; } - if !state.snapshot_emitted { - // One snapshot invalidation tells trusted UIs to resync once without replaying the whole burst - state.snapshot_emitted = true; - return NotificationSignalMode::SnapshotOnly; - } - // Extra events inside the same burst window add no value once the snapshot refresh is queued - NotificationSignalMode::Suppress + // Every trailing commit invalidates the prior snapshot because its fetch may already be running + NotificationSignalMode::SnapshotOnly } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs index b4ee6ae91..3ce85f6c5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs @@ -3,12 +3,13 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use super::{ - notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, - NOTIFICATION_DIRECT_SIGNAL_LIMIT, NOTIFICATION_SIGNAL_TRACK_LIMIT, NOTIFICATION_SIGNAL_WINDOW, + notification_signal_mode_for_sender, notification_signal_mode_for_sender_at, + NotificationBurstState, NotificationSignalMode, NOTIFICATION_DIRECT_SIGNAL_LIMIT, + NOTIFICATION_SIGNAL_TRACK_LIMIT, NOTIFICATION_SIGNAL_WINDOW, }; #[test] -fn notification_signal_mode_falls_back_after_burst_limit() { +fn notification_signal_mode_invalidates_every_trailing_burst_commit() { let cache = Mutex::new(HashMap::::new()); for _ in 0..NOTIFICATION_DIRECT_SIGNAL_LIMIT { @@ -18,16 +19,18 @@ fn notification_signal_mode_falls_back_after_burst_limit() { ); } - // One snapshot tells clients to resync without flooding the bus + // The first overflow switches clients to the bounded snapshot path assert_eq!( notification_signal_mode_for_sender(&cache, ":1.55"), NotificationSignalMode::SnapshotOnly ); - // Further events inside the same burst window are redundant - assert_eq!( - notification_signal_mode_for_sender(&cache, ":1.55"), - NotificationSignalMode::Suppress - ); + // Later commits need their own invalidation because the first fetch may already be in flight + for _ in 0..3 { + assert_eq!( + notification_signal_mode_for_sender(&cache, ":1.55"), + NotificationSignalMode::SnapshotOnly + ); + } } #[test] @@ -41,7 +44,6 @@ fn notification_signal_mode_caps_unique_senders_without_blocking_known_sender() window_started: now, last_seen: now, count: 1, - snapshot_emitted: false, }, ); } @@ -72,7 +74,6 @@ fn notification_signal_mode_prunes_expired_senders_before_track_limit_check() { window_started: stale, last_seen: stale, count: 1, - snapshot_emitted: false, }, ); } @@ -98,7 +99,6 @@ fn notification_signal_mode_resets_existing_sender_after_window_expires() { // Recent last_seen keeps the sender in the map so only the per-sender window resets last_seen: now, count: NOTIFICATION_DIRECT_SIGNAL_LIMIT + 1, - snapshot_emitted: true, }, ); let cache = Mutex::new(seeded); @@ -109,3 +109,24 @@ fn notification_signal_mode_resets_existing_sender_after_window_expires() { NotificationSignalMode::Direct ); } + +#[test] +fn notification_signal_mode_resets_at_the_exact_window_boundary() { + let now = Instant::now(); + let window_started = now + .checked_sub(NOTIFICATION_SIGNAL_WINDOW) + .expect("test clock should represent the burst boundary"); + let cache = Mutex::new(HashMap::from([( + ":1.boundary".to_string(), + NotificationBurstState { + window_started, + last_seen: now, + count: NOTIFICATION_DIRECT_SIGNAL_LIMIT + 1, + }, + )])); + + assert_eq!( + notification_signal_mode_for_sender_at(&cache, ":1.boundary", now), + NotificationSignalMode::Direct + ); +} From 74b9cf8b80c5c5388117e84805e557eee3dea535 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:27 -0500 Subject: [PATCH 108/275] fix(night-toggle): stop backends without blocking resets Summary: stop backends without blocking resets. Scope: night-toggle. --- .../assets/scripts/unixnotis-blue-light-lib | 29 ++++++++++++++----- .../src/config/loading/io/tests/blue_light.rs | 17 +++++++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib index fa127d679..02313ebae 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib @@ -41,25 +41,40 @@ selected_backend() { active_backend || installed_backend } +terminate_backend() { + backend="$1" + pkill -x "$backend" >/dev/null 2>&1 || return 0 + + # A short grace period lets the backend restore display state before refresh runs + attempts=0 + while backend_running "$backend" && [ "$attempts" -lt 10 ]; do + sleep 0.05 + attempts=$((attempts + 1)) + done + + # A stuck backend must not keep fighting the replacement selected by the toggle + if backend_running "$backend"; then + pkill -KILL -x "$backend" >/dev/null 2>&1 || true + fi +} + stop_backend() { case "$1" in hyprsunset) - pkill -x hyprsunset >/dev/null 2>&1 || true + terminate_backend hyprsunset ;; gammastep) - if has_backend gammastep; then - gammastep -x >/dev/null 2>&1 || true - fi - pkill -x gammastep >/dev/null 2>&1 || true + # SIGTERM lets the running backend restore its own gamma ramps + terminate_backend gammastep ;; wlsunset) - pkill -x wlsunset >/dev/null 2>&1 || true + terminate_backend wlsunset ;; sunsetr) if has_backend sunsetr; then sunsetr stop >/dev/null 2>&1 || true fi - pkill -x sunsetr >/dev/null 2>&1 || true + terminate_backend sunsetr ;; esac } diff --git a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs index abf27353b..3d443bdf6 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs @@ -69,6 +69,7 @@ fn stopping_night_mode_visits_every_active_supported_backend() { &root.join("bin/pkill"), "#!/bin/sh\nprintf 'pkill %s\\n' \"$*\" >> \"$TEST_LOG\"\n", ); + write_executable(&root.join("bin/sleep"), "#!/bin/sh\nexit 0\n"); let status = Command::new("/bin/sh") .args(["-c", ". \"$1\"; stop_active_backends", "blue-light-test"]) @@ -82,7 +83,6 @@ fn stopping_night_mode_visits_every_active_supported_backend() { let calls = fs::read_to_string(&log).expect("read stop calls"); for expected in [ "pkill -x hyprsunset", - "gammastep -x", "pkill -x gammastep", "pkill -x wlsunset", "sunsetr stop", @@ -93,6 +93,10 @@ fn stopping_night_mode_visits_every_active_supported_backend() { "missing {expected}" ); } + assert!( + !calls.lines().any(|call| call == "gammastep -x"), + "stopping Night mode must not invoke the blocking reset process" + ); let _ = fs::remove_dir_all(root); } @@ -109,7 +113,10 @@ fn night_mode_falls_through_when_the_first_installed_backend_cannot_stay_running "#!/bin/sh\n[ \"$2\" = gammastep ] && [ -f \"$TEST_MARKER\" ]\n", ); write_executable(&root.join("bin/pkill"), "#!/bin/sh\nexit 0\n"); - write_executable(&root.join("bin/sleep"), "#!/bin/sh\nexit 0\n"); + write_executable( + &root.join("bin/sleep"), + "#!/bin/sh\nexec /bin/sleep \"$1\"\n", + ); let status = Command::new("/bin/sh") .args(["-c", ". \"$1\"; start_available_backend", "blue-light-test"]) @@ -117,7 +124,7 @@ fn night_mode_falls_through_when_the_first_installed_backend_cannot_stay_running .env("PATH", root.join("bin")) .env("TEST_LOG", &log) .env("TEST_MARKER", &marker) - .env("UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY", "0") + .env("UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY", "0.05") .status() .expect("start first healthy blue-light backend"); @@ -127,6 +134,10 @@ fn night_mode_falls_through_when_the_first_installed_backend_cannot_stay_running assert!(calls .lines() .any(|call| call.starts_with("gammastep -m wayland"))); + assert!( + !calls.lines().any(|call| call == "gammastep -x"), + "fallback must not block on an inactive gammastep reset" + ); let _ = fs::remove_dir_all(root); } From 1a6908efeca6ca5ff7d961e7050c991c74ead548 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:36 -0500 Subject: [PATCH 109/275] refactor(installer): simplify infallible workflow paths Summary: simplify infallible workflow paths. Scope: installer. --- .../src/actions/config/backup/restore.rs | 8 +- .../src/actions/config/backup/retention.rs | 16 ++-- .../actions/config/backup/tests/retention.rs | 8 +- .../src/actions/conflicts.rs | 23 +++-- .../unixnotis-installer/src/actions/daemon.rs | 53 ++++++----- .../src/actions/environment/shell_path.rs | 27 +++--- .../src/actions/environment/sync.rs | 60 ++++++------- .../src/actions/format/daemon_status.rs | 12 +-- .../src/actions/format/tests/daemon_status.rs | 4 +- .../src/actions/hyprland/manage.rs | 65 ++++++++++---- .../src/actions/install/service/files.rs | 8 +- .../src/actions/install/service/flow.rs | 15 +--- .../src/actions/install/service/lifecycle.rs | 14 +-- .../src/actions/install/service/refresh.rs | 3 +- .../src/actions/install_state.rs | 25 ++---- .../src/actions/process.rs | 21 ++--- .../unixnotis-installer/src/actions/state.rs | 88 ++++++++++--------- .../src/actions/tests/process.rs | 4 +- .../unixnotis-installer/src/app/handlers.rs | 68 +++++++------- crates/unixnotis-installer/src/app/runtime.rs | 17 ++-- crates/unixnotis-installer/src/app/state.rs | 5 +- .../src/app/tests/handlers.rs | 62 ++++++------- .../unixnotis-installer/src/app/workflow.rs | 33 ++++--- .../unixnotis-installer/src/checks/session.rs | 8 +- .../unixnotis-installer/src/checks/system.rs | 9 +- crates/unixnotis-installer/src/cli/model.rs | 2 +- crates/unixnotis-installer/src/detect.rs | 6 +- crates/unixnotis-installer/src/main.rs | 16 +--- .../src/paths/tests/s6_live.rs | 30 ++----- .../src/service_manager/backends/dinit.rs | 36 ++++---- .../src/service_manager/backends/runit.rs | 22 ++--- .../src/service_manager/backends/s6.rs | 22 ++--- .../src/service_manager/backends/systemd.rs | 68 +++++++------- .../service_manager/backends/tests/dinit.rs | 12 +-- .../service_manager/backends/tests/runit.rs | 22 ++--- .../src/service_manager/backends/tests/s6.rs | 17 ++-- .../service_manager/backends/tests/systemd.rs | 18 ++-- .../src/service_manager/contract/command.rs | 4 +- .../orchestration/artifacts.rs | 13 +-- .../orchestration/environment.rs | 2 +- .../orchestration/lifecycle.rs | 24 +++-- .../service_manager/orchestration/model.rs | 6 +- .../unixnotis-installer/src/trial/launch.rs | 6 +- crates/unixnotis-installer/src/ui/confirm.rs | 10 ++- crates/unixnotis-installer/src/ui/welcome.rs | 2 +- crates/unixnotis-installer/src/ui/widgets.rs | 4 +- 46 files changed, 475 insertions(+), 523 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 9dfa8f493..e0308bf54 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -133,10 +133,10 @@ fn normalize_path_for_compare(path: &Path) -> PathBuf { let absolute = if path.is_absolute() { path.to_path_buf() } else { - match std::env::current_dir() { - Ok(current_dir) => current_dir.join(path), - Err(_) => path.to_path_buf(), - } + std::env::current_dir().map_or_else( + |_error| path.to_path_buf(), + |current_dir| current_dir.join(path), + ) }; if let Ok(canonical) = fs::canonicalize(&absolute) { return canonical; diff --git a/crates/unixnotis-installer/src/actions/config/backup/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/retention.rs index ef1a9cb8e..6855f0541 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/retention.rs @@ -24,7 +24,7 @@ pub(in crate::actions::config) fn create_backup_dir( } // Each reset gets its own dated directory so filenames stay simple - let stamp = backup_stamp_from_system_time()?; + let stamp = backup_stamp_from_system_time(); let base_name = format!("{BACKUP_PREFIX}{stamp}"); let mut candidate = config_dir.join(base_name); @@ -42,7 +42,7 @@ pub(in crate::actions::config) fn create_backup_dir( format!("Backup directory created: {}", format_with_home(&candidate)), ); - prune_old_backups_except(ctx, config_dir, keep, Some(candidate.as_path()))?; + prune_old_backups_except(ctx, config_dir, keep, Some(candidate.as_path())); Ok(Some(candidate)) } @@ -73,9 +73,9 @@ pub(in crate::actions::config::backup) fn prune_old_backups_except( config_dir: &Path, keep: usize, protected_backup: Option<&Path>, -) -> Result<()> { +) { if keep == 0 { - return Ok(()); + return; } let mut backups = list_backup_dirs(config_dir); @@ -83,7 +83,7 @@ pub(in crate::actions::config::backup) fn prune_old_backups_except( backups.sort(); if backups.len() <= keep { - return Ok(()); + return; } let mut excess = backups.len().saturating_sub(keep); @@ -111,11 +111,9 @@ pub(in crate::actions::config::backup) fn prune_old_backups_except( } excess -= 1; } - - Ok(()) } -fn backup_stamp_from_system_time() -> Result { +fn backup_stamp_from_system_time() -> String { // Use chrono for a stable YYYY-MM-DD stamp without hand-rolled time math - Ok(Local::now().format("%Y-%m-%d").to_string()) + Local::now().format("%Y-%m-%d").to_string() } diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs index 204b36a1d..78f980c14 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs @@ -15,9 +15,9 @@ fn prune_old_backups( ctx: &mut crate::actions::ActionContext, config_dir: &std::path::Path, keep: usize, -) -> anyhow::Result<()> { +) { // Direct retention tests do not need to protect a newly created backup - prune_old_backups_except(ctx, config_dir, keep, None) + prune_old_backups_except(ctx, config_dir, keep, None); } #[test] @@ -55,7 +55,7 @@ fn prune_old_backups_keeps_newest() { restore_backup: None, service_reload_required: Arc::new(AtomicBool::new(false)), }; - prune_old_backups(&mut ctx, &root, 2).expect("prune should succeed"); + prune_old_backups(&mut ctx, &root, 2); // Only the two newest entries should remain let mut remaining = list_backup_dirs(&root) @@ -157,7 +157,7 @@ fn prune_old_backups_rejects_symlink_children_without_touching_target() { let paths = super::support::test_paths(&root); let mut context = super::support::test_context(&detection, &paths); - prune_old_backups(&mut context, &root, 1).expect("prune remains best effort"); + prune_old_backups(&mut context, &root, 1); assert!(oldest.exists()); assert!(newest.exists()); diff --git a/crates/unixnotis-installer/src/actions/conflicts.rs b/crates/unixnotis-installer/src/actions/conflicts.rs index c18d58a6c..e95514818 100644 --- a/crates/unixnotis-installer/src/actions/conflicts.rs +++ b/crates/unixnotis-installer/src/actions/conflicts.rs @@ -42,19 +42,16 @@ pub(in crate::actions) fn detect_service_manager_conflict_state( .iter() .all(crate::service_manager::ServiceArtifact::is_present_safely); // Active probes are best-effort because missing tools should not become false conflicts - let active = match manager.active_probe() { - Some(probe) => match probe.evaluate() { - Ok(active) => active, - Err(err) => { - // Probe failures do not block install, but they should not disappear either - warnings.push(format!( - "could not check whether {} is active: {err}", - manager.label() - )); - false - } - }, - None => false, + let active = match manager.active_probe().evaluate() { + Ok(active) => active, + Err(err) => { + // Probe failures do not block install, but they should not disappear either + warnings.push(format!( + "could not check whether {} is active: {err}", + manager.label() + )); + false + } }; // Only real evidence should block install; probe errors are treated as not active diff --git a/crates/unixnotis-installer/src/actions/daemon.rs b/crates/unixnotis-installer/src/actions/daemon.rs index 5a7f855b6..f98550445 100644 --- a/crates/unixnotis-installer/src/actions/daemon.rs +++ b/crates/unixnotis-installer/src/actions/daemon.rs @@ -50,13 +50,7 @@ pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { let (label, command) = if is_unixnotis { // Reinstall can race with session hooks that start the daemon when the bus name drops // The irreversible stop job keeps that start request from canceling the stop in flight - let spec = ctx - .paths - .service - .stop_for_reinstall_command() - .ok_or_else(|| { - anyhow!("service manager cannot stop unixnotis for reinstall") - })?; + let spec = ctx.paths.service.stop_for_reinstall_command(); (spec.label().to_string(), spec.to_command()?) } else { let mut command = system_tools::command("systemctl") @@ -117,22 +111,35 @@ pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { } } - if let Some(comm) = owner_comm { - let message = format!( - "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." - ); - log_line(ctx, message.clone()); - return Err(anyhow!(message)); - } - if let Some(pid) = owner_pid { - let message = format!( - "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." - ); - log_line(ctx, message.clone()); - return Err(anyhow!(message)); - } - let message = "Detected owner is not managed by a known unit; stop it manually before install." - .to_string(); + unmanaged_owner_error(ctx, owner_comm, owner_pid) +} + +fn unmanaged_owner_error( + ctx: &mut ActionContext, + owner_comm: Option<&str>, + owner_pid: Option, +) -> Result<()> { + // Preserve the strongest broker identity available in the manual-stop instruction + let message = owner_comm.map_or_else( + || { + owner_pid.map_or_else( + || { + "Detected owner is not managed by a known unit; stop it manually before install." + .to_string() + }, + |pid| { + format!( + "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." + ) + }, + ) + }, + |comm| { + format!( + "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." + ) + }, + ); log_line(ctx, message.clone()); Err(anyhow!(message)) } diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index 608b2f6cd..66c270b6b 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -242,18 +242,17 @@ pub(in crate::actions::environment) fn format_path_for_shell_line( bin_dir: &Path, ) -> String { // Prefer `$HOME` when possible so startup files stay portable across usernames - if let Ok(stripped) = bin_dir.strip_prefix(home) { - let tail = stripped.to_string_lossy(); - - // If the bin directory is exactly the home directory, `$HOME` alone is enough - if tail.is_empty() { - "$HOME".to_string() - } else { - // Convert the home-relative suffix into a shell-friendly `$HOME/...` path - format!("$HOME/{}", tail.trim_start_matches('/')) - } - } else { - // Fall back to the absolute path when the bin directory is outside home - bin_dir.display().to_string() - } + bin_dir.strip_prefix(home).map_or_else( + |_error| bin_dir.display().to_string(), + |stripped| { + let tail = stripped.to_string_lossy(); + // `$HOME` alone covers the exact home directory + if tail.is_empty() { + "$HOME".to_string() + } else { + // The home-relative suffix keeps startup files portable across usernames + format!("$HOME/{}", tail.trim_start_matches('/')) + } + }, + ) } diff --git a/crates/unixnotis-installer/src/actions/environment/sync.rs b/crates/unixnotis-installer/src/actions/environment/sync.rs index 9ee3e1441..a1f4dff95 100644 --- a/crates/unixnotis-installer/src/actions/environment/sync.rs +++ b/crates/unixnotis-installer/src/actions/environment/sync.rs @@ -55,41 +55,41 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { let message = "no session environment variables found to import for the service manager"; log_line(ctx, format!("Error: {message}")); return Err(anyhow!(message)); - } else { - let env_artifacts = ctx - .paths - .service - .environment_sync_artifacts(import_var_names, &vars); - for artifact in &env_artifacts { - // Artifact-based managers persist a small envdir instead of importing into a daemon - write_service_artifact(ctx, artifact)?; - updated = true; - } - if !env_artifacts.is_empty() { - log_line(ctx, "Environment synced with service environment files"); - } + } + + let env_artifacts = ctx + .paths + .service + .environment_sync_artifacts(import_var_names, &vars); + for artifact in &env_artifacts { + // Artifact-based managers persist a small envdir instead of importing into a daemon + write_service_artifact(ctx, artifact)?; + updated = true; + } + if !env_artifacts.is_empty() { + log_line(ctx, "Environment synced with service environment files"); + } - let specs = ctx - .paths - .service - .environment_sync_commands(&vars, dbus_update_available); - for spec in specs { - log_line(ctx, format!("Syncing environment with {}", spec.program())); - // Import commands can echo names or values on stdout on some setups - let command = match spec.to_command() { - Ok(command) => command, - Err(err) => { - log_line(ctx, format!("Warning: {err}")); - continue; - } - }; - if let Err(err) = run_command_without_stdout(ctx, spec.label(), command, None) { + let specs = ctx + .paths + .service + .environment_sync_commands(&vars, dbus_update_available); + for spec in specs { + log_line(ctx, format!("Syncing environment with {}", spec.program())); + // Import commands can echo names or values on stdout on some setups + let command = match spec.to_command() { + Ok(command) => command, + Err(err) => { log_line(ctx, format!("Warning: {err}")); continue; } - log_line(ctx, format!("Environment synced with {}", spec.program())); - updated = true; + }; + if let Err(err) = run_command_without_stdout(ctx, spec.label(), command, None) { + log_line(ctx, format!("Warning: {err}")); + continue; } + log_line(ctx, format!("Environment synced with {}", spec.program())); + updated = true; } if !updated { diff --git a/crates/unixnotis-installer/src/actions/format/daemon_status.rs b/crates/unixnotis-installer/src/actions/format/daemon_status.rs index f842a7c8e..c696c9c17 100644 --- a/crates/unixnotis-installer/src/actions/format/daemon_status.rs +++ b/crates/unixnotis-installer/src/actions/format/daemon_status.rs @@ -2,18 +2,18 @@ use crate::detect::DetectedDaemon; -pub fn summarize_owner(owner: &Option) -> String { - match owner { - Some(info) => { +pub fn summarize_owner(owner: Option<&crate::detect::OwnerInfo>) -> String { + owner.map_or_else( + || "none detected".to_string(), + |info| { // Keep missing fields readable instead of showing an empty tuple let name = info.comm.as_deref().unwrap_or("unknown"); let pid = info .pid .map_or_else(|| "unknown".to_string(), |pid| pid.to_string()); format!("{name} (pid {pid})") - } - None => "none detected".to_string(), - } + }, + ) } pub const fn daemon_has_displayable_status(daemon: &DetectedDaemon) -> bool { diff --git a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs index aaea269e1..8882b02db 100644 --- a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs +++ b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs @@ -8,14 +8,14 @@ fn summarize_owner_includes_comm_and_pid() { pid: Some(4242), comm: Some("unixnotis-daemon".to_string()), }; - let rendered = summarize_owner(&Some(owner)); + let rendered = summarize_owner(Some(&owner)); assert_eq!(rendered, "unixnotis-daemon (pid 4242)"); } #[test] fn summarize_owner_handles_missing_owner() { // Ensures the empty-owner branch renders a stable placeholder string. - let rendered = summarize_owner(&None); + let rendered = summarize_owner(None); assert_eq!(rendered, "none detected"); } diff --git a/crates/unixnotis-installer/src/actions/hyprland/manage.rs b/crates/unixnotis-installer/src/actions/hyprland/manage.rs index ef4e2d1f1..b4dcb609b 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/manage.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/manage.rs @@ -1,6 +1,7 @@ //! Hyprland bootstrap flow for install and uninstall use std::fs; +use std::path::Path; use super::super::{log_line, ActionContext}; use super::block::strip_hyprland_bootstrap_block; @@ -8,7 +9,9 @@ use super::detect::{ has_import_command_with_vars, has_legacy_dbus_update, has_startup_command, hyprland_startup_line, }; -use super::paths::{existing_hyprland_config_targets, hyprland_config_target}; +use super::paths::{ + existing_hyprland_config_targets, hyprland_config_target, HyprlandConfigSyntax, +}; use super::write_target::resolve_hyprland_write_path; use crate::paths::format_with_home; use crate::write_target::reject_unsafe_write_target; @@ -52,19 +55,8 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { ); return; } - let contents = match fs::read_to_string(&write_path) { - Ok(contents) => contents, - Err(err) => { - log_line( - ctx, - format!( - "Warning: failed to read {}: {}", - format_with_home(&hypr_config), - err - ), - ); - return; - } + let Some(contents) = read_hyprland_config(ctx, &write_path, &hypr_config) else { + return; }; // Strip any managed block first so missing lines can be rebuilt cleanly @@ -121,17 +113,34 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { return; } - let mut updated_contents = stripped; + write_hyprland_bootstrap( + ctx, + &write_path, + &hypr_config, + target.syntax, + &additions, + stripped, + ); +} + +fn write_hyprland_bootstrap( + ctx: &mut ActionContext, + write_path: &Path, + hypr_config: &Path, + syntax: HyprlandConfigSyntax, + additions: &[String], + mut updated_contents: String, +) { + // Keep publication in one helper so the discovery path remains easy to audit if !updated_contents.ends_with('\n') { updated_contents.push('\n'); } updated_contents.push_str(&super::block::render_hyprland_bootstrap_block( - target.syntax, - &additions, + syntax, additions, )); if let Err(err) = - write_file_atomic_preserving_mode(&write_path, updated_contents.as_bytes(), 0o644) + write_file_atomic_preserving_mode(write_path, updated_contents.as_bytes(), 0o644) { log_line( ctx, @@ -142,12 +151,30 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { ctx, format!( "Updated Hyprland config at {}", - format_with_home(&hypr_config) + format_with_home(hypr_config) ), ); } } +fn read_hyprland_config( + ctx: &mut ActionContext, + write_path: &Path, + display_path: &Path, +) -> Option { + fs::read_to_string(write_path) + .map_err(|error| { + log_line( + ctx, + format!( + "Warning: failed to read {}: {error}", + format_with_home(display_path) + ), + ); + }) + .ok() +} + fn hyprland_command_present(contents: &str, command: &str, import_variables: &[&str]) -> bool { if command.starts_with("dbus-update-activation-environment") { return has_legacy_dbus_update(contents) || has_startup_command(contents, command); diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 722f27e5e..1d6e7617d 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -58,10 +58,10 @@ pub(in crate::actions::install::service) fn write_regular_service_file( if contents_changed { // Explicit modes keep service scripts independent of process umask - match mode { - Some(mode) => write_file_atomic(path, contents.as_bytes(), mode), - None => write_file_atomic_preserving_mode(path, contents.as_bytes(), 0o644), - } + mode.map_or_else( + || write_file_atomic_preserving_mode(path, contents.as_bytes(), 0o644), + |mode| write_file_atomic(path, contents.as_bytes(), mode), + ) .with_context(|| format!("failed to write {artifact_label}"))?; } else if mode_changed { #[cfg(unix)] diff --git a/crates/unixnotis-installer/src/actions/install/service/flow.rs b/crates/unixnotis-installer/src/actions/install/service/flow.rs index b3ab70646..5c3394453 100644 --- a/crates/unixnotis-installer/src/actions/install/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/service/flow.rs @@ -98,18 +98,9 @@ pub fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { let unsafe_artifact_exists = log_unsafe_service_artifacts(ctx, &artifacts); if artifact_exists { - if let Some(spec) = ctx.paths.service.disable_now_command() { - if let Err(err) = run_command_spec(ctx, &spec) { - log_line(ctx, format!("Warning: {err}")); - } - } else { - log_line( - ctx, - format!( - "Skipping disable; {} has no disable command", - ctx.paths.service.label() - ), - ); + let spec = ctx.paths.service.disable_now_command(); + if let Err(err) = run_command_spec(ctx, &spec) { + log_line(ctx, format!("Warning: {err}")); } for artifact in artifacts.iter().rev() { diff --git a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs index 303d2e4d0..06e95bc24 100644 --- a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs @@ -1,6 +1,6 @@ //! Service lifecycle command helpers -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result}; use crate::paths::format_with_home; use crate::service_manager::CommandSpec; @@ -42,11 +42,7 @@ pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> ctx, format!("Enabling and starting {}", ctx.paths.service.service_name()), ); - let spec = ctx - .paths - .service - .enable_now_command() - .ok_or_else(|| anyhow!("service manager cannot enable and start service"))?; + let spec = ctx.paths.service.enable_now_command(); run_command_spec(ctx, &spec) } ServiceStartMode::StartOnly => { @@ -55,11 +51,7 @@ pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> ctx, format!("Starting {}", ctx.paths.service.service_name()), ); - let spec = ctx - .paths - .service - .start_command() - .ok_or_else(|| anyhow!("service manager cannot start service"))?; + let spec = ctx.paths.service.start_command(); run_command_spec(ctx, &spec) } } diff --git a/crates/unixnotis-installer/src/actions/install/service/refresh.rs b/crates/unixnotis-installer/src/actions/install/service/refresh.rs index b9b784069..0a5c9779c 100644 --- a/crates/unixnotis-installer/src/actions/install/service/refresh.rs +++ b/crates/unixnotis-installer/src/actions/install/service/refresh.rs @@ -260,11 +260,12 @@ pub(in crate::actions::install) fn strip_ansi_csi_sequences(line: &str) -> Strin } pub(in crate::actions::install) fn truncate_diagnostic(mut line: String, max_len: usize) -> String { + const ELLIPSIS: &str = "..."; + if line.len() <= max_len { return line; } - const ELLIPSIS: &str = "..."; if max_len <= ELLIPSIS.len() { // Very small budgets still need valid UTF-8 and must not exceed the caller limit return ELLIPSIS[..max_len].to_string(); diff --git a/crates/unixnotis-installer/src/actions/install_state.rs b/crates/unixnotis-installer/src/actions/install_state.rs index 3dcb762c4..e577c3196 100644 --- a/crates/unixnotis-installer/src/actions/install_state.rs +++ b/crates/unixnotis-installer/src/actions/install_state.rs @@ -78,10 +78,7 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { // Enabled state decides whether reinstall can skip `enable --now` // Some backends store enablement as installer-owned artifacts instead of manager state let mut service_enabled_error = None; - let service_enabled = if let Some(enabled) = paths.service.enabled_by_artifacts() { - // Artifact-backed managers prove enablement through installer-owned filesystem state - enabled - } else { + let service_enabled = paths.service.enabled_by_artifacts().unwrap_or_else(|| { if let Some(spec) = paths.service.is_enabled_command() { match spec.to_command().and_then(|mut command| command.status()) { // Command-backed managers still use the native manager status probe @@ -97,22 +94,16 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { Some("service manager has no enabled-state command".to_string()); false } - }; + }); // Active state still matters for the install summary shown in the UI let mut service_active_error = None; - let service_active = if let Some(probe) = paths.service.active_probe() { - match probe.evaluate() { - // Active probes can be plain exit status or stdout parsing, depending on backend - Ok(active) => active, - Err(err) => { - service_active_error = Some(err.to_string()); - false - } + let service_active = match paths.service.active_probe().evaluate() { + // Active probes can be plain exit status or stdout parsing, depending on backend + Ok(active) => active, + Err(err) => { + service_active_error = Some(err.to_string()); + false } - } else { - // Backends without active state still allow install, but cannot claim a running service - service_active_error = Some("service manager has no active-state command".to_string()); - false }; let (service_conflicts, service_conflict_warnings) = diff --git a/crates/unixnotis-installer/src/actions/process.rs b/crates/unixnotis-installer/src/actions/process.rs index 05370461a..c34879d3e 100644 --- a/crates/unixnotis-installer/src/actions/process.rs +++ b/crates/unixnotis-installer/src/actions/process.rs @@ -81,13 +81,13 @@ fn run_command_with_output( let stdout_handle = stdout.map(|stream| { let tx = log_tx.clone(); let label = label_string.clone(); - thread::spawn(move || read_stream(stream, tx, label, "stdout")) + thread::spawn(move || read_stream(stream, &tx, &label, "stdout")) }); let stderr_handle = stderr.map(|stream| { let tx = log_tx.clone(); let label = label_string.clone(); - thread::spawn(move || read_stream(stream, tx, label, "stderr")) + thread::spawn(move || read_stream(stream, &tx, &label, "stderr")) }); let status = child @@ -114,7 +114,8 @@ fn run_command_with_output( } pub fn log_line(ctx: &mut ActionContext, line: impl Into) { - send_log_line(&ctx.log_tx, line.into()); + let line = line.into(); + send_log_line(&ctx.log_tx, &line); } fn sanitize_log_line(line: &str) -> String { @@ -134,8 +135,8 @@ fn sanitize_log_line_with_source_truncation(line: &str, source_truncated: bool) fn read_stream( stream: impl std::io::Read, - tx: SyncSender, - label: String, + tx: &SyncSender, + label: &str, stream_name: &str, ) { let mut reader = BufReader::new(stream); @@ -146,13 +147,13 @@ fn read_stream( Ok(Some(source_truncated)) => { // Invalid subprocess bytes are replaced only after the retained input is bounded let line = String::from_utf8_lossy(&line); - send_log_line_with_source_truncation(&tx, &line, source_truncated); + send_log_line_with_source_truncation(tx, &line, source_truncated); } Ok(None) => break, Err(err) => { send_log_line( - &tx, - format!("Warning: log stream error for {label} ({stream_name}): {err}"), + tx, + &format!("Warning: log stream error for {label} ({stream_name}): {err}"), ); break; } @@ -202,8 +203,8 @@ fn read_bounded_log_line( } } -fn send_log_line(tx: &SyncSender, line: String) { - let line = sanitize_log_line(&line); +fn send_log_line(tx: &SyncSender, line: &str) { + let line = sanitize_log_line(line); send_sanitized_log_line(tx, line); } diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index 0467e268b..fe1496596 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -9,7 +9,7 @@ use crate::paths::format_with_home; use crate::service_manager::ReadinessIssue; use super::installation_channel::reject_conflicting_installation_channel; -use super::{context::ActionContext, install_state::check_install_state, log_line}; +use super::{context::ActionContext, install_state::check_install_state, log_line, InstallState}; pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { // Use cached install state when available to keep the UI consistent with the plan @@ -60,43 +60,7 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { if let Some(err) = state.service_enabled_error.as_ref() { log_line(ctx, format!("- service enable check failed: {err}")); } - for warning in &state.service_conflict_warnings { - // Non-selected backend path issues are diagnostics, not blockers for the selected backend - log_line( - ctx, - format!("Warning: could not inspect another service manager ({warning})"), - ); - } - if !state.service_conflicts.is_empty() { - // Block before build/copy/write steps so two managers never race to restart the daemon - for conflict in &state.service_conflicts { - if conflict.active { - log_line( - ctx, - format!( - "Error: UnixNotis is active under {}; selected backend is {}", - conflict.manager_label, - ctx.paths.service.label() - ), - ); - } - if conflict.installed { - log_line( - ctx, - format!( - "Error: {} already exists under {} at {}", - conflict.artifact_label, - conflict.manager_label, - format_with_home(&conflict.artifact_path) - ), - ); - } - } - return Err(anyhow!( - "UnixNotis already appears managed by another service manager; uninstall or migrate it before installing with {}", - ctx.paths.service.label() - )); - } + reject_service_manager_conflicts(ctx, &state)?; // The source installer must not shadow or combine with package-owned systemd artifacts reject_conflicting_installation_channel(ctx)?; let mut readiness_errors = Vec::new(); @@ -119,6 +83,11 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { readiness_errors.join("; ") )); } + log_install_summary(ctx, &state); + Ok(()) +} + +fn log_install_summary(ctx: &mut ActionContext, state: &InstallState) { log_line( ctx, format!( @@ -133,7 +102,6 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { if state.service_active { "yes" } else { "no" } ), ); - if state.is_fully_installed() { if matches!(ctx.action_mode, ActionMode::Install) { log_line( @@ -159,8 +127,48 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { } else { log_line(ctx, "Install will continue and update missing items."); } +} - Ok(()) +fn reject_service_manager_conflicts(ctx: &mut ActionContext, state: &InstallState) -> Result<()> { + for warning in &state.service_conflict_warnings { + // Non-selected backend path issues are diagnostics, not blockers for the selected backend + log_line( + ctx, + format!("Warning: could not inspect another service manager ({warning})"), + ); + } + if state.service_conflicts.is_empty() { + return Ok(()); + } + + // Block before build/copy/write steps so two managers never race to restart the daemon + for conflict in &state.service_conflicts { + if conflict.active { + log_line( + ctx, + format!( + "Error: UnixNotis is active under {}; selected backend is {}", + conflict.manager_label, + ctx.paths.service.label() + ), + ); + } + if conflict.installed { + log_line( + ctx, + format!( + "Error: {} already exists under {} at {}", + conflict.artifact_label, + conflict.manager_label, + format_with_home(&conflict.artifact_path) + ), + ); + } + } + Err(anyhow!( + "UnixNotis already appears managed by another service manager; uninstall or migrate it before installing with {}", + ctx.paths.service.label() + )) } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/actions/tests/process.rs b/crates/unixnotis-installer/src/actions/tests/process.rs index 4409c12bf..aac6ca34d 100644 --- a/crates/unixnotis-installer/src/actions/tests/process.rs +++ b/crates/unixnotis-installer/src/actions/tests/process.rs @@ -127,7 +127,7 @@ fn send_log_line_delivers_worker_log_event() { let _guard = lock_dropped_log_state(); let (tx, rx) = mpsc::sync_channel(1); - send_log_line(&tx, "hello".to_string()); + send_log_line(&tx, "hello"); let event = rx.try_recv().expect("log event"); assert!(matches!( @@ -141,7 +141,7 @@ fn send_log_line_sanitizes_before_queueing() { let _guard = lock_dropped_log_state(); let (tx, rx) = mpsc::sync_channel(1); - send_log_line(&tx, "unsafe\u{1b}[2Jline".to_string()); + send_log_line(&tx, "unsafe\u{1b}[2Jline"); let event = rx.try_recv().expect("log event"); assert!(matches!( diff --git a/crates/unixnotis-installer/src/app/handlers.rs b/crates/unixnotis-installer/src/app/handlers.rs index 4cd8ce08a..a166c9f6e 100644 --- a/crates/unixnotis-installer/src/app/handlers.rs +++ b/crates/unixnotis-installer/src/app/handlers.rs @@ -15,27 +15,27 @@ use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::terminal::TerminalGuard; -pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Option { match key.code { - KeyCode::Char('q' | 'Q') => Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Up | KeyCode::Char('k') => { if app.menu_index > 0 { app.menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { if app.menu_index + 1 < App::menu_items().len() { app.menu_index += 1; } - Ok(None) + None } KeyCode::Char('r' | 'R') => { app.refresh(); - Ok(None) + None } KeyCode::Enter => match app.selected_menu() { - MenuItem::Quit => Ok(Some(ExitAction::None)), + MenuItem::Quit => Some(ExitAction::None), MenuItem::Action(mode) => { if mode == ActionMode::Reset { // Reset uses a submenu to avoid accidental destructive actions @@ -44,32 +44,32 @@ pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } -pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { app.screen = Screen::Welcome; - Ok(None) + None } KeyCode::Up | KeyCode::Char('k') => { // Clamp selection to keep navigation predictable in small terminals if app.reset_menu_index > 0 { app.reset_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { // Reset menu has three entries; enforce bounds if app.reset_menu_index < 2 { app.reset_menu_index += 1; } - Ok(None) + None } KeyCode::Enter => { match app.reset_menu_index { @@ -87,31 +87,31 @@ pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } -pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { app.screen = Screen::ResetMenu; - Ok(None) + None } KeyCode::Up | KeyCode::Char('k') => { // Backup selection should never underflow if app.restore_menu_index > 0 { app.restore_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { // Only advance selection when there are backup entries if app.restore_menu_index + 1 < app.restore_backups.len() { app.restore_menu_index += 1; } - Ok(None) + None } KeyCode::Enter => { // Restore proceeds only when a backup is selected @@ -119,9 +119,9 @@ pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } @@ -163,13 +163,13 @@ pub fn handle_confirm_key( } } -pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Option { if matches!(app.progress_state, ProgressState::Running) { - return Ok(None); + return None; } if let Some(ready_at) = app.progress_ready_at { if Instant::now() < ready_at { - return Ok(None); + return None; } } match key.code { @@ -183,41 +183,41 @@ pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Result Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Esc => { app.screen = Screen::Welcome; - Ok(None) + None } - _ => Ok(None), + _ => None, } } -pub fn handle_build_accel_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_build_accel_key(app: &mut App, key: KeyEvent) -> Option { match key.code { - KeyCode::Char('q' | 'Q') => Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Up | KeyCode::Char('k') => { if app.build_accel_menu_index > 0 { app.build_accel_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { if app.build_accel_menu_index + 1 < app.build_accel_menu_len() { app.build_accel_menu_index += 1; } - Ok(None) + None } KeyCode::Esc => { reset_to_menu(app); - Ok(None) + None } KeyCode::Enter => { handle_build_accel_enter(app); - Ok(None) + None } - _ => Ok(None), + _ => None, } } diff --git a/crates/unixnotis-installer/src/app/runtime.rs b/crates/unixnotis-installer/src/app/runtime.rs index dba6c7ad1..094d608d0 100644 --- a/crates/unixnotis-installer/src/app/runtime.rs +++ b/crates/unixnotis-installer/src/app/runtime.rs @@ -31,7 +31,7 @@ pub fn run_app(terminal_guard: &mut TerminalGuard, app: &mut App) -> Result { - if let Some(exit) = handle_event(app, terminal_guard, &ui_tx, input)? { + if let Some(exit) = handle_event(app, terminal_guard, &ui_tx, &input)? { return Ok(exit); } } @@ -54,18 +54,17 @@ fn handle_event( app: &mut App, terminal_guard: &mut TerminalGuard, ui_tx: &mpsc::SyncSender, - event: Event, + event: &Event, ) -> Result> { match event { Event::Key(key) => match app.screen { - Screen::Welcome => handle_welcome_key(app, key), - Screen::Confirm(mode) => handle_confirm_key(app, terminal_guard, ui_tx, key, mode), - Screen::ResetMenu => handle_reset_menu_key(app, key), - Screen::RestoreSelect => handle_restore_select_key(app, key), - Screen::Progress(_) => handle_progress_key(app, key), - Screen::BuildAccel => handle_build_accel_key(app, key), + Screen::Welcome => Ok(handle_welcome_key(app, *key)), + Screen::Confirm(mode) => handle_confirm_key(app, terminal_guard, ui_tx, *key, mode), + Screen::ResetMenu => Ok(handle_reset_menu_key(app, *key)), + Screen::RestoreSelect => Ok(handle_restore_select_key(app, *key)), + Screen::Progress(_) => Ok(handle_progress_key(app, *key)), + Screen::BuildAccel => Ok(handle_build_accel_key(app, *key)), }, - Event::Resize(_, _) => Ok(None), _ => Ok(None), } } diff --git a/crates/unixnotis-installer/src/app/state.rs b/crates/unixnotis-installer/src/app/state.rs index ba2054579..96e68d73c 100644 --- a/crates/unixnotis-installer/src/app/state.rs +++ b/crates/unixnotis-installer/src/app/state.rs @@ -193,12 +193,11 @@ impl App { } } - pub fn build_accel_menu_len(&self) -> usize { + pub const fn build_accel_menu_len(&self) -> usize { // Keep menu length aligned with the chosen mode to avoid invalid indices match self.build_accel_menu_mode() { BuildAccelMenuMode::ReturnOnly => 1, - BuildAccelMenuMode::EnableOrSkip => 2, - BuildAccelMenuMode::Reinstall => 2, + BuildAccelMenuMode::EnableOrSkip | BuildAccelMenuMode::Reinstall => 2, } } diff --git a/crates/unixnotis-installer/src/app/tests/handlers.rs b/crates/unixnotis-installer/src/app/tests/handlers.rs index 694dbe3bc..bd656f4a4 100644 --- a/crates/unixnotis-installer/src/app/tests/handlers.rs +++ b/crates/unixnotis-installer/src/app/tests/handlers.rs @@ -21,14 +21,14 @@ fn vim_keys_move_welcome_menu_like_arrow_keys() { let mut app = App::new(None); // j/k should mirror Down/Up without changing menu bounds - handle_welcome_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_welcome_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.menu_index, 1); - handle_welcome_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_welcome_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.menu_index, 0); // Extra movement at the top should clamp instead of wrapping - handle_welcome_key(&mut app, key(KeyCode::Char('k'))).expect("k should clamp at top"); + handle_welcome_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.menu_index, 0); } @@ -37,7 +37,7 @@ fn welcome_menu_quit_key_exits_without_starting_action() { let _lock = crate::test_support::env::test_env_lock(); let mut app = App::new(None); - let action = handle_welcome_key(&mut app, key(KeyCode::Char('q'))).expect("q should exit"); + let action = handle_welcome_key(&mut app, key(KeyCode::Char('q'))); // Quit should be an explicit exit action, not a silent screen transition assert!(matches!(action, Some(ExitAction::None))); @@ -50,13 +50,13 @@ fn welcome_enter_opens_confirm_or_reset_submenu_for_selected_action() { let mut app = App::new(None); app.menu_index = 1; - handle_welcome_key(&mut app, key(KeyCode::Enter)).expect("install enter"); + handle_welcome_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Install)); app.screen = Screen::Welcome; app.menu_index = 2; app.reset_menu_index = 2; - handle_welcome_key(&mut app, key(KeyCode::Enter)).expect("reset enter"); + handle_welcome_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::ResetMenu); assert_eq!(app.reset_menu_index, 0); } @@ -67,12 +67,12 @@ fn vim_keys_move_reset_menu_like_arrow_keys() { let mut app = App::new(None); // Reset has a fixed three-entry menu, so j/k must stay within 0..=2 - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should clamp"); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.reset_menu_index, 2); - handle_reset_menu_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_reset_menu_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.reset_menu_index, 1); } @@ -82,22 +82,22 @@ fn reset_menu_escape_and_enter_select_expected_destinations() { let mut app = App::new(None); app.screen = Screen::ResetMenu; - handle_reset_menu_key(&mut app, key(KeyCode::Esc)).expect("escape should return"); + handle_reset_menu_key(&mut app, key(KeyCode::Esc)); assert_eq!(app.screen, Screen::Welcome); app.screen = Screen::ResetMenu; app.reset_menu_index = 0; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("defaults enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Reset)); app.screen = Screen::ResetMenu; app.reset_menu_index = 1; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("restore enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::RestoreSelect); app.screen = Screen::ResetMenu; app.reset_menu_index = 2; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("cancel enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Welcome); } @@ -107,14 +107,14 @@ fn vim_keys_move_restore_selection_only_when_backups_exist() { let mut app = App::new(None); // Empty restore lists should not underflow or invent a selection - handle_restore_select_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.restore_menu_index, 0); app.restore_backups = vec!["first".into(), "second".into()]; - handle_restore_select_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.restore_menu_index, 1); - handle_restore_select_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.restore_menu_index, 0); } @@ -124,16 +124,16 @@ fn restore_selection_escape_and_enter_only_confirm_existing_backup() { let mut app = App::new(None); app.screen = Screen::RestoreSelect; - handle_restore_select_key(&mut app, key(KeyCode::Enter)).expect("empty enter"); + handle_restore_select_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::RestoreSelect); app.restore_backups = vec!["first".into(), "second".into()]; app.restore_menu_index = 1; - handle_restore_select_key(&mut app, key(KeyCode::Enter)).expect("backup enter"); + handle_restore_select_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Reset)); app.screen = Screen::RestoreSelect; - handle_restore_select_key(&mut app, key(KeyCode::Esc)).expect("escape should return"); + handle_restore_select_key(&mut app, key(KeyCode::Esc)); assert_eq!(app.screen, Screen::ResetMenu); } @@ -144,13 +144,13 @@ fn progress_screen_ignores_keys_while_running_and_returns_after_completion() { app.screen = Screen::Progress(ActionMode::Uninstall); app.progress_state = ProgressState::Running; - let action = handle_progress_key(&mut app, key(KeyCode::Char('q'))).expect("running key"); + let action = handle_progress_key(&mut app, key(KeyCode::Char('q'))); assert!(action.is_none()); assert_eq!(app.screen, Screen::Progress(ActionMode::Uninstall)); app.progress_state = ProgressState::Completed; app.progress_ready_at = None; - handle_progress_key(&mut app, key(KeyCode::Enter)).expect("completed enter"); + handle_progress_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Welcome); assert_eq!(app.progress_state, ProgressState::Idle); } @@ -163,11 +163,11 @@ fn progress_screen_quit_and_escape_work_after_action_finishes() { app.progress_state = ProgressState::Failed; app.progress_ready_at = None; - let action = handle_progress_key(&mut app, key(KeyCode::Char('Q'))).expect("quit key"); + let action = handle_progress_key(&mut app, key(KeyCode::Char('Q'))); assert!(matches!(action, Some(ExitAction::None))); app.screen = Screen::Progress(ActionMode::Install); - let action = handle_progress_key(&mut app, key(KeyCode::Esc)).expect("escape key"); + let action = handle_progress_key(&mut app, key(KeyCode::Esc)); assert!(action.is_none()); assert_eq!(app.screen, Screen::Welcome); } @@ -180,7 +180,7 @@ fn progress_screen_respects_ready_delay_after_completion() { app.progress_state = ProgressState::Completed; app.progress_ready_at = Some(Instant::now() + Duration::from_mins(1)); - let action = handle_progress_key(&mut app, key(KeyCode::Enter)).expect("delayed enter"); + let action = handle_progress_key(&mut app, key(KeyCode::Enter)); // The short delay prevents fast key repeats from skipping the completion state assert!(action.is_none()); @@ -196,7 +196,7 @@ fn completed_install_progress_enters_build_accel_prompt() { app.progress_state = ProgressState::Completed; app.progress_ready_at = None; - handle_progress_key(&mut app, key(KeyCode::Enter)).expect("completed install enter"); + handle_progress_key(&mut app, key(KeyCode::Enter)); // Successful install should offer the optional build acceleration prompt before returning assert_eq!(app.screen, Screen::BuildAccel); @@ -217,11 +217,11 @@ fn vim_keys_move_build_accel_menu_like_arrow_keys() { }); // Build acceleration uses dynamic menu length, so j/k must respect that mode - handle_build_accel_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_build_accel_key(&mut app, key(KeyCode::Char('j'))).expect("j should clamp"); + handle_build_accel_key(&mut app, key(KeyCode::Char('j'))); + handle_build_accel_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.build_accel_menu_index, 1); - handle_build_accel_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_build_accel_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.build_accel_menu_index, 0); } @@ -232,11 +232,11 @@ fn build_accel_escape_and_quit_have_distinct_outcomes() { app.screen = Screen::BuildAccel; app.progress_state = ProgressState::Completed; - let quit = handle_build_accel_key(&mut app, key(KeyCode::Char('q'))).expect("quit"); + let quit = handle_build_accel_key(&mut app, key(KeyCode::Char('q'))); assert!(matches!(quit, Some(ExitAction::None))); assert_eq!(app.screen, Screen::BuildAccel); - handle_build_accel_key(&mut app, key(KeyCode::Esc)).expect("escape"); + handle_build_accel_key(&mut app, key(KeyCode::Esc)); // Escape returns to the menu and clears stale progress, unlike q which exits assert_eq!(app.screen, Screen::Welcome); diff --git a/crates/unixnotis-installer/src/app/workflow.rs b/crates/unixnotis-installer/src/app/workflow.rs index 06c3d2d64..e6488e00d 100644 --- a/crates/unixnotis-installer/src/app/workflow.rs +++ b/crates/unixnotis-installer/src/app/workflow.rs @@ -1,7 +1,6 @@ //! Action workflow, worker coordination, and state transitions for the installer use anyhow::Result; -use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::mpsc; use std::sync::Arc; @@ -63,13 +62,13 @@ pub fn start_action( let ui_tx = ui_tx.clone(); thread::spawn(move || { run_action_worker( - plan, + &plan, mode, - detection, - paths, - install_state, - restore_backup, - ui_tx, + &detection, + &paths, + install_state.as_ref(), + restore_backup.as_deref(), + &ui_tx, ); }); @@ -77,13 +76,13 @@ pub fn start_action( } fn run_action_worker( - plan: Vec, + plan: &[StepKind], mode: ActionMode, - detection: crate::detect::Detection, - paths: InstallPaths, - install_state: Option, - restore_backup: Option, - ui_tx: mpsc::SyncSender, + detection: &crate::detect::Detection, + paths: &InstallPaths, + install_state: Option<&crate::actions::InstallState>, + restore_backup: Option<&std::path::Path>, + ui_tx: &mpsc::SyncSender, ) { // Run plan steps on the worker thread and stream progress events to the UI // The flag lives across steps so install can decide later whether reload is needed @@ -95,12 +94,12 @@ fn run_action_worker( // Build per-step context; clone install_state to avoid borrow issues let result = { let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: install_state.clone(), + detection, + paths, + install_state: install_state.cloned(), log_tx: ui_tx.clone(), action_mode: mode, - restore_backup: restore_backup.clone(), + restore_backup: restore_backup.map(std::path::Path::to_path_buf), service_reload_required: service_reload_required.clone(), }; run_step(*step, &mut ctx) diff --git a/crates/unixnotis-installer/src/checks/session.rs b/crates/unixnotis-installer/src/checks/session.rs index 682e3acb5..3b8a54aca 100644 --- a/crates/unixnotis-installer/src/checks/session.rs +++ b/crates/unixnotis-installer/src/checks/session.rs @@ -54,10 +54,10 @@ impl Checks { let gtk4_layer_shell = gtk::gtk4_layer_shell_check(&pkg_config); let busctl = system::busctl_check(); - let dbus_update_env = match &discovered_paths { - Ok(paths) => system::dbus_update_env_check(Some(&paths.service)), - Err(_) => system::dbus_update_env_check(None), - }; + let dbus_update_env = discovered_paths.as_ref().map_or_else( + |_error| system::dbus_update_env_check(None), + |paths| system::dbus_update_env_check(Some(&paths.service)), + ); let (install_paths, path_contains_bin) = match discovered_paths { Ok(paths) => { // Path discovery runs once so every later row reports the same install target diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index 3dffc15f1..ced012702 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -64,11 +64,10 @@ fn availability_check_item( issues: &[ReadinessIssue], ) -> CheckItem { match spec.to_command().and_then(|mut command| command.status()) { - Ok(status) if status.success() => match readiness_warning_detail(manager, issues) { - // A manager can be available while still needing user setup for autostart - Some(detail) => CheckItem::warn("Service manager", &detail), - None => CheckItem::ok("Service manager", &format!("{} available", manager.label())), - }, + Ok(status) if status.success() => readiness_warning_detail(manager, issues).map_or_else( + || CheckItem::ok("Service manager", &format!("{} available", manager.label())), + |detail| CheckItem::warn("Service manager", &detail), + ), Ok(_) => CheckItem::fail( "Service manager", &format!("{} unavailable", manager.label()), diff --git a/crates/unixnotis-installer/src/cli/model.rs b/crates/unixnotis-installer/src/cli/model.rs index 4f1533860..9dd0c8a89 100644 --- a/crates/unixnotis-installer/src/cli/model.rs +++ b/crates/unixnotis-installer/src/cli/model.rs @@ -15,7 +15,7 @@ pub struct CliArgs { } /// Top-level command-line result -#[derive(Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CliAction { /// Continue into installer startup using the parsed options Run(CliArgs), diff --git a/crates/unixnotis-installer/src/detect.rs b/crates/unixnotis-installer/src/detect.rs index de639ea38..87ef314f2 100644 --- a/crates/unixnotis-installer/src/detect.rs +++ b/crates/unixnotis-installer/src/detect.rs @@ -35,7 +35,7 @@ pub use unixnotis_core::KNOWN_NOTIFICATION_DAEMONS as KNOWN_DAEMONS; pub fn detect() -> Detection { let owner = detect_owner(); - let daemons = detect_known_daemons(&owner); + let daemons = detect_known_daemons(owner.as_ref()); Detection { owner, daemons } } @@ -209,8 +209,8 @@ fn run_busctl(args: &[&str]) -> Option { Some(String::from_utf8_lossy(&output.stdout).to_string()) } -fn detect_known_daemons(owner: &Option) -> Vec { - let owner_name = owner.as_ref().and_then(|info| info.comm.as_deref()); +fn detect_known_daemons(owner: Option<&OwnerInfo>) -> Vec { + let owner_name = owner.and_then(|info| info.comm.as_deref()); KNOWN_DAEMONS .iter() .map(|daemon| { diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 188ff868c..4913ea698 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -1,19 +1,5 @@ //! `UnixNotis` installer entrypoint with a ratatui-driven flow -#![expect( - clippy::collapsible_else_if, - clippy::items_after_statements, - clippy::match_same_arms, - clippy::missing_const_for_fn, - clippy::needless_pass_by_value, - clippy::option_if_let_else, - clippy::redundant_else, - clippy::ref_option, - clippy::too_many_lines, - clippy::unnecessary_wraps, - reason = "reviewed installer state-machine, backend, and TUI boundaries keep explicit control flow for auditable lifecycle behavior" -)] - mod actions; mod app; mod checks; @@ -64,7 +50,7 @@ fn main() -> Result<()> { match exit_action { Ok(ExitAction::None) => Ok(()), - Ok(ExitAction::RunTrial { repo_root }) => run_trial(repo_root), + Ok(ExitAction::RunTrial { repo_root }) => run_trial(&repo_root), Err(err) => Err(err), } } diff --git a/crates/unixnotis-installer/src/paths/tests/s6_live.rs b/crates/unixnotis-installer/src/paths/tests/s6_live.rs index 29e5b0d2d..70f2af0cf 100644 --- a/crates/unixnotis-installer/src/paths/tests/s6_live.rs +++ b/crates/unixnotis-installer/src/paths/tests/s6_live.rs @@ -24,11 +24,7 @@ fn install_paths_use_existing_local_s6_live_root_when_run_root_is_missing() { assert_eq!(paths.service.artifact_root(), data_root.as_path()); assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", local_live.to_string_lossy().as_ref(), @@ -74,11 +70,7 @@ fn install_paths_use_symlinked_local_s6_live_root() { // s6-rc-update expects the live symlink name, not the resolved live:initial directory assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", linked_live.to_string_lossy().as_ref(), @@ -116,11 +108,7 @@ fn install_paths_use_existing_tmp_s6_live_root_for_standalone_supervision() { // Artix standalone local s6 uses a user-owned live root outside /run assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", standalone_live.to_string_lossy().as_ref(), @@ -164,11 +152,7 @@ fn install_paths_ignore_symlinked_tmp_s6_live_root() { // Auto-detection must not follow a symlinked /tmp live root into another tree assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", expected_fallback.as_ref(), @@ -242,11 +226,7 @@ fn install_paths_allow_explicit_symlinked_s6_live_root() { let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args()[1], + paths.service.start_command().args()[1], linked_live.as_os_str() ); diff --git a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs index 8341d0e6d..7be2ef2b5 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs @@ -43,15 +43,13 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub fn availability_command() -> Option { - Some( - CommandSpec::new( - "dinitctl --user --quiet list", - "dinitctl", - ["--user", "--quiet", "list"], - ) - .quiet(), +pub fn availability_command() -> CommandSpec { + CommandSpec::new( + "dinitctl --user --quiet list", + "dinitctl", + ["--user", "--quiet", "list"], ) + .quiet() } pub const fn is_enabled_command() -> Option { @@ -59,12 +57,12 @@ pub const fn is_enabled_command() -> Option { None } -pub fn is_active_command() -> Option { - Some(CommandSpec::new( +pub fn is_active_command() -> CommandSpec { + CommandSpec::new( format!("dinitctl --user --quiet is-started {SERVICE_NAME}"), "dinitctl", ["--user", "--quiet", "is-started", SERVICE_NAME], - )) + ) } pub const fn reload_after_artifact_change() -> Option { @@ -72,25 +70,25 @@ pub const fn reload_after_artifact_change() -> Option { None } -pub fn enable_now_command() -> Option { +pub fn enable_now_command() -> CommandSpec { // The boot.d artifact owns persistence; start only handles the live session start_command() } -pub fn start_command() -> Option { - Some(CommandSpec::new( +pub fn start_command() -> CommandSpec { + CommandSpec::new( format!("dinitctl --user start {SERVICE_NAME}"), "dinitctl", ["--user", "start", SERVICE_NAME], - )) + ) } -pub fn disable_now_command() -> Option { - Some(stop_ignoring_unstarted()) +pub fn disable_now_command() -> CommandSpec { + stop_ignoring_unstarted() } -pub fn stop_for_reinstall_command() -> Option { - Some(stop_ignoring_unstarted()) +pub fn stop_for_reinstall_command() -> CommandSpec { + stop_ignoring_unstarted() } pub fn hyprland_startup_commands(import_vars: &[&str]) -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/runit.rs index 973356709..2a6f1399e 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/runit.rs @@ -67,9 +67,9 @@ pub fn install_artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec Option { +pub fn availability_command() -> CommandSpec { // `sv -V` checks the control binary without requiring the service to exist yet - Some(CommandSpec::new("sv -V", "sv", ["-V"]).quiet()) + CommandSpec::new("sv -V", "sv", ["-V"]).quiet() } pub const fn is_enabled_command() -> Option { @@ -87,7 +87,7 @@ pub fn enabled_by_artifacts(artifact_root: &Path) -> bool { && path_is_missing(&service.join(DOWN_FILE)) } -pub fn active_probe(artifact_root: &Path) -> Option { +pub fn active_probe(artifact_root: &Path) -> ServiceProbe { let service = service_dir_arg(artifact_root); // sv check can succeed for a requested down state, so parse status text instead let command = CommandSpec::new( @@ -95,7 +95,7 @@ pub fn active_probe(artifact_root: &Path) -> Option { "sv", ["status".to_string(), service], ); - Some(ServiceProbe::stdout(command, status_output_is_running)) + ServiceProbe::stdout(command, status_output_is_running) } pub const fn reload_after_artifact_change() -> Option { @@ -103,20 +103,20 @@ pub const fn reload_after_artifact_change() -> Option { None } -pub fn enable_now_command(artifact_root: &Path) -> Option { +pub fn enable_now_command(artifact_root: &Path) -> CommandSpec { start_command(artifact_root) } -pub fn start_command(artifact_root: &Path) -> Option { - Some(sv_command("start", artifact_root)) +pub fn start_command(artifact_root: &Path) -> CommandSpec { + sv_command("start", artifact_root) } -pub fn disable_now_command(artifact_root: &Path) -> Option { - Some(sv_command("stop", artifact_root)) +pub fn disable_now_command(artifact_root: &Path) -> CommandSpec { + sv_command("stop", artifact_root) } -pub fn stop_for_reinstall_command(artifact_root: &Path) -> Option { - Some(sv_command("stop", artifact_root)) +pub fn stop_for_reinstall_command(artifact_root: &Path) -> CommandSpec { + sv_command("stop", artifact_root) } pub fn hyprland_startup_commands(_artifact_root: &Path, _import_vars: &[&str]) -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/s6.rs index acff45889..33d0775a9 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/s6.rs @@ -91,7 +91,7 @@ pub fn enabled_by_artifacts(artifact_root: &Path) -> bool { && is_regular_file(&default_bundle_member(artifact_root)) } -pub fn active_probe(live_dir: &Path) -> Option { +pub fn active_probe(live_dir: &Path) -> ServiceProbe { let service = live_service_dir(live_dir).display().to_string(); // s6-svstat -o up is machine-readable and avoids parsing human status text let command = CommandSpec::new( @@ -99,33 +99,33 @@ pub fn active_probe(live_dir: &Path) -> Option { "s6-svstat", ["-o".to_string(), "up".to_string(), service], ); - Some(ServiceProbe::stdout(command, status_output_is_running)) + ServiceProbe::stdout(command, status_output_is_running) } pub fn refresh_after_artifact_change( artifact_root: &Path, live_dir: &Path, -) -> Option { +) -> ServiceArtifactRefresh { // s6 source changes must be compiled into a database before s6-rc can see them - Some(ServiceArtifactRefresh::S6Database(S6DatabaseRefresh::new( + ServiceArtifactRefresh::S6Database(S6DatabaseRefresh::new( artifact_root.to_path_buf(), live_dir.to_path_buf(), - ))) + )) } -pub fn enable_now_command(live_dir: &Path) -> Option { +pub fn enable_now_command(live_dir: &Path) -> CommandSpec { start_command(live_dir) } -pub fn start_command(live_dir: &Path) -> Option { - Some(s6_rc_change_command(live_dir, "-u")) +pub fn start_command(live_dir: &Path) -> CommandSpec { + s6_rc_change_command(live_dir, "-u") } -pub fn disable_now_command(live_dir: &Path) -> Option { - Some(s6_rc_change_command(live_dir, "-d")) +pub fn disable_now_command(live_dir: &Path) -> CommandSpec { + s6_rc_change_command(live_dir, "-d") } -pub fn stop_for_reinstall_command(live_dir: &Path) -> Option { +pub fn stop_for_reinstall_command(live_dir: &Path) -> CommandSpec { disable_now_command(live_dir) } diff --git a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs index 9556f0c98..91018fc54 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs @@ -29,78 +29,76 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub fn availability_command() -> Option { - Some( - CommandSpec::new( - "systemctl --user --no-pager --plain list-units --type=service", - "systemctl", - [ - "--user", - "--no-pager", - "--plain", - "list-units", - "--type=service", - ], - ) - .quiet(), +pub fn availability_command() -> CommandSpec { + CommandSpec::new( + "systemctl --user --no-pager --plain list-units --type=service", + "systemctl", + [ + "--user", + "--no-pager", + "--plain", + "list-units", + "--type=service", + ], ) + .quiet() } -pub fn is_enabled_command() -> Option { - Some(CommandSpec::new( +pub fn is_enabled_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user is-enabled --quiet {SERVICE_NAME}"), "systemctl", ["--user", "is-enabled", "--quiet", SERVICE_NAME], - )) + ) } -pub fn is_active_command() -> Option { - Some(CommandSpec::new( +pub fn is_active_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user is-active --quiet {SERVICE_NAME}"), "systemctl", ["--user", "is-active", "--quiet", SERVICE_NAME], - )) + ) } -pub fn reload_after_artifact_change() -> Option { - Some(CommandSpec::new( +pub fn reload_after_artifact_change() -> CommandSpec { + CommandSpec::new( "systemctl --user daemon-reload", "systemctl", ["--user", "daemon-reload"], - )) + ) } -pub fn enable_now_command() -> Option { - Some(CommandSpec::new( +pub fn enable_now_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user enable --now {SERVICE_NAME}"), "systemctl", ["--user", "enable", "--now", SERVICE_NAME], - )) + ) } -pub fn start_command() -> Option { - Some(CommandSpec::new( +pub fn start_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user start {SERVICE_NAME}"), "systemctl", ["--user", "start", SERVICE_NAME], - )) + ) } -pub fn disable_now_command() -> Option { - Some(CommandSpec::new( +pub fn disable_now_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user disable --now {SERVICE_NAME}"), "systemctl", ["--user", "disable", "--now", SERVICE_NAME], - )) + ) } -pub fn stop_for_reinstall_command() -> Option { +pub fn stop_for_reinstall_command() -> CommandSpec { // Stop only this unit during reinstall so systemd never treats the user session as disposable - Some(CommandSpec::new( + CommandSpec::new( format!("systemctl --user stop {SERVICE_NAME}"), "systemctl", ["--user", "stop", SERVICE_NAME], - )) + ) } pub fn hyprland_startup_commands(import_vars: &[&str]) -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs index 3211bc7c1..b53f75c10 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs @@ -62,9 +62,7 @@ fn dinit_backend_commands_match_expected_behavior() { // Enablement is artifact-backed, so no manager command should be required for install state assert!(manager.is_enabled_command().is_none()); - let active = manager - .active_probe() - .expect("dinit has an active-state command"); + let active = manager.active_probe(); assert_eq!( active.command().args(), &[ @@ -78,17 +76,13 @@ fn dinit_backend_commands_match_expected_behavior() { // First install should not reload a service that dinit has not loaded yet assert!(manager.refresh_after_artifact_change().is_none()); - let enable = manager - .enable_now_command() - .expect("dinit starts after artifacts handle persistence"); + let enable = manager.enable_now_command(); assert_eq!( enable.args(), &["--user", "start", UNIXNOTIS_DAEMON_DINIT_SERVICE] ); - let disable = manager - .disable_now_command() - .expect("dinit can stop during uninstall"); + let disable = manager.disable_now_command(); assert_eq!( disable.args(), &[ diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs index d844fbaf8..880e1a24e 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs @@ -80,9 +80,7 @@ fn runit_backend_commands_match_expected_behavior() { assert!(manager.refresh_after_artifact_change().is_none()); // sv check tracks the requested state, so active status must parse sv status output - let active = manager - .active_probe() - .expect("runit can parse current status"); + let active = manager.active_probe(); assert_eq!(active.command().args(), &["status", service_path]); assert_eq!( active.parser_matches("run: /tmp/service/unixnotis-daemon: (pid 123) 2s"), @@ -93,19 +91,13 @@ fn runit_backend_commands_match_expected_behavior() { Some(false) ); - let enable = manager - .enable_now_command() - .expect("runit starts watched service directories"); + let enable = manager.enable_now_command(); assert_eq!(enable.args(), &["start", service_path]); - let disable = manager - .disable_now_command() - .expect("runit stops watched service directories"); + let disable = manager.disable_now_command(); assert_eq!(disable.args(), &["stop", service_path]); - let stop = manager - .stop_for_reinstall_command() - .expect("runit can stop before reinstall"); + let stop = manager.stop_for_reinstall_command(); assert_eq!(stop.args(), &["stop", service_path]); } @@ -277,7 +269,7 @@ fn runit_readiness_rejects_chpst_that_exists_only_on_path() { let trusted_bin = root.join("trusted-bin"); fs::create_dir_all(&path_bin).expect("path bin"); fs::create_dir_all(&trusted_bin).expect("trusted bin"); - write_executable(path_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_executable(&path_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); let _path = EnvPathGuard::prepend(&path_bin); let _tools = use_fake_tool_bin(&trusted_bin); @@ -296,8 +288,8 @@ fn test_root(name: &str) -> PathBuf { root } -fn write_executable(path: PathBuf, contents: &str) { - write_test_executable(&path, contents); +fn write_executable(path: &Path, contents: &str) { + write_test_executable(path, contents); } struct EnvPathGuard { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs index adaa6bdb2..c9e4b40b2 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs @@ -94,7 +94,7 @@ fn s6_backend_commands_match_expected_behavior() { &["-l", "/run/user/s6-rc", "/tmp/s6-data/rc/compiled-next"] ); assert_eq!( - manager.start_command().expect("s6 start command").args(), + manager.start_command().args(), &[ "-l", "/run/user/s6-rc", @@ -104,10 +104,7 @@ fn s6_backend_commands_match_expected_behavior() { ] ); assert_eq!( - manager - .disable_now_command() - .expect("s6 stop command") - .args(), + manager.disable_now_command().args(), &[ "-l", "/run/user/s6-rc", @@ -124,7 +121,7 @@ fn s6_backend_active_probe_parses_s6_svstat_output() { PathBuf::from("/tmp/s6-data"), PathBuf::from("/run/user/s6-rc"), ); - let active = manager.active_probe().expect("s6 active probe"); + let active = manager.active_probe(); // s6-svstat -o up prints a boolean, so parsing stays exact and cheap assert_eq!(active.parser_matches("true\n"), Some(true)); @@ -299,7 +296,7 @@ fn s6_readiness_rejects_tools_that_exist_only_on_path() { "s6-envdir", "s6-svstat", ] { - write_executable(path_bin.join(tool), "#!/bin/sh\nexit 0\n"); + write_executable(&path_bin.join(tool), "#!/bin/sh\nexit 0\n"); } let _path = EnvPathGuard::prepend(&path_bin); let _tools = use_fake_tool_bin(&trusted_bin); @@ -390,7 +387,7 @@ fn s6_active_probe_rejects_truthy_but_non_exact_output() { PathBuf::from("/tmp/s6-data"), PathBuf::from("/run/user/s6-rc"), ); - let active = manager.active_probe().expect("s6 active probe"); + let active = manager.active_probe(); // s6-svstat -o up emits exact true/false, so loose text must not count as active assert_eq!(active.parser_matches(" true\n"), Some(true)); @@ -404,8 +401,8 @@ fn test_root(name: &str) -> PathBuf { root } -fn write_executable(path: PathBuf, contents: &str) { - write_test_executable(&path, contents); +fn write_executable(path: &Path, contents: &str) { + write_test_executable(path, contents); } struct EnvPathGuard { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs index 77928c243..0096539ae 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs @@ -87,9 +87,7 @@ fn systemd_backend_commands_match_existing_behavior() { &["--user", "is-enabled", "--quiet", UNIXNOTIS_DAEMON_SERVICE] ); - let active = manager - .active_probe() - .expect("systemd has an active-state command"); + let active = manager.active_probe(); assert_eq!( active.command().args(), &["--user", "is-active", "--quiet", UNIXNOTIS_DAEMON_SERVICE] @@ -102,29 +100,23 @@ fn systemd_backend_commands_match_existing_behavior() { }; assert_eq!(reload.args(), &["--user", "daemon-reload"]); - let enable = manager - .enable_now_command() - .expect("systemd can enable and start"); + let enable = manager.enable_now_command(); assert_eq!( enable.args(), &["--user", "enable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); - let start = manager.start_command().expect("systemd can start"); + let start = manager.start_command(); assert_eq!(start.args(), &["--user", "start", UNIXNOTIS_DAEMON_SERVICE]); - let disable = manager - .disable_now_command() - .expect("systemd can disable and stop"); + let disable = manager.disable_now_command(); assert_eq!( disable.args(), &["--user", "disable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); // Reinstall should stop only UnixNotis and never broaden into user-session targets - let stop = manager - .stop_for_reinstall_command() - .expect("systemd can stop during reinstall"); + let stop = manager.stop_for_reinstall_command(); assert_eq!(stop.args(), &["--user", "stop", UNIXNOTIS_DAEMON_SERVICE]); } diff --git a/crates/unixnotis-installer/src/service_manager/contract/command.rs b/crates/unixnotis-installer/src/service_manager/contract/command.rs index a65fb378a..417ed2bb1 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/command.rs @@ -65,7 +65,9 @@ impl CommandSpec { self.command.args().unwrap_or_default() } - pub fn envs(&self) -> &std::collections::BTreeMap { + pub const fn envs( + &self, + ) -> &std::collections::BTreeMap { self.command .env() .expect("installer service commands are always direct") diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs b/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs index cab226caf..3effce095 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs @@ -40,18 +40,19 @@ impl ServiceManager { pub fn refresh_after_artifact_change(&self) -> Option { // s6 returns a compile plan while simpler managers return one reload command match self.kind { - ServiceManagerKind::Systemd => { - systemd::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) - } + ServiceManagerKind::Systemd => Some(ServiceArtifactRefresh::Command( + systemd::reload_after_artifact_change(), + )), ServiceManagerKind::Dinit => { dinit::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) } ServiceManagerKind::Runit => { runit::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) } - ServiceManagerKind::S6 => { - s6::refresh_after_artifact_change(&self.artifact_root, self.live_root()) - } + ServiceManagerKind::S6 => Some(s6::refresh_after_artifact_change( + &self.artifact_root, + self.live_root(), + )), } } diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs index 8f948f25c..30dcb5f0d 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs @@ -5,7 +5,7 @@ use super::super::contract::{CommandSpec, ServiceArtifact}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { - pub fn import_variable_names(&self) -> &'static [&'static str] { + pub const fn import_variable_names(&self) -> &'static [&'static str] { // Backend-specific policy prevents transient shell state from reaching systemd unixnotis_core::service_manager::variables_for_backend(self.shared_kind()) } diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs index 9091982db..9f1817d40 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs @@ -8,9 +8,9 @@ impl ServiceManager { pub fn availability_command(&self) -> Option { // Availability checks must stay read-only and must not start a service match self.kind { - ServiceManagerKind::Systemd => systemd::availability_command(), - ServiceManagerKind::Dinit => dinit::availability_command(), - ServiceManagerKind::Runit => runit::availability_command(), + ServiceManagerKind::Systemd => Some(systemd::availability_command()), + ServiceManagerKind::Dinit => Some(dinit::availability_command()), + ServiceManagerKind::Runit => Some(runit::availability_command()), ServiceManagerKind::S6 => s6::availability_command(), } } @@ -18,7 +18,7 @@ impl ServiceManager { pub fn is_enabled_command(&self) -> Option { // Some artifact-backed managers have no separate enabled-state command match self.kind { - ServiceManagerKind::Systemd => systemd::is_enabled_command(), + ServiceManagerKind::Systemd => Some(systemd::is_enabled_command()), ServiceManagerKind::Dinit => dinit::is_enabled_command(), ServiceManagerKind::Runit => runit::is_enabled_command(), ServiceManagerKind::S6 => s6::is_enabled_command(), @@ -35,19 +35,17 @@ impl ServiceManager { } } - pub fn active_probe(&self) -> Option { + pub fn active_probe(&self) -> ServiceProbe { // Probe parsing stays inside each backend because status formats differ match self.kind { - ServiceManagerKind::Systemd => { - systemd::is_active_command().map(ServiceProbe::exit_status) - } - ServiceManagerKind::Dinit => dinit::is_active_command().map(ServiceProbe::exit_status), + ServiceManagerKind::Systemd => ServiceProbe::exit_status(systemd::is_active_command()), + ServiceManagerKind::Dinit => ServiceProbe::exit_status(dinit::is_active_command()), ServiceManagerKind::Runit => runit::active_probe(&self.artifact_root), ServiceManagerKind::S6 => s6::active_probe(self.live_root()), } } - pub fn enable_now_command(&self) -> Option { + pub fn enable_now_command(&self) -> CommandSpec { // Enable-and-start is used only when the backend provides one atomic operation match self.kind { ServiceManagerKind::Systemd => systemd::enable_now_command(), @@ -57,7 +55,7 @@ impl ServiceManager { } } - pub fn start_command(&self) -> Option { + pub fn start_command(&self) -> CommandSpec { // Start commands operate on the already installed backend artifact match self.kind { ServiceManagerKind::Systemd => systemd::start_command(), @@ -67,7 +65,7 @@ impl ServiceManager { } } - pub fn disable_now_command(&self) -> Option { + pub fn disable_now_command(&self) -> CommandSpec { // Disable commands stop the service while removing persistent activation match self.kind { ServiceManagerKind::Systemd => systemd::disable_now_command(), @@ -77,7 +75,7 @@ impl ServiceManager { } } - pub fn stop_for_reinstall_command(&self) -> Option { + pub fn stop_for_reinstall_command(&self) -> CommandSpec { // Reinstall stops the old process without discarding persistent enablement match self.kind { ServiceManagerKind::Systemd => systemd::stop_for_reinstall_command(), diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs index 13d32dffa..d4346e638 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs @@ -68,7 +68,7 @@ impl ServiceManager { } } - pub fn label(&self) -> &'static str { + pub const fn label(&self) -> &'static str { self.kind.label() } @@ -98,7 +98,7 @@ impl ServiceManager { } } - pub fn artifact_label(&self) -> &'static str { + pub const fn artifact_label(&self) -> &'static str { // Artifact labels describe the manager-specific file shown in summaries match self.kind { ServiceManagerKind::Systemd => systemd::artifact_label(), @@ -108,7 +108,7 @@ impl ServiceManager { } } - pub fn manager_label(&self) -> &'static str { + pub const fn manager_label(&self) -> &'static str { // Manager labels remain separate from short service identifiers match self.kind { ServiceManagerKind::Systemd => systemd::manager_label(), diff --git a/crates/unixnotis-installer/src/trial/launch.rs b/crates/unixnotis-installer/src/trial/launch.rs index 9d8d9a971..7aa9d5361 100644 --- a/crates/unixnotis-installer/src/trial/launch.rs +++ b/crates/unixnotis-installer/src/trial/launch.rs @@ -1,6 +1,6 @@ //! Trial process launch and signal-time cleanup shell rendering -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::{anyhow, Result}; @@ -11,11 +11,11 @@ use crate::system_tools; const TRIAL_DAEMON_ARGS: [&str; 4] = ["--trial", "--restore", "auto", "--yes"]; -pub fn run_trial(repo_root: PathBuf) -> Result<()> { +pub fn run_trial(repo_root: &Path) -> Result<()> { println!("Starting UnixNotis trial run."); println!("Press Ctrl+C to stop and restore the previous daemon."); - let binaries = build_trial_binaries(&repo_root)?; + let binaries = build_trial_binaries(repo_root)?; println!("Trial control binary: {}", binaries.control.display()); // A temporary PATH shim is optional; direct control-binary usage remains valid diff --git a/crates/unixnotis-installer/src/ui/confirm.rs b/crates/unixnotis-installer/src/ui/confirm.rs index beb7eeaef..a568c178f 100644 --- a/crates/unixnotis-installer/src/ui/confirm.rs +++ b/crates/unixnotis-installer/src/ui/confirm.rs @@ -39,7 +39,9 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { "Current owner: ", Style::default().add_modifier(Modifier::BOLD), ), - Span::raw(crate::actions::summarize_owner(&app.detection.owner)), + Span::raw(crate::actions::summarize_owner( + app.detection.owner.as_ref(), + )), ])); // Blocked state is rendered inline so it is visible before execution @@ -120,6 +122,10 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { layout[1], ); + render_confirmation_footer(frame, layout[2]); +} + +fn render_confirmation_footer(frame: &mut Frame<'_>, area: ratatui::layout::Rect) { let footer = Paragraph::new(Text::from(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" = proceed "), @@ -128,5 +134,5 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { ]))) .alignment(ratatui::layout::Alignment::Center) .block(Block::default().borders(Borders::TOP)); - frame.render_widget(footer, layout[2]); + frame.render_widget(footer, area); } diff --git a/crates/unixnotis-installer/src/ui/welcome.rs b/crates/unixnotis-installer/src/ui/welcome.rs index a875d0a67..8d7c7c5b1 100644 --- a/crates/unixnotis-installer/src/ui/welcome.rs +++ b/crates/unixnotis-installer/src/ui/welcome.rs @@ -125,7 +125,7 @@ fn render_daemon_section(app: &App, lines: &mut Vec>) { lines.push(Line::from(vec![ Span::styled("Owner: ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( - summarize_owner(&app.detection.owner), + summarize_owner(app.detection.owner.as_ref()), daemon_owner_style(app.detection.owner.is_some()), ), ])); diff --git a/crates/unixnotis-installer/src/ui/widgets.rs b/crates/unixnotis-installer/src/ui/widgets.rs index 66233f257..48ade58b1 100644 --- a/crates/unixnotis-installer/src/ui/widgets.rs +++ b/crates/unixnotis-installer/src/ui/widgets.rs @@ -114,6 +114,8 @@ fn take_display_width(text: &str, width: usize) -> String { } pub(super) fn summarize_error(err: &str) -> String { + const MAX_LEN: usize = 72; + // Provide a short user-friendly error line while keeping full details in logs if err.contains("failed to install") { return "failed to install binary (see logs)".to_string(); @@ -128,8 +130,6 @@ pub(super) fn summarize_error(err: &str) -> String { return "repository root not found (see logs)".to_string(); } - const MAX_LEN: usize = 72; - let mut out = String::new(); for ch in err.chars().take(MAX_LEN) { out.push(ch); From 84c6a56939106d69082a012a6ba76ba2ebd13926 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 27 Jul 2026 23:07:40 -0500 Subject: [PATCH 110/275] chore(lints): enforce the exact CI policy Summary: enforce the exact CI policy. Scope: lints. --- .github/workflows/ci.yml | 1 + .../css_check/geometry/parse/lengths/tests/edges.rs | 2 +- .../ui/widgets/command_slider/value/tests/change.rs | 2 +- .../src/ui/widgets/command_slider/value/tests/parse.rs | 5 ----- crates/unixnotis-core/src/bus_identity.rs | 4 +++- .../src/config/runtime/sanitize/tests/plugins.rs | 2 +- .../src/config/runtime/sanitize/tests/theme.rs | 2 +- .../unixnotis-core/src/config/widgets/tests/sliders.rs | 2 +- crates/unixnotis-core/src/control/proxy.rs | 5 ++++- crates/unixnotis-core/src/css/tests/tokens.rs | 2 +- crates/unixnotis-core/src/notifications.rs | 5 ++++- .../daemon/notifications/server/notify_body/cursor.rs | 10 ++++++---- 12 files changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8511fdee..2f01e9403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: libgtk-4-dev \ libgtk4-layer-shell-dev \ pkg-config \ + ripgrep \ shellcheck \ xauth \ xvfb \ diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs index 3e3b64a75..5c9d95a70 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "the parser returns exact finite values for these integer CSS inputs" )] diff --git a/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs index 06cfb7d98..6dce879ee 100644 --- a/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "the tolerance helper returns exact configured constants for these finite inputs" )] diff --git a/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs index 836a6874d..e744d7781 100644 --- a/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs @@ -1,8 +1,3 @@ -#![allow( - clippy::float_cmp, - reason = "the parser produces exact values for these bounded decimal inputs" -)] - use unixnotis_core::NumericParseMode; use super::{parse_muted, parse_numeric}; diff --git a/crates/unixnotis-core/src/bus_identity.rs b/crates/unixnotis-core/src/bus_identity.rs index 2f126a800..9371e8f71 100644 --- a/crates/unixnotis-core/src/bus_identity.rs +++ b/crates/unixnotis-core/src/bus_identity.rs @@ -26,7 +26,9 @@ pub async fn log_session_bus_identity( let dbus = DBusProxy::new(connection).await?; let bus_id = tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, dbus.get_id()) .await - .map_err(|_| zbus::Error::Failure("session bus identity probe timed out".to_string()))? + .map_err(|_elapsed| { + zbus::Error::Failure("session bus identity probe timed out".to_string()) + })? .map_err(zbus::Error::from)?; let unique_name = connection .unique_name() diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs index fd1ee91fa..2adf4979e 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "sanitization assigns exact finite constants and test inputs" )] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs index d8d69dc6f..122cef860 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "theme sanitization assigns exact clamp boundaries and explicit fallback constants" )] diff --git a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs index bf6915911..349bf9a43 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "TOML parsing preserves these exactly representable slider values" )] diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 6f067cc39..3994fef57 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -1,7 +1,10 @@ //! Generated D-Bus control proxy contract // The proxy macro creates signal collections consumed through generated streams -#![allow(clippy::collection_is_never_read)] +#![expect( + clippy::collection_is_never_read, + reason = "the zbus proxy macro generates signal collections consumed through generated streams" +)] use zbus::proxy; diff --git a/crates/unixnotis-core/src/css/tests/tokens.rs b/crates/unixnotis-core/src/css/tests/tokens.rs index c0f37129a..e9c754ab6 100644 --- a/crates/unixnotis-core/src/css/tests/tokens.rs +++ b/crates/unixnotis-core/src/css/tests/tokens.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "theme-token resolution returns exact configured and clamped constants" )] diff --git a/crates/unixnotis-core/src/notifications.rs b/crates/unixnotis-core/src/notifications.rs index 4a50d8ced..08eca8b6b 100644 --- a/crates/unixnotis-core/src/notifications.rs +++ b/crates/unixnotis-core/src/notifications.rs @@ -18,7 +18,10 @@ pub trait Notifications { fn get_server_information(&self) -> zbus::Result<(String, String, String, String)>; /// Submit one notification and return its assigned identifier - #[allow(clippy::too_many_arguments)] + #[expect( + clippy::too_many_arguments, + reason = "the D-Bus method must match the freedesktop notification protocol" + )] fn notify( &self, app_name: &str, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs index d4cde3a3e..e616b8c2a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs @@ -90,8 +90,9 @@ impl<'a> Cursor<'a> { budget: &mut StringBudget, ) -> Result<&'a [u8], PreflightError> { // Length is rejected before a slice is exposed to later parsing - let length = usize::try_from(self.read_u32()?) - .map_err(|_| PreflightError::LimitsExceeded("Notify string is too large"))?; + let length = usize::try_from(self.read_u32()?).map_err(|_conversion_error| { + PreflightError::LimitsExceeded("Notify string is too large") + })?; if length > limit { return Err(PreflightError::LimitsExceeded( "Notify string exceeds its field limit", @@ -141,8 +142,9 @@ impl<'a> Cursor<'a> { element_alignment: usize, ) -> Result { // Array byte lengths are validated before any element walk begins - let length = usize::try_from(self.read_u32()?) - .map_err(|_| PreflightError::LimitsExceeded("Notify array is too large"))?; + let length = usize::try_from(self.read_u32()?).map_err(|_conversion_error| { + PreflightError::LimitsExceeded("Notify array is too large") + })?; self.align(element_alignment)?; let end = self .offset From 301dbdebdc89df63729a32bcb79a030ca56ac973 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:21:27 -0500 Subject: [PATCH 111/275] fix(notifications): bind timers and events to generations Summary: bind timers and events to generations. Scope: notifications. --- .../src/output/tests/notifications.rs | 1 + crates/unixnotis-center/src/control/events.rs | 14 +- crates/unixnotis-center/src/control/model.rs | 10 +- .../src/control/subscriptions.rs | 12 +- .../src/control/tests/events.rs | 18 +- crates/unixnotis-center/src/ui/events.rs | 17 +- .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 1 + .../src/ui/notifications/model/tests/item.rs | 1 + .../row/notification/tests/support.rs | 1 + .../src/ui/notifications/row/tests/group.rs | 1 + .../src/ui/notifications/store/mutation.rs | 24 +- .../ui/notifications/store/tests/mutation.rs | 54 ++++- .../src/ui/notifications/tests/support.rs | 1 + .../src/control/notification.rs | 12 +- crates/unixnotis-core/src/control/proxy.rs | 15 +- crates/unixnotis-core/src/model/mod.rs | 2 +- .../unixnotis-core/src/model/notification.rs | 23 ++ .../src/model/tests/notification.rs | 1 + .../src/daemon/control/query.rs | 14 +- .../src/daemon/control/server.rs | 29 ++- .../src/daemon/control/tests/action.rs | 1 + .../src/daemon/control/tests/reply.rs | 1 + .../src/daemon/control/tests/server.rs | 15 +- .../src/daemon/events/notifications.rs | 82 +++---- .../src/daemon/events/tests/notifications.rs | 9 +- .../src/daemon/events/tests/state.rs | 30 ++- .../daemon/notifications/ingress/payload.rs | 2 + .../notifications/ingress/tests/payload.rs | 2 + .../src/daemon/notifications/server/flow.rs | 59 ++--- .../daemon/notifications/server/tests/flow.rs | 21 +- .../daemon/state/notification_lifecycle.rs | 53 +++-- .../src/daemon/state/schedulers.rs | 11 +- .../state/tests/notification_lifecycle.rs | 3 +- .../src/daemon/state/tests/scheduler.rs | 29 ++- crates/unixnotis-daemon/src/expire.rs | 123 ++++++----- crates/unixnotis-daemon/src/store/mod.rs | 2 +- crates/unixnotis-daemon/src/store/model.rs | 28 ++- .../src/store/notifications/insertion.rs | 18 +- .../src/store/notifications/lifecycle.rs | 77 +++++-- .../store/notifications/tests/lifecycle.rs | 35 ++- crates/unixnotis-daemon/src/store/runtime.rs | 13 +- .../src/store/test_support.rs | 1 + .../unixnotis-daemon/src/store/tests/model.rs | 19 +- .../src/store/tests/runtime.rs | 35 +++ crates/unixnotis-daemon/src/tests/expire.rs | 207 +++++++++++++++--- .../src/dbus/runtime/delivery.rs | 42 +++- .../src/dbus/runtime/generation.rs | 12 +- .../src/dbus/runtime/tests/delivery.rs | 47 ++++ .../src/dbus/runtime/tests/mod.rs | 1 + crates/unixnotis-popups/src/dbus/types.rs | 6 +- .../src/ui/icons/tests/resolver/support.rs | 1 + .../src/ui/popups/mutation.rs | 46 +++- .../src/ui/popups/tests/mutation.rs | 16 +- .../src/ui/popups/tests/reconcile.rs | 1 + .../unixnotis-popups/src/ui/state/events.rs | 6 +- .../src/ui/state/tests/constructor.rs | 2 + 57 files changed, 978 insertions(+), 330 deletions(-) create mode 100644 crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 3547be29f..e3f48281c 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -7,6 +7,7 @@ fn sample_notification() -> NotificationView { // Bad bytes on purpose NotificationView { id: 7, + generation: 1, app_name: "mailer\n\x1b[31m".to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: "mailer\n\x1b[31m".to_string(), diff --git a/crates/unixnotis-center/src/control/events.rs b/crates/unixnotis-center/src/control/events.rs index 27cf974f4..0a3649535 100644 --- a/crates/unixnotis-center/src/control/events.rs +++ b/crates/unixnotis-center/src/control/events.rs @@ -9,13 +9,13 @@ pub(super) async fn push_active_notification_event( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, id: u32, - show_popup: bool, + generation: u64, is_add: bool, ) { // Trusted UIs fetch current payloads through the authorized control method match timed_dbus_call(proxy.get_active_notification(id)).await { Ok(notifications) => { - if let Some(event) = active_notification_event(notifications, show_popup, is_add) { + if let Some(event) = active_notification_event(notifications, generation, is_add) { let _ = sender.send(event).await; } } @@ -27,15 +27,19 @@ pub(super) async fn push_active_notification_event( fn active_notification_event( mut notifications: Vec, - show_popup: bool, + generation: u64, is_add: bool, ) -> Option { // A close may win the race before this follow-up payload fetch completes let notification = notifications.pop()?; + if notification.generation != generation { + // The fetched payload belongs to a newer commit than the delayed signal + return None; + } if is_add { - Some(UiEvent::NotificationAdded(notification, show_popup)) + Some(UiEvent::NotificationAdded(notification)) } else { - Some(UiEvent::NotificationUpdated(notification, show_popup)) + Some(UiEvent::NotificationUpdated(notification)) } } diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index 7c2d4eb72..393f33fda 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -2,7 +2,9 @@ use std::fmt; -use unixnotis_core::{CloseReason, ControlState, Margins, NotificationView, PanelRequest}; +use unixnotis_core::{ + CloseReason, ControlState, Margins, NotificationKey, NotificationView, PanelRequest, +}; use crate::media::MediaInfo; @@ -16,9 +18,9 @@ pub enum UiEvent { active: Vec, history: Vec, }, - NotificationAdded(NotificationView, bool), - NotificationUpdated(NotificationView, bool), - NotificationClosed(u32, CloseReason), + NotificationAdded(NotificationView), + NotificationUpdated(NotificationView), + NotificationClosed(NotificationKey, CloseReason), StateChanged(ControlState), PanelRequested(PanelRequest), GroupToggled(String), diff --git a/crates/unixnotis-center/src/control/subscriptions.rs b/crates/unixnotis-center/src/control/subscriptions.rs index 896e02de0..22a439ea7 100644 --- a/crates/unixnotis-center/src/control/subscriptions.rs +++ b/crates/unixnotis-center/src/control/subscriptions.rs @@ -184,7 +184,7 @@ pub(super) async fn run_control_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), true, ).await; } @@ -199,7 +199,7 @@ pub(super) async fn run_control_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), false, ).await; } @@ -211,7 +211,13 @@ pub(super) async fn run_control_generation( }; if let Ok(args) = signal.args() { let _ = sender - .send(UiEvent::NotificationClosed(*args.id(), *args.reason())) + .send(UiEvent::NotificationClosed( + unixnotis_core::NotificationKey { + id: *args.id(), + generation: *args.generation(), + }, + *args.reason(), + )) .await; } } diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 44e3660af..8523be2e7 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -5,6 +5,7 @@ use super::{active_notification_event, UiEvent}; fn notification(id: u32) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: "example".to_string(), attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), @@ -20,20 +21,25 @@ fn notification(id: u32) -> NotificationView { #[test] fn closed_notification_race_emits_no_stale_event() { - assert!(active_notification_event(Vec::new(), true, true).is_none()); + assert!(active_notification_event(Vec::new(), 1, true).is_none()); } #[test] -fn fetched_payload_preserves_add_update_and_popup_semantics() { - let added = active_notification_event(vec![notification(7)], true, true); - let updated = active_notification_event(vec![notification(8)], false, false); +fn fetched_payload_preserves_matching_add_and_update_generations() { + let added = active_notification_event(vec![notification(7)], 7, true); + let updated = active_notification_event(vec![notification(8)], 8, false); assert!(matches!( added, - Some(UiEvent::NotificationAdded(notification, true)) if notification.id == 7 + Some(UiEvent::NotificationAdded(notification)) if notification.id == 7 )); assert!(matches!( updated, - Some(UiEvent::NotificationUpdated(notification, false)) if notification.id == 8 + Some(UiEvent::NotificationUpdated(notification)) if notification.id == 8 )); } + +#[test] +fn fetched_replacement_is_rejected_for_older_signal_generation() { + assert!(active_notification_event(vec![notification(8)], 7, false).is_none()); +} diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 2db235aad..593086172 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -35,7 +35,7 @@ impl UiState { self.update_state(state); self.refresh_counts(); } - UiEvent::NotificationAdded(notification, _show_popup) => { + UiEvent::NotificationAdded(notification) => { debug!( id = notification.id, app = %notification.app_name, @@ -51,7 +51,7 @@ impl UiState { // Header count reflects the combined active + history totals self.refresh_counts(); } - UiEvent::NotificationUpdated(notification, _show_popup) => { + UiEvent::NotificationUpdated(notification) => { debug!( id = notification.id, app = %notification.app_name, @@ -67,12 +67,17 @@ impl UiState { // Updates may shift groups; refresh count even when list is stable self.refresh_counts(); } - UiEvent::NotificationClosed(id, reason) => { - debug!(id, ?reason, "notification closed"); + UiEvent::NotificationClosed(key, reason) => { + debug!( + id = key.id, + generation = key.generation, + ?reason, + "notification closed" + ); self.log_debug(PanelDebugLevel::Verbose, || { - format!("notification closed: #{id} ({reason:?})") + format!("notification closed: #{} ({reason:?})", key.id) }); - self.list.mark_closed(id, reason); + self.list.mark_closed(key, reason); // Marking closed can move entries between active/history buckets self.refresh_counts(); } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index dab5cacf7..f7cd92628 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -72,6 +72,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { let resolver = resolver_inner(update_tx); let notification = NotificationView { id: 1, + generation: 1, app_name: "Icon test".to_string(), attribution: unixnotis_core::NotificationAttribution::default(), summary: String::new(), diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 9bcd5f0d0..e68ea56d3 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -15,6 +15,7 @@ fn notification_view( ) -> NotificationView { NotificationView { id: 1, + generation: 1, app_name: app_name.to_string(), attribution, summary: String::new(), diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index ec75db76c..dd4e71777 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -9,6 +9,7 @@ use super::{RowData, RowItem, RowKind, RowPresentation}; fn notification(id: u32) -> Rc { Rc::new(NotificationView { id, + generation: u64::from(id), app_name: "Terminal".to_string(), attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 6253cb10f..846d8a285 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -15,6 +15,7 @@ use super::state::NotificationRowWidgets; pub(super) fn sample_notification() -> NotificationView { NotificationView { id: 1, + generation: 1, app_name: "demo".to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: "demo".to_string(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 7229ce61b..17302fafc 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -13,6 +13,7 @@ use crate::ui::notifications::test_support as support; fn notification(app_name: &str) -> Rc { Rc::new(NotificationView { id: 1, + generation: 1, app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: app_name.to_string(), diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 5f04ad4f0..4171ff69d 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -3,7 +3,9 @@ //! These paths own entry-level changes after the list has already been constructed use tracing::debug; -use unixnotis_core::{should_archive_closed_notification, CloseReason, NotificationView}; +use unixnotis_core::{ + should_archive_closed_notification, CloseReason, NotificationKey, NotificationView, +}; use super::types::NotificationList; @@ -11,6 +13,15 @@ impl NotificationList { pub fn add_or_update(&mut self, notification: NotificationView, is_active: bool) { let id = notification.id; let existing_entry = self.entries.get(&id); + if existing_entry.is_some_and(|entry| entry.view.generation > notification.generation) { + // Reordered signals must never roll a row back to an older payload + debug!( + id, + generation = notification.generation, + "stale row update skipped" + ); + return; + } let old_group = existing_entry.map(|entry| entry.app_key.clone()); let was_in_active = existing_entry.is_some_and(|entry| entry.is_active); let was_in_history = existing_entry.is_some() && !was_in_active; @@ -160,7 +171,16 @@ impl NotificationList { self.request_rebuild(); } - pub fn mark_closed(&mut self, id: u32, reason: CloseReason) { + pub fn mark_closed(&mut self, key: NotificationKey, reason: CloseReason) { + let id = key.id; + let Some(current) = self.entries.get(&id) else { + return; + }; + if current.view.generation != key.generation { + // A close for an older generation cannot mutate its replacement + debug!(id, generation = key.generation, "stale row close skipped"); + return; + } let group_key = self.entries.get(&id).map(|entry| entry.app_key.clone()); let should_archive = self.entries.get(&id).is_some_and(|entry| { should_archive_entry(entry.view.as_ref(), reason, self.transient_to_history) diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index c7f82db8b..923fbe44a 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -6,6 +6,7 @@ use crate::ui::notifications::test_support as support; fn make_view(is_transient: bool) -> NotificationView { NotificationView { id: 7, + generation: 7, app_name: "Test".to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: "Test".to_string(), @@ -29,6 +30,7 @@ fn make_view(is_transient: bool) -> NotificationView { fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: app_name.to_string(), @@ -46,6 +48,13 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { } } +fn notification_key(id: u32) -> NotificationKey { + NotificationKey { + id, + generation: u64::from(id), + } +} + #[test] fn active_move_policy_covers_history_new_and_non_front_rows() { assert!(should_move_active_to_front(true, false, false)); @@ -260,7 +269,7 @@ fn mark_closed_dismissed_row_removes_entry_and_marks_group_dirty() { list.flush_rebuild(); let key = list.entries.get(&1).expect("entry").app_key.clone(); - list.mark_closed(1, CloseReason::DismissedByUser); + list.mark_closed(notification_key(1), CloseReason::DismissedByUser); assert!(!list.entries.contains_key(&1)); assert!(list.active_order.is_empty()); @@ -276,7 +285,7 @@ fn mark_closed_expired_row_archives_to_history_when_policy_allows_it() { list.flush_rebuild(); let key = list.entries.get(&1).expect("entry").app_key.clone(); - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); assert!(list.active_order.is_empty()); assert_eq!( @@ -301,12 +310,49 @@ fn mark_closed_archived_row_does_not_duplicate_existing_history_id() { list.seed(vec![view(1, "Terminal", false)], Vec::new()); list.flush_rebuild(); - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); list.needs_rebuild = false; - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); assert_eq!( list.history_order.iter().copied().collect::>(), vec![1] ); } + +#[gtk::test] +fn reordered_update_cannot_replace_a_newer_row_generation() { + let mut list = support::make_list(); + let mut newest = view(1, "Terminal", false); + newest.generation = 3; + list.seed(vec![newest], Vec::new()); + let mut stale = view(1, "Terminal", false); + stale.generation = 2; + stale.summary = "stale payload".to_string(); + + list.add_or_update(stale, true); + + let current = &list.entries.get(&1).expect("current row").view; + assert_eq!(current.generation, 3); + assert_ne!(current.summary, "stale payload"); +} + +#[gtk::test] +fn reordered_close_cannot_remove_or_archive_a_newer_row_generation() { + let mut list = support::make_list(); + let mut replacement = view(1, "Terminal", false); + replacement.generation = 3; + list.seed(vec![replacement], Vec::new()); + + list.mark_closed( + NotificationKey { + id: 1, + generation: 2, + }, + CloseReason::Expired, + ); + + assert!(list.entries.get(&1).expect("replacement row").is_active); + assert_eq!(list.active_order.iter().copied().collect::>(), [1]); + assert!(list.history_order.is_empty()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index edc3c759a..8a33d1c0d 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -57,6 +57,7 @@ pub(super) fn channels() -> (mpsc::Sender, Sender) { pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: app_name.to_string(), diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index 999e4c4c4..e3fa3ff06 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -1,10 +1,13 @@ //! Notification close reason wire types +use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; +use crate::NotificationView; + /// Reason codes aligned with the notification specification -#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] #[repr(u32)] pub enum CloseReason { Expired = 1, @@ -12,3 +15,10 @@ pub enum CloseReason { ClosedByCall = 3, Undefined = 4, } + +/// One atomic popup payload and its current admission decision +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Type)] +pub struct PopupCandidate { + pub notification: NotificationView, + pub should_show: bool, +} diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 3994fef57..b32e915db 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -8,7 +8,7 @@ use zbus::proxy; -use crate::NotificationView; +use crate::{NotificationView, PopupCandidate}; use super::{ CloseReason, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, PopupGateState, @@ -33,6 +33,8 @@ trait Control { fn list_history(&self) -> zbus::Result>; /// Fetch one currently active notification by identifier fn get_active_notification(&self, id: u32) -> zbus::Result>; + /// Fetch one current popup payload and admission decision atomically + fn get_popup_candidate(&self, id: u32) -> zbus::Result>; /// Open the control center panel fn open_panel(&self) -> zbus::Result<()>; /// Open the control center panel with debug logging @@ -77,11 +79,16 @@ trait Control { fn mark_popups_not_ready(&self) -> zbus::Result<()>; #[zbus(signal)] - fn notification_added(&self, id: u32, show_popup: bool) -> zbus::Result<()>; + fn notification_added(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] - fn notification_updated(&self, id: u32, show_popup: bool) -> zbus::Result<()>; + fn notification_updated(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] - fn notification_closed(&self, id: u32, reason: CloseReason) -> zbus::Result<()>; + fn notification_closed( + &self, + id: u32, + generation: u64, + reason: CloseReason, + ) -> zbus::Result<()>; #[zbus(signal)] fn state_changed(&self, state: ControlState) -> zbus::Result<()>; /// Emitted only when popup gating changes diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index d717d6121..9a57f8112 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -10,6 +10,6 @@ mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. pub use attribution::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; pub use image::{ImageData, NotificationImage}; -pub use notification::{Notification, NotificationView}; +pub use notification::{Notification, NotificationKey, NotificationView}; pub use reply::InlineReply; pub use types::{Action, Urgency}; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index bba24a119..38698dd52 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -12,11 +12,20 @@ use super::reply::InlineReply; use super::types::{Action, Urgency}; use crate::util::{fold_text_for_layout, MAX_DISPLAY_TOKEN_WIDTH}; +/// Exact identity of one committed notification payload +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq, Hash)] +pub struct NotificationKey { + pub id: u32, + pub generation: u64, +} + /// Full notification record stored by the daemon #[derive(Debug)] pub struct Notification { // Stable identifier assigned by the daemon pub id: u32, + // Process-wide commit generation distinguishes same-ID replacements + pub generation: u64, // Origin metadata for display and filtering pub app_name: String, pub app_icon: String, @@ -54,11 +63,21 @@ pub struct Notification { } impl Notification { + /// Return the exact key for this committed payload + #[must_use] + pub const fn key(&self) -> NotificationKey { + NotificationKey { + id: self.id, + generation: self.generation, + } + } + /// Convert to a lightweight view for UI consumption #[must_use] pub fn to_view(&self) -> NotificationView { NotificationView { id: self.id, + generation: self.generation, app_name: self.attribution.display_name.clone(), attribution: self.attribution.clone(), summary: notification_display_text(&self.summary), @@ -80,6 +99,7 @@ impl Notification { pub fn to_list_view(&self) -> NotificationView { NotificationView { id: self.id, + generation: self.generation, app_name: self.attribution.display_name.clone(), attribution: self.attribution.clone(), summary: notification_display_text(&self.summary), @@ -105,6 +125,7 @@ impl Notification { image.image_data = Default::default(); Self { id: self.id, + generation: self.generation, app_name: self.app_name.clone(), app_icon: self.app_icon.clone(), attribution: self.attribution.clone(), @@ -288,6 +309,8 @@ fn collapse_notification_whitespace(input: &str) -> String { pub struct NotificationView { // Identifier matches Notification::id pub id: u32, + // Generation identifies the exact same-ID payload represented by this view + pub generation: u64, // Lightweight fields used for UI display and filtering // Intentionally omits daemon-only protocol flags and timestamps pub app_name: String, diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index da68e766e..34610e87c 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -18,6 +18,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { Notification { id: 42, + generation: 11, app_name: "Mail".to_string(), app_icon: "mail".to_string(), attribution: NotificationAttribution::associated( diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 40b150837..015e8a774 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -2,7 +2,7 @@ //! //! Keeps read-only control methods grouped outside the main interface file -use unixnotis_core::{ControlState, InhibitorInfo, NotificationView}; +use unixnotis_core::{ControlState, InhibitorInfo, NotificationView, PopupCandidate}; use zbus::message::Header; use super::ControlServer; @@ -60,6 +60,18 @@ impl ControlServer { Ok(store.active_notification_view(id).into_iter().collect()) } + pub(super) async fn query_popup_candidate( + &self, + id: u32, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Admission and content must describe the same committed generation + self.authorize_control_call(header, "GetPopupCandidate") + .await?; + let store = self.state.store.lock().await; + Ok(store.popup_candidate(id).into_iter().collect()) + } + pub(super) async fn query_inhibitors( &self, header: &Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 66adf96ce..d29dd96cd 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use unixnotis_core::{ - CloseReason, ControlState, InhibitorInfo, NotificationView, PanelDebugLevel, PanelRequest, - PopupGateState, UiHealth, + CloseReason, ControlState, InhibitorInfo, NotificationKey, NotificationView, PanelDebugLevel, + PanelRequest, PopupCandidate, PopupGateState, UiHealth, }; use zbus::message::Header; use zbus::{interface, SignalContext}; @@ -52,13 +52,15 @@ impl ControlServer { )) } - pub(super) async fn drain_active_notifications(&self) -> Vec { - let ids = { + pub(super) async fn drain_active_notifications(&self) -> Vec { + let keys = { let mut store = self.state.store.lock().await; - store.drain_active_ids() + let keys = store.drain_active_keys(); + // Cancellation is sent before same-ID replacements can commit + self.state.cancel_expirations(&keys); + keys }; - self.state.cancel_expirations(&ids); - ids + keys } pub(super) async fn clear_saved_history(&self) { @@ -106,6 +108,14 @@ impl ControlServer { self.query_active_notification(id, &header).await } + async fn get_popup_candidate( + &self, + id: u32, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_popup_candidate(id, &header).await + } + async fn open_panel(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.request_panel_command(&header, "OpenPanel", PanelRequest::open()) .await @@ -268,20 +278,21 @@ impl ControlServer { pub(crate) async fn notification_added( ctx: &SignalContext<'_>, id: u32, - show_popup: bool, + generation: u64, ) -> zbus::Result<()>; #[zbus(signal)] pub(crate) async fn notification_updated( ctx: &SignalContext<'_>, id: u32, - show_popup: bool, + generation: u64, ) -> zbus::Result<()>; #[zbus(signal)] pub(crate) async fn notification_closed( ctx: &SignalContext<'_>, id: u32, + generation: u64, reason: CloseReason, ) -> zbus::Result<()>; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index f66a03f2a..b387c33e9 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -102,6 +102,7 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { fn action_notification(sender: &Connection, key: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "ActionApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 9777ae656..5397d4830 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -282,6 +282,7 @@ async fn inline_reply_signal_reaches_owner_but_not_unrelated_observer() { fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { Notification { id: 0, + generation: 0, app_name: "Messages".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 1f6175e45..6c41ebfc4 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -13,6 +13,7 @@ use crate::test_support::daemon_state_for_test; fn notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), @@ -57,7 +58,7 @@ async fn next_cancel_id( .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => id, + ExpirationCommand::Cancel { id, .. } => id, ExpirationCommand::Schedule { .. } => panic!("clear should cancel expiration"), } } @@ -68,18 +69,18 @@ async fn drain_active_notifications_returns_ids_and_cancels_expirations() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); let server = ControlServer::new(state.clone()); - let ids = { + let keys = { let mut store = state.store.lock().await; - let first = store.insert(notification("first"), 0).notification.id; - let second = store.insert(notification("second"), 0).notification.id; + let first = store.insert(notification("first"), 0).notification.key(); + let second = store.insert(notification("second"), 0).notification.key(); vec![second, first] }; let drained = server.drain_active_notifications().await; - assert_eq!(drained, ids); - assert_eq!(next_cancel_id(&mut receiver).await, ids[0]); - assert_eq!(next_cancel_id(&mut receiver).await, ids[1]); + assert_eq!(drained, keys); + assert_eq!(next_cancel_id(&mut receiver).await, keys[0].id); + assert_eq!(next_cancel_id(&mut receiver).await, keys[1].id); assert!(state.store.lock().await.list_active().is_empty()); } diff --git a/crates/unixnotis-daemon/src/daemon/events/notifications.rs b/crates/unixnotis-daemon/src/daemon/events/notifications.rs index bb5e35d28..e3637bac2 100644 --- a/crates/unixnotis-daemon/src/daemon/events/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/events/notifications.rs @@ -2,7 +2,7 @@ use futures_util::stream::{self, StreamExt}; use tracing::warn; -use unixnotis_core::CloseReason; +use unixnotis_core::{CloseReason, NotificationKey}; use crate::daemon::{ControlServer, DaemonState, NotificationServer, NotificationSignalMode}; @@ -17,9 +17,9 @@ pub(super) struct ClearAllSignalPlan { pub(super) publish_state_changed: bool, } -pub(super) const fn clear_all_signal_plan(ids: &[u32]) -> ClearAllSignalPlan { +pub(super) const fn clear_all_signal_plan(keys: &[NotificationKey]) -> ClearAllSignalPlan { ClearAllSignalPlan { - publish_close_signals: !ids.is_empty(), + publish_close_signals: !keys.is_empty(), // Empty clears remain a recovery path for stale materialized client views publish_snapshot_invalidated: true, publish_state_changed: true, @@ -27,14 +27,14 @@ pub(super) const fn clear_all_signal_plan(ids: &[u32]) -> ClearAllSignalPlan { } impl DaemonState { - pub(in crate::daemon) async fn publish_notification_closed( + pub(crate) async fn publish_notification_closed( &self, - id: u32, + key: NotificationKey, reason: CloseReason, ) -> zbus::Result<()> { let mut first_error = self .events - .notification_closed(id, reason, true) + .notification_closed(key, reason, true) .await .err(); if let Err(error) = self.publish_state_changed().await { @@ -45,12 +45,12 @@ impl DaemonState { pub(in crate::daemon) async fn publish_notification_dismissed( &self, - id: u32, + key: NotificationKey, removed_active: bool, ) -> zbus::Result<()> { let mut first_error = self .events - .notification_closed(id, CloseReason::DismissedByUser, removed_active) + .notification_closed(key, CloseReason::DismissedByUser, removed_active) .await .err(); if let Err(error) = self.publish_state_changed().await { @@ -62,26 +62,26 @@ impl DaemonState { pub(in crate::daemon) async fn publish_notification_change( &self, mode: NotificationSignalMode, - id: u32, + key: NotificationKey, replaced: bool, - show_popup: bool, ) -> zbus::Result<()> { - self.events - .notification_change(mode, id, replaced, show_popup) - .await + self.events.notification_change(mode, key, replaced).await } pub(in crate::daemon) async fn publish_evicted_notifications( &self, - ids: &[u32], + keys: &[NotificationKey], ) -> zbus::Result<()> { - self.events.evicted_notifications(ids).await + self.events.evicted_notifications(keys).await } - pub(in crate::daemon) async fn publish_notifications_cleared(&self, ids: Vec) { - let plan = clear_all_signal_plan(&ids); + pub(in crate::daemon) async fn publish_notifications_cleared( + &self, + keys: Vec, + ) { + let plan = clear_all_signal_plan(&keys); if plan.publish_close_signals { - if let Err(error) = self.events.cleared_notifications(ids).await { + if let Err(error) = self.events.cleared_notifications(keys).await { warn!( ?error, "notification clear committed but close fanout failed" @@ -110,7 +110,7 @@ impl DaemonState { impl DaemonEventPublisher { async fn notification_closed( &self, - id: u32, + key: NotificationKey, reason: CloseReason, publish_freedesktop: bool, ) -> zbus::Result<()> { @@ -119,7 +119,8 @@ impl DaemonEventPublisher { match self.notification_context() { Ok(context) => { if let Err(error) = - NotificationServer::notification_closed(&context, id, reason as u32).await + NotificationServer::notification_closed(&context, key.id, reason as u32) + .await { record_first_error(&mut first_error, error); } @@ -129,7 +130,10 @@ impl DaemonEventPublisher { } match self.control_context() { Ok(context) => { - if let Err(error) = ControlServer::notification_closed(&context, id, reason).await { + if let Err(error) = + ControlServer::notification_closed(&context, key.id, key.generation, reason) + .await + { record_first_error(&mut first_error, error); } } @@ -141,43 +145,46 @@ impl DaemonEventPublisher { async fn notification_change( &self, mode: NotificationSignalMode, - id: u32, + key: NotificationKey, replaced: bool, - show_popup: bool, ) -> zbus::Result<()> { match mode { NotificationSignalMode::Direct => { let context = self.control_context()?; if replaced { - ControlServer::notification_updated(&context, id, show_popup).await + ControlServer::notification_updated(&context, key.id, key.generation).await } else { - ControlServer::notification_added(&context, id, show_popup).await + ControlServer::notification_added(&context, key.id, key.generation).await } } NotificationSignalMode::SnapshotOnly => self.snapshot_invalidated().await, } } - async fn evicted_notifications(&self, ids: &[u32]) -> zbus::Result<()> { - if ids.is_empty() { + async fn evicted_notifications(&self, keys: &[NotificationKey]) -> zbus::Result<()> { + if keys.is_empty() { return Ok(()); } let notification_context = self.notification_context()?; let control_context = self.control_context()?; let mut first_error = None; - for &id in ids { + for &key in keys { if let Err(error) = NotificationServer::notification_closed( ¬ification_context, - id, + key.id, CloseReason::Undefined as u32, ) .await { record_first_error(&mut first_error, error); } - if let Err(error) = - ControlServer::notification_closed(&control_context, id, CloseReason::Undefined) - .await + if let Err(error) = ControlServer::notification_closed( + &control_context, + key.id, + key.generation, + CloseReason::Undefined, + ) + .await { record_first_error(&mut first_error, error); } @@ -185,21 +192,21 @@ impl DaemonEventPublisher { first_error.map_or(Ok(()), Err) } - async fn cleared_notifications(&self, ids: Vec) -> zbus::Result<()> { + async fn cleared_notifications(&self, keys: Vec) -> zbus::Result<()> { let notification_context = self.notification_context()?; let control_context = self.control_context()?; let first_error = std::sync::Mutex::new(None); // Contexts are reused and concurrency remains bounded for large configured stores - stream::iter(ids) - .for_each_concurrent(CLEAR_ALL_CONCURRENCY, |id| { + stream::iter(keys) + .for_each_concurrent(CLEAR_ALL_CONCURRENCY, |key| { let notification_context = notification_context.clone(); let control_context = control_context.clone(); let first_error = &first_error; async move { if let Err(error) = NotificationServer::notification_closed( ¬ification_context, - id, + key.id, CloseReason::DismissedByUser as u32, ) .await @@ -211,7 +218,8 @@ impl DaemonEventPublisher { } if let Err(error) = ControlServer::notification_closed( &control_context, - id, + key.id, + key.generation, CloseReason::DismissedByUser, ) .await diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs index a3fcb52fe..9935462fd 100644 --- a/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs @@ -1,5 +1,10 @@ use super::super::notifications::clear_all_signal_plan; use crate::test_support::daemon_state_for_test; +use unixnotis_core::NotificationKey; + +const fn key(id: u32, generation: u64) -> NotificationKey { + NotificationKey { id, generation } +} #[test] fn clear_all_with_no_active_rows_still_invalidates_snapshot() { @@ -15,7 +20,7 @@ fn clear_all_with_no_active_rows_still_invalidates_snapshot() { #[test] fn clear_all_with_active_rows_keeps_close_fanout_and_refresh() { - let plan = clear_all_signal_plan(&[11, 12]); + let plan = clear_all_signal_plan(&[key(11, 1), key(12, 2)]); // Active rows still need the normal close signals assert!(plan.publish_close_signals); @@ -26,7 +31,7 @@ fn clear_all_with_active_rows_keeps_close_fanout_and_refresh() { #[test] fn clear_all_signal_plan_treats_any_non_empty_id_set_as_close_fanout() { - let plan = clear_all_signal_plan(&[99]); + let plan = clear_all_signal_plan(&[key(99, 3)]); // A single active row still needs both freedesktop and control close fanout assert!(plan.publish_close_signals); diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/state.rs b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs index 60e10f271..2dce28a5a 100644 --- a/crates/unixnotis-daemon/src/daemon/events/tests/state.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs @@ -3,7 +3,9 @@ use std::time::Duration; use futures_util::TryStreamExt; use tokio::sync::Barrier; -use unixnotis_core::{CloseReason, Config, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; +use unixnotis_core::{ + CloseReason, Config, ControlState, NotificationKey, PopupGateState, CONTROL_OBJECT_PATH, +}; use zbus::message::Type; use zbus::{Connection, MatchRule, Message, MessageStream}; @@ -153,7 +155,13 @@ async fn publish_notification_closed_sends_freedesktop_and_control_close_signals let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; state - .publish_notification_closed(7, CloseReason::ClosedByCall) + .publish_notification_closed( + NotificationKey { + id: 7, + generation: 70, + }, + CloseReason::ClosedByCall, + ) .await .expect("close fanout should emit"); @@ -166,11 +174,12 @@ async fn publish_notification_closed_sends_freedesktop_and_control_close_signals assert_eq!(freedesktop_reason, CloseReason::ClosedByCall as u32); let control_signal = next_signal(&mut control_stream).await; - let (control_id, control_reason) = control_signal + let (control_id, control_generation, control_reason) = control_signal .body() - .deserialize::<(u32, CloseReason)>() + .deserialize::<(u32, u64, CloseReason)>() .expect("control close body"); assert_eq!(control_id, 7); + assert_eq!(control_generation, 70); assert_eq!(control_reason as u32, CloseReason::ClosedByCall as u32); } @@ -180,16 +189,23 @@ async fn publish_notification_dismissed_sends_control_close_signal() { let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; state - .publish_notification_dismissed(8, false) + .publish_notification_dismissed( + NotificationKey { + id: 8, + generation: 80, + }, + false, + ) .await .expect("dismiss fanout should emit"); let control_signal = next_signal(&mut control_stream).await; - let (control_id, control_reason) = control_signal + let (control_id, control_generation, control_reason) = control_signal .body() - .deserialize::<(u32, CloseReason)>() + .deserialize::<(u32, u64, CloseReason)>() .expect("control close body"); assert_eq!(control_id, 8); + assert_eq!(control_generation, 80); assert_eq!(control_reason as u32, CloseReason::DismissedByUser as u32); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index 7ef73c519..608fb03fd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -79,6 +79,8 @@ pub(in crate::daemon::notifications) fn build_notification( Notification { id: 0, + // The store assigns a process-wide generation during the commit + generation: 0, app_name: if app_name.is_empty() { // Keep explicit fallback text for empty callers "Unknown".to_string() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index 168345e93..438432632 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -307,6 +307,7 @@ fn resolve_expiration_respects_protocol_and_config_rules() { let mut notification = unixnotis_core::Notification { id: 1, + generation: 1, app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), @@ -361,6 +362,7 @@ fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_ config.popups.default_timeout_ms = 0; let mut notification = unixnotis_core::Notification { id: 1, + generation: 1, app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 7f23e5612..09e93fa98 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,9 +1,8 @@ use std::collections::HashMap; use std::time::Duration; -use std::time::Instant; use tracing::{debug, warn}; -use unixnotis_core::Notification; +use unixnotis_core::{Notification, NotificationKey}; use zbus::message::Header; use zbus::zvariant::OwnedValue; @@ -21,7 +20,6 @@ use super::NotificationServer; struct StoredNotification { outcome: InsertOutcome, - expiration: Option, } struct WireNotification { @@ -76,8 +74,7 @@ impl NotificationServer { ) .await; let stored = self.store_notification(notification, replaces_id).await; - self.finish_notification_change(stored.outcome, stored.expiration) - .await + self.finish_notification_change(stored.outcome).await } fn log_received_notification( @@ -181,24 +178,28 @@ impl NotificationServer { notification: Notification, replaces_id: u32, ) -> StoredNotification { - // Store mutation and expiration scheduling happen under one lock scope - let (outcome, expiration) = { + // Store mutation and scheduler delivery share one serialized lock scope + let outcome = { let mut store = self.state.store.lock().await; let outcome = store.insert(notification, replaces_id); - let expiration = if outcome.dropped { - None - } else { + if !outcome.dropped { // Resolve timeout after insertion so rule-mapped fields are already final let expiration = resolve_expiration(store.config(), &outcome.notification); - store.set_expiration(outcome.notification.id, expiration); - expiration - }; - (outcome, expiration) + store.set_expiration(&outcome.notification, expiration); + // Unbounded send is synchronous, so commit order is preserved without an await + self.scheduler.schedule( + outcome.notification.id, + outcome.notification.generation, + expiration, + ); + } + // Eviction cancellation is committed in the same order as the insertion + for key in &outcome.evicted { + self.scheduler.schedule(key.id, key.generation, None); + } + outcome }; - StoredNotification { - outcome, - expiration, - } + StoredNotification { outcome } } fn handle_dropped_notification(outcome: &InsertOutcome) -> Option { @@ -213,8 +214,7 @@ impl NotificationServer { Some(outcome.notification.id) } - fn schedule_and_play(&self, outcome: &InsertOutcome, expiration: Option) { - self.scheduler.schedule(outcome.notification.id, expiration); + fn play_sound(&self, outcome: &InsertOutcome) { // Sound is best-effort and decided by rules and per-notification hints self.state .sound @@ -233,26 +233,17 @@ impl NotificationServer { ); } self.state - .publish_notification_change( - mode, - outcome.notification.id, - outcome.replaced, - outcome.popup_admission.should_show(), - ) + .publish_notification_change(mode, outcome.notification.key(), outcome.replaced) .await .map_err(to_fdo_error) } - async fn finish_notification_change( - &self, - outcome: InsertOutcome, - expiration: Option, - ) -> zbus::fdo::Result { + async fn finish_notification_change(&self, outcome: InsertOutcome) -> zbus::fdo::Result { if let Some(id) = Self::handle_dropped_notification(&outcome) { return Ok(id); } - self.schedule_and_play(&outcome, expiration); + self.play_sound(&outcome); debug!( id = outcome.notification.id, decision = ?outcome.popup_admission, @@ -284,13 +275,11 @@ impl NotificationServer { Ok(id) } - async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { + async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { if evicted.is_empty() { // Fast path avoids context allocation when no eviction happened return Ok(()); } - self.state.cancel_expirations(&evicted); - self.state .publish_evicted_notifications(&evicted) .await diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index e1dab2ea8..769a75816 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -23,6 +23,7 @@ use crate::test_support::daemon_state_for_test; fn notification_with_id(id: u32) -> Arc { Arc::new(Notification { id, + generation: 1, app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), @@ -276,12 +277,21 @@ async fn ingest_notify_emits_notification_added_signal() { .expect("notify should store"); let signal = next_signal(&mut stream).await; - let (signal_id, show_popup) = signal + let (signal_id, generation) = signal .body() - .deserialize::<(u32, bool)>() + .deserialize::<(u32, u64)>() .expect("notification added body"); assert_eq!(signal_id, id); - assert!(show_popup); + assert_eq!( + generation, + state + .store + .lock() + .await + .active_notification_view(id) + .expect("signalled notification should remain active") + .generation + ); } #[tokio::test] @@ -325,10 +335,11 @@ async fn ingest_notify_emits_control_close_for_evicted_active_notification() { .expect("second notify should store"); let signal = next_signal(&mut stream).await; - let (signal_id, reason) = signal + let (signal_id, signal_generation, reason) = signal .body() - .deserialize::<(u32, CloseReason)>() + .deserialize::<(u32, u64, CloseReason)>() .expect("notification closed body"); assert_eq!(signal_id, first_id); + assert!(signal_generation > 0); assert_eq!(reason as u32, CloseReason::Undefined as u32); } diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index 410ae7f3e..8cc8026c2 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -9,15 +9,20 @@ impl DaemonState { pub async fn close_notification(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { let removed = { let mut store = self.store.lock().await; - store.close(id, reason) + let removed = store.close(id, reason); + if let Some(notification) = removed.as_ref() { + // Cancellation is ordered before a replacement can acquire the store lock + self.cancel_expiration(notification.key()); + } + removed }; - if removed.is_none() { + let Some(removed) = removed else { return Ok(()); - } - // Timer cancel happens before signal fanout so stale wakeups stop right away - self.cancel_expiration(id); - - if let Err(err) = self.publish_notification_closed(id, reason).await { + }; + if let Err(err) = self + .publish_notification_closed(removed.key(), reason) + .await + { warn!( ?err, id, @@ -31,19 +36,24 @@ impl DaemonState { pub async fn dismiss_from_panel(&self, id: u32) -> zbus::Result<()> { let outcome = { let mut store = self.store.lock().await; - store.dismiss_from_panel(id) + let outcome = store.dismiss_from_panel(id); + if let Some(key) = outcome.removed_active { + self.cancel_expiration(key); + } + outcome }; if !outcome.removed_any() { return Ok(()); } - if outcome.removed_active { - // Panel dismiss removes the active entry, so its timer must go too - self.cancel_expiration(id); - } + let removed_active = outcome.removed_active.is_some(); + let key = outcome + .removed_active + .or(outcome.removed_history) + .expect("a removed notification must retain its generation"); if let Err(err) = self - .publish_notification_dismissed(id, outcome.removed_active) + .publish_notification_dismissed(key, removed_active) .await { warn!( @@ -62,18 +72,23 @@ impl DaemonState { let outcome = { // Object identity prevents an older action from deleting a same-ID replacement let mut store = self.store.lock().await; - store.dismiss_replied_generation(id, expected) + let outcome = store.dismiss_replied_generation(id, expected); + if let Some(key) = outcome.removed_active { + self.cancel_expiration(key); + } + outcome }; if !outcome.removed_any() { return Ok(false); } - if outcome.removed_active { - // Only the matching active generation owns this expiration timer - self.cancel_expiration(id); - } + let removed_active = outcome.removed_active.is_some(); + let key = outcome + .removed_active + .or(outcome.removed_history) + .expect("a removed reply target must retain its generation"); if let Err(err) = self - .publish_notification_dismissed(id, outcome.removed_active) + .publish_notification_dismissed(key, removed_active) .await { warn!( diff --git a/crates/unixnotis-daemon/src/daemon/state/schedulers.rs b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs index 6eebad13f..695a1d117 100644 --- a/crates/unixnotis-daemon/src/daemon/state/schedulers.rs +++ b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs @@ -8,6 +8,7 @@ use tracing::{debug, warn}; use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::store::DndWrite; +use unixnotis_core::NotificationKey; use super::DaemonState; @@ -151,21 +152,21 @@ impl DaemonState { !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) } - pub(in crate::daemon) fn cancel_expiration(&self, id: u32) { + pub(in crate::daemon) fn cancel_expiration(&self, key: NotificationKey) { // Missing scheduler means startup is still incomplete, so skip quietly let Some(scheduler) = self.scheduler() else { return; }; - scheduler.schedule(id, None); + scheduler.schedule(key.id, key.generation, None); } - pub fn cancel_expirations(&self, ids: &[u32]) { + pub fn cancel_expirations(&self, keys: &[NotificationKey]) { // Per-id cancel keeps the lazy expiration heap bounded without rebuilding it here let Some(scheduler) = self.scheduler() else { return; }; - for id in ids { - scheduler.schedule(*id, None); + for key in keys { + scheduler.schedule(key.id, key.generation, None); } } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 74d45fe91..fe1588195 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -11,6 +11,7 @@ use crate::test_support::daemon_state_for_test; fn notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), @@ -44,7 +45,7 @@ async fn next_cancel_id( .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => id, + ExpirationCommand::Cancel { id, .. } => id, ExpirationCommand::Schedule { .. } => panic!("dismiss should cancel expiration"), } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs index 03a73d738..21bdd88e9 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs @@ -1,12 +1,19 @@ use std::time::Duration; use chrono::Utc; -use unixnotis_core::Config; +use unixnotis_core::{Config, NotificationKey}; use crate::expire::{ExpirationCommand, ExpirationScheduler}; use crate::store::NotificationStore; use crate::test_support::{daemon_state_for_test, TempRoot}; +fn key(id: u32) -> NotificationKey { + NotificationKey { + id, + generation: u64::from(id), + } +} + #[tokio::test] async fn dnd_state_rolls_back_when_persistence_fails() { let state = daemon_state_for_test(false).await; @@ -118,14 +125,16 @@ async fn cancel_expiration_sends_cancel_command_when_scheduler_is_installed() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); - state.cancel_expiration(42); + state.cancel_expiration(key(42)); let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) .await .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => assert_eq!(id, 42), + ExpirationCommand::Cancel { id, generation } => { + assert_eq!((id, generation), (42, 42)); + } ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } } @@ -136,7 +145,7 @@ async fn cancel_expirations_sends_cancel_for_each_id_in_order() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); - state.cancel_expirations(&[7, 8, 9]); + state.cancel_expirations(&[key(7), key(8), key(9)]); let mut ids = Vec::new(); for _ in 0..3 { @@ -145,7 +154,7 @@ async fn cancel_expirations_sends_cancel_for_each_id_in_order() { .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => ids.push(id), + ExpirationCommand::Cancel { id, .. } => ids.push(id), ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } } @@ -162,14 +171,16 @@ async fn duplicate_scheduler_install_keeps_original_sender() { state.set_scheduler(first_scheduler); state.set_scheduler(second_scheduler); - state.cancel_expiration(11); + state.cancel_expiration(key(11)); let command = tokio::time::timeout(Duration::from_millis(100), first_receiver.recv()) .await .expect("original scheduler should receive cancel") .expect("original scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => assert_eq!(id, 11), + ExpirationCommand::Cancel { id, generation } => { + assert_eq!((id, generation), (11, 11)); + } ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } assert!(second_receiver.try_recv().is_err()); @@ -179,8 +190,8 @@ async fn duplicate_scheduler_install_keeps_original_sender() { async fn missing_scheduler_cancel_is_a_noop() { let state = daemon_state_for_test(false).await; - state.cancel_expiration(1); - state.cancel_expirations(&[2, 3]); + state.cancel_expiration(key(1)); + state.cancel_expirations(&[key(2), key(3)]); assert!(!state.mark_missing_scheduler_warning_needed()); } diff --git a/crates/unixnotis-daemon/src/expire.rs b/crates/unixnotis-daemon/src/expire.rs index 8fe7251cc..6a623a89c 100644 --- a/crates/unixnotis-daemon/src/expire.rs +++ b/crates/unixnotis-daemon/src/expire.rs @@ -9,12 +9,13 @@ use tokio::sync::mpsc; use tracing::warn; use crate::daemon::DaemonState; +use crate::store::ExpirationTicket; use unixnotis_core::CloseReason; /// Commands sent to the expiration scheduler pub enum ExpirationCommand { - Schedule { id: u32, deadline: Instant }, - Cancel { id: u32 }, + Schedule { ticket: ExpirationTicket }, + Cancel { id: u32, generation: u64 }, } /// Asynchronous expiration manager backed by a priority queue @@ -29,9 +30,9 @@ impl ExpirationScheduler { tokio::spawn(async move { let mut heap: BinaryHeap = BinaryHeap::new(); // Tracks the latest deadline per notification to discard stale heap entries - let mut scheduled: HashMap = HashMap::new(); + let mut scheduled: HashMap = HashMap::new(); loop { - let next_deadline = heap.peek().map(|item| item.deadline); + let next_deadline = heap.peek().map(|item| item.ticket.deadline); if next_deadline.is_none() { let Some(cmd) = receiver.recv().await else { break; @@ -51,47 +52,45 @@ impl ExpirationScheduler { () = tokio::time::sleep_until(deadline.into()) => { let now = Instant::now(); while let Some(item) = heap.peek() { - if item.deadline > now { + if item.ticket.deadline > now { break; } let Some(item) = heap.pop() else { break; }; - let is_current = scheduled - .get(&item.id) - .is_some_and(|deadline| *deadline == item.deadline); + let is_current = + scheduled.get(&item.ticket.id) == Some(&item.ticket); if !is_current { continue; } - // Verify the deadline is still current before closing the notification - let expiration = { - let store = state.store.lock().await; - store.expiration_for(item.id) + // Validation and removal share the same store lock + let removed = { + let mut store = state.store.lock().await; + store.expire_if_current(item.ticket) }; - let is_still_current = expiration - .is_some_and(|deadline| deadline == item.deadline); - if is_still_current { - // Remove the scheduled entry only once the deadline is confirmed - // to still be active. This avoids dropping new schedules created - // while the expiration task was waiting on the store lock - if scheduled.get(&item.id) == Some(&item.deadline) { - scheduled.remove(&item.id); - } - // Expiration closes must be observable so signal/state failures - // are visible in logs instead of being silently ignored - if let Err(err) = - state.close_notification(item.id, CloseReason::Expired).await + // Remove only the exact scheduler generation that was inspected + if scheduled.get(&item.ticket.id) == Some(&item.ticket) { + scheduled.remove(&item.ticket.id); + } + if removed.is_some() { + // Fanout happens only after the exact generation was removed + if let Err(err) = state + .publish_notification_closed( + unixnotis_core::NotificationKey { + id: item.ticket.id, + generation: item.ticket.generation, + }, + CloseReason::Expired, + ) + .await { warn!( ?err, - id = item.id, + id = item.ticket.id, + generation = item.ticket.generation, "failed to close expired notification" ); } - } else if scheduled.get(&item.id) == Some(&item.deadline) { - // The store no longer expects this deadline (dismissed or updated), - // so drop the stale schedule to avoid repeated checks - scheduled.remove(&item.id); } } maybe_compact(&mut heap, &scheduled); @@ -104,10 +103,16 @@ impl ExpirationScheduler { Self { sender } } - pub fn schedule(&self, id: u32, deadline: Option) { + pub fn schedule(&self, id: u32, generation: u64, deadline: Option) { let command = match deadline { - Some(deadline) => ExpirationCommand::Schedule { id, deadline }, - None => ExpirationCommand::Cancel { id }, + Some(deadline) => ExpirationCommand::Schedule { + ticket: ExpirationTicket { + id, + generation, + deadline, + }, + }, + None => ExpirationCommand::Cancel { id, generation }, }; if let Err(err) = self.sender.send(command) { warn!(?err, "expiration schedule request dropped"); @@ -117,13 +122,12 @@ impl ExpirationScheduler { #[derive(Debug, Copy, Clone)] struct ExpirationItem { - id: u32, - deadline: Instant, + ticket: ExpirationTicket, } impl PartialEq for ExpirationItem { fn eq(&self, other: &Self) -> bool { - self.deadline.eq(&other.deadline) + self.ticket.eq(&other.ticket) } } @@ -137,30 +141,48 @@ impl PartialOrd for ExpirationItem { impl Ord for ExpirationItem { fn cmp(&self, other: &Self) -> Ordering { - // Reverse ordering to make BinaryHeap a min-heap on deadline - other.deadline.cmp(&self.deadline) + // Reverse every field so BinaryHeap remains a deterministic min-heap + other + .ticket + .deadline + .cmp(&self.ticket.deadline) + .then_with(|| other.ticket.generation.cmp(&self.ticket.generation)) + .then_with(|| other.ticket.id.cmp(&self.ticket.id)) } } fn apply_command( cmd: ExpirationCommand, heap: &mut BinaryHeap, - scheduled: &mut HashMap, + scheduled: &mut HashMap, ) { match cmd { - ExpirationCommand::Schedule { id, deadline } => { - // Keep the newest deadline and push to the heap for ordering - scheduled.insert(id, deadline); - heap.push(ExpirationItem { id, deadline }); + ExpirationCommand::Schedule { ticket } => { + // Older commands cannot replace a later committed generation + let may_replace = scheduled + .get(&ticket.id) + .is_none_or(|current| current.generation <= ticket.generation); + if may_replace { + scheduled.insert(ticket.id, ticket); + heap.push(ExpirationItem { ticket }); + } } - ExpirationCommand::Cancel { id } => { - // Cancel only updates the tracking map; stale heap entries are ignored - scheduled.remove(&id); + ExpirationCommand::Cancel { id, generation } => { + // A delayed close from an older generation must preserve a replacement timer + let may_remove = scheduled + .get(&id) + .is_some_and(|current| current.generation <= generation); + if may_remove { + scheduled.remove(&id); + } } } } -fn maybe_compact(heap: &mut BinaryHeap, scheduled: &HashMap) { +fn maybe_compact( + heap: &mut BinaryHeap, + scheduled: &HashMap, +) { // Count how many expiration entries are still real and expected to happen let live = scheduled.len(); @@ -182,11 +204,8 @@ fn maybe_compact(heap: &mut BinaryHeap, scheduled: &HashMap>, // Archived notifications with bounded retention pub(super) history: HistoryStore, - // Optional expiration deadline per active id - pub(super) expirations: HashMap, + // Exact expiration identity per active notification generation + pub(super) expirations: HashMap, // Effective DND switch after loading persisted state pub(super) dnd_enabled: bool, // Wall-clock deadline survives daemon restarts; None means indefinite @@ -48,11 +50,19 @@ pub struct InsertOutcome { // Whether sound playback is allowed for this payload pub allow_sound: bool, // Active ids evicted because max_active was exceeded - pub evicted: Vec, + pub evicted: Vec, // True when payload was intentionally dropped by inhibit mode pub dropped: bool, } +/// Exact identity required to expire one committed notification +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ExpirationTicket { + pub id: u32, + pub generation: u64, + pub deadline: Instant, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PopupAdmission { Show, @@ -91,15 +101,15 @@ pub struct DndWrite { } pub struct DismissOutcome { - // True when an active entry was removed - pub removed_active: bool, - // True when a history entry was removed - pub removed_history: bool, + // Exact active generation removed by the operation + pub removed_active: Option, + // Exact history generation removed by the operation + pub removed_history: Option, } impl DismissOutcome { pub const fn removed_any(&self) -> bool { // Convenience helper for callers that only need yes/no - self.removed_active || self.removed_history + self.removed_active.is_some() || self.removed_history.is_some() } } diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index bad3265f6..6c2b3b85b 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use unixnotis_core::{ popup_allowed_by_state, should_archive_closed_notification, CloseReason, ControlState, - Notification, Urgency, + Notification, NotificationKey, Urgency, }; use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; @@ -47,6 +47,12 @@ impl NotificationStore { self.next_id() }; notification.id = assigned_id; + // A replacement keeps its protocol ID but always receives a fresh commit identity + notification.generation = self.next_generation; + self.next_generation = self + .next_generation + .checked_add(1) + .expect("notification generation space must not be exhausted"); // Drop stale copies before inserting the fresh one self.active.shift_remove(&assigned_id); @@ -69,18 +75,19 @@ impl NotificationStore { } } - fn enforce_active_limit(&mut self) -> Vec { + fn enforce_active_limit(&mut self) -> Vec { // Config limit still applies, but active list never exceeds the global safety cap let max_active = self.config.history.max_active.min(ACTIVE_HARD_CAP); if max_active == 0 { // max_active=0 means archive everything immediately let mut evicted = Vec::new(); while let Some((id, notification)) = self.active.shift_remove_index(0) { + let key = notification.key(); // Evicted notifications should not retain pending expiration entries self.expirations.remove(&id); // Active-cap eviction behaves like a daemon-side close for history policy self.push_history(notification, CloseReason::Undefined); - evicted.push(id); + evicted.push(key); } return evicted; } @@ -89,11 +96,12 @@ impl NotificationStore { while self.active.len() > max_active { // remove_index(0) always pops the oldest notification first if let Some((id, notification)) = self.active.shift_remove_index(0) { + let key = notification.key(); // Eviction path mirrors close path so state stays consistent self.expirations.remove(&id); // Evicted rows still need the same archive rule as any other close self.push_history(notification, CloseReason::Undefined); - evicted.push(id); + evicted.push(key); } else { // Defensive break for impossible map/index mismatch cases break; @@ -126,7 +134,7 @@ impl NotificationStore { self.history.evict_to_limit(self.config.history.max_entries); } - fn popup_admission(&self, notification: &Notification) -> PopupAdmission { + pub(crate) fn popup_admission(&self, notification: &Notification) -> PopupAdmission { // Rule-level popup suppression is highest priority if notification.suppress_popup { return PopupAdmission::Suppressed(PopupSuppressionReason::Rule); diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index 4999b1b1c..19498b933 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use std::time::Instant; -use unixnotis_core::{CloseReason, Notification}; +use unixnotis_core::{CloseReason, Notification, NotificationKey}; -use crate::store::{DismissOutcome, NotificationStore}; +use crate::store::{DismissOutcome, ExpirationTicket, NotificationStore}; impl NotificationStore { pub fn close(&mut self, id: u32, reason: CloseReason) -> Option> { @@ -19,15 +19,18 @@ impl NotificationStore { pub fn dismiss_from_panel(&mut self, id: u32) -> DismissOutcome { // Panel dismissal can target active, history, or both - let removed_active = self.active.shift_remove(&id).is_some(); - if removed_active { + let removed_active = self.active.shift_remove(&id); + if removed_active.is_some() { self.expirations.remove(&id); } - let removed_history = self.history.remove(&id).is_some(); + let removed_history = self + .history + .remove(&id) + .map(|notification| notification.key()); DismissOutcome { - removed_active, + removed_active: removed_active.map(|notification| notification.key()), removed_history, } } @@ -53,16 +56,20 @@ impl NotificationStore { id: u32, expected: &Arc, ) -> DismissOutcome { - let removed_active = self.dismiss_active_if_current(id, expected); - let removed_history = if removed_active { + let removed_active = self + .dismiss_active_if_current(id, expected) + .then(|| expected.key()); + let removed_history = if removed_active.is_some() { // Active cleanup already removed the exact generation - false + None } else if self.active.contains_key(&id) { // Any remaining active entry is a replacement with the same numeric id - false + None } else { // A close may archive the replied generation before reply cleanup resumes - self.history.remove_if_source(id, expected).is_some() + self.history + .remove_if_source(id, expected) + .map(|notification| notification.key()) }; DismissOutcome { removed_active, @@ -70,27 +77,61 @@ impl NotificationStore { } } - pub fn drain_active_ids(&mut self) -> Vec { + pub fn drain_active_keys(&mut self) -> Vec { // Drain in one pass so callers do not need repeated lookups - let ids = self.active.keys().rev().copied().collect(); + let keys = self + .active + .values() + .rev() + .map(|notification| notification.key()) + .collect(); self.active.clear(); self.expirations.clear(); - ids + keys } - pub fn set_expiration(&mut self, id: u32, deadline: Option) { + pub fn set_expiration( + &mut self, + notification: &Arc, + deadline: Option, + ) -> Option { // None removes a stale timer for resident or already-dismissed notifications match deadline { Some(deadline) => { - self.expirations.insert(id, deadline); + let ticket = ExpirationTicket { + id: notification.id, + generation: notification.generation, + deadline, + }; + self.expirations.insert(notification.id, ticket); + Some(ticket) } None => { - self.expirations.remove(&id); + self.expirations.remove(¬ification.id); + None } } } - pub fn expiration_for(&self, id: u32) -> Option { + #[cfg(test)] + pub fn expiration_for(&self, id: u32) -> Option { self.expirations.get(&id).copied() } + + pub fn expire_if_current(&mut self, ticket: ExpirationTicket) -> Option> { + // Both identities must match inside this one store-lock critical section + let current = self.active.get(&ticket.id)?; + if current.generation != ticket.generation { + return None; + } + if self.expirations.get(&ticket.id) != Some(&ticket) { + return None; + } + + // Removal, timer cleanup, and history insertion commit atomically + let removed = self.active.shift_remove(&ticket.id)?; + self.expirations.remove(&ticket.id); + self.push_history(removed.clone(), CloseReason::Expired); + Some(removed) + } } diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs index e09020d82..9f66f20e5 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -1,16 +1,19 @@ use super::support::*; #[test] -fn drain_active_ids_returns_newest_first_and_clears_expirations() { +fn drain_active_keys_returns_newest_first_and_clears_expirations() { let mut store = make_store_with_limits(10, 10); let first = store.insert(make_notification("first"), 0); let second = store.insert(make_notification("second"), 0); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - store.set_expiration(first.notification.id, Some(deadline)); + store.set_expiration(&first.notification, Some(deadline)); - let ids = store.drain_active_ids(); + let keys = store.drain_active_keys(); - assert_eq!(ids, vec![second.notification.id, first.notification.id]); + assert_eq!( + keys, + vec![second.notification.key(), first.notification.key()] + ); assert!(store.list_active().is_empty()); assert_eq!(store.expiration_for(first.notification.id), None); } @@ -22,13 +25,23 @@ fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { let first = std::time::Instant::now() + std::time::Duration::from_secs(1); let second = std::time::Instant::now() + std::time::Duration::from_secs(2); - store.set_expiration(outcome.notification.id, Some(first)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(first)); + let first_ticket = store + .set_expiration(&outcome.notification, Some(first)) + .expect("positive deadline should create a ticket"); + assert_eq!( + store.expiration_for(outcome.notification.id), + Some(first_ticket) + ); - store.set_expiration(outcome.notification.id, Some(second)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(second)); + let second_ticket = store + .set_expiration(&outcome.notification, Some(second)) + .expect("replacement deadline should create a ticket"); + assert_eq!( + store.expiration_for(outcome.notification.id), + Some(second_ticket) + ); - store.set_expiration(outcome.notification.id, None); + store.set_expiration(&outcome.notification, None); assert_eq!(store.expiration_for(outcome.notification.id), None); } @@ -69,8 +82,8 @@ fn replied_generation_is_removed_after_sender_archives_it() { let outcome = store.dismiss_replied_generation(id, &original); - assert!(!outcome.removed_active); - assert!(outcome.removed_history); + assert!(outcome.removed_active.is_none()); + assert_eq!(outcome.removed_history, Some(original.key())); assert!(store.list_history().is_empty()); } diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index fee1c7f9c..76f3f8bb7 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use indexmap::IndexMap; use tracing::{debug, warn}; -use unixnotis_core::{Config, ControlState, Notification, NotificationView}; +use unixnotis_core::{Config, ControlState, Notification, NotificationView, PopupCandidate}; use super::dnd::{DndStateStore, DND_STATE_VERSION}; use super::model::NotificationStore; @@ -61,6 +61,8 @@ impl NotificationStore { Self { // IDs start at 1 to preserve protocol expectations next_id: 1, + // Generation zero stays reserved for payloads not committed to the store + next_generation: 1, dnd_enabled, dnd_expires_at, dnd_revision: 0, @@ -131,6 +133,15 @@ impl NotificationStore { .map(|notification| notification.to_view()) } + pub fn popup_candidate(&self, id: u32) -> Option { + // Payload and live gate policy are read from one immutable lock snapshot + let notification = self.active.get(&id)?; + Some(PopupCandidate { + notification: notification.to_view(), + should_show: self.popup_admission(notification).should_show(), + }) + } + pub fn active_inline_reply_target(&self, id: u32) -> Option> { let notification = self.active.get(&id)?; // Both fields must agree so malformed internal data cannot widen reply access diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs index 77fb1568b..f7c1111a2 100644 --- a/crates/unixnotis-daemon/src/store/test_support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -21,6 +21,7 @@ impl NotificationStore { pub(in crate::store) fn make_notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), diff --git a/crates/unixnotis-daemon/src/store/tests/model.rs b/crates/unixnotis-daemon/src/store/tests/model.rs index 6a0a2fa12..286e255cd 100644 --- a/crates/unixnotis-daemon/src/store/tests/model.rs +++ b/crates/unixnotis-daemon/src/store/tests/model.rs @@ -1,20 +1,27 @@ use crate::store::DismissOutcome; +use unixnotis_core::NotificationKey; #[test] fn dismiss_outcome_reports_any_removed_side() { assert!(DismissOutcome { - removed_active: true, - removed_history: false, + removed_active: Some(NotificationKey { + id: 1, + generation: 1, + }), + removed_history: None, } .removed_any()); assert!(DismissOutcome { - removed_active: false, - removed_history: true, + removed_active: None, + removed_history: Some(NotificationKey { + id: 2, + generation: 2, + }), } .removed_any()); assert!(!DismissOutcome { - removed_active: false, - removed_history: false, + removed_active: None, + removed_history: None, } .removed_any()); } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 74cfc90b5..01e8f6157 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -29,6 +29,41 @@ fn active_notification_view_returns_current_active_payload() { assert_eq!(view.summary, "visible"); } +#[test] +fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("allowed"), 0).notification; + let mut suppressed = make_notification("rule suppressed"); + suppressed.suppress_popup = true; + let replacement = store.insert(suppressed, original.id).notification; + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain an active popup candidate"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "rule suppressed"); + assert!(!candidate.should_show); +} + +#[test] +fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("allowed"), 0).notification; + store.set_dnd(true); + let replacement = store + .insert(make_notification("dnd suppressed"), original.id) + .notification; + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain active during DND"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "dnd suppressed"); + assert!(!candidate.should_show); +} + #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { let mut store = make_store_with_limits(12, 20); diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 73915fbab..e5317d78b 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -7,26 +7,37 @@ impl ExpirationScheduler { } } use chrono::Utc; +use futures_util::TryStreamExt; use std::collections::HashMap; use std::time::Duration; -use unixnotis_core::{Notification, NotificationImage, Urgency}; +use unixnotis_core::{ + Notification, NotificationImage, Urgency, CONTROL_INTERFACE, CONTROL_OBJECT_PATH, +}; +use zbus::message::Type; use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +fn ticket(id: u32, generation: u64, deadline: Instant) -> ExpirationTicket { + ExpirationTicket { + id, + generation, + deadline, + } +} #[test] fn expiration_heap_orders_by_deadline() { let now = Instant::now(); let mut heap = BinaryHeap::new(); heap.push(ExpirationItem { - id: 1, - deadline: now + Duration::from_secs(2), + ticket: ticket(1, 1, now + Duration::from_secs(2)), }); heap.push(ExpirationItem { - id: 2, - deadline: now + Duration::from_secs(1), + ticket: ticket(2, 2, now + Duration::from_secs(1)), }); let first = heap.pop().expect("first item"); - assert_eq!(first.id, 2); + assert_eq!(first.ticket.id, 2); } #[test] @@ -37,26 +48,52 @@ fn apply_command_tracks_latest_schedule() { apply_command( ExpirationCommand::Schedule { - id: 7, - deadline: now + Duration::from_secs(5), + ticket: ticket(7, 1, now + Duration::from_secs(5)), }, &mut heap, &mut scheduled, ); apply_command( ExpirationCommand::Schedule { - id: 7, - deadline: now + Duration::from_secs(3), + ticket: ticket(7, 2, now + Duration::from_secs(3)), }, &mut heap, &mut scheduled, ); assert_eq!(scheduled.len(), 1); - assert_eq!(scheduled.get(&7), Some(&(now + Duration::from_secs(3)))); + assert_eq!( + scheduled.get(&7), + Some(&ticket(7, 2, now + Duration::from_secs(3))) + ); assert_eq!(heap.len(), 2); } +#[test] +fn late_older_schedule_cannot_replace_newer_generation() { + let now = Instant::now(); + let mut heap = BinaryHeap::new(); + let mut scheduled = HashMap::new(); + let newer = ticket(7, 2, now + Duration::from_secs(10)); + + // This order reproduces delivery after two store commits were reversed + apply_command( + ExpirationCommand::Schedule { ticket: newer }, + &mut heap, + &mut scheduled, + ); + apply_command( + ExpirationCommand::Schedule { + ticket: ticket(7, 1, now + Duration::from_secs(1)), + }, + &mut heap, + &mut scheduled, + ); + + assert_eq!(scheduled.get(&7), Some(&newer)); + assert_eq!(heap.len(), 1); +} + #[test] fn apply_command_cancel_removes_schedule() { let now = Instant::now(); @@ -65,14 +102,16 @@ fn apply_command_cancel_removes_schedule() { apply_command( ExpirationCommand::Schedule { - id: 9, - deadline: now + Duration::from_secs(2), + ticket: ticket(9, 4, now + Duration::from_secs(2)), }, &mut heap, &mut scheduled, ); apply_command( - ExpirationCommand::Cancel { id: 9 }, + ExpirationCommand::Cancel { + id: 9, + generation: 4, + }, &mut heap, &mut scheduled, ); @@ -80,24 +119,51 @@ fn apply_command_cancel_removes_schedule() { assert!(scheduled.is_empty()); } +#[test] +fn late_older_cancel_preserves_newer_generation() { + let now = Instant::now(); + let mut heap = BinaryHeap::new(); + let mut scheduled = HashMap::new(); + let newer = ticket(9, 5, now + Duration::from_secs(2)); + apply_command( + ExpirationCommand::Schedule { ticket: newer }, + &mut heap, + &mut scheduled, + ); + + apply_command( + ExpirationCommand::Cancel { + id: 9, + generation: 4, + }, + &mut heap, + &mut scheduled, + ); + + assert_eq!(scheduled.get(&9), Some(&newer)); +} + #[test] fn maybe_compact_rebuilds_from_scheduled() { let now = Instant::now(); let mut heap = BinaryHeap::new(); let mut scheduled = HashMap::new(); - scheduled.insert(1_u32, now + Duration::from_secs(1)); + scheduled.insert(1_u32, ticket(1, 1, now + Duration::from_secs(1))); for id in 0..129_u32 { heap.push(ExpirationItem { - id, - deadline: now + Duration::from_secs(u64::from(id) + 1), + ticket: ticket( + id, + u64::from(id) + 1, + now + Duration::from_secs(u64::from(id) + 1), + ), }); } maybe_compact(&mut heap, &scheduled); assert_eq!(heap.len(), scheduled.len()); let item = heap.pop().expect("rebuilt item"); - assert_eq!(item.id, 1); + assert_eq!(item.ticket.id, 1); } #[tokio::test] @@ -107,21 +173,21 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { state.set_scheduler(scheduler.clone()); let deadline = Instant::now() + Duration::from_millis(20); - let id = { + let key = { let mut store = state.store.lock().await; let outcome = store.insert(make_notification("expires"), 0); - let id = outcome.notification.id; - store.set_expiration(id, Some(deadline)); - id + let key = outcome.notification.key(); + store.set_expiration(&outcome.notification, Some(deadline)); + key }; - scheduler.schedule(id, Some(deadline)); + scheduler.schedule(key.id, key.generation, Some(deadline)); let expired = tokio::time::timeout(Duration::from_secs(1), async { loop { let is_active = { let store = state.store.lock().await; - store.active_notification_view(id).is_some() + store.active_notification_view(key.id).is_some() }; if !is_active { break; @@ -133,12 +199,103 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { assert!(expired.is_ok()); let store = state.store.lock().await; - assert_eq!(store.expiration_for(id), None); + assert_eq!(store.expiration_for(key.id), None); +} + +#[tokio::test] +async fn old_timer_never_closes_or_signals_for_same_id_replacement() { + let state = crate::test_support::daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + state.set_scheduler(scheduler.clone()); + let mut closed_signals = control_closed_stream(&state).await; + let old_deadline = Instant::now() + Duration::from_millis(30); + + // Holding the store lock forces the expired worker to wait at its commit point + let mut store = state.store.lock().await; + let original = store.insert(make_notification("original"), 0).notification; + store.set_expiration(&original, Some(old_deadline)); + scheduler.schedule(original.id, original.generation, Some(old_deadline)); + tokio::time::sleep(Duration::from_millis(80)).await; + + let replacement = store + .insert(make_notification("replacement"), original.id) + .notification; + let replacement_deadline = Instant::now() + Duration::from_millis(250); + store.set_expiration(&replacement, Some(replacement_deadline)); + scheduler.schedule( + replacement.id, + replacement.generation, + Some(replacement_deadline), + ); + drop(store); + + // The stale timer now resumes but cannot remove the replacement generation + tokio::time::sleep(Duration::from_millis(60)).await; + let active = state + .store + .lock() + .await + .active_notification_view(replacement.id) + .expect("replacement should remain active after the old deadline"); + assert_eq!(active.generation, replacement.generation); + assert_eq!(active.summary, "replacement"); + assert!( + tokio::time::timeout(Duration::from_millis(60), closed_signals.try_next()) + .await + .is_err(), + "old generation must not emit a close signal" + ); + + // The replacement keeps its own schedule and expires normally + let signal = tokio::time::timeout(Duration::from_millis(500), closed_signals.try_next()) + .await + .expect("replacement close signal should arrive") + .expect("close signal stream should remain healthy") + .expect("replacement close signal"); + let (closed_id, closed_generation, reason) = signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("notification close signal body"); + assert_eq!(closed_id, replacement.id); + assert_eq!(closed_generation, replacement.generation); + assert_eq!(reason as u32, CloseReason::Expired as u32); + assert!(state + .store + .lock() + .await + .active_notification_view(replacement.id) + .is_none()); +} + +async fn control_closed_stream(state: &DaemonState) -> MessageStream { + let receiver = Connection::session() + .await + .expect("receiver should connect to the test session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection should have a unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("daemon sender should be a valid bus name") + .path(CONTROL_OBJECT_PATH) + .expect("control object path should be valid") + .interface(CONTROL_INTERFACE) + .expect("control interface should be valid") + .member("NotificationClosed") + .expect("close member should be valid") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("close signal subscription should succeed") } fn make_notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), diff --git a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs index 935f78350..312b36424 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs @@ -1,7 +1,7 @@ //! Authenticated notification pulls after lightweight signal delivery use tracing::warn; -use unixnotis_core::{timed_dbus_call, ControlProxy}; +use unixnotis_core::{timed_dbus_call, ControlProxy, PopupCandidate}; use crate::dbus::UiEvent; @@ -9,21 +9,15 @@ pub(super) async fn push_active_notification_event( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, id: u32, - show_popup: bool, + generation: u64, is_add: bool, ) { - // The daemon remains the authority for the complete notification payload - match timed_dbus_call(proxy.get_active_notification(id)).await { - Ok(mut notifications) => { - // A close signal may win this fetch race, making an empty result normal - let Some(notification) = notifications.pop() else { + // Payload and popup policy come from one daemon-side store snapshot + match timed_dbus_call(proxy.get_popup_candidate(id)).await { + Ok(candidates) => { + let Some(event) = popup_event(candidates, generation, is_add) else { return; }; - let event = if is_add { - UiEvent::NotificationAdded(notification, show_popup) - } else { - UiEvent::NotificationUpdated(notification, show_popup) - }; let _ = sender.send(event).await; } Err(error) => { @@ -35,3 +29,27 @@ pub(super) async fn push_active_notification_event( } } } + +pub(super) fn popup_event( + mut candidates: Vec, + generation: u64, + is_add: bool, +) -> Option { + // A close signal may win this fetch race, making an empty result normal + let candidate = candidates.pop()?; + // A delayed signal must never lend its admission to a replacement payload + if candidate.notification.generation != generation { + return None; + } + if is_add { + Some(UiEvent::NotificationAdded( + candidate.notification, + candidate.should_show, + )) + } else { + Some(UiEvent::NotificationUpdated( + candidate.notification, + candidate.should_show, + )) + } +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/generation.rs index 097f607c9..702f3d0c5 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/generation.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/generation.rs @@ -203,7 +203,7 @@ pub(super) async fn run_owner_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), true, ).await; } @@ -218,7 +218,7 @@ pub(super) async fn run_owner_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), false, ).await; } @@ -230,7 +230,13 @@ pub(super) async fn run_owner_generation( }; if let Ok(args) = signal.args() { let _ = sender - .send(UiEvent::NotificationClosed(*args.id(), *args.reason())) + .send(UiEvent::NotificationClosed( + unixnotis_core::NotificationKey { + id: *args.id(), + generation: *args.generation(), + }, + *args.reason(), + )) .await; } } diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs new file mode 100644 index 000000000..020de44fe --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -0,0 +1,47 @@ +use unixnotis_core::{NotificationImage, NotificationView, PopupCandidate}; + +use super::super::delivery::popup_event; +use crate::dbus::UiEvent; + +fn candidate(generation: u64, should_show: bool) -> PopupCandidate { + PopupCandidate { + notification: NotificationView { + id: 7, + generation, + app_name: "example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: format!("generation {generation}"), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + is_transient: false, + image: NotificationImage::default(), + }, + should_show, + } +} + +#[test] +fn old_allowed_signal_cannot_display_new_suppressed_replacement() { + let event = popup_event(vec![candidate(2, false)], 1, true); + + assert!(event.is_none()); +} + +#[test] +fn current_suppressed_replacement_is_delivered_as_hidden_update() { + let event = popup_event(vec![candidate(2, false)], 2, false); + + assert!(matches!( + event, + Some(UiEvent::NotificationUpdated(notification, false)) + if notification.generation == 2 + )); +} + +#[test] +fn missing_candidate_after_reordered_close_emits_no_event() { + assert!(popup_event(Vec::new(), 3, false).is_none()); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs index b58bd3635..898c495ad 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs @@ -1,4 +1,5 @@ mod bootstrap; mod connection; +mod delivery; mod generation; mod readiness; diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index f37c73c78..27d86c555 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -1,6 +1,8 @@ //! D-Bus-facing popup event and command types -use unixnotis_core::{CloseReason, ControlState, NotificationView, PopupGateState}; +use unixnotis_core::{ + CloseReason, ControlState, NotificationKey, NotificationView, PopupGateState, +}; /// Events delivered to the GTK main loop #[derive(Debug, Clone)] @@ -14,7 +16,7 @@ pub enum UiEvent { // Add and update reuse the shared lightweight NotificationView payload NotificationAdded(NotificationView, bool), NotificationUpdated(NotificationView, bool), - NotificationClosed(u32, CloseReason), + NotificationClosed(NotificationKey, CloseReason), // Popup gate is split out so panel-only state changes do not wake the popup UI PopupGateChanged(PopupGateState), CssReload, diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index 98734b418..abffa9334 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -3,6 +3,7 @@ use unixnotis_core::{NotificationImage, NotificationView}; pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView { NotificationView { id: 1, + generation: 1, app_name: app_name.to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: app_name.to_string(), diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 28d45f9e6..4647d9e3e 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -2,7 +2,7 @@ use gtk::prelude::*; use tracing::debug; -use unixnotis_core::NotificationView; +use unixnotis_core::{NotificationKey, NotificationView}; use unixnotis_ui::CutCorner; use super::super::entry::PopupEntry; @@ -18,6 +18,14 @@ pub(super) struct ReconcilePlan { pub(super) desired_order: std::collections::VecDeque, } +pub(super) fn incoming_generation_is_stale(existing: Option, incoming: u64) -> bool { + existing.is_some_and(|generation| generation > incoming) +} + +pub(super) fn generation_matches(existing: Option, expected: u64) -> bool { + existing.is_some_and(|generation| generation == expected) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct VisiblePopupUpdate { // True when stack order, materialization, or reveal state changed @@ -36,9 +44,13 @@ impl UiState { refresh_visibility: bool, ) { let id = notification.id; - // Duplicate ids point at an upstream state bug - if self.popups.contains_key(&id) { - debug!(id, "popup insert skipped because id already exists"); + if let Some(existing) = self.popups.get(&id) { + // A later generation always dominates an old or duplicated add event + if existing.notification.generation >= notification.generation { + debug!(id, "stale popup insert skipped"); + return; + } + self.update_popup_internal(notification, true, refresh_visibility); return; } @@ -63,8 +75,21 @@ impl UiState { refresh_visibility: bool, ) -> bool { let id = notification.id; + let existing_generation = self + .popups + .get(&id) + .map(|entry| entry.notification.generation); + if incoming_generation_is_stale(existing_generation, notification.generation) { + // Reordered older updates cannot roll a popup back + debug!( + id, + generation = notification.generation, + "stale popup update skipped" + ); + return false; + } if !show_popup { - // Hidden updates act like a close for this popup id + // A newer suppressed generation removes any older visible payload for this ID self.remove_popup_internal(id, refresh_visibility); return false; } @@ -97,9 +122,14 @@ impl UiState { rebuilt_visible_row } - pub(in crate::ui) fn remove_popup(&mut self, id: u32) { - // Runtime close path keeps one place for remove semantics - self.remove_popup_internal(id, true); + pub(in crate::ui) fn remove_popup_if_generation(&mut self, key: NotificationKey) { + let existing_generation = self + .popups + .get(&key.id) + .map(|entry| entry.notification.generation); + if generation_matches(existing_generation, key.generation) { + self.remove_popup_internal(key.id, true); + } } pub(super) fn remove_popup_internal(&mut self, id: u32, refresh_visibility: bool) { diff --git a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs index fd8f63238..e5ec75b87 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs @@ -1,4 +1,4 @@ -use super::VisiblePopupUpdate; +use super::{generation_matches, incoming_generation_is_stale, VisiblePopupUpdate}; #[test] fn visible_update_starts_without_stack_changes() { @@ -6,3 +6,17 @@ fn visible_update_starts_without_stack_changes() { assert!(!update.stack_changed); } + +#[test] +fn newer_popup_generation_rejects_reordered_older_update() { + assert!(incoming_generation_is_stale(Some(8), 7)); + assert!(!incoming_generation_is_stale(Some(7), 8)); + assert!(!incoming_generation_is_stale(None, 8)); +} + +#[test] +fn popup_close_matches_only_the_exact_generation() { + assert!(generation_matches(Some(8), 8)); + assert!(!generation_matches(Some(8), 7)); + assert!(!generation_matches(None, 8)); +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 27577ae0e..20560489a 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -6,6 +6,7 @@ use unixnotis_core::{Action, ControlState, NotificationImage, NotificationView, fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: "Test".to_string(), attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), diff --git a/crates/unixnotis-popups/src/ui/state/events.rs b/crates/unixnotis-popups/src/ui/state/events.rs index ceaf5ee1e..6943f365e 100644 --- a/crates/unixnotis-popups/src/ui/state/events.rs +++ b/crates/unixnotis-popups/src/ui/state/events.rs @@ -40,9 +40,9 @@ impl UiState { ); self.update_popup(notification, show_popup); } - UiEvent::NotificationClosed(id, _reason) => { - debug!(id, "popup closed"); - self.remove_popup(id); + UiEvent::NotificationClosed(key, _reason) => { + debug!(id = key.id, generation = key.generation, "popup closed"); + self.remove_popup_if_generation(key); } UiEvent::PopupGateChanged(gate) => { // Gate updates change only policy fields and preserve unrelated daemon state diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 0b765bfbd..0110d8982 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -47,6 +47,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { ); let notification = NotificationView { id: 1, + generation: 1, app_name: "Demo".to_string(), attribution: unixnotis_core::NotificationAttribution::default(), summary: "Summary".to_string(), @@ -114,6 +115,7 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { ); let notification = NotificationView { id: 2, + generation: 2, app_name: "Critical probe".to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: "Critical probe".to_string(), From f325706ac0104794e068af6b734f67d2ea7a4252 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:24:55 -0500 Subject: [PATCH 112/275] fix(center): scope readiness to the current owner Summary: scope readiness to the current owner. Scope: center. --- .../unixnotis-center/src/control/reconnect.rs | 105 ++++++++++++++++-- .../src/control/tests/reconnect.rs | 68 ++++++++++++ .../src/child_process/tests/command.rs | 2 +- .../src/daemon/bus/clients.rs | 2 + .../src/daemon/control/panel.rs | 5 +- .../src/daemon/control/tests/server.rs | 16 +++ .../src/daemon/state/model.rs | 3 + .../src/daemon/state/status.rs | 28 ++++- .../src/daemon/state/tests/status.rs | 33 +++++- 9 files changed, 245 insertions(+), 17 deletions(-) diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index b59b95c41..fa521a624 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -1,15 +1,16 @@ //! Session-bus reconnection and control-generation lifecycle use std::collections::VecDeque; +use std::future::Future; use std::time::Duration; -use futures_util::StreamExt; +use futures_util::{Stream, StreamExt}; use tokio::sync::mpsc; use unixnotis_core::{ log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, INTERNAL_DBUS_CALL_TIMEOUT, }; use zbus::fdo::DBusProxy; -use zbus::names::BusName; +use zbus::names::{BusName, UniqueName}; use zbus::proxy::OwnerChangedStream; use zbus::Connection; @@ -130,6 +131,7 @@ pub(super) async fn run_control_loop( } } +#[derive(Debug, Eq, PartialEq)] enum OwnerWait { Ready(String), Disconnected, @@ -145,18 +147,54 @@ async fn wait_for_control_owner( ) -> OwnerWait { let control_name = BusName::try_from(CONTROL_BUS_NAME) .expect("static UnixNotis control bus name must be valid"); - if let Ok(Ok(owner)) = tokio::time::timeout( - INTERNAL_DBUS_CALL_TIMEOUT, - dbus.get_name_owner(control_name), + wait_for_control_owner_with_probe( + || probe_control_owner(dbus, control_name.clone()), + owner_changes, + sender, + command_rx, + offline_commands, + BACKOFF_BASE_MS, ) .await - { - return OwnerWait::Ready(owner.to_string()); - } +} + +#[derive(Debug)] +enum GetOwnerError { + NoOwner, + Disconnected(String), + Transient(String), +} +async fn wait_for_control_owner_with_probe( + mut probe: P, + owner_changes: &mut S, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + offline_commands: &mut VecDeque, + retry_base_ms: u64, +) -> OwnerWait +where + P: FnMut() -> F, + F: Future>, + S: Stream>> + Unpin, +{ + let mut probe_backoff = Backoff::new(retry_base_ms, BACKOFF_MAX_MS); + let mut probe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + match probe().await { + Ok(owner) => return OwnerWait::Ready(owner), + Err(GetOwnerError::Disconnected(error)) => { + probe_log.warn_or_debug(&error, "control owner lookup lost its bus connection"); + return OwnerWait::Disconnected; + } + Err(GetOwnerError::Transient(error)) => { + probe_log.warn_or_debug(&error, "control owner lookup failed; retrying"); + } + Err(GetOwnerError::NoOwner) => {} + } // Missing ownership is a stable disconnected state, not a connection failure let _ = sender.send(UiEvent::Disconnected).await; loop { + let retry_delay = probe_backoff.next_sleep(); tokio::select! { command = command_rx.recv() => { let Some(command) = command else { @@ -171,6 +209,57 @@ async fn wait_for_control_owner( None => return OwnerWait::Disconnected, } } + () = tokio::time::sleep(retry_delay) => { + match probe().await { + Ok(owner) => return OwnerWait::Ready(owner), + Err(GetOwnerError::NoOwner) => {} + Err(GetOwnerError::Disconnected(error)) => { + probe_log.warn_or_debug( + &error, + "control owner lookup lost its bus connection", + ); + return OwnerWait::Disconnected; + } + Err(GetOwnerError::Transient(error)) => { + probe_log.warn_or_debug( + &error, + "control owner lookup failed; retrying", + ); + } + } + } + } + } +} + +async fn probe_control_owner( + dbus: &DBusProxy<'_>, + control_name: BusName<'_>, +) -> Result { + match tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + Ok(Ok(owner)) => Ok(owner.to_string()), + Ok(Err(zbus::fdo::Error::NameHasNoOwner(_))) => Err(GetOwnerError::NoOwner), + Ok(Err(error)) if owner_error_is_disconnected(&error) => { + Err(GetOwnerError::Disconnected(error.to_string())) } + Ok(Err(error)) => Err(GetOwnerError::Transient(error.to_string())), + Err(_) => Err(GetOwnerError::Transient( + "control owner lookup timed out".to_string(), + )), + } +} + +fn owner_error_is_disconnected(error: &zbus::fdo::Error) -> bool { + match error { + zbus::fdo::Error::IOError(_) + | zbus::fdo::Error::NoServer(_) + | zbus::fdo::Error::NoNetwork(_) => true, + zbus::fdo::Error::ZBus(zbus::Error::InputOutput(_)) => true, + _ => false, } } diff --git a/crates/unixnotis-center/src/control/tests/reconnect.rs b/crates/unixnotis-center/src/control/tests/reconnect.rs index 24e50bc0f..d55c32b8e 100644 --- a/crates/unixnotis-center/src/control/tests/reconnect.rs +++ b/crates/unixnotis-center/src/control/tests/reconnect.rs @@ -7,6 +7,9 @@ use futures_util::StreamExt; use zbus::fdo::DBusProxy; use zbus::ConnectionBuilder; +use super::{ + owner_error_is_disconnected, wait_for_control_owner_with_probe, GetOwnerError, OwnerWait, +}; use crate::test_support::broker::read_broker_address; static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); @@ -161,3 +164,68 @@ fn broker_socket_is_scoped_to_a_unique_temporary_directory() { let _ = std::fs::remove_dir_all(first.parent().expect("temporary socket has a parent")); let _ = std::fs::remove_dir_all(second.parent().expect("temporary socket has a parent")); } + +#[test] +fn transient_initial_owner_probe_retries_without_an_owner_change_signal() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime"); + runtime.block_on(async { + let attempts = std::sync::Arc::new(AtomicUsize::new(0)); + let mut owner_changes = + futures_util::stream::pending::>>(); + let (event_tx, event_rx) = async_channel::bounded(4); + let (_command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let mut offline_commands = std::collections::VecDeque::new(); + let outcome = tokio::time::timeout( + Duration::from_millis(100), + wait_for_control_owner_with_probe( + { + let attempts = attempts.clone(); + move || { + let attempt = attempts.fetch_add(1, Ordering::SeqCst); + async move { + if attempt == 0 { + Err(GetOwnerError::Transient("injected timeout".to_string())) + } else { + Ok(":1.42".to_string()) + } + } + } + }, + &mut owner_changes, + &event_tx, + &mut command_rx, + &mut offline_commands, + 1, + ), + ) + .await + .expect("owner retry should finish without a signal"); + + assert_eq!(outcome, OwnerWait::Ready(":1.42".to_string())); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert!(matches!( + event_rx.try_recv(), + Ok(super::UiEvent::Disconnected) + )); + assert!(event_rx.try_recv().is_err()); + }); +} + +#[test] +fn owner_lookup_errors_distinguish_connection_loss_from_transient_failures() { + assert!(owner_error_is_disconnected(&zbus::fdo::Error::IOError( + "broken socket".to_string() + ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::NoServer( + "missing broker".to_string() + ))); + assert!(!owner_error_is_disconnected(&zbus::fdo::Error::Timeout( + "slow broker".to_string() + ))); + assert!(!owner_error_is_disconnected( + &zbus::fdo::Error::NameHasNoOwner("service absent".to_string()) + )); +} diff --git a/crates/unixnotis-daemon/src/child_process/tests/command.rs b/crates/unixnotis-daemon/src/child_process/tests/command.rs index cfc3b965f..ecfc71723 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/command.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/command.rs @@ -22,7 +22,7 @@ async fn mark_running_updates_popup_health_and_resets_center_readiness() { assert!(state.popups_process_running()); // Center process spawn is not readiness; readiness only flips after live subscriptions - state.set_panel_ready(true); + state.set_panel_ready(":1.20", true); UiProcessKind::Center.mark_running(&state, true); assert!(!state.panel_ready()); } diff --git a/crates/unixnotis-daemon/src/daemon/bus/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/clients.rs index 28acbb800..85544e997 100644 --- a/crates/unixnotis-daemon/src/daemon/bus/clients.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/clients.rs @@ -6,6 +6,8 @@ impl DaemonState { pub(in crate::daemon) async fn remove_disconnected_client(&self, owner: &str) { // Sender metadata is keyed by unique names and cannot survive owner loss self.sender_metadata_cache.remove(owner); + // Panel readiness follows the same unique-owner lease as popup readiness + self.set_panel_ready(owner, false); // Only the owner that published the active popup generation can clear it self.set_popups_ready(owner, false); diff --git a/crates/unixnotis-daemon/src/daemon/control/panel.rs b/crates/unixnotis-daemon/src/daemon/control/panel.rs index c4b920316..c28392aa5 100644 --- a/crates/unixnotis-daemon/src/daemon/control/panel.rs +++ b/crates/unixnotis-daemon/src/daemon/control/panel.rs @@ -34,8 +34,11 @@ impl ControlServer { ready: bool, ) -> zbus::fdo::Result<()> { self.authorize_panel_readiness_call(header, method).await?; + let owner = header + .sender() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; // Center reports ready only after it is subscribed to panel_requested - self.state.set_panel_ready(ready); + self.state.set_panel_ready(owner.as_str(), ready); Ok(()) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 6c41ebfc4..013539a78 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -113,6 +113,22 @@ async fn clear_saved_history_removes_archived_notifications() { .all(|view| view.id != id)); } +#[tokio::test] +async fn panel_command_availability_fails_after_ready_owner_disconnects() { + let state = daemon_state_for_test(false).await; + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + let server = ControlServer::new(state.clone()); + assert!(server.ensure_panel_available().is_ok()); + + state.remove_disconnected_client(":1.20").await; + + let error = server + .ensure_panel_available() + .expect_err("panel command should fail without a ready center owner"); + assert!(error.to_string().contains("unavailable")); +} + #[tokio::test] async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 4b3483414..075860e10 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -25,6 +25,8 @@ pub struct DaemonState { // Panel control should only succeed once the center has subscribed // This avoids accepting requests that no live listener can receive pub(in crate::daemon::state) panel_ready: AtomicBool, + // Unique owner prevents a delayed disconnect from clearing a newer center lease + pub(in crate::daemon::state) panel_ready_owner: StdMutex>, pub(in crate::daemon::state) center_process_running: AtomicBool, pub(in crate::daemon::state) popups_process_running: AtomicBool, pub(in crate::daemon::state) popups_ready: AtomicBool, @@ -90,6 +92,7 @@ impl DaemonState { sound, connection: connection.clone(), panel_ready: AtomicBool::new(false), + panel_ready_owner: StdMutex::new(None), center_process_running: AtomicBool::new(false), popups_process_running: AtomicBool::new(false), popups_ready: AtomicBool::new(false), diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 2a3e6ac87..27975938f 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -7,15 +7,35 @@ use crate::daemon::notifications::{notification_signal_mode_for_sender, Notifica use super::DaemonState; impl DaemonState { - pub(crate) fn set_panel_ready(&self, ready: bool) { - // SeqCst keeps state changes easy to follow during crash recovery - self.panel_ready.store(ready, Ordering::SeqCst); + pub(crate) fn set_panel_ready(&self, owner: &str, ready: bool) { + let mut current_owner = self + .panel_ready_owner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ready { + // The latest successful handshake owns the active readiness lease + *current_owner = Some(owner.to_string()); + self.panel_ready.store(true, Ordering::SeqCst); + } else if current_owner.as_deref() == Some(owner) { + // Only the matching center generation can clear its lease + *current_owner = None; + self.panel_ready.store(false, Ordering::SeqCst); + } + } + + fn clear_panel_ready(&self) { + let mut current_owner = self + .panel_ready_owner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *current_owner = None; + self.panel_ready.store(false, Ordering::SeqCst); } pub(crate) fn set_center_process_running(&self, running: bool) { self.center_process_running.store(running, Ordering::SeqCst); // Every process generation must complete its own subscription handshake - self.set_panel_ready(false); + self.clear_panel_ready(); } pub(crate) fn set_popups_process_running(&self, running: bool) { diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs index c2a6f83dc..ed4dbd87d 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -19,7 +19,7 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { // These atomics gate user-visible command handling, so getters must reflect writes exactly state.set_center_process_running(true); - state.set_panel_ready(true); + state.set_panel_ready(":1.20", true); state.set_popups_process_running(true); assert!(state.panel_ready()); @@ -37,10 +37,10 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { async fn daemon_state_boolean_flags_can_return_to_false() { let state = daemon_state_for_test(true).await; - state.set_panel_ready(true); + state.set_panel_ready(":1.20", true); state.set_center_process_running(true); state.set_popups_process_running(true); - state.set_panel_ready(false); + state.set_panel_ready(":1.20", false); state.set_center_process_running(false); state.set_popups_process_running(false); @@ -61,6 +61,33 @@ async fn popup_readiness_can_only_be_cleared_by_its_owner_generation() { assert!(!state.popups_ready()); } +#[tokio::test] +async fn panel_owner_loss_clears_readiness_and_panel_availability() { + let state = daemon_state_for_test(true).await; + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + assert!(state.panel_ready()); + + state.remove_disconnected_client(":1.20").await; + + assert!(!state.panel_ready()); +} + +#[tokio::test] +async fn delayed_old_panel_disconnect_keeps_new_owner_ready() { + let state = daemon_state_for_test(true).await; + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + state.set_panel_ready(":1.21", true); + + state.remove_disconnected_client(":1.20").await; + + assert!(state.panel_ready()); + assert!(state.ui_health().center_ready); + state.remove_disconnected_client(":1.21").await; + assert!(!state.panel_ready()); +} + #[tokio::test] async fn popup_owner_loss_clears_readiness_for_the_matching_generation() { let state = daemon_state_for_test(true).await; From 33c15fcdab5e0197fd9e4615d85816c923f008da Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:26:47 -0500 Subject: [PATCH 113/275] fix(theme): tolerate stock backup name collisions Summary: tolerate stock backup name collisions. Scope: theme. --- .../config/loading/io/tests/theme_stock.rs | 15 ++++-- .../src/config/loading/io/theme_stock.rs | 52 ++++++++++++++----- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs index 202c4012e..244316394 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs @@ -106,7 +106,7 @@ fn matching_existing_backup_allows_a_retried_migration() { } #[test] -fn conflicting_existing_backup_prevents_replacement() { +fn conflicting_existing_backup_uses_a_new_suffix_without_overwriting() { let root = test_root("stock-migration-backup-conflict"); fs::create_dir_all(&root).expect("theme root"); let target = root.join("panel.css"); @@ -114,14 +114,21 @@ fn conflicting_existing_backup_prevents_replacement() { fs::write(&target, OLD_STOCK).expect("legacy stock"); fs::write(&backup, b"custom backup").expect("conflicting stock backup"); - let result = migrate(&target, OLD_STOCK); + let migrated = migrate(&target, OLD_STOCK).expect("collision-safe migration"); + let mut fallback_name = backup.as_os_str().to_os_string(); + fallback_name.push(".1"); + let fallback = std::path::PathBuf::from(fallback_name); - assert!(result.is_err()); - assert_eq!(fs::read(&target).expect("legacy stock"), OLD_STOCK); + assert!(migrated); + assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); assert_eq!( fs::read(&backup).expect("conflicting stock backup"), b"custom backup" ); + assert_eq!( + fs::read(fallback).expect("fallback stock backup"), + OLD_STOCK + ); let _ = fs::remove_dir_all(root); } diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs index 45cc1cbc1..64ec91d20 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs @@ -12,6 +12,7 @@ use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; use super::{ConfigError, ThemePaths}; const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; +const MAX_BACKUP_COLLISION_RETRIES: u8 = 8; const LEGACY_BACKUP_TAG: &str = "unixnotis-stock-9ca42584"; const LEGACY_PANEL_DIGEST: &str = "bd2342e4ff91dab10dbdece082d1c58e9352b3b8167e046697dd921b6de4ceb3"; @@ -74,24 +75,51 @@ pub(super) fn migrate_stock_file_with_writer( } // The exact previous bytes are recoverable before the current stock file is published - let backup = stock_backup_path(path, backup_tag)?; - let backup_created = write_file_if_missing(&backup, &existing, 0o644) - .map_err(|error| migration_error(path, &error))?; - if !backup_created - && !regular_file_contents_equal(&backup, &existing, MAX_STOCK_THEME_BYTES) - .map_err(|error| migration_error(path, &error))? - { - return Err(ConfigError::ReadFailed(format!( - "refusing to replace stock theme because backup differs: {}", - backup.display() - ))); - } + let Some(_backup) = reserve_stock_backup(path, backup_tag, &existing)? else { + // A backup problem must retain the old theme without blocking daemon startup + return Ok(false); + }; // Atomic replacement keeps the prior complete file visible if publication is interrupted replace_file(path, current_stock).map_err(|error| migration_error(path, &error))?; Ok(true) } +fn reserve_stock_backup( + path: &Path, + backup_tag: &str, + existing: &[u8], +) -> Result, ConfigError> { + let base = stock_backup_path(path, backup_tag)?; + for suffix in 0..=MAX_BACKUP_COLLISION_RETRIES { + let candidate = backup_candidate(&base, suffix); + match write_file_if_missing(&candidate, existing, 0o644) { + Ok(true) => return Ok(Some(candidate)), + Ok(false) => { + // Identical content is already a complete valid backup + if regular_file_contents_equal(&candidate, existing, MAX_STOCK_THEME_BYTES) + .unwrap_or(false) + { + return Ok(Some(candidate)); + } + } + Err(_) => { + // Another suffix may still be usable after a single path collision or race + } + } + } + Ok(None) +} + +fn backup_candidate(base: &Path, suffix: u8) -> PathBuf { + if suffix == 0 { + return base.to_path_buf(); + } + let mut name = base.as_os_str().to_os_string(); + name.push(format!(".{suffix}")); + PathBuf::from(name) +} + pub(super) fn stock_backup_path(path: &Path, backup_tag: &str) -> Result { let file_name = path.file_name().ok_or_else(|| { ConfigError::ReadFailed(format!("theme path has no file name: {}", path.display())) From 903b4ed4568c9f2e0734e4f3cb5c72570a4435c9 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:31:06 -0500 Subject: [PATCH 114/275] feat(popups): rebuild the compact notification banner Summary: rebuild the compact notification banner. Scope: popups. --- .../src/output/tests/notifications.rs | 1 + .../src/control/tests/events.rs | 1 + .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 1 + .../src/ui/notifications/model/tests/item.rs | 1 + .../row/notification/tests/support.rs | 1 + .../src/ui/notifications/row/tests/group.rs | 1 + .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + crates/unixnotis-core/assets/popup.css | 60 ++++++++-------- .../unixnotis-core/src/config/layout/mod.rs | 3 + .../unixnotis-core/src/config/layout/popup.rs | 2 +- .../src/config/layout/tests/mod.rs | 1 + .../src/config/layout/tests/popup.rs | 8 +++ crates/unixnotis-core/src/css/tokens.rs | 4 +- .../unixnotis-core/src/model/notification.rs | 4 ++ .../src/dbus/runtime/tests/delivery.rs | 1 + crates/unixnotis-popups/src/ui/entry/build.rs | 59 ++++++++++++---- .../unixnotis-popups/src/ui/entry/labels.rs | 2 +- crates/unixnotis-popups/src/ui/icon_state.rs | 38 +++++++++-- .../src/ui/icons/tests/resolver/support.rs | 1 + .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/constructor.rs | 68 +++++++++++++++++++ .../src/ui/tests/icon_state.rs | 48 +++++++++++++ 24 files changed, 257 insertions(+), 53 deletions(-) create mode 100644 crates/unixnotis-core/src/config/layout/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/layout/tests/popup.rs diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index e3f48281c..fc69919ad 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -23,6 +23,7 @@ fn sample_notification() -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, // CLI formatting only needs the lightweight transport fields image: NotificationImage::default(), diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 8523be2e7..864b93f29 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -14,6 +14,7 @@ fn notification(id: u32) -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index f7cd92628..40417964f 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -81,6 +81,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage { image_path: path.to_string_lossy().into_owned(), diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index e68ea56d3..43329f6ea 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -24,6 +24,7 @@ fn notification_view( inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, image, } diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index dd4e71777..0274103f0 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -18,6 +18,7 @@ fn notification(id: u32) -> Rc { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), }) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 846d8a285..c816d2bbb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -29,6 +29,7 @@ pub(super) fn sample_notification() -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: Urgency::Normal as u8, + category: String::new(), is_transient: false, image: NotificationImage::default(), } diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 17302fafc..244ad656e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -25,6 +25,7 @@ fn notification(app_name: &str) -> Rc { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), }) diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 923fbe44a..c72518580 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -22,6 +22,7 @@ fn make_view(is_transient: bool) -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient, image: NotificationImage::default(), } @@ -43,6 +44,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient, image: NotificationImage::default(), } diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 8a33d1c0d..fd6617b53 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -70,6 +70,7 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), } diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 23cd8895d..e946a0ccd 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -15,9 +15,15 @@ min-height: var(--unixnotis-popup-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.70); + opacity: 0; transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; } +.unixnotis-popup-card:hover .unixnotis-popup-close, +.unixnotis-popup-close:focus { + opacity: 1; +} + .unixnotis-popup-close:hover { background: alpha(#fb7185, 0.16); border-color: alpha(#fb7185, 0.45); @@ -46,56 +52,56 @@ border-radius: var(--unixnotis-popup-card-radius); padding: 14px 16px; padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.09); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); + border: 1px solid alpha(#ffffff, 0.10); box-shadow: - 0 20px 40px -20px alpha(#000000, 0.9), - 0 0 26px -22px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.08); + 0 12px 32px -12px alpha(#000000, 0.56), + 0 2px 8px -4px alpha(#000000, 0.36); } .unixnotis-popup-header-row { - margin-bottom: 8px; - padding-bottom: 6px; - border-bottom: 1px solid alpha(#ffffff, 0.06); + margin-bottom: 2px; } .unixnotis-popup-header { - color: @unixnotis-accent; - font-weight: 800; - font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; + color: alpha(#ffffff, 0.76); + font-weight: 600; + font-size: 12px; } .unixnotis-popup-summary { font-weight: 700; - font-size: 13px; - margin-top: 4px; + font-size: 16px; + margin-top: 1px; } .unixnotis-popup-icon { - margin-right: 8px; - min-width: 18px; - min-height: 18px; + min-width: 44px; + min-height: 44px; } .unixnotis-popup-body { color: #cbd5e1; - font-weight: 500; - font-size: 12px; + font-weight: 400; + font-size: 14px; margin-top: 2px; } +.unixnotis-popup-source { + color: alpha(#fbbf24, 0.86); + font-size: 12px; +} + .unixnotis-popup-content-image { - min-width: 96px; - min-height: 96px; + min-width: 72px; + min-height: 72px; margin-top: 6px; border-radius: 10px; } +.unixnotis-popup-card.unverified { + border-color: alpha(#fbbf24, 0.45); +} + .unixnotis-popup-actions { margin-top: 8px; margin-top: var(--unixnotis-popup-actions-gap); @@ -154,7 +160,7 @@ box-shadow: 0 18px 38px -22px alpha(#000000, 0.92), 0 0 20px -16px alpha(@unixnotis-critical-border, 0.38), - inset 0 1px 0 alpha(#ffffff, 0.06); + inset 3px 0 @unixnotis-critical-border; } .unixnotis-popup-card.critical .unixnotis-popup-header { @@ -162,11 +168,7 @@ } .unixnotis-popup-card.critical .unixnotis-popup-icon { - background: alpha(@unixnotis-critical-border, 0.12); - border: 1px solid alpha(@unixnotis-critical-border, 0.30); - border-radius: 8px; color: @unixnotis-critical-icon; - padding: 4px; } .unixnotis-popup-card.critical .unixnotis-popup-summary { diff --git a/crates/unixnotis-core/src/config/layout/mod.rs b/crates/unixnotis-core/src/config/layout/mod.rs index 732cb3f74..db20ed3c2 100644 --- a/crates/unixnotis-core/src/config/layout/mod.rs +++ b/crates/unixnotis-core/src/config/layout/mod.rs @@ -3,6 +3,9 @@ mod common; mod popup; +#[cfg(test)] +mod tests; + pub use self::common::{ Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT, PANEL_RUNTIME_WIDTH_MIN, diff --git a/crates/unixnotis-core/src/config/layout/popup.rs b/crates/unixnotis-core/src/config/layout/popup.rs index 84411dbaf..dad934c90 100644 --- a/crates/unixnotis-core/src/config/layout/popup.rs +++ b/crates/unixnotis-core/src/config/layout/popup.rs @@ -25,7 +25,7 @@ impl Default for PopupConfig { margin: Margins::default(), width: 360, spacing: 12, - max_visible: 4, + max_visible: 3, default_timeout_ms: 5000, critical_timeout_ms: None, allow_click_through: false, diff --git a/crates/unixnotis-core/src/config/layout/tests/mod.rs b/crates/unixnotis-core/src/config/layout/tests/mod.rs new file mode 100644 index 000000000..8b2895849 --- /dev/null +++ b/crates/unixnotis-core/src/config/layout/tests/mod.rs @@ -0,0 +1 @@ +mod popup; diff --git a/crates/unixnotis-core/src/config/layout/tests/popup.rs b/crates/unixnotis-core/src/config/layout/tests/popup.rs new file mode 100644 index 000000000..fb2117ca7 --- /dev/null +++ b/crates/unixnotis-core/src/config/layout/tests/popup.rs @@ -0,0 +1,8 @@ +use super::super::PopupConfig; + +#[test] +fn popup_defaults_limit_the_visible_stack_to_three_notifications() { + let popup = PopupConfig::default(); + + assert_eq!(popup.max_visible, 3); +} diff --git a/crates/unixnotis-core/src/css/tokens.rs b/crates/unixnotis-core/src/css/tokens.rs index aa3125658..f4475af31 100644 --- a/crates/unixnotis-core/src/css/tokens.rs +++ b/crates/unixnotis-core/src/css/tokens.rs @@ -235,9 +235,9 @@ const fn layout_tokens() -> &'static [(&'static str, &'static str)] { ("--unixnotis-notification-action-padding-y", "4px"), ("--unixnotis-notification-action-padding-x", "10px"), ("--unixnotis-popup-stack-padding", "8px"), - ("--unixnotis-popup-card-radius", "20px"), + ("--unixnotis-popup-card-radius", "22px"), ("--unixnotis-popup-card-padding-y", "14px"), - ("--unixnotis-popup-card-padding-x", "16px"), + ("--unixnotis-popup-card-padding-x", "14px"), ("--unixnotis-popup-actions-gap", "6px"), ("--unixnotis-popup-close-size", "24px"), ("--unixnotis-popup-reveal-duration", "200ms"), diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 38698dd52..9a845ac9e 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -86,6 +86,7 @@ impl Notification { inline_reply: self.inline_reply.clone(), inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), + category: self.category.clone().unwrap_or_default(), // Center and popup policy both need the transient bit to stay in sync is_transient: self.is_transient, // UIs only need the text, actions, and image payload used for rendering @@ -108,6 +109,7 @@ impl Notification { inline_reply: self.inline_reply.clone(), inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), + category: self.category.clone().unwrap_or_default(), // History policy still depends on the transient bit in panel rows is_transient: self.is_transient, // List rows should avoid carrying raw image buffers across D-Bus @@ -322,6 +324,8 @@ pub struct NotificationView { pub inline_reply: InlineReply, pub inline_reply_policy: InlineReplyPolicy, pub urgency: u8, + // Category lets compact clients distinguish real media from decorative icon payloads + pub category: String, // Close handling needs this flag so history policy stays shared pub is_transient: bool, // Image metadata intended for UI usage diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs index 020de44fe..1e54b1841 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -16,6 +16,7 @@ fn candidate(generation: u64, should_show: bool) -> PopupCandidate { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), }, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 9d8285386..5d2639801 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -3,7 +3,7 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; -use unixnotis_core::{hooks, Action, NotificationView, Urgency}; +use unixnotis_core::{hooks, Action, AttributionClass, NotificationView, Urgency}; use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; @@ -78,6 +78,13 @@ impl UiState { // Critical rows keep the shared urgency class at the root root.add_css_class(hooks::shared_state::CRITICAL); } + if matches!( + notification.attribution.class, + AttributionClass::Unknown | AttributionClass::Conflict + ) { + // Unknown claims receive a visible semantic border instead of trusted branding + root.add_css_class("unverified"); + } let has_popup_actions = notification.actions.iter().any(popup_action_is_visible); // State classes make popup theming less dependent on child selector tricks set_class_state( @@ -92,8 +99,15 @@ impl UiState { ); set_class_state(&root, hooks::popup_card::HAS_ACTIONS, has_popup_actions); - // Header keeps icon, app name, and close in one stable row - let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); + // Main row keeps the large badge beside one compact text column + let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); + main.add_css_class("unixnotis-popup-main"); + let content = gtk::Box::new(gtk::Orientation::Vertical, 2); + content.set_hexpand(true); + content.add_css_class("unixnotis-popup-content"); + + // Header keeps app identity and close control on one compact line + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); header.add_css_class("unixnotis-popup-header-row"); if let Some(icon) = self.build_image_widget(notification) { // Icon presence is exposed as a state class for theme rules @@ -101,7 +115,7 @@ impl UiState { icon.set_valign(Align::Center); icon.set_halign(Align::Start); icon.add_css_class("unixnotis-popup-icon"); - header.append(&icon); + main.append(&icon); } else { // Missing icons also get a root class so themes can rebalance spacing set_class_state(&root, hooks::popup_card::NO_ICON, true); @@ -133,13 +147,22 @@ impl UiState { header.append(&build_popup_header_spacer()); header.append(&close); + // Source warnings remain visible instead of living only in a tooltip + let source = gtk::Label::new(None); + source.set_xalign(0.0); + source.set_wrap(true); + source.set_wrap_mode(WrapMode::WordChar); + source.set_lines(2); + source.add_css_class("unixnotis-popup-source"); + update_optional_label(&source, ¬ification.attribution.source_label, 96); + // Summary stays short and collapses when the payload has no title let summary = gtk::Label::new(Some(¬ification.summary)); summary.set_xalign(0.0); summary.set_wrap(true); summary.set_wrap_mode(WrapMode::WordChar); summary.set_ellipsize(EllipsizeMode::End); - summary.set_lines(3); + summary.set_lines(2); summary.set_max_width_chars(POPUP_SUMMARY_MAX_CHARS as i32); summary.add_css_class("unixnotis-popup-summary"); update_optional_label(&summary, ¬ification.summary, POPUP_SUMMARY_MAX_CHARS); @@ -150,23 +173,26 @@ impl UiState { body.set_wrap(true); body.set_wrap_mode(WrapMode::WordChar); body.set_ellipsize(EllipsizeMode::End); - body.set_lines(6); + body.set_lines(3); body.set_max_width_chars(POPUP_BODY_MAX_CHARS as i32); body.add_css_class("unixnotis-popup-body"); update_optional_label(&body, ¬ification.body, POPUP_BODY_MAX_CHARS); - // The root order is stable so CSS can assume header, summary, body, image, actions - root.append(&header); - root.append(&summary); - root.append(&body); + // Text rows stay beside the badge so compact cards do not grow around empty icon space + content.append(&header); + content.append(&source); + content.append(&summary); + content.append(&body); if let Some(image) = self.build_content_image_widget(notification) { // Caller content stays in the body and never becomes the application badge set_class_state(&root, hooks::popup_card::HAS_IMAGE, true); image.set_halign(Align::Start); image.add_css_class("unixnotis-popup-content-image"); - root.append(&image); + content.append(&image); } + main.append(&content); + root.append(&main); // Action buttons are only built when the payload exposes actions if has_popup_actions { @@ -247,8 +273,15 @@ impl UiState { // Revealers keep entry animations out of the popup list bookkeeping let revealer = gtk::Revealer::new(); revealer.add_css_class("unixnotis-popup-revealer"); - revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - revealer.set_transition_duration(200); + if self.config.panel.reduced_motion { + // Reduced motion keeps state changes immediate without hiding content + revealer.set_transition_type(gtk::RevealerTransitionType::None); + revealer.set_transition_duration(0); + } else { + // A short fade avoids geometry-heavy card animations + revealer.set_transition_type(gtk::RevealerTransitionType::Crossfade); + revealer.set_transition_duration(200); + } // The shared primitive clips the full styled popup instead of approximating the corners let plate = CutCorner::new(root, self.config.theme.notification_corners); revealer.set_child(Some(&plate)); diff --git a/crates/unixnotis-popups/src/ui/entry/labels.rs b/crates/unixnotis-popups/src/ui/entry/labels.rs index 45d934371..25cc1f698 100644 --- a/crates/unixnotis-popups/src/ui/entry/labels.rs +++ b/crates/unixnotis-popups/src/ui/entry/labels.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; use gtk::prelude::*; // Header/app title stays single-line and clipped at this length -pub(super) const POPUP_APP_MAX_CHARS: usize = 40; +pub(super) const POPUP_APP_MAX_CHARS: usize = 64; // Summary is visually dominant but still bounded to avoid tall cards pub(super) const POPUP_SUMMARY_MAX_CHARS: usize = 120; // Body keeps enough context while preventing oversized popup growth diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index 22cf8dfa7..6d0a33fa5 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -22,9 +22,10 @@ const ICON_CACHE_MAX_ENTRIES: usize = 256; // Skip caching decoded textures above this size to avoid holding large buffers const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1024 * 1024; // Popup icon size is fixed so rows stay visually consistent across icon sources -const POPUP_ICON_SIZE: i32 = 20; +const POPUP_APP_BADGE_SIZE: i32 = 44; // Content stays visibly separate from the daemon-associated application badge -const POPUP_CONTENT_IMAGE_SIZE: i32 = 96; +const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 72; +const DECORATIVE_SQUARE_IMAGE_MAX: i32 = 128; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); @@ -33,16 +34,19 @@ impl UiState { &self, notification: &NotificationView, ) -> Option { + if content_image_is_decorative(notification) { + return None; + } if let Some(texture) = image_data_texture(¬ification.image) { let widget = gtk::Image::from_paintable(Some(&texture)); - set_popup_icon_size(&widget, POPUP_CONTENT_IMAGE_SIZE); + set_popup_icon_size(&widget, POPUP_CONTENT_THUMBNAIL_SIZE); return Some(widget); } if notification.image.image_path.trim().is_empty() { return None; } - self.resolve_icon_widget(¬ification.image.image_path, POPUP_CONTENT_IMAGE_SIZE) + self.resolve_icon_widget(¬ification.image.image_path, POPUP_CONTENT_THUMBNAIL_SIZE) } pub(super) fn build_image_widget( @@ -57,7 +61,7 @@ impl UiState { ); if let Some(cached) = self.icon_cache.get(&cache_key) { if let Some(icon_name) = &cached.resolved { - return self.resolve_icon_widget(icon_name, POPUP_ICON_SIZE); + return self.resolve_icon_widget(icon_name, POPUP_APP_BADGE_SIZE); } if negative_cache_is_fresh(cached.cached_at, Instant::now()) { return None; @@ -75,7 +79,7 @@ impl UiState { if let Some(icon_names) = self.desktop_icons.icons_for(candidate) { for icon_name in icon_names { if let Some(widget) = - self.resolve_icon_widget(icon_name.as_str(), POPUP_ICON_SIZE) + self.resolve_icon_widget(icon_name.as_str(), POPUP_APP_BADGE_SIZE) { resolved = Some((icon_name, widget)); break; @@ -89,7 +93,7 @@ impl UiState { if resolved.is_none() { for candidate in candidates { - if let Some(widget) = self.resolve_icon_widget(&candidate, POPUP_ICON_SIZE) { + if let Some(widget) = self.resolve_icon_widget(&candidate, POPUP_APP_BADGE_SIZE) { resolved = Some((candidate, widget)); break; } @@ -206,6 +210,26 @@ impl UiState { } } +fn content_image_is_decorative(notification: &NotificationView) -> bool { + let category_is_media = notification.category.starts_with("image") + || notification.category.starts_with("media") + || notification.category.starts_with("photo"); + if category_is_media { + return false; + } + + let badge = notification.attribution.badge_icon.trim(); + let source_matches_badge = !badge.is_empty() + && (notification.image.icon_name.trim() == badge + || notification.image.image_path.trim() == badge); + let data = ¬ification.image.image_data; + let looks_like_small_square_icon = notification.image.has_image_data + && data.width > 0 + && data.width == data.height + && data.width <= DECORATIVE_SQUARE_IMAGE_MAX; + source_matches_badge || looks_like_small_square_icon +} + fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { now.saturating_duration_since(cached_at) < NEGATIVE_ICON_CACHE_TTL } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index abffa9334..e6af56476 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -16,6 +16,7 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage { icon_name: icon_name.to_string(), diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 20560489a..15e13ab2c 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -18,6 +18,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: urgency as u8, + category: String::new(), is_transient: false, image: NotificationImage::default(), } diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 0110d8982..91fa8ec18 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -56,6 +56,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, image: NotificationImage::default(), }; @@ -127,6 +128,7 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { inline_reply: unixnotis_core::InlineReply::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: Urgency::Critical as u8, + category: String::new(), is_transient: false, image: NotificationImage::default(), }; @@ -140,6 +142,55 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { )); } +#[gtk::test] +fn unknown_attribution_builds_a_visible_unverified_state() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupUnverifiedProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup unverified probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-unverified-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 3, + generation: 3, + app_name: "Signal".to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "Unverified application".to_string(), + source_label: "Claims to be Signal".to_string(), + class: unixnotis_core::AttributionClass::Unknown, + ..unixnotis_core::NotificationAttribution::default() + }, + summary: "John Doe".to_string(), + body: "Are you free later?".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: String::new(), + is_transient: false, + image: NotificationImage::default(), + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("unverified")); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "Claims to be Signal" + )); +} + fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { let mut child = widget.first_child(); while let Some(current) = child { @@ -153,3 +204,20 @@ fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool } false } + +fn visible_descendant_has_text(widget: >k::Widget, expected: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current + .downcast_ref::() + .is_some_and(|label| label.get_visible() && label.text() == expected) + { + return true; + } + if visible_descendant_has_text(¤t, expected) { + return true; + } + child = current.next_sibling(); + } + false +} diff --git a/crates/unixnotis-popups/src/ui/tests/icon_state.rs b/crates/unixnotis-popups/src/ui/tests/icon_state.rs index 877fb2188..a556930e0 100644 --- a/crates/unixnotis-popups/src/ui/tests/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/tests/icon_state.rs @@ -21,3 +21,51 @@ fn negative_icon_cache_handles_future_timestamp_without_panicking() { assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); } + +#[test] +fn small_square_image_data_is_suppressed_as_decorative_content() { + let mut notification = notification_with_image(); + notification.image.has_image_data = true; + notification.image.image_data.width = 96; + notification.image.image_data.height = 96; + + assert!(content_image_is_decorative(¬ification)); +} + +#[test] +fn media_category_keeps_a_small_square_content_thumbnail() { + let mut notification = notification_with_image(); + notification.category = "image.photo".to_string(); + notification.image.has_image_data = true; + notification.image.image_data.width = 96; + notification.image.image_data.height = 96; + + assert!(!content_image_is_decorative(¬ification)); +} + +#[test] +fn content_source_matching_badge_is_suppressed_without_image_data() { + let mut notification = notification_with_image(); + notification.attribution.badge_icon = "signal".to_string(); + notification.image.icon_name = "signal".to_string(); + + assert!(content_image_is_decorative(¬ification)); +} + +fn notification_with_image() -> unixnotis_core::NotificationView { + unixnotis_core::NotificationView { + id: 1, + generation: 1, + app_name: "Example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + image: unixnotis_core::NotificationImage::default(), + } +} From 6b3db7760d23ee480bb198c828e6393183f14cc7 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:34:12 -0500 Subject: [PATCH 115/275] fix(installer): terminate daemons through stable process handles Summary: terminate daemons through stable process handles. Scope: installer. --- .../unixnotis-installer/src/actions/daemon.rs | 102 +------ .../src/actions/daemon/process_handle.rs | 176 ++++++++++++ .../actions/daemon/tests/process_handle.rs | 65 +++++ .../src/actions/tests/daemon.rs | 258 +++--------------- 4 files changed, 293 insertions(+), 308 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/daemon/process_handle.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs diff --git a/crates/unixnotis-installer/src/actions/daemon.rs b/crates/unixnotis-installer/src/actions/daemon.rs index f98550445..304e030b4 100644 --- a/crates/unixnotis-installer/src/actions/daemon.rs +++ b/crates/unixnotis-installer/src/actions/daemon.rs @@ -1,14 +1,14 @@ //! Stop and verify the currently running notification daemon -use std::process::Stdio; -use std::thread; -use std::time::{Duration, Instant}; - use anyhow::{anyhow, Context, Result}; use super::{log_line, run_command, ActionContext}; use crate::system_tools; +mod process_handle; + +use process_handle::{ProcessHandle, ProcessState}; + pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { let Some(owner) = ctx.detection.owner.as_ref() else { log_line(ctx, "No active notification daemon detected."); @@ -80,34 +80,18 @@ pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { if let Some(pid) = owner_pid { log_line(ctx, format!("Stopping {} (pid {})", daemon.name, pid)); - // If the process is already gone, the stop goal is satisfied - if !pid_alive(pid)? { - log_line(ctx, format!("Process {pid} already stopped.")); - return Ok(()); - } - // Re-check the command name to avoid signaling a recycled PID - if !pid_matches_comm(pid, &daemon.name)? { - // Re-check liveness to treat a natural exit as success - if !pid_alive(pid)? { + // A stable process handle prevents a recycled PID from receiving the signal + let handle = match ProcessHandle::open(pid, &daemon.name)? { + ProcessState::Gone => { log_line(ctx, format!("Process {pid} already stopped.")); return Ok(()); } - return Err(anyhow!( - "pid {} no longer matches expected daemon {}; aborting stop", - pid, - daemon.name - )); - } - let status = system_tools::command("kill") - .context("failed to locate trusted kill")? - .args(["-TERM", &pid.to_string()]) - .status() - .context("failed to terminate notification daemon")?; - if status.success() { - wait_for_exit(ctx, pid, &daemon.name)?; - return Ok(()); - } - return Err(anyhow!("failed to stop {}", daemon.name)); + ProcessState::Running(handle) => handle, + }; + handle.terminate()?; + handle.wait_for_exit()?; + log_line(ctx, format!("Process {pid} stopped.")); + return Ok(()); } } @@ -144,66 +128,6 @@ fn unmanaged_owner_error( Err(anyhow!(message)) } -fn wait_for_exit(ctx: &mut ActionContext, pid: u32, expected_comm: &str) -> Result<()> { - let start = Instant::now(); - let timeout = Duration::from_secs(5); - let poll = Duration::from_millis(100); - - while start.elapsed() < timeout { - if !pid_alive(pid)? { - log_line(ctx, format!("Process {pid} stopped.")); - return Ok(()); - } - // PID reuse protection verifies the command name during the wait loop - if !pid_matches_comm(pid, expected_comm)? { - return Err(anyhow!( - "pid {pid} no longer matches expected daemon {expected_comm}; aborting wait" - )); - } - thread::sleep(poll); - } - - Err(anyhow!("process {pid} did not exit after 5s")) -} - -fn pid_alive(pid: u32) -> Result { - if pid == 0 || pid > i32::MAX as u32 { - return Ok(false); - } - - let status = system_tools::command("kill") - .context("failed to locate trusted kill")? - .args(["-0", &pid.to_string()]) - // Dead-PID probes are expected during waits, so keep kill diagnostics out of the TUI - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .with_context(|| format!("failed to probe pid {pid}"))?; - Ok(status.success()) -} - -fn pid_matches_comm(pid: u32, expected: &str) -> Result { - // Argv preserves daemon basenames longer than Linux's 15-byte comm field - if let Some(program) = crate::detect::read_cmdline_program(pid) { - return Ok(program == expected); - } - // Validate the process name with ps before sending signals to avoid PID reuse hazards - let output = system_tools::command("ps") - .context("failed to locate trusted ps")? - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output() - .with_context(|| format!("failed to read comm for pid {pid}"))?; - if !output.status.success() { - return Ok(false); - } - let comm = String::from_utf8_lossy(&output.stdout); - let comm = comm.trim(); - if comm.is_empty() { - return Ok(false); - } - Ok(comm == expected) -} - fn is_systemd_unit_inactive(unit: &str) -> Result { // A failed stop command is only recoverable when systemd agrees the unit is no longer running let output = system_tools::command("systemctl") diff --git a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs new file mode 100644 index 000000000..ad89fbf10 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs @@ -0,0 +1,176 @@ +//! Stable Linux process handles used while stopping an unmanaged daemon + +use std::fs; +use std::os::fd::OwnedFd; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; +use rustix::event::{poll, PollFd, PollFlags, Timespec}; +use rustix::process::{kill_process, pidfd_open, pidfd_send_signal, Pid, PidfdFlags, Signal}; + +const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5); +const FALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(super) enum ProcessState { + Gone, + Running(ProcessHandle), +} + +pub(super) struct ProcessHandle { + pid: Pid, + start_time: u64, + pidfd: Option, +} + +impl ProcessHandle { + pub(super) fn open(raw_pid: u32, expected_program: &str) -> Result { + let Some(pid) = process_id(raw_pid) else { + return Ok(ProcessState::Gone); + }; + + // pidfd keeps the process identity stable even if the numeric PID is later reused + let pidfd = match pidfd_open(pid, PidfdFlags::empty()) { + Ok(pidfd) => Some(pidfd), + Err(rustix::io::Errno::SRCH) => return Ok(ProcessState::Gone), + // Older Linux kernels need the start-time guarded fallback below + Err(rustix::io::Errno::NOSYS) => None, + Err(error) => { + return Err(anyhow!( + "failed to open stable handle for pid {raw_pid}: {error}" + )) + } + }; + + // Read lifetime evidence around program validation so fallback signaling fails closed + let Some(start_before) = read_process_start_time(raw_pid)? else { + return Ok(ProcessState::Gone); + }; + if !process_matches_program(raw_pid, expected_program) { + return Err(anyhow!( + "pid {raw_pid} no longer matches expected daemon {expected_program}; aborting stop" + )); + } + let Some(start_after) = read_process_start_time(raw_pid)? else { + return Ok(ProcessState::Gone); + }; + if start_before != start_after { + return Err(anyhow!( + "pid {raw_pid} changed while its identity was checked; aborting stop" + )); + } + + Ok(ProcessState::Running(Self { + pid, + start_time: start_before, + pidfd, + })) + } + + pub(super) fn terminate(&self) -> Result<()> { + if let Some(pidfd) = &self.pidfd { + // The signal targets the opened process object instead of a reusable number + return pidfd_send_signal(pidfd, Signal::TERM) + .context("failed to terminate notification daemon through pidfd"); + } + + // The fallback repeats the lifetime read immediately before the numeric signal + self.require_current_lifetime()?; + kill_process(self.pid, Signal::TERM) + .context("failed to terminate notification daemon through native signal") + } + + pub(super) fn wait_for_exit(&self) -> Result<()> { + if let Some(pidfd) = &self.pidfd { + return wait_for_pidfd(pidfd); + } + + let started = Instant::now(); + while started.elapsed() < PROCESS_EXIT_TIMEOUT { + match read_process_start_time(self.pid.as_raw_pid() as u32)? { + None => return Ok(()), + // A new lifetime means the original target exited and must not be inspected + Some(current) if current != self.start_time => return Ok(()), + Some(_) => thread::sleep(FALLBACK_POLL_INTERVAL), + } + } + + Err(anyhow!( + "process {} did not exit after 5s", + self.pid.as_raw_pid() + )) + } + + fn require_current_lifetime(&self) -> Result<()> { + let current = read_process_start_time(self.pid.as_raw_pid() as u32)?; + if current == Some(self.start_time) { + return Ok(()); + } + Err(anyhow!( + "pid {} changed before signaling; aborting stop", + self.pid.as_raw_pid() + )) + } +} + +fn wait_for_pidfd(pidfd: &OwnedFd) -> Result<()> { + let mut descriptors = [PollFd::new(pidfd, PollFlags::IN)]; + let timeout = Timespec { + tv_sec: PROCESS_EXIT_TIMEOUT.as_secs() as i64, + tv_nsec: 0, + }; + let ready = poll(&mut descriptors, Some(&timeout)) + .context("failed while waiting for notification daemon pidfd")?; + if ready > 0 && descriptors[0].revents().contains(PollFlags::IN) { + return Ok(()); + } + Err(anyhow!("process did not exit after 5s")) +} + +fn process_id(raw_pid: u32) -> Option { + let raw_pid = i32::try_from(raw_pid).ok()?; + Pid::from_raw(raw_pid) +} + +fn process_matches_program(pid: u32, expected: &str) -> bool { + // Argv preserves daemon basenames longer than Linux's 15-byte comm field + crate::detect::read_cmdline_program(pid) + .or_else(|| read_proc_comm(pid)) + .is_some_and(|program| program == expected) +} + +fn read_proc_comm(pid: u32) -> Option { + let contents = fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let comm = contents.trim(); + (!comm.is_empty()).then(|| comm.to_string()) +} + +fn read_process_start_time(pid: u32) -> Result> { + let path = format!("/proc/{pid}/stat"); + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| format!("failed to read process state from {path}")) + } + }; + parse_process_start_time(&contents) + .map(Some) + .ok_or_else(|| anyhow!("failed to parse process start time for pid {pid}")) +} + +fn parse_process_start_time(stat: &str) -> Option { + // The command field is parenthesized and may itself contain spaces + let command_end = stat.rfind(')')?; + let fields_after_command = stat.get(command_end + 2..)?; + // Field 3 begins here, placing the process start time at zero-based index 19 + fields_after_command + .split_whitespace() + .nth(19)? + .parse() + .ok() +} + +#[cfg(test)] +#[path = "tests/process_handle.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs new file mode 100644 index 000000000..7e754e03c --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs @@ -0,0 +1,65 @@ +use std::process::{Command, Stdio}; + +use super::*; + +#[test] +fn process_start_time_parser_handles_spaces_in_the_command_name() { + let stat = "42 (daemon with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; + + assert_eq!(parse_process_start_time(stat), Some(987_654)); +} + +#[test] +fn process_start_time_parser_rejects_missing_and_invalid_fields() { + assert!(parse_process_start_time("42 missing-parenthesis").is_none()); + assert!(parse_process_start_time("42 (daemon) S 1 2 3").is_none()); + + let invalid = "42 (daemon) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 invalid 20"; + assert!(parse_process_start_time(invalid).is_none()); +} + +#[test] +fn process_handle_rejects_a_mismatched_program_before_signaling() { + let error = match ProcessHandle::open(std::process::id(), "not-the-test-process") { + Ok(_) => panic!("mismatched program must fail closed"), + Err(error) => error, + }; + + assert!(error + .to_string() + .contains("no longer matches expected daemon")); +} + +#[test] +fn pidfd_signal_and_wait_stop_the_exact_child_process() { + let mut child = Command::new("sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep child"); + let pid = child.id(); + + let handle = match ProcessHandle::open(pid, "sleep").expect("open sleep process handle") { + ProcessState::Running(handle) => handle, + ProcessState::Gone => panic!("sleep child should still be running"), + }; + handle.terminate().expect("terminate exact sleep child"); + handle.wait_for_exit().expect("wait for exact sleep child"); + + let status = child.wait().expect("reap sleep child"); + assert!(!status.success()); +} + +#[test] +fn invalid_process_ids_are_treated_as_already_gone() { + assert!(matches!( + ProcessHandle::open(0, "daemon").expect("zero pid should be harmless"), + ProcessState::Gone + )); + assert!(matches!( + ProcessHandle::open(u32::MAX, "daemon").expect("oversized pid should be harmless"), + ProcessState::Gone + )); +} diff --git a/crates/unixnotis-installer/src/actions/tests/daemon.rs b/crates/unixnotis-installer/src/actions/tests/daemon.rs index d2c1474b0..46cf16d7c 100644 --- a/crates/unixnotis-installer/src/actions/tests/daemon.rs +++ b/crates/unixnotis-installer/src/actions/tests/daemon.rs @@ -1,3 +1,4 @@ +use std::process::{Command, Stdio}; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; @@ -10,8 +11,7 @@ use crate::service_manager::ServiceManager; use crate::test_support::fs::write_executable; use super::{ - is_systemd_unit_inactive, pid_alive, pid_matches_comm, stop_active_daemon, - systemd_stop_error_is_satisfied_by_state, wait_for_exit, + is_systemd_unit_inactive, stop_active_daemon, systemd_stop_error_is_satisfied_by_state, }; #[test] @@ -45,78 +45,36 @@ fn stop_active_daemon_errors_for_unmanaged_owner() { } #[test] -fn stop_active_daemon_uses_owner_command_match_before_pid_fallback() { - let root = fake_daemon_tool_root("owner-command-match"); - let state = root.join("kill-state"); - write_executable( - &root.join("kill"), - &format!( - "#!/bin/sh\nif [ \"$1\" = \"-0\" ]; then if [ -e {0} ]; then exit 1; fi; : > {0}; fi\nexit 0\n", - state.display() - ), - ); - write_executable(&root.join("ps"), "#!/bin/sh\nprintf 'mako\\n'\n"); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("matching owner command should stop daemon"); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn stop_active_daemon_skips_process_inspection_when_pid_is_already_gone() { - let root = fake_daemon_tool_root("already-gone"); - let ps_marker = root.join("ps-ran"); - write_executable(&root.join("kill"), "#!/bin/sh\nexit 1\n"); - write_executable( - &root.join("ps"), - &format!("#!/bin/sh\nprintf hit > {}\nexit 0\n", ps_marker.display()), - ); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("already stopped process should be accepted"); - - assert!(!ps_marker.exists()); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn stop_active_daemon_accepts_natural_exit_after_command_mismatch() { - let root = fake_daemon_tool_root("natural-exit"); - let state = root.join("kill-state"); - let ps_marker = root.join("ps-ran"); - write_executable( - &root.join("kill"), - &format!( - "#!/bin/sh\nif [ -e {0} ]; then exit 1; fi\n: > {0}\nexit 0\n", - state.display() - ), - ); - write_executable( - &root.join("ps"), - &format!( - "#!/bin/sh\nprintf hit > {}\nprintf 'different-daemon\\n'\n", - ps_marker.display() - ), - ); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); +fn stop_active_daemon_terminates_the_exact_non_systemd_owner() { + let mut child = Command::new("sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep daemon"); + let detection = Detection { + owner: Some(OwnerInfo { + pid: Some(child.id()), + comm: Some("sleep".to_string()), + }), + daemons: vec![DetectedDaemon { + name: "sleep".to_string(), + unit: "sleep.service".to_string(), + systemd_active: false, + systemd_error: None, + running_pids: vec![child.id()], + is_owner: true, + }], + }; let paths = test_install_paths(); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = action_context(&detection, &paths, tx); - stop_active_daemon(&mut ctx).expect("natural process exit should satisfy stop"); + stop_active_daemon(&mut ctx).expect("stable process stop should succeed"); - assert!(ps_marker.exists()); - let _ = std::fs::remove_dir_all(root); + let status = child.wait().expect("reap stopped sleep daemon"); + assert!(!status.success()); } #[test] @@ -209,136 +167,6 @@ fn is_systemd_unit_inactive_reads_trusted_systemctl_state() { let _ = std::fs::remove_dir_all(root); } -#[test] -fn pid_alive_reports_current_process_as_alive() { - let pid = std::process::id(); - - // The current test process should always satisfy a kill -0 probe - assert!(pid_alive(pid).expect("current pid probe")); -} - -#[test] -fn pid_alive_reports_impossible_pid_as_not_alive() { - let alive = pid_alive(u32::MAX).expect("invalid pid probe should still run"); - - // A non-existent PID must not be treated as safe to signal - assert!(!alive); -} - -#[test] -fn pid_alive_probes_largest_valid_process_id() { - let root = fake_daemon_tool_root("max-pid"); - write_executable(&root.join("kill"), "#!/bin/sh\nexit 0\n"); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - - assert!(pid_alive(i32::MAX as u32).expect("largest valid pid probe")); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn pid_alive_reports_zero_pid_as_not_alive() { - let alive = pid_alive(0).expect("zero pid probe should still run"); - - // PID 0 targets the caller's process group, not one daemon process - assert!(!alive); -} - -#[test] -fn pid_alive_ignores_kill_from_inherited_path() { - let _lock = crate::test_support::env::test_env_lock(); - let root = - std::env::temp_dir().join(format!("unixnotis-daemon-kill-path-{}", std::process::id())); - let path_bin = root.join("path-bin"); - let trusted_bin = root.join("trusted-bin"); - let marker = root.join("path-kill-ran"); - std::fs::create_dir_all(&path_bin).expect("path bin"); - std::fs::create_dir_all(&trusted_bin).expect("trusted bin"); - write_executable( - &path_bin.join("kill"), - &format!("#!/bin/sh\nprintf hit > {}\nexit 0\n", marker.display()), - ); - write_executable(&trusted_bin.join("kill"), "#!/bin/sh\nexit 0\n"); - let _path = EnvGuard::set("PATH", &path_bin); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&trusted_bin); - - assert!(pid_alive(std::process::id()).expect("trusted pid probe")); - assert!(!marker.exists()); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn pid_matches_comm_rejects_wrong_process_name() { - let pid = std::process::id(); - - let matches = pid_matches_comm(pid, "definitely-not-unixnotis").expect("comm probe"); - - // PID reuse protection depends on rejecting mismatched command names - assert!(!matches); -} - -#[test] -fn pid_matches_comm_accepts_current_process_argv_basename() { - let pid = std::process::id(); - let expected = crate::detect::read_cmdline_program(pid) - .expect("proc should expose the current process argv basename"); - - let matches = pid_matches_comm(pid, &expected).expect("comm probe"); - - // A matching argv basename is the only case where stop logic may signal the PID - assert!(matches); -} - -#[test] -fn wait_for_exit_aborts_immediately_when_pid_name_no_longer_matches() { - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - }; - let (tx, _rx) = mpsc::sync_channel::(4); - let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let err = wait_for_exit( - &mut ctx, - std::process::id(), - "definitely-not-current-process", - ) - .expect_err("mismatched comm should abort"); - - // The wait loop must fail before sleeping when PID reuse is detected - assert!(err - .to_string() - .contains("no longer matches expected daemon")); -} - -fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-daemon-{label}-{}-{stamp}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("fake daemon tool bin"); - root -} - fn known_daemon_detection(name: &str, systemd_active: bool, running_pids: Vec) -> Detection { Detection { owner: Some(OwnerInfo { @@ -380,24 +208,16 @@ fn action_context<'a>( } } -struct EnvGuard { - name: &'static str, - previous: Option, -} - -impl EnvGuard { - fn set(name: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(name); - std::env::set_var(name, value); - Self { name, previous } - } -} - -impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } +fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-daemon-{label}-{}-{stamp}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("fake daemon tool bin"); + root } From 3c07abdc8556f89256fe3cc01123bc095a6c9a91 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:39:06 -0500 Subject: [PATCH 116/275] fix(identity): require live exact launch contracts Summary: require live exact launch contracts. Scope: identity. --- .../identity/desktop_index/index.rs | 58 ++++---- .../identity/desktop_index/launch.rs | 6 +- .../identity/desktop_index/mod.rs | 3 +- .../identity/desktop_index/model.rs | 1 - .../identity/desktop_index/names.rs | 35 ----- .../identity/desktop_index/record.rs | 10 +- .../identity/desktop_index/tests/launch.rs | 4 +- .../identity/desktop_index/tests/mod.rs | 1 - .../identity/desktop_index/tests/names.rs | 26 ---- .../notifications/identity/executable.rs | 8 +- .../daemon/notifications/identity/resolver.rs | 48 +++---- .../daemon/notifications/identity/sender.rs | 29 ++++ .../identity/tests/executable.rs | 28 +++- .../notifications/identity/tests/resolver.rs | 133 +++++++++++++++--- .../notifications/identity/tests/sender.rs | 47 +++++++ 15 files changed, 282 insertions(+), 155 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index 91d1b620a..406294910 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -4,7 +4,7 @@ use std::path::Path; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::model::{DesktopIdentityIndex, DesktopRecord, ExecutableIdentity}; -use super::names::{is_shared_launcher, normalize_brand_name, normalize_desktop_id}; +use super::names::{normalize_brand_name, normalize_desktop_id}; impl DesktopIdentityIndex { pub(in crate::daemon::notifications::identity) fn records_for_id( @@ -33,28 +33,6 @@ impl DesktopIdentityIndex { .collect() } - pub(in crate::daemon::notifications::identity) fn requires_launch_arguments( - &self, - record: &DesktopRecord, - ) -> bool { - let Some(identity) = record.executable_identity else { - return true; - }; - let Some(path) = record.executable_path.as_deref() else { - return true; - }; - // Generic runtimes need their fixed payload because the binary is not the application - if is_shared_launcher(path) { - return true; - } - - let record_id = normalize_desktop_id(&record.id); - // One binary serving distinct desktop applications needs argv to select the right record - self.records_for_executable(identity) - .iter() - .any(|candidate| normalize_desktop_id(&candidate.id) != record_id) - } - pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( &self, claim: &str, @@ -85,11 +63,24 @@ impl DesktopIdentityIndex { pub(in crate::daemon::notifications::identity) fn trusted_portal_path( &self, - identity: FileIdentity, + sender_identity: FileIdentity, + sender_path: &Path, ) -> Option<&Path> { self.trusted_portals .iter() - .find(|portal| portal.identity.same_file(identity)) + .find(|portal| { + let Some(current) = executable_evidence_for_path(&portal.path) else { + return false; + }; + // Both the running path and installed path must remain under protected roots + trusted_system_executable_path(sender_path) + && trusted_system_executable_path(¤t.canonical_path) + && current.canonical_path == portal.path + && current.identity.same_file(portal.identity) + && current.identity.same_file(sender_identity) + && current.identity.is_system_managed() + && current.identity.is_executable_regular() + }) .map(|portal| portal.path.as_path()) } @@ -151,7 +142,7 @@ impl DesktopIdentityIndex { .entry(normalize_desktop_id(&record.id)) .or_default() .push(record_index); - // Generic launchers are presentation records but never executable evidence + // Only records with a reproducible launch contract become executable evidence if record.association_eligible { if let Some(identity) = record.executable_identity { self.by_identity @@ -163,3 +154,18 @@ impl DesktopIdentityIndex { self.records.push(record); } } + +fn trusted_system_executable_path(path: &Path) -> bool { + const ROOTS: [&str; 8] = [ + "/bin", + "/lib", + "/lib64", + "/usr/bin", + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ]; + + path.is_absolute() && ROOTS.iter().any(|root| path.starts_with(root)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs index ccd99d5cd..270305d4b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs @@ -27,7 +27,6 @@ pub(super) fn build_launch_spec( } let mut arguments = Vec::with_capacity(words.len().saturating_sub(1)); - let mut protected_literal_files = 0_usize; let mut literal_files_are_system_managed = true; for word in words.into_iter().skip(1) { let argument = match word.as_str() { @@ -47,9 +46,7 @@ pub(super) fn build_launch_spec( let literal = literal_argument(literal.into_bytes()); if let LaunchArgument::Literal(literal) = &literal { if let Some((_path, identity)) = &literal.file { - if identity.is_system_managed() { - protected_literal_files += 1; - } else { + if !identity.is_system_managed() { literal_files_are_system_managed = false; } } else if literal_path_candidate(&literal.value) { @@ -66,7 +63,6 @@ pub(super) fn build_launch_spec( Some(LaunchSpec { executable, arguments, - protected_literal_files, literal_files_are_system_managed, }) } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index 008cf9bf9..57fa97b14 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -21,7 +21,8 @@ pub(in crate::daemon::notifications::identity) fn record_launch_matches( cmdline: Option<&[Vec]>, ) -> bool { match &record.launch_spec { - None => true, + // Missing or unparsable Exec metadata cannot bind a process to an application + None => false, Some(spec) => cmdline.is_some_and(|cmdline| { launch::launch_spec_matches_sender(spec, sender_identity, cmdline) }), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index fc27ec5ca..788c776a1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -10,7 +10,6 @@ use super::names::normalize_name; pub(in crate::daemon::notifications::identity) struct LaunchSpec { pub(in crate::daemon::notifications::identity) executable: FileIdentity, pub(in crate::daemon::notifications::identity) arguments: Vec, - pub(in crate::daemon::notifications::identity) protected_literal_files: usize, pub(in crate::daemon::notifications::identity) literal_files_are_system_managed: bool, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs index bd64d7844..3a9e37057 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs @@ -1,42 +1,7 @@ //! Desktop identifiers, aliases, and protected brand normalization -use std::path::Path; - use unicode_security::skeleton; -pub(in crate::daemon::notifications::identity) fn is_shared_launcher(program: &Path) -> bool { - let Some(name) = program.file_name().and_then(|value| value.to_str()) else { - return true; - }; - let name = name.to_ascii_lowercase(); - matches!( - name.as_str(), - "sh" | "bash" - | "dash" - | "zsh" - | "fish" - | "env" - | "node" - | "nodejs" - | "java" - | "electron" - | "wine" - | "wine64" - | "flatpak" - | "gtk-launch" - | "perl" - | "ruby" - | "php" - | "lua" - | "deno" - | "bun" - ) || name.strip_prefix("python").is_some_and(|suffix| { - suffix - .chars() - .all(|character| character.is_ascii_digit() || character == '.') - }) -} - pub(in crate::daemon::notifications::identity) fn normalize_desktop_id(value: &str) -> String { // Desktop hints commonly include an optional suffix and mixed case value diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index 100abae1d..8ff2eee16 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -8,7 +8,7 @@ use gio::prelude::AppInfoExt; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::launch::build_launch_spec; use super::model::{DesktopIdentityIndex, DesktopRecord}; -use super::names::{is_shared_launcher, normalize_desktop_id, normalize_name}; +use super::names::{normalize_desktop_id, normalize_name}; use super::program::{desktop_executable, resolve_program}; impl DesktopIdentityIndex { @@ -36,12 +36,8 @@ impl DesktopIdentityIndex { let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); let launch_spec = executable_identity.and_then(|identity| build_launch_spec(&desktop, path, identity)); - let shared_launcher = desktop_program.as_deref().is_none_or(is_shared_launcher); - // Shared runtimes need one immutable application payload in addition to exact argv matching - let association_eligible = launch_spec.as_ref().is_some_and(|spec| { - !shared_launcher - || (spec.protected_literal_files != 0 && spec.literal_files_are_system_managed) - }); + // Every association needs a complete Exec contract instead of a runtime-name exception + let association_eligible = launch_spec.is_some(); // System association requires protected metadata and a reproducible launch specification let system_association = association_eligible && system_origin diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs index e0b40fa33..b451ffe71 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -10,7 +10,7 @@ use crate::daemon::notifications::identity::executable::executable_evidence_for_ use crate::test_support::TempRoot; #[test] -fn shared_launcher_requires_the_fixed_immutable_application_argument() { +fn fixed_immutable_application_argument_is_matched_exactly() { let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); let immutable_script = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); @@ -26,7 +26,6 @@ fn shared_launcher_requires_the_fixed_immutable_application_argument() { let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); let spec = build_launch_spec(&desktop, &path, shell.identity).expect("build launch spec"); - assert_eq!(spec.protected_literal_files, 1); assert!(launch_spec_matches_sender( &spec, shell.identity, @@ -201,7 +200,6 @@ fn process_matcher_checks_identity_emptiness_and_argument_limits_independently() let spec = LaunchSpec { executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], - protected_literal_files: 0, literal_files_are_system_managed: true, }; let exact_limit = diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs index b83f603fa..5ff4b3c5a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs @@ -1,3 +1,2 @@ -mod names; mod parsing; mod scan; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs deleted file mode 100644 index efde7b9b4..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/names.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::path::Path; - -use super::super::names::is_shared_launcher; - -#[test] -fn shared_launchers_are_never_application_specific_associations() { - for launcher in [ - "/bin/sh", - "/usr/bin/bash", - "/usr/bin/env", - "/usr/bin/python3", - "/usr/bin/python3.12", - "/usr/bin/node", - "/usr/bin/java", - "/usr/bin/electron", - "/usr/bin/wine", - "/usr/bin/flatpak", - "/usr/bin/gtk-launch", - ] { - assert!( - is_shared_launcher(Path::new(launcher)), - "{launcher} must not establish application identity" - ); - } - assert!(!is_shared_launcher(Path::new("/usr/bin/signal-desktop"))); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs index efe54ed9c..cde564f24 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs @@ -1,6 +1,7 @@ //! Stable executable identity captured from open file metadata use std::fs::{File, Metadata}; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; @@ -54,9 +55,14 @@ pub(in crate::daemon) fn executable_evidence_for_pid(pid: u32) -> Option(live_path)) .ok()?; Some(ExecutableEvidence { canonical_path, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index 8e2c1dbc9..e0e792814 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -12,7 +12,7 @@ use super::desktop_index::{ }; use super::executable::{executable_evidence_for_path, FileIdentity}; use super::policy::inline_reply_policy; -use super::sender::SenderMetadata; +use super::sender::{refresh_sender_security_evidence, SenderMetadata}; const MAX_DESKTOP_ID_BYTES: usize = 256; @@ -68,7 +68,9 @@ pub(in crate::daemon) async fn resolve_attribution( owned_desktop_ids.insert(normalize_desktop_id(&desktop_id)); } } - resolve_with_evidence(claim, sender, index, &owned_desktop_ids) + // Cached D-Bus metadata is refreshed before it can grant application authority + let sender = refresh_sender_security_evidence(sender); + resolve_with_evidence(claim, &sender, index, &owned_desktop_ids) } fn resolve_with_evidence( @@ -83,18 +85,14 @@ fn resolve_with_evidence( if let Some(desktop_id) = desktop_entry.as_deref() { let records = index.records_for_id(desktop_id); if !records.is_empty() { - if claim.reported_name.trim().is_empty() - && sender - .sender_executable_identity - .and_then(|identity| index.trusted_portal_path(identity)) - .is_some() + if claim.reported_name.trim().is_empty() && trusted_portal_path(sender, index).is_some() { // Portal backends forward a broker-verified app id as desktop-entry return resolution_for_portal_record(records[0], sender, index); } if let Some(record) = records .iter() - .find_map(|record| verify_record_sender(record, sender, index)) + .find_map(|record| verify_record_sender(record, sender)) { return resolution_for_record(record, claim.reported_name, sender, index); } @@ -114,14 +112,12 @@ fn resolve_with_evidence( if let Some(identity) = sender.sender_executable_identity { // Exact file association is stronger than every caller-controlled application name let records = index.records_for_executable(identity); - if let Some(record) = - verified_executable_record(&records, claim.reported_name, sender, index) - { + if let Some(record) = verified_executable_record(&records, claim.reported_name, sender) { return resolution_for_record(record, claim.reported_name, sender, index); } if records .iter() - .any(|record| record.system_association && record_matches_sender(record, sender, index)) + .any(|record| record.system_association && record_matches_sender(record, sender)) { // A known executable with a conflicting name must fail closed return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); @@ -172,7 +168,7 @@ fn resolution_for_portal_record( ) -> AttributionResolution { let portal = sender .sender_executable_identity - .and_then(|identity| index.trusted_portal_path(identity)) + .and_then(|_| trusted_portal_path(sender, index)) .map_or_else( || "desktop portal".to_string(), |path| path.display().to_string(), @@ -190,6 +186,15 @@ fn resolution_for_portal_record( policy_resolution(attribution) } +fn trusted_portal_path<'index>( + sender: &SenderMetadata, + index: &'index DesktopIdentityIndex, +) -> Option<&'index std::path::Path> { + let identity = sender.sender_executable_identity?; + let path = std::path::Path::new(sender.sender_executable.as_deref()?); + index.trusted_portal_path(identity, path) +} + fn resolution_for_record( verified: VerifiedDesktopRecord<'_>, reported_name: &str, @@ -273,12 +278,11 @@ fn verified_executable_record<'record>( records: &[&'record DesktopRecord], reported_name: &str, sender: &SenderMetadata, - index: &DesktopIdentityIndex, ) -> Option> { let missing_name = reported_name.trim().is_empty(); let mut matches = records.iter().filter_map(|record| { (missing_name || record.claim_matches(reported_name)) - .then(|| verify_record_sender(record, sender, index)) + .then(|| verify_record_sender(record, sender)) .flatten() }); let first = matches.next()?; @@ -298,11 +302,7 @@ fn verified_executable_record<'record>( Some(preferred) } -fn record_matches_sender( - record: &DesktopRecord, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, -) -> bool { +fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { if !record.association_eligible { return false; } @@ -333,9 +333,8 @@ fn record_matches_sender( } } - // Dedicated application binaries may add safe runtime flags after desktop activation - !index.requires_launch_arguments(record) - || record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) + // Exact argv matching prevents any executable from acting as an implicit shared launcher + record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) } const fn current_system_identity_matches_sender( @@ -351,10 +350,9 @@ const fn current_system_identity_matches_sender( fn verify_record_sender<'record>( record: &'record DesktopRecord, sender: &SenderMetadata, - index: &DesktopIdentityIndex, ) -> Option> { // This wrapper makes sender launch verification mandatory at every association call site - record_matches_sender(record, sender, index).then_some(VerifiedDesktopRecord(record)) + record_matches_sender(record, sender).then_some(VerifiedDesktopRecord(record)) } fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index 686956df5..9594b47af 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -107,6 +107,35 @@ pub(in crate::daemon) async fn resolve_sender_metadata( metadata } +pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> SenderMetadata { + let mut refreshed = metadata.clone(); + let (Some(pid), Some(expected_start)) = (metadata.sender_pid, metadata.sender_start_time) + else { + return refreshed; + }; + + // Refresh every process-derived field before a security-sensitive association decision + let start_before = read_process_start_time(pid); + let executable = executable_evidence_for_pid(pid); + let cmdline = read_process_cmdline(pid); + let start_after = read_process_start_time(pid); + if start_before != Some(expected_start) || start_after != Some(expected_start) { + // Stale cache entries retain bus context but lose all application identity authority + refreshed.sender_start_time = None; + refreshed.sender_executable = None; + refreshed.sender_executable_identity = None; + refreshed.sender_cmdline = None; + return refreshed; + } + + refreshed.sender_executable = executable + .as_ref() + .map(|evidence| evidence.canonical_path.display().to_string()); + refreshed.sender_executable_identity = executable.map(|evidence| evidence.identity); + refreshed.sender_cmdline = cmdline; + refreshed +} + #[cfg(target_os = "linux")] fn read_process_start_time(pid: u32) -> Option { // /proc//stat keeps the process lifetime tick count in field 22 diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs index 3ee8ce412..eef9adfe4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs @@ -1,4 +1,5 @@ -use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::process::{Command, Stdio}; use super::*; @@ -86,3 +87,28 @@ fn missing_executable_path_has_no_identity_evidence() { )) .is_none()); } + +#[test] +fn deleted_running_executable_has_no_trusted_identity_evidence() { + let root = crate::test_support::TempRoot::new("deleted-running-executable"); + let source = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find protected sleep executable"); + let executable = root.join("temporary-sleep"); + std::fs::copy(source, &executable).expect("copy sleep executable"); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)) + .expect("make copied executable runnable"); + let mut child = Command::new(&executable) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn copied executable"); + std::fs::remove_file(&executable).expect("unlink running executable"); + + let evidence = executable_evidence_for_pid(child.id()); + + child.kill().expect("stop copied executable"); + child.wait().expect("reap copied executable"); + assert!(evidence.is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 0c43d1436..aa225675c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -43,7 +43,11 @@ impl DesktopRecordFixture for DesktopRecord { system_association: system_entry, association_eligible: true, dbus_activatable, - launch_spec: None, + launch_spec: Some(LaunchSpec { + executable: identity, + arguments: Vec::new(), + literal_files_are_system_managed: true, + }), names: HashSet::from([normalize_name(display_name)]), } } @@ -63,7 +67,6 @@ impl DesktopRecordFixture for DesktopRecord { }) }) .collect(), - protected_literal_files: 1, literal_files_are_system_managed: true, }); self @@ -121,6 +124,7 @@ fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { sender_name: Some(":1.42".to_string()), sender_executable: Some(path.to_string()), sender_executable_identity: Some(identity), + sender_cmdline: Some(vec![path.as_bytes().to_vec()]), ..SenderMetadata::default() } } @@ -186,7 +190,7 @@ fn system_desktop_identity_allows_legitimate_signal_reply() { } #[test] -fn dedicated_system_binary_with_empty_claim_accepts_runtime_added_flags() { +fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { let (signal_path, signal_identity) = installed_system_executable(); let record = system_record("signal", "Signal", &signal_path, signal_identity) .with_launch_literals(&["--", "sgnl://expected"]); @@ -207,12 +211,11 @@ fn dedicated_system_binary_with_empty_claim_accepts_runtime_added_flags() { &HashSet::new(), ); - assert_eq!( + assert_ne!( resolution.attribution.class, AttributionClass::SystemAssociated ); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] @@ -239,11 +242,7 @@ fn verified_executable_recovers_from_stale_desktop_hint() { // Electron derives this hint from a differently named local desktop file desktop_entry: Some("signal-desktop"), }, - &sender_with_arguments( - &signal_path, - signal_identity, - &["--password-store=kwallet6", "--"], - ), + &sender(&signal_path, signal_identity), &index, &HashSet::new(), ); @@ -299,9 +298,8 @@ fn duplicate_desktop_id_prefers_the_protected_record() { let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); let records = index.records_for_executable(app_identity); - let verified = - verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) - .expect("duplicate desktop id should keep one verified record"); + let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) + .expect("duplicate desktop id should keep one verified record"); assert!(verified.0.system_association); assert_eq!(verified.0.badge_icon, "protected-signal"); @@ -317,9 +315,8 @@ fn duplicate_protected_desktop_id_keeps_stable_index_order() { let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); let records = index.records_for_executable(app_identity); - let verified = - verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) - .expect("duplicate protected records should keep one verified record"); + let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) + .expect("duplicate protected records should keep one verified record"); assert_eq!(verified.0.badge_icon, "first-signal"); } @@ -563,6 +560,69 @@ fn matching_fixed_system_application_argument_allows_association() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } +#[test] +fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.CustomRuntime", + "Custom Runtime App", + &launcher_path, + launcher_identity, + ) + .with_launch_literals(&["/usr/share/custom-runtime/application.bin"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Custom Runtime App", + desktop_entry: Some("org.example.CustomRuntime"), + }, + &sender_with_arguments( + &launcher_path, + launcher_identity, + &["/tmp/attacker-controlled.bin"], + ), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn unavailable_process_command_line_fails_closed() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.CommandLine", + "Command Line App", + &launcher_path, + launcher_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut missing_command_line = sender(&launcher_path, launcher_identity); + missing_command_line.sender_cmdline = None; + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Command Line App", + desktop_entry: Some("org.example.CommandLine"), + }, + &missing_command_line, + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + #[test] fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { for (serial, executable, expected, actual) in [ @@ -759,7 +819,7 @@ fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { #[test] fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { let flatpak_identity = identity(22, 220, 0); - let portal_identity = identity(23, 230, 0); + let (portal_path, portal_identity) = installed_system_executable(); let mut record = system_record( "org.example.FlatpakApp", "Flatpak App", @@ -768,10 +828,8 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { ); record.association_eligible = false; record.system_association = false; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()).with_trusted_portal( - PathBuf::from("/usr/lib/xdg-desktop-portal-gtk"), - portal_identity, - ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); let resolution = resolve_with_evidence( AppClaim { @@ -779,7 +837,7 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { reported_name: "", desktop_entry: Some("org.example.FlatpakApp"), }, - &sender("/usr/lib/xdg-desktop-portal-gtk", portal_identity), + &sender(&portal_path, portal_identity), &index, &HashSet::new(), ); @@ -792,6 +850,35 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } +#[test] +fn trusted_portal_rejects_a_stale_indexed_inode() { + let (portal_path, live_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }; + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), stale_identity); + + assert!(index + .trusted_portal_path(live_identity, std::path::Path::new(&portal_path)) + .is_none()); +} + +#[test] +fn trusted_portal_rejects_a_live_path_outside_protected_roots() { + let (portal_path, portal_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + assert!(index + .trusted_portal_path( + portal_identity, + std::path::Path::new("/tmp/xdg-desktop-portal") + ) + .is_none()); +} + #[test] fn user_shadow_cannot_join_the_system_desktop_group() { let system_identity = identity(30, 300, 0); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 0fd7111bc..02190a094 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -66,3 +66,50 @@ fn stable_process_evidence_discards_pid_reuse_or_missing_observations() { (None, None) ); } + +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_reloads_current_process_evidence() { + let pid = std::process::id(); + let start_time = read_process_start_time(pid).expect("current process start time"); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(start_time), + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert_eq!(refreshed.sender_start_time, Some(start_time)); + assert!(refreshed.sender_executable_identity.is_some()); + assert!(refreshed.sender_cmdline.is_some()); +} + +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { + let pid = std::process::id(); + let stale_start = read_process_start_time(pid) + .expect("current process start time") + .saturating_add(1); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(stale_start), + sender_executable: Some("/usr/bin/trusted-app".to_string()), + sender_executable_identity: Some(FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }), + sender_cmdline: Some(vec![b"/usr/bin/trusted-app".to_vec()]), + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert!(refreshed.sender_start_time.is_none()); + assert!(refreshed.sender_executable.is_none()); + assert!(refreshed.sender_executable_identity.is_none()); + assert!(refreshed.sender_cmdline.is_none()); +} From 980c89762c75c5092e5c6a368b160f73810c93ec Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:51:13 -0500 Subject: [PATCH 117/275] test(identity): split resolver coverage by trust path Summary: split resolver coverage by trust path. Scope: identity. --- .../notifications/identity/tests/resolver.rs | 1000 +---------------- .../identity/tests/resolver/association.rs | 270 +++++ .../identity/tests/resolver/portal.rs | 125 +++ .../identity/tests/resolver/runtime.rs | 334 ++++++ .../identity/tests/resolver/spoof.rs | 268 +++++ 5 files changed, 1005 insertions(+), 992 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index aa225675c..8f67b16ec 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -153,995 +153,11 @@ fn installed_system_executable() -> (String, FileIdentity) { (path.display().to_string(), evidence.identity) } -#[test] -fn system_desktop_identity_allows_legitimate_signal_reply() { - let (signal_path, signal_identity) = installed_system_executable(); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.signal.Signal", - "Signal", - &signal_path, - signal_identity, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - desktop_entry: Some("org.signal.Signal.desktop"), - }, - &sender(&signal_path, signal_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!( - resolution.attribution.group_key, - "system-desktop:org.signal.Signal" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); - assert!(!resolution.attribution.source_label.contains("unverified")); -} - -#[test] -fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { - let (signal_path, signal_identity) = installed_system_executable(); - let record = system_record("signal", "Signal", &signal_path, signal_identity) - .with_launch_literals(&["--", "sgnl://expected"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - // Signal sends an empty app name and adds Electron flags after desktop activation - reported_name: "", - desktop_entry: None, - }, - &sender_with_arguments( - &signal_path, - signal_identity, - &["--password-store=kwallet6", "--ozone-platform=x11", "--"], - ), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn verified_executable_recovers_from_stale_desktop_hint() { - let (signal_path, signal_identity) = installed_system_executable(); - let mut stale_user_entry = DesktopRecord::fixture( - "signal-desktop", - "Signal", - "/usr/bin/env", - identity(90, 900, 0), - false, - false, - ); - // An env wrapper cannot associate the user entry with the dedicated Signal process - stale_user_entry.association_eligible = false; - stale_user_entry.system_association = false; - let system_entry = system_record("signal", "Signal", &signal_path, signal_identity); - let index = - DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - // Electron derives this hint from a differently named local desktop file - desktop_entry: Some("signal-desktop"), - }, - &sender(&signal_path, signal_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!(resolution.attribution.desktop_id, "signal"); -} - -#[test] -fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { - let (runtime_path, runtime_identity) = installed_system_executable(); - let first = system_record( - "org.example.First", - "First App", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["--app-id=first"]); - let second = system_record( - "org.example.Second", - "Second App", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["--app-id=second"]); - let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "", - desktop_entry: None, - }, - &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn duplicate_desktop_id_prefers_the_protected_record() { - let (app_path, app_identity) = installed_system_executable(); - let user_record = - DesktopRecord::fixture("signal", "Signal", &app_path, app_identity, false, false); - let mut system_record = system_record("signal", "Signal", &app_path, app_identity); - system_record.badge_icon = "protected-signal".to_string(); - let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); - let records = index.records_for_executable(app_identity); - - let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) - .expect("duplicate desktop id should keep one verified record"); - - assert!(verified.0.system_association); - assert_eq!(verified.0.badge_icon, "protected-signal"); -} - -#[test] -fn duplicate_protected_desktop_id_keeps_stable_index_order() { - let (app_path, app_identity) = installed_system_executable(); - let mut first = system_record("signal", "Signal", &app_path, app_identity); - first.badge_icon = "first-signal".to_string(); - let mut second = system_record("signal", "Signal", &app_path, app_identity); - second.badge_icon = "second-signal".to_string(); - let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); - let records = index.records_for_executable(app_identity); - - let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) - .expect("duplicate protected records should keep one verified record"); - - assert_eq!(verified.0.badge_icon, "first-signal"); -} - -#[test] -fn reopened_system_identity_must_remain_protected_and_executable() { - let (_, trusted) = installed_system_executable(); - let unprotected = FileIdentity { - uid: 1_000, - ..trusted - }; - let non_executable = FileIdentity { - mode: 0o100_644, - ..trusted - }; - - assert!(current_system_identity_matches_sender(trusted, trusted)); - assert!(!current_system_identity_matches_sender( - unprotected, - trusted - )); - assert!(!current_system_identity_matches_sender( - non_executable, - trusted - )); -} - -#[test] -fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { - let (system_path, cached_identity) = installed_system_executable(); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.example.Protected", - "Protected App", - &system_path, - cached_identity, - )], - Vec::new(), - ); - let untrusted_identities = [ - FileIdentity { - uid: 1_000, - ..cached_identity - }, - FileIdentity { - mode: 0o100_777, - ..cached_identity - }, - ]; - - for desktop_entry in [Some("org.example.Protected"), None] { - for sender_identity in untrusted_identities { - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Protected App", - desktop_entry, - }, - &sender(&system_path, sender_identity), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, - "stale system identity accepted for hint {desktop_entry:?}" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - } - } -} - -#[test] -fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { - let app_identity = identity(6, 60, 1000); - let index = DesktopIdentityIndex::from_records( - vec![DesktopRecord::fixture( - "org.example.LocalApp", - "Local App", - "/home/user/bin/local-app", - app_identity, - false, - false, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Local App", - desktop_entry: Some("org.example.LocalApp"), - }, - &sender("/home/user/bin/local-app", app_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::UserAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(!resolution.attribution.has_warning()); -} - -#[test] -fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { - let python_identity = identity(20, 200, 0); - let mut record = system_record( - "org.example.PasswordManager", - "Example Password Manager", - "/usr/bin/python3", - python_identity, - ); - record.association_eligible = false; - record.system_association = false; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example Password Manager", - desktop_entry: Some("org.example.PasswordManager"), - }, - &sender("/usr/bin/python3", python_identity), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn unlisted_runtimes_cannot_associate_a_different_application_payload() { - for (serial, executable, expected, actual) in [ - ( - 1_u64, - "/usr/bin/pypy3", - "/usr/share/app/main.py", - "/tmp/fake.py", - ), - (2, "/usr/bin/gjs", "/usr/share/app/main.js", "/tmp/fake.js"), - ( - 3, - "/usr/bin/dotnet", - "/usr/share/app/Example.dll", - "/tmp/Fake.dll", - ), - ] { - let runtime_identity = identity(50, 500 + serial, 0); - let record = system_record( - "org.example.RuntimeApp", - "Runtime App", - executable, - runtime_identity, - ) - .with_launch_literals(&[expected]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Runtime App", - desktop_entry: Some("org.example.RuntimeApp"), - }, - &sender_with_arguments(executable, runtime_identity, &[actual]), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, - "{executable} accepted a different application payload" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - } -} - -#[test] -fn java_cannot_associate_a_different_jar() { - let java_identity = identity(51, 510, 0); - let record = system_record( - "org.example.JavaApp", - "Java App", - "/usr/bin/java", - java_identity, - ) - .with_launch_literals(&["-jar", "/usr/share/java/example.jar"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Java App", - desktop_entry: Some("org.example.JavaApp"), - }, - &sender_with_arguments("/usr/bin/java", java_identity, &["-jar", "/tmp/fake.jar"]), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn matching_fixed_system_application_argument_allows_association() { - let (runtime_path, runtime_identity) = installed_system_executable(); - let record = system_record( - "org.example.ScriptApp", - "Script App", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["/usr/share/script-app/main.py"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Script App", - desktop_entry: Some("org.example.ScriptApp"), - }, - &sender_with_arguments( - &runtime_path, - runtime_identity, - &["/usr/share/script-app/main.py"], - ), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); -} - -#[test] -fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { - let (launcher_path, launcher_identity) = installed_system_executable(); - let record = system_record( - "org.example.CustomRuntime", - "Custom Runtime App", - &launcher_path, - launcher_identity, - ) - .with_launch_literals(&["/usr/share/custom-runtime/application.bin"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Custom Runtime App", - desktop_entry: Some("org.example.CustomRuntime"), - }, - &sender_with_arguments( - &launcher_path, - launcher_identity, - &["/tmp/attacker-controlled.bin"], - ), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn unavailable_process_command_line_fails_closed() { - let (launcher_path, launcher_identity) = installed_system_executable(); - let record = system_record( - "org.example.CommandLine", - "Command Line App", - &launcher_path, - launcher_identity, - ); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - let mut missing_command_line = sender(&launcher_path, launcher_identity); - missing_command_line.sender_cmdline = None; - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Command Line App", - desktop_entry: Some("org.example.CommandLine"), - }, - &missing_command_line, - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { - for (serial, executable, expected, actual) in [ - ( - 1_u64, - "/usr/bin/python3", - "/usr/share/password-manager/main.py", - "/tmp/fake.py", - ), - ( - 2, - "/usr/bin/pypy3", - "/usr/share/password-manager/main.py", - "/tmp/fake.py", - ), - ( - 3, - "/usr/bin/gjs", - "/usr/share/password-manager/main.js", - "/tmp/fake.js", - ), - ( - 4, - "/usr/bin/dotnet", - "/usr/share/password-manager/PasswordManager.dll", - "/tmp/Fake.dll", - ), - ( - 5, - "/usr/bin/java", - "/usr/share/password-manager/password-manager.jar", - "/tmp/fake.jar", - ), - ] { - let runtime_identity = identity(60, 600 + serial, 0); - let fixed_arguments = if executable == "/usr/bin/java" { - vec!["-jar", expected] - } else { - vec![expected] - }; - let sender_arguments = if executable == "/usr/bin/java" { - vec!["-jar", actual] - } else { - vec![actual] - }; - let record = system_record( - "org.example.PasswordManager", - "Example Password Manager", - executable, - runtime_identity, - ) - .with_launch_literals(&fixed_arguments); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example Password Manager", - desktop_entry: None, - }, - &sender_with_arguments(executable, runtime_identity, &sender_arguments), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, - "{executable} accepted a different no-hint application payload" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - } -} - -#[test] -fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { - let (runtime_path, runtime_identity) = installed_system_executable(); - let record = system_record( - "org.example.PasswordManager", - "Example Password Manager", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["/usr/share/password-manager/main.py"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example Password Manager", - desktop_entry: None, - }, - &sender_with_arguments( - &runtime_path, - runtime_identity, - &["/usr/share/password-manager/main.py"], - ), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); -} - -#[test] -fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { - let runtime_identity = identity(62, 620, 0); - let record = system_record( - "org.example.PasswordManager", - "Example Password Manager", - "/usr/bin/python3", - runtime_identity, - ) - .with_launch_literals(&["/usr/share/password-manager/main.py"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Unrelated Local Script", - desktop_entry: None, - }, - &sender_with_arguments("/usr/bin/python3", runtime_identity, &["/tmp/local.py"]), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn unmediated_flatpak_process_cannot_become_portal_associated() { - let flatpak_identity = identity(21, 210, 0); - let mut record = system_record( - "org.example.FlatpakApp", - "Flatpak App", - "/usr/bin/flatpak", - flatpak_identity, - ); - record.association_eligible = false; - record.system_association = false; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Flatpak App", - desktop_entry: Some("org.example.FlatpakApp"), - }, - &sender("/usr/bin/flatpak", flatpak_identity), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { - let flatpak_identity = identity(24, 240, 0); - let relay_identity = identity(25, 250, 0); - let mut record = system_record( - "org.example.FlatpakApp", - "Flatpak App", - "/usr/bin/flatpak", - flatpak_identity, - ); - record.association_eligible = false; - record.system_association = false; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "", - desktop_entry: Some("org.example.FlatpakApp"), - }, - &sender("/usr/lib/untrusted-relay", relay_identity), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { - let flatpak_identity = identity(22, 220, 0); - let (portal_path, portal_identity) = installed_system_executable(); - let mut record = system_record( - "org.example.FlatpakApp", - "Flatpak App", - "/usr/bin/flatpak", - flatpak_identity, - ); - record.association_eligible = false; - record.system_association = false; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) - .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); - - let resolution = resolve_with_evidence( - AppClaim { - // The GTK portal backend forwards an empty app name and verified desktop-entry hint - reported_name: "", - desktop_entry: Some("org.example.FlatpakApp"), - }, - &sender(&portal_path, portal_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); - assert_eq!(resolution.attribution.display_name, "Flatpak App"); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); -} - -#[test] -fn trusted_portal_rejects_a_stale_indexed_inode() { - let (portal_path, live_identity) = installed_system_executable(); - let stale_identity = FileIdentity { - inode: live_identity.inode.saturating_add(1), - ..live_identity - }; - let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) - .with_trusted_portal(PathBuf::from(&portal_path), stale_identity); - - assert!(index - .trusted_portal_path(live_identity, std::path::Path::new(&portal_path)) - .is_none()); -} - -#[test] -fn trusted_portal_rejects_a_live_path_outside_protected_roots() { - let (portal_path, portal_identity) = installed_system_executable(); - let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) - .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); - - assert!(index - .trusted_portal_path( - portal_identity, - std::path::Path::new("/tmp/xdg-desktop-portal") - ) - .is_none()); -} - -#[test] -fn user_shadow_cannot_join_the_system_desktop_group() { - let system_identity = identity(30, 300, 0); - let user_identity = identity(31, 310, 1000); - let system = system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - system_identity, - ); - let mut user = DesktopRecord::fixture( - "org.signal.Signal", - "Signal", - "/home/user/bin/signal", - user_identity, - false, - false, - ); - user.desktop_identity = Some(identity(32, 320, 1000)); - let index = DesktopIdentityIndex::from_records(vec![user, system], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - desktop_entry: Some("org.signal.Signal"), - }, - &sender("/home/user/bin/signal", user_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::UserAssociated - ); - assert!(resolution.attribution.has_warning()); - assert!(resolution - .attribution - .group_key - .starts_with("user-desktop:")); - assert_ne!( - resolution.attribution.group_key, - "system-desktop:org.signal.Signal" - ); -} - -#[test] -fn visually_confusable_system_brand_is_a_conflict() { - let signal_identity = identity(40, 400, 0); - let hostile_identity = identity(41, 410, 1000); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, - )], - Vec::new(), - ); - - for claim in ["Sіgnal", "Signaⅼ"] { - let resolution = resolve_with_evidence( - AppClaim { - reported_name: claim, - desktop_entry: None, - }, - &sender("/tmp/fake", hostile_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - } -} - -#[test] -fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { - let signal_identity = identity(1, 10, 0); - let hostile_identity = identity(7, 70, 1000); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - desktop_entry: None, - }, - &sender("/tmp/signal-desktop", hostile_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert_ne!( - resolution.attribution.group_key, - "desktop:org.signal.Signal" - ); -} - -#[test] -fn exact_keepassxc_name_spoof_never_becomes_system_associated() { - let keepass_identity = identity(2, 20, 0); - let hostile_identity = identity(8, 80, 1000); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.keepassxc.KeePassXC", - "KeePassXC", - "/usr/bin/keepassxc", - keepass_identity, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "KeePassXC", - desktop_entry: None, - }, - &sender("/tmp/keepassxc", hostile_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn exact_system_notify_send_identity_is_a_non_replying_relay() { - let relay_identity = identity(3, 30, 0); - let index = DesktopIdentityIndex::from_records( - Vec::new(), - vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Screenshot", - desktop_entry: None, - }, - &sender("/usr/bin/notify-send", relay_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); - assert_eq!(resolution.attribution.display_name, "Screenshot"); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(!resolution.attribution.has_warning()); - assert!(!resolution.attribution.source_label.contains("unverified")); -} - -#[test] -fn trusted_relay_claiming_a_system_app_keeps_the_relay_class_and_adds_a_warning() { - let signal_identity = identity(1, 10, 0); - let relay_identity = identity(3, 30, 0); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, - )], - vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - desktop_entry: None, - }, - &sender("/usr/bin/notify-send", relay_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(resolution.attribution.has_warning()); - assert_ne!( - resolution.attribution.group_key, - "desktop:org.signal.Signal" - ); -} - -#[test] -fn malicious_notify_send_basename_is_not_a_trusted_relay() { - let real_relay = identity(3, 30, 0); - let hostile_identity = identity(9, 90, 1000); - let index = DesktopIdentityIndex::from_records( - Vec::new(), - vec![(PathBuf::from("/usr/bin/notify-send"), real_relay)], - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Screenshot", - desktop_entry: None, - }, - &sender("/tmp/notify-send", hostile_identity), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn owned_dbus_application_name_cannot_replace_executable_association() { - let app_identity = identity(4, 40, 0); - let mut record = DesktopRecord::fixture( - "org.example.App", - "Example App", - "/usr/bin/example-app", - app_identity, - true, - true, - ); - record.executable_identity = None; - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - let owned = HashSet::from(["org.example.app".to_string()]); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example App", - desktop_entry: Some("org.example.App"), - }, - &sender("/usr/lib/example-launcher", identity(5, 50, 0)), - &index, - &owned, - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(resolution - .attribution - .source_label - .contains("bus name ownership lacks executable association")); -} - -#[test] -fn desktop_id_validation_never_accepts_a_path_or_control_character() { - assert_eq!( - validate_desktop_id("org.signal.Signal.desktop").as_deref(), - Some("org.signal.Signal") - ); - assert_eq!(validate_desktop_id("../signal"), None); - assert_eq!(validate_desktop_id("org.example.\nApp"), None); - assert_eq!(validate_desktop_id("."), None); - assert_eq!(validate_desktop_id(".desktop"), None); - assert_eq!( - validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), - Some(256) - ); - assert_eq!(validate_desktop_id(&"a".repeat(257)), None); -} +#[path = "resolver/association.rs"] +mod association; +#[path = "resolver/portal.rs"] +mod portal; +#[path = "resolver/runtime.rs"] +mod runtime; +#[path = "resolver/spoof.rs"] +mod spoof; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs new file mode 100644 index 000000000..cea0a963e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs @@ -0,0 +1,270 @@ +use super::*; + +#[test] +fn system_desktop_identity_allows_legitimate_signal_reply() { + let (signal_path, signal_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + &signal_path, + signal_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: Some("org.signal.Signal.desktop"), + }, + &sender(&signal_path, signal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!( + resolution.attribution.group_key, + "system-desktop:org.signal.Signal" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert!(!resolution.attribution.source_label.contains("unverified")); +} + +#[test] +fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { + let (signal_path, signal_identity) = installed_system_executable(); + let record = system_record("signal", "Signal", &signal_path, signal_identity) + .with_launch_literals(&["--", "sgnl://expected"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + // Signal sends an empty app name and adds Electron flags after desktop activation + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments( + &signal_path, + signal_identity, + &["--password-store=kwallet6", "--ozone-platform=x11", "--"], + ), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn verified_executable_recovers_from_stale_desktop_hint() { + let (signal_path, signal_identity) = installed_system_executable(); + let mut stale_user_entry = DesktopRecord::fixture( + "signal-desktop", + "Signal", + "/usr/bin/env", + identity(90, 900, 0), + false, + false, + ); + // An env wrapper cannot associate the user entry with the dedicated Signal process + stale_user_entry.association_eligible = false; + stale_user_entry.system_association = false; + let system_entry = system_record("signal", "Signal", &signal_path, signal_identity); + let index = + DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + // Electron derives this hint from a differently named local desktop file + desktop_entry: Some("signal-desktop"), + }, + &sender(&signal_path, signal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!(resolution.attribution.desktop_id, "signal"); +} + +#[test] +fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let first = system_record( + "org.example.First", + "First App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=first"]); + let second = system_record( + "org.example.Second", + "Second App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=second"]); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn duplicate_desktop_id_prefers_the_protected_record() { + let (app_path, app_identity) = installed_system_executable(); + let user_record = + DesktopRecord::fixture("signal", "Signal", &app_path, app_identity, false, false); + let mut system_record = system_record("signal", "Signal", &app_path, app_identity); + system_record.badge_icon = "protected-signal".to_string(); + let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) + .expect("duplicate desktop id should keep one verified record"); + + assert!(verified.0.system_association); + assert_eq!(verified.0.badge_icon, "protected-signal"); +} + +#[test] +fn duplicate_protected_desktop_id_keeps_stable_index_order() { + let (app_path, app_identity) = installed_system_executable(); + let mut first = system_record("signal", "Signal", &app_path, app_identity); + first.badge_icon = "first-signal".to_string(); + let mut second = system_record("signal", "Signal", &app_path, app_identity); + second.badge_icon = "second-signal".to_string(); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) + .expect("duplicate protected records should keep one verified record"); + + assert_eq!(verified.0.badge_icon, "first-signal"); +} + +#[test] +fn reopened_system_identity_must_remain_protected_and_executable() { + let (_, trusted) = installed_system_executable(); + let unprotected = FileIdentity { + uid: 1_000, + ..trusted + }; + let non_executable = FileIdentity { + mode: 0o100_644, + ..trusted + }; + + assert!(current_system_identity_matches_sender(trusted, trusted)); + assert!(!current_system_identity_matches_sender( + unprotected, + trusted + )); + assert!(!current_system_identity_matches_sender( + non_executable, + trusted + )); +} + +#[test] +fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { + let (system_path, cached_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected App", + &system_path, + cached_identity, + )], + Vec::new(), + ); + let untrusted_identities = [ + FileIdentity { + uid: 1_000, + ..cached_identity + }, + FileIdentity { + mode: 0o100_777, + ..cached_identity + }, + ]; + + for desktop_entry in [Some("org.example.Protected"), None] { + for sender_identity in untrusted_identities { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry, + }, + &sender(&system_path, sender_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "stale system identity accepted for hint {desktop_entry:?}" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } + } +} + +#[test] +fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { + let app_identity = identity(6, 60, 1000); + let index = DesktopIdentityIndex::from_records( + vec![DesktopRecord::fixture( + "org.example.LocalApp", + "Local App", + "/home/user/bin/local-app", + app_identity, + false, + false, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.LocalApp"), + }, + &sender("/home/user/bin/local-app", app_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::UserAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(!resolution.attribution.has_warning()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs new file mode 100644 index 000000000..19c63cc9e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs @@ -0,0 +1,125 @@ +use super::*; + +#[test] +fn unmediated_flatpak_process_cannot_become_portal_associated() { + let flatpak_identity = identity(21, 210, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Flatpak App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/bin/flatpak", flatpak_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { + let flatpak_identity = identity(24, 240, 0); + let relay_identity = identity(25, 250, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/lib/untrusted-relay", relay_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { + let flatpak_identity = identity(22, 220, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + // The GTK portal backend forwards an empty app name and verified desktop-entry hint + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::PortalAssociated + ); + assert_eq!(resolution.attribution.display_name, "Flatpak App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn trusted_portal_rejects_a_stale_indexed_inode() { + let (portal_path, live_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }; + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), stale_identity); + + assert!(index + .trusted_portal_path(live_identity, std::path::Path::new(&portal_path)) + .is_none()); +} + +#[test] +fn trusted_portal_rejects_a_live_path_outside_protected_roots() { + let (portal_path, portal_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + assert!(index + .trusted_portal_path( + portal_identity, + std::path::Path::new("/tmp/xdg-desktop-portal") + ) + .is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs new file mode 100644 index 000000000..169f342d1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs @@ -0,0 +1,334 @@ +use super::*; + +#[test] +fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { + let python_identity = identity(20, 200, 0); + let mut record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + python_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: Some("org.example.PasswordManager"), + }, + &sender("/usr/bin/python3", python_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn unlisted_runtimes_cannot_associate_a_different_application_payload() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/pypy3", + "/usr/share/app/main.py", + "/tmp/fake.py", + ), + (2, "/usr/bin/gjs", "/usr/share/app/main.js", "/tmp/fake.js"), + ( + 3, + "/usr/bin/dotnet", + "/usr/share/app/Example.dll", + "/tmp/Fake.dll", + ), + ] { + let runtime_identity = identity(50, 500 + serial, 0); + let record = system_record( + "org.example.RuntimeApp", + "Runtime App", + executable, + runtime_identity, + ) + .with_launch_literals(&[expected]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Runtime App", + desktop_entry: Some("org.example.RuntimeApp"), + }, + &sender_with_arguments(executable, runtime_identity, &[actual]), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "{executable} accepted a different application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn java_cannot_associate_a_different_jar() { + let java_identity = identity(51, 510, 0); + let record = system_record( + "org.example.JavaApp", + "Java App", + "/usr/bin/java", + java_identity, + ) + .with_launch_literals(&["-jar", "/usr/share/java/example.jar"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Java App", + desktop_entry: Some("org.example.JavaApp"), + }, + &sender_with_arguments("/usr/bin/java", java_identity, &["-jar", "/tmp/fake.jar"]), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn matching_fixed_system_application_argument_allows_association() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let record = system_record( + "org.example.ScriptApp", + "Script App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["/usr/share/script-app/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Script App", + desktop_entry: Some("org.example.ScriptApp"), + }, + &sender_with_arguments( + &runtime_path, + runtime_identity, + &["/usr/share/script-app/main.py"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.CustomRuntime", + "Custom Runtime App", + &launcher_path, + launcher_identity, + ) + .with_launch_literals(&["/usr/share/custom-runtime/application.bin"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Custom Runtime App", + desktop_entry: Some("org.example.CustomRuntime"), + }, + &sender_with_arguments( + &launcher_path, + launcher_identity, + &["/tmp/attacker-controlled.bin"], + ), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn unavailable_process_command_line_fails_closed() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.CommandLine", + "Command Line App", + &launcher_path, + launcher_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut missing_command_line = sender(&launcher_path, launcher_identity); + missing_command_line.sender_cmdline = None; + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Command Line App", + desktop_entry: Some("org.example.CommandLine"), + }, + &missing_command_line, + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/python3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 2, + "/usr/bin/pypy3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 3, + "/usr/bin/gjs", + "/usr/share/password-manager/main.js", + "/tmp/fake.js", + ), + ( + 4, + "/usr/bin/dotnet", + "/usr/share/password-manager/PasswordManager.dll", + "/tmp/Fake.dll", + ), + ( + 5, + "/usr/bin/java", + "/usr/share/password-manager/password-manager.jar", + "/tmp/fake.jar", + ), + ] { + let runtime_identity = identity(60, 600 + serial, 0); + let fixed_arguments = if executable == "/usr/bin/java" { + vec!["-jar", expected] + } else { + vec![expected] + }; + let sender_arguments = if executable == "/usr/bin/java" { + vec!["-jar", actual] + } else { + vec![actual] + }; + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + executable, + runtime_identity, + ) + .with_launch_literals(&fixed_arguments); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments(executable, runtime_identity, &sender_arguments), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.class, + AttributionClass::SystemAssociated, + "{executable} accepted a different no-hint application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["/usr/share/password-manager/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments( + &runtime_path, + runtime_identity, + &["/usr/share/password-manager/main.py"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { + let runtime_identity = identity(62, 620, 0); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + runtime_identity, + ) + .with_launch_literals(&["/usr/share/password-manager/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Unrelated Local Script", + desktop_entry: None, + }, + &sender_with_arguments("/usr/bin/python3", runtime_identity, &["/tmp/local.py"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs new file mode 100644 index 000000000..3ea29db57 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs @@ -0,0 +1,268 @@ +use super::*; + +#[test] +fn user_shadow_cannot_join_the_system_desktop_group() { + let system_identity = identity(30, 300, 0); + let user_identity = identity(31, 310, 1000); + let system = system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + system_identity, + ); + let mut user = DesktopRecord::fixture( + "org.signal.Signal", + "Signal", + "/home/user/bin/signal", + user_identity, + false, + false, + ); + user.desktop_identity = Some(identity(32, 320, 1000)); + let index = DesktopIdentityIndex::from_records(vec![user, system], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: Some("org.signal.Signal"), + }, + &sender("/home/user/bin/signal", user_identity), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::UserAssociated + ); + assert!(resolution.attribution.has_warning()); + assert!(resolution + .attribution + .group_key + .starts_with("user-desktop:")); + assert_ne!( + resolution.attribution.group_key, + "system-desktop:org.signal.Signal" + ); +} + +#[test] +fn visually_confusable_system_brand_is_a_conflict() { + let signal_identity = identity(40, 400, 0); + let hostile_identity = identity(41, 410, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + Vec::new(), + ); + + for claim in ["Sіgnal", "Signaⅼ"] { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: claim, + desktop_entry: None, + }, + &sender("/tmp/fake", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { + let signal_identity = identity(1, 10, 0); + let hostile_identity = identity(7, 70, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: None, + }, + &sender("/tmp/signal-desktop", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!( + resolution.attribution.group_key, + "desktop:org.signal.Signal" + ); +} + +#[test] +fn exact_keepassxc_name_spoof_never_becomes_system_associated() { + let keepass_identity = identity(2, 20, 0); + let hostile_identity = identity(8, 80, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.keepassxc.KeePassXC", + "KeePassXC", + "/usr/bin/keepassxc", + keepass_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "KeePassXC", + desktop_entry: None, + }, + &sender("/tmp/keepassxc", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn exact_system_notify_send_identity_is_a_non_replying_relay() { + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); + assert_eq!(resolution.attribution.display_name, "Screenshot"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(!resolution.attribution.has_warning()); + assert!(!resolution.attribution.source_label.contains("unverified")); +} + +#[test] +fn trusted_relay_claiming_a_system_app_keeps_the_relay_class_and_adds_a_warning() { + let signal_identity = identity(1, 10, 0); + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.signal.Signal", + "Signal", + "/usr/bin/signal-desktop", + signal_identity, + )], + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution.attribution.has_warning()); + assert_ne!( + resolution.attribution.group_key, + "desktop:org.signal.Signal" + ); +} + +#[test] +fn malicious_notify_send_basename_is_not_a_trusted_relay() { + let real_relay = identity(3, 30, 0); + let hostile_identity = identity(9, 90, 1000); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), real_relay)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/tmp/notify-send", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn owned_dbus_application_name_cannot_replace_executable_association() { + let app_identity = identity(4, 40, 0); + let mut record = DesktopRecord::fixture( + "org.example.App", + "Example App", + "/usr/bin/example-app", + app_identity, + true, + true, + ); + record.executable_identity = None; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let owned = HashSet::from(["org.example.app".to_string()]); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender("/usr/lib/example-launcher", identity(5, 50, 0)), + &index, + &owned, + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution + .attribution + .source_label + .contains("bus name ownership lacks executable association")); +} + +#[test] +fn desktop_id_validation_never_accepts_a_path_or_control_character() { + assert_eq!( + validate_desktop_id("org.signal.Signal.desktop").as_deref(), + Some("org.signal.Signal") + ); + assert_eq!(validate_desktop_id("../signal"), None); + assert_eq!(validate_desktop_id("org.example.\nApp"), None); + assert_eq!(validate_desktop_id("."), None); + assert_eq!(validate_desktop_id(".desktop"), None); + assert_eq!( + validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), + Some(256) + ); + assert_eq!(validate_desktop_id(&"a".repeat(257)), None); +} From caf75f2b9037e137686030cd19155dd782ef6b72 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 00:54:29 -0500 Subject: [PATCH 118/275] test: close daemon and center lifecycle gaps Summary: close daemon and center lifecycle gaps. Scope: repository. --- .../unixnotis-center/src/control/reconnect.rs | 12 +++++----- .../src/control/tests/reconnect.rs | 10 ++++++++ .../identity/desktop_index/tests/launch.rs | 23 +++++++++++++++++++ .../daemon/notifications/identity/sender.rs | 10 +++++++- .../notifications/identity/tests/sender.rs | 9 ++++++++ crates/unixnotis-daemon/src/tests/expire.rs | 21 +++++++++++++++++ 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index fa521a624..242baad16 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -255,11 +255,11 @@ async fn probe_control_owner( } fn owner_error_is_disconnected(error: &zbus::fdo::Error) -> bool { - match error { + matches!( + error, zbus::fdo::Error::IOError(_) - | zbus::fdo::Error::NoServer(_) - | zbus::fdo::Error::NoNetwork(_) => true, - zbus::fdo::Error::ZBus(zbus::Error::InputOutput(_)) => true, - _ => false, - } + | zbus::fdo::Error::NoServer(_) + | zbus::fdo::Error::NoNetwork(_) + | zbus::fdo::Error::ZBus(zbus::Error::InputOutput(_)) + ) } diff --git a/crates/unixnotis-center/src/control/tests/reconnect.rs b/crates/unixnotis-center/src/control/tests/reconnect.rs index d55c32b8e..463944ea3 100644 --- a/crates/unixnotis-center/src/control/tests/reconnect.rs +++ b/crates/unixnotis-center/src/control/tests/reconnect.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures_util::StreamExt; @@ -222,6 +223,15 @@ fn owner_lookup_errors_distinguish_connection_loss_from_transient_failures() { assert!(owner_error_is_disconnected(&zbus::fdo::Error::NoServer( "missing broker".to_string() ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::NoNetwork( + "network unavailable".to_string() + ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::ZBus( + zbus::Error::InputOutput(Arc::new(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "broker closed", + ))) + ))); assert!(!owner_error_is_disconnected(&zbus::fdo::Error::Timeout( "slow broker".to_string() ))); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs index b451ffe71..46dbd84fa 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -46,6 +46,29 @@ fn fixed_immutable_application_argument_is_matched_exactly() { )); } +#[test] +fn user_writable_literal_payload_cannot_support_a_system_association() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let root = TempRoot::new("launch-spec-user-payload"); + let payload = root.join("application-script"); + fs::write(&payload, "exit 0\n").expect("write user payload"); + let desktop_path = root.join("org.example.UserPayload.desktop"); + fs::write( + &desktop_path, + format!( + "[Desktop Entry]\nType=Application\nName=User Payload\nExec=/usr/bin/sh {}\n", + payload.display() + ), + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&desktop_path).expect("parse desktop entry"); + + let spec = + build_launch_spec(&desktop, &desktop_path, shell.identity).expect("build launch spec"); + + assert!(!spec.literal_files_are_system_managed); +} + #[test] fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { let executable = diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index 9594b47af..f84c16523 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -119,7 +119,7 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen let executable = executable_evidence_for_pid(pid); let cmdline = read_process_cmdline(pid); let start_after = read_process_start_time(pid); - if start_before != Some(expected_start) || start_after != Some(expected_start) { + if !process_lifetime_matches(start_before, expected_start, start_after) { // Stale cache entries retain bus context but lose all application identity authority refreshed.sender_start_time = None; refreshed.sender_executable = None; @@ -136,6 +136,14 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen refreshed } +fn process_lifetime_matches( + start_before: Option, + expected_start: u64, + start_after: Option, +) -> bool { + start_before == Some(expected_start) && start_after == Some(expected_start) +} + #[cfg(target_os = "linux")] fn read_process_start_time(pid: u32) -> Option { // /proc//stat keeps the process lifetime tick count in field 22 diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 02190a094..3ffcd8950 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -113,3 +113,12 @@ fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { assert!(refreshed.sender_executable_identity.is_none()); assert!(refreshed.sender_cmdline.is_none()); } + +#[test] +fn process_lifetime_match_requires_both_reads_to_equal_the_cached_start() { + assert!(process_lifetime_matches(Some(42), 42, Some(42))); + assert!(!process_lifetime_matches(Some(41), 42, Some(42))); + assert!(!process_lifetime_matches(Some(42), 42, Some(43))); + assert!(!process_lifetime_matches(None, 42, Some(42))); + assert!(!process_lifetime_matches(Some(42), 42, None)); +} diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index e5317d78b..570c6ca7b 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -40,6 +40,27 @@ fn expiration_heap_orders_by_deadline() { assert_eq!(first.ticket.id, 2); } +#[test] +fn expiration_items_are_equal_only_for_the_same_complete_ticket() { + let deadline = Instant::now() + Duration::from_secs(1); + let item = ExpirationItem { + ticket: ticket(7, 3, deadline), + }; + + assert_eq!( + item, + ExpirationItem { + ticket: ticket(7, 3, deadline) + } + ); + assert_ne!( + item, + ExpirationItem { + ticket: ticket(7, 4, deadline) + } + ); +} + #[test] fn apply_command_tracks_latest_schedule() { let now = Instant::now(); From 6628b88b7040e83e733e5ced0f342db3798e0e8a Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 01:02:37 -0500 Subject: [PATCH 119/275] test: cover process, decorative image, and wait boundaries Summary: cover process, decorative image, and wait boundaries. Scope: repository. --- .../src/actions/daemon/process_handle.rs | 44 ++++++--- .../actions/daemon/tests/process_handle.rs | 92 +++++++++++++++++++ .../src/ui/popups/tests/mutation.rs | 1 + .../src/ui/tests/icon_state.rs | 55 +++++++++-- 4 files changed, 171 insertions(+), 21 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs index ad89fbf10..35d494f9e 100644 --- a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs @@ -21,6 +21,7 @@ pub(super) struct ProcessHandle { pid: Pid, start_time: u64, pidfd: Option, + exit_timeout: Duration, } impl ProcessHandle { @@ -64,6 +65,7 @@ impl ProcessHandle { pid, start_time: start_before, pidfd, + exit_timeout: PROCESS_EXIT_TIMEOUT, })) } @@ -82,22 +84,25 @@ impl ProcessHandle { pub(super) fn wait_for_exit(&self) -> Result<()> { if let Some(pidfd) = &self.pidfd { - return wait_for_pidfd(pidfd); + return wait_for_pidfd(pidfd, self.exit_timeout); } let started = Instant::now(); - while started.elapsed() < PROCESS_EXIT_TIMEOUT { + while started.elapsed() < self.exit_timeout { match read_process_start_time(self.pid.as_raw_pid() as u32)? { None => return Ok(()), // A new lifetime means the original target exited and must not be inspected Some(current) if current != self.start_time => return Ok(()), - Some(_) => thread::sleep(FALLBACK_POLL_INTERVAL), + Some(_) => thread::sleep( + FALLBACK_POLL_INTERVAL.min(self.exit_timeout.saturating_sub(started.elapsed())), + ), } } Err(anyhow!( - "process {} did not exit after 5s", - self.pid.as_raw_pid() + "process {} did not exit after {:?}", + self.pid.as_raw_pid(), + self.exit_timeout )) } @@ -113,18 +118,18 @@ impl ProcessHandle { } } -fn wait_for_pidfd(pidfd: &OwnedFd) -> Result<()> { +fn wait_for_pidfd(pidfd: &OwnedFd, timeout: Duration) -> Result<()> { let mut descriptors = [PollFd::new(pidfd, PollFlags::IN)]; let timeout = Timespec { - tv_sec: PROCESS_EXIT_TIMEOUT.as_secs() as i64, - tv_nsec: 0, + tv_sec: timeout.as_secs() as i64, + tv_nsec: i64::from(timeout.subsec_nanos()), }; - let ready = poll(&mut descriptors, Some(&timeout)) + poll(&mut descriptors, Some(&timeout)) .context("failed while waiting for notification daemon pidfd")?; - if ready > 0 && descriptors[0].revents().contains(PollFlags::IN) { + if descriptors[0].revents().contains(PollFlags::IN) { return Ok(()); } - Err(anyhow!("process did not exit after 5s")) + Err(anyhow!("process did not exit after {timeout:?}")) } fn process_id(raw_pid: u32) -> Option { @@ -147,16 +152,25 @@ fn read_proc_comm(pid: u32) -> Option { fn read_process_start_time(pid: u32) -> Result> { let path = format!("/proc/{pid}/stat"); - let contents = match fs::read_to_string(&path) { + read_process_start_time_from_path(std::path::Path::new(&path)) +} + +fn read_process_start_time_from_path(path: &std::path::Path) -> Result> { + let contents = match fs::read_to_string(path) { Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) if process_state_is_missing(&error) => return Ok(None), Err(error) => { - return Err(error).with_context(|| format!("failed to read process state from {path}")) + return Err(error) + .with_context(|| format!("failed to read process state from {}", path.display())) } }; parse_process_start_time(&contents) .map(Some) - .ok_or_else(|| anyhow!("failed to parse process start time for pid {pid}")) + .ok_or_else(|| anyhow!("failed to parse process start time from {}", path.display())) +} + +fn process_state_is_missing(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::NotFound } fn parse_process_start_time(stat: &str) -> Option { diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs index 7e754e03c..e870a6a3a 100644 --- a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs @@ -63,3 +63,95 @@ fn invalid_process_ids_are_treated_as_already_gone() { ProcessState::Gone )); } + +#[test] +fn current_process_start_time_is_read_from_proc() { + let start_time = read_process_start_time(std::process::id()) + .expect("read current process state") + .expect("current process should exist"); + + assert!(start_time > 1); +} + +#[test] +fn missing_process_start_time_returns_none() { + assert_eq!( + read_process_start_time(i32::MAX as u32).expect("missing process is not an I/O failure"), + None + ); +} + +#[test] +fn only_not_found_process_state_errors_mean_the_process_exited() { + assert!(process_state_is_missing(&std::io::Error::from( + std::io::ErrorKind::NotFound + ))); + assert!(!process_state_is_missing(&std::io::Error::from( + std::io::ErrorKind::PermissionDenied + ))); +} + +#[test] +fn fallback_lifetime_check_accepts_current_and_rejects_stale_start_times() { + let raw_pid = std::process::id(); + let pid = process_id(raw_pid).expect("current process id"); + let start_time = read_process_start_time(raw_pid) + .expect("read current process state") + .expect("current process should exist"); + let current = ProcessHandle { + pid, + start_time, + pidfd: None, + exit_timeout: Duration::from_millis(10), + }; + let stale = ProcessHandle { + pid, + start_time: start_time.saturating_add(1), + pidfd: None, + exit_timeout: Duration::from_millis(10), + }; + + current + .require_current_lifetime() + .expect("matching fallback lifetime"); + assert!(current.wait_for_exit().is_err()); + assert!(stale.require_current_lifetime().is_err()); + stale + .wait_for_exit() + .expect("a different lifetime means the original process exited"); +} + +#[test] +fn pidfd_wait_times_out_while_the_exact_process_is_still_running() { + let mut child = Command::new("sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep child"); + let mut handle = match ProcessHandle::open(child.id(), "sleep").expect("open sleep handle") { + ProcessState::Running(handle) => handle, + ProcessState::Gone => panic!("sleep child should still be running"), + }; + handle.exit_timeout = Duration::from_millis(10); + + assert!(handle.wait_for_exit().is_err()); + + child.kill().expect("stop sleep child"); + child.wait().expect("reap sleep child"); +} + +#[test] +fn non_process_io_errors_are_not_collapsed_into_a_missing_process() { + let root = std::env::temp_dir().join(format!( + "unixnotis-process-state-directory-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create process state directory"); + + let result = read_process_start_time_from_path(&root); + + let _ = std::fs::remove_dir(&root); + assert!(result.is_err()); +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs index e5ec75b87..161893e90 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs @@ -10,6 +10,7 @@ fn visible_update_starts_without_stack_changes() { #[test] fn newer_popup_generation_rejects_reordered_older_update() { assert!(incoming_generation_is_stale(Some(8), 7)); + assert!(!incoming_generation_is_stale(Some(8), 8)); assert!(!incoming_generation_is_stale(Some(7), 8)); assert!(!incoming_generation_is_stale(None, 8)); } diff --git a/crates/unixnotis-popups/src/ui/tests/icon_state.rs b/crates/unixnotis-popups/src/ui/tests/icon_state.rs index a556930e0..0d11b2b1d 100644 --- a/crates/unixnotis-popups/src/ui/tests/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/tests/icon_state.rs @@ -34,13 +34,18 @@ fn small_square_image_data_is_suppressed_as_decorative_content() { #[test] fn media_category_keeps_a_small_square_content_thumbnail() { - let mut notification = notification_with_image(); - notification.category = "image.photo".to_string(); - notification.image.has_image_data = true; - notification.image.image_data.width = 96; - notification.image.image_data.height = 96; + for category in ["image.photo", "media.video", "photo"] { + let mut notification = notification_with_image(); + notification.category = category.to_string(); + notification.image.has_image_data = true; + notification.image.image_data.width = 96; + notification.image.image_data.height = 96; - assert!(!content_image_is_decorative(¬ification)); + assert!( + !content_image_is_decorative(¬ification), + "{category} should preserve real content" + ); + } } #[test] @@ -52,6 +57,44 @@ fn content_source_matching_badge_is_suppressed_without_image_data() { assert!(content_image_is_decorative(¬ification)); } +#[test] +fn content_path_matching_badge_is_suppressed_without_image_data() { + let mut notification = notification_with_image(); + notification.attribution.badge_icon = "/usr/share/icons/signal.png".to_string(); + notification.image.image_path = "/usr/share/icons/signal.png".to_string(); + + assert!(content_image_is_decorative(¬ification)); +} + +#[test] +fn empty_badge_does_not_match_empty_content_sources() { + let notification = notification_with_image(); + + assert!(!content_image_is_decorative(¬ification)); +} + +#[test] +fn square_image_heuristic_requires_data_positive_dimensions_and_size_limit() { + let cases = [ + (false, 96, 96), + (true, 0, 0), + (true, 96, 72), + (true, 129, 129), + ]; + + for (has_image_data, width, height) in cases { + let mut notification = notification_with_image(); + notification.image.has_image_data = has_image_data; + notification.image.image_data.width = width; + notification.image.image_data.height = height; + + assert!( + !content_image_is_decorative(¬ification), + "data={has_image_data} width={width} height={height} should remain nondecorative" + ); + } +} + fn notification_with_image() -> unixnotis_core::NotificationView { unixnotis_core::NotificationView { id: 1, From d06a8e43bafa8a1d3995e36fdc065020f0322750 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 01:03:33 -0500 Subject: [PATCH 120/275] test(popups): exercise generation-aware control state Summary: exercise generation-aware control state. Scope: popups. --- .../src/control/tests/reconnect.rs | 37 ++++- .../src/ui/state/tests/constructor.rs | 19 +-- .../src/ui/state/tests/mod.rs | 2 + .../src/ui/state/tests/mutation.rs | 137 ++++++++++++++++++ .../src/ui/state/tests/support.rs | 15 ++ 5 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/state/tests/mutation.rs create mode 100644 crates/unixnotis-popups/src/ui/state/tests/support.rs diff --git a/crates/unixnotis-center/src/control/tests/reconnect.rs b/crates/unixnotis-center/src/control/tests/reconnect.rs index 463944ea3..159289688 100644 --- a/crates/unixnotis-center/src/control/tests/reconnect.rs +++ b/crates/unixnotis-center/src/control/tests/reconnect.rs @@ -5,11 +5,14 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures_util::StreamExt; +use unixnotis_core::CONTROL_BUS_NAME; use zbus::fdo::DBusProxy; +use zbus::names::BusName; use zbus::ConnectionBuilder; use super::{ - owner_error_is_disconnected, wait_for_control_owner_with_probe, GetOwnerError, OwnerWait, + owner_error_is_disconnected, probe_control_owner, wait_for_control_owner_with_probe, + GetOwnerError, OwnerWait, }; use crate::test_support::broker::read_broker_address; @@ -215,6 +218,38 @@ fn transient_initial_owner_probe_retries_without_an_owner_change_signal() { }); } +#[test] +fn owner_probe_returns_the_live_control_service_unique_name() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime"); + runtime.block_on(async { + let broker = PrivateBroker::start(broker_socket()); + let service = connect(&broker.address).await.expect("connect service"); + service + .request_name(CONTROL_BUS_NAME) + .await + .expect("claim control service name"); + let observer = connect(&broker.address).await.expect("connect observer"); + let dbus = DBusProxy::new(&observer).await.expect("create D-Bus proxy"); + let control_name = BusName::try_from(CONTROL_BUS_NAME).expect("valid control bus name"); + + // The probe must return the unique owner instead of the requested well-known name + let owner = probe_control_owner(&dbus, control_name) + .await + .expect("probe live control owner"); + + assert_eq!( + owner, + service + .unique_name() + .expect("service connection has a unique name") + .to_string() + ); + }); +} + #[test] fn owner_lookup_errors_distinguish_connection_loss_from_transient_failures() { assert!(owner_error_is_disconnected(&zbus::fdo::Error::IOError( diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 91fa8ec18..d708083ce 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -1,24 +1,9 @@ -use std::path::Path; - use gtk::prelude::*; -use unixnotis_core::{ - hooks, Config, CutCorners, NotificationImage, NotificationView, ThemePaths, Urgency, -}; +use unixnotis_core::{hooks, Config, CutCorners, NotificationImage, NotificationView, Urgency}; use unixnotis_ui::{css::CssManager, CutCorner}; use super::super::UiState; - -fn theme_paths(root: &Path) -> ThemePaths { - let root = root.to_path_buf(); - ThemePaths { - base_dir: root.clone(), - base_css: root.join("base.css"), - popup_css: root.join("popup.css"), - panel_css: root.join("panel.css"), - widgets_css: root.join("widgets.css"), - media_css: root.join("media.css"), - } -} +use super::support::theme_paths; #[gtk::test] fn popup_entry_uses_the_configured_cut_corner_primitive() { diff --git a/crates/unixnotis-popups/src/ui/state/tests/mod.rs b/crates/unixnotis-popups/src/ui/state/tests/mod.rs index f7dd6dc6e..6feaff8dc 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mod.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mod.rs @@ -1,3 +1,5 @@ mod constructor; mod events; mod model; +mod mutation; +mod support; diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs new file mode 100644 index 000000000..c40df39bd --- /dev/null +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -0,0 +1,137 @@ +use gtk::prelude::*; +use unixnotis_core::{ + CloseReason, Config, ImageData, NotificationImage, NotificationKey, NotificationView, +}; +use unixnotis_ui::css::CssManager; + +use super::super::UiState; +use super::support::theme_paths; +use crate::dbus::UiEvent; + +#[gtk::test] +fn popup_events_preserve_newest_generation_and_exact_close_identity() { + let mut state = popup_state("org.unixnotis.PopupMutationEvents"); + let original = notification(7, 1, "original"); + + state.handle_event(UiEvent::NotificationAdded(original.clone(), true)); + assert_eq!( + state.popups.get(&7).unwrap().notification.summary, + "original" + ); + + let duplicate = notification(7, 1, "duplicate"); + // Equal generations cannot replace the payload already accepted by the UI + state.handle_event(UiEvent::NotificationAdded(duplicate, true)); + assert_eq!( + state.popups.get(&7).unwrap().notification.summary, + "original" + ); + + let replacement = notification(7, 2, "replacement"); + state.handle_event(UiEvent::NotificationUpdated(replacement, true)); + assert_eq!( + state.popups.get(&7).unwrap().notification.summary, + "replacement" + ); + + // A delayed close for generation one must leave generation two visible + state.handle_event(UiEvent::NotificationClosed( + NotificationKey { + id: 7, + generation: 1, + }, + CloseReason::Expired, + )); + assert!(state.popups.contains_key(&7)); + + // A newer suppressed decision removes the older visible generation + state.handle_event(UiEvent::NotificationUpdated( + notification(7, 3, "suppressed"), + false, + )); + assert!(!state.popups.contains_key(&7)); + + // The next admitted generation may create the popup again + state.handle_event(UiEvent::NotificationUpdated( + notification(7, 4, "restored"), + true, + )); + assert!(state.popups.contains_key(&7)); + + state.handle_event(UiEvent::NotificationClosed( + NotificationKey { + id: 7, + generation: 4, + }, + CloseReason::Expired, + )); + assert!(!state.popups.contains_key(&7)); +} + +#[gtk::test] +fn popup_image_builders_distinguish_content_badges_and_missing_sources() { + let mut state = popup_state("org.unixnotis.PopupMutationImages"); + let mut content = notification(8, 1, "content"); + content.category = "image.photo".to_string(); + content.image = NotificationImage { + has_image_data: true, + image_data: ImageData { + width: 2, + height: 1, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255; 8], + }, + ..NotificationImage::default() + }; + // Image categories retain real content even when the thumbnail is compact + assert!(state.build_content_image_widget(&content).is_some()); + + let mut missing_content = notification(9, 1, "missing"); + missing_content.attribution.badge_icon.clear(); + missing_content.attribution.desktop_id.clear(); + // Empty content and badge sources must not create placeholder image widgets + assert!(state.build_content_image_widget(&missing_content).is_none()); + assert!(state.build_image_widget(&missing_content).is_none()); + + // A daemon-selected badge remains independent from caller image content + missing_content.attribution.badge_icon = "dialog-information".to_string(); + assert!(state.build_image_widget(&missing_content).is_some()); +} + +fn popup_state(application_id: &str) -> UiState { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup mutation application"); + let mut config = Config::default(); + // Queued-only rows keep the state test independent of compositor animation timing + config.popups.max_visible = 0; + let root = std::env::temp_dir().join("unixnotis-popup-mutation"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + + UiState::new(&app, config, root.join("config.toml"), command_tx, css) +} + +fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { + NotificationView { + id, + generation, + app_name: "Example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: summary.to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + image: NotificationImage::default(), + } +} diff --git a/crates/unixnotis-popups/src/ui/state/tests/support.rs b/crates/unixnotis-popups/src/ui/state/tests/support.rs new file mode 100644 index 000000000..2d31f17b2 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/state/tests/support.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +use unixnotis_core::ThemePaths; + +pub(super) fn theme_paths(root: &Path) -> ThemePaths { + let root = root.to_path_buf(); + ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + } +} From 4c599f3980b2ea30f25700285dc23fbafeaeaa34 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 01:14:45 -0500 Subject: [PATCH 121/275] test: satisfy strict workspace organization Summary: satisfy strict workspace organization. Scope: repository. --- .../unixnotis-center/src/control/reconnect.rs | 2 +- .../src/store/notifications/lifecycle.rs | 30 +++++++------------ .../store/notifications/tests/lifecycle.rs | 14 ++++++--- crates/unixnotis-daemon/src/tests/expire.rs | 5 +++- .../src/actions/daemon/process_handle.rs | 7 +++-- .../actions/daemon/tests/process_handle.rs | 13 ++++---- .../src/actions/tests/daemon.rs | 4 ++- .../src/ui/state/tests/mutation.rs | 23 +++++++++++--- 8 files changed, 60 insertions(+), 38 deletions(-) diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index 242baad16..6d61312be 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -254,7 +254,7 @@ async fn probe_control_owner( } } -fn owner_error_is_disconnected(error: &zbus::fdo::Error) -> bool { +const fn owner_error_is_disconnected(error: &zbus::fdo::Error) -> bool { matches!( error, zbus::fdo::Error::IOError(_) diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index 19498b933..13ca59e27 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -96,28 +96,20 @@ impl NotificationStore { deadline: Option, ) -> Option { // None removes a stale timer for resident or already-dismissed notifications - match deadline { - Some(deadline) => { - let ticket = ExpirationTicket { - id: notification.id, - generation: notification.generation, - deadline, - }; - self.expirations.insert(notification.id, ticket); - Some(ticket) - } - None => { - self.expirations.remove(¬ification.id); - None - } + if let Some(deadline) = deadline { + let ticket = ExpirationTicket { + id: notification.id, + generation: notification.generation, + deadline, + }; + self.expirations.insert(notification.id, ticket); + Some(ticket) + } else { + self.expirations.remove(¬ification.id); + None } } - #[cfg(test)] - pub fn expiration_for(&self, id: u32) -> Option { - self.expirations.get(&id).copied() - } - pub fn expire_if_current(&mut self, ticket: ExpirationTicket) -> Option> { // Both identities must match inside this one store-lock critical section let current = self.active.get(&ticket.id)?; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs index 9f66f20e5..0b7414227 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -1,4 +1,10 @@ use super::support::*; +use crate::store::ExpirationTicket; + +fn expiration_for(store: &NotificationStore, id: u32) -> Option { + // Test-only inspection stays beside lifecycle regressions instead of production methods + store.expirations.get(&id).copied() +} #[test] fn drain_active_keys_returns_newest_first_and_clears_expirations() { @@ -15,7 +21,7 @@ fn drain_active_keys_returns_newest_first_and_clears_expirations() { vec![second.notification.key(), first.notification.key()] ); assert!(store.list_active().is_empty()); - assert_eq!(store.expiration_for(first.notification.id), None); + assert_eq!(expiration_for(&store, first.notification.id), None); } #[test] @@ -29,7 +35,7 @@ fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { .set_expiration(&outcome.notification, Some(first)) .expect("positive deadline should create a ticket"); assert_eq!( - store.expiration_for(outcome.notification.id), + expiration_for(&store, outcome.notification.id), Some(first_ticket) ); @@ -37,12 +43,12 @@ fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { .set_expiration(&outcome.notification, Some(second)) .expect("replacement deadline should create a ticket"); assert_eq!( - store.expiration_for(outcome.notification.id), + expiration_for(&store, outcome.notification.id), Some(second_ticket) ); store.set_expiration(&outcome.notification, None); - assert_eq!(store.expiration_for(outcome.notification.id), None); + assert_eq!(expiration_for(&store, outcome.notification.id), None); } #[test] diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 570c6ca7b..5023a30bf 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -220,7 +220,10 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { assert!(expired.is_ok()); let store = state.store.lock().await; - assert_eq!(store.expiration_for(key.id), None); + assert!(store.active_notification_view(key.id).is_none()); + assert!(store.list_history().iter().any(|notification| { + notification.id == key.id && notification.generation == key.generation + })); } #[tokio::test] diff --git a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs index 35d494f9e..5ec4813de 100644 --- a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs @@ -89,7 +89,7 @@ impl ProcessHandle { let started = Instant::now(); while started.elapsed() < self.exit_timeout { - match read_process_start_time(self.pid.as_raw_pid() as u32)? { + match read_process_start_time(self.pid.as_raw_pid().cast_unsigned())? { None => return Ok(()), // A new lifetime means the original target exited and must not be inspected Some(current) if current != self.start_time => return Ok(()), @@ -107,7 +107,7 @@ impl ProcessHandle { } fn require_current_lifetime(&self) -> Result<()> { - let current = read_process_start_time(self.pid.as_raw_pid() as u32)?; + let current = read_process_start_time(self.pid.as_raw_pid().cast_unsigned())?; if current == Some(self.start_time) { return Ok(()); } @@ -121,7 +121,8 @@ impl ProcessHandle { fn wait_for_pidfd(pidfd: &OwnedFd, timeout: Duration) -> Result<()> { let mut descriptors = [PollFd::new(pidfd, PollFlags::IN)]; let timeout = Timespec { - tv_sec: timeout.as_secs() as i64, + // Poll accepts signed seconds, so durations above its range saturate safely + tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX), tv_nsec: i64::from(timeout.subsec_nanos()), }; poll(&mut descriptors, Some(&timeout)) diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs index e870a6a3a..2edc727d7 100644 --- a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs @@ -20,9 +20,8 @@ fn process_start_time_parser_rejects_missing_and_invalid_fields() { #[test] fn process_handle_rejects_a_mismatched_program_before_signaling() { - let error = match ProcessHandle::open(std::process::id(), "not-the-test-process") { - Ok(_) => panic!("mismatched program must fail closed"), - Err(error) => error, + let Err(error) = ProcessHandle::open(std::process::id(), "not-the-test-process") else { + panic!("mismatched program must fail closed"); }; assert!(error @@ -32,7 +31,9 @@ fn process_handle_rejects_a_mismatched_program_before_signaling() { #[test] fn pidfd_signal_and_wait_stop_the_exact_child_process() { - let mut child = Command::new("sleep") + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) .arg("30") .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -123,7 +124,9 @@ fn fallback_lifetime_check_accepts_current_and_rejects_stale_start_times() { #[test] fn pidfd_wait_times_out_while_the_exact_process_is_still_running() { - let mut child = Command::new("sleep") + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) .arg("30") .stdin(Stdio::null()) .stdout(Stdio::null()) diff --git a/crates/unixnotis-installer/src/actions/tests/daemon.rs b/crates/unixnotis-installer/src/actions/tests/daemon.rs index 46cf16d7c..7b71f68f3 100644 --- a/crates/unixnotis-installer/src/actions/tests/daemon.rs +++ b/crates/unixnotis-installer/src/actions/tests/daemon.rs @@ -46,7 +46,9 @@ fn stop_active_daemon_errors_for_unmanaged_owner() { #[test] fn stop_active_daemon_terminates_the_exact_non_systemd_owner() { - let mut child = Command::new("sleep") + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) .arg("30") .stdin(Stdio::null()) .stdout(Stdio::null()) diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index c40df39bd..9000b6a11 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -13,9 +13,14 @@ fn popup_events_preserve_newest_generation_and_exact_close_identity() { let mut state = popup_state("org.unixnotis.PopupMutationEvents"); let original = notification(7, 1, "original"); - state.handle_event(UiEvent::NotificationAdded(original.clone(), true)); + state.handle_event(UiEvent::NotificationAdded(original, true)); assert_eq!( - state.popups.get(&7).unwrap().notification.summary, + state + .popups + .get(&7) + .expect("original popup should be visible") + .notification + .summary, "original" ); @@ -23,14 +28,24 @@ fn popup_events_preserve_newest_generation_and_exact_close_identity() { // Equal generations cannot replace the payload already accepted by the UI state.handle_event(UiEvent::NotificationAdded(duplicate, true)); assert_eq!( - state.popups.get(&7).unwrap().notification.summary, + state + .popups + .get(&7) + .expect("equal generation should preserve the popup") + .notification + .summary, "original" ); let replacement = notification(7, 2, "replacement"); state.handle_event(UiEvent::NotificationUpdated(replacement, true)); assert_eq!( - state.popups.get(&7).unwrap().notification.summary, + state + .popups + .get(&7) + .expect("newer generation should replace the popup") + .notification + .summary, "replacement" ); From 707bc95cf5062b4961b79c81c570b8cd4dd87d2d Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 02:27:41 -0500 Subject: [PATCH 122/275] fix(actions): deny signals for weak attribution Summary: deny signals for weak attribution. Scope: actions. --- .../unixnotis-core/src/model/attribution.rs | 17 ++++++ .../src/model/tests/attribution.rs | 60 +++++++++++++++++++ .../src/daemon/control/tests/action.rs | 40 ++++++++++++- crates/unixnotis-daemon/src/store/runtime.rs | 4 ++ .../src/store/tests/runtime.rs | 44 +++++++++++++- 5 files changed, 162 insertions(+), 3 deletions(-) diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index d2b28396e..b31c6e248 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -135,6 +135,23 @@ impl NotificationAttribution { pub const fn has_warning(&self) -> bool { self.warning } + + /// Whether application-owned actions may be sent back to this notification source + #[must_use] + pub const fn allows_application_actions(&self) -> bool { + // A warning means current evidence conflicts even when a weak association was found + if self.warning { + return false; + } + + // Relay and unknown senders may display content but cannot receive trusted UI actions + matches!( + self.class, + AttributionClass::SystemAssociated + | AttributionClass::PortalAssociated + | AttributionClass::UserAssociated + ) + } } fn bounded_text(value: &str) -> String { diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 680fc1334..6565c998e 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -111,3 +111,63 @@ fn unknown_sender_keeps_bounded_presentation_without_gaining_association() { assert_eq!(attribution.group_key, "executable:7:9:localhelper"); assert!(!attribution.has_warning()); } + +#[test] +fn application_actions_require_a_non_conflicting_desktop_association() { + for class in [ + AttributionClass::SystemAssociated, + AttributionClass::PortalAssociated, + AttributionClass::UserAssociated, + ] { + let attribution = NotificationAttribution::associated( + "Associated", + "org.example.Associated", + "org.example.Associated", + "", + class, + false, + "associated".to_string(), + ); + + assert!( + attribution.allows_application_actions(), + "{class:?} should allow application actions" + ); + } + + for class in [ + AttributionClass::TrustedRelay, + AttributionClass::Unknown, + AttributionClass::Conflict, + ] { + let attribution = NotificationAttribution::associated( + "Weak source", + "", + "dialog-information-symbolic", + "", + class, + false, + "weak".to_string(), + ); + + assert!( + !attribution.allows_application_actions(), + "{class:?} should deny application actions" + ); + } +} + +#[test] +fn warning_state_denies_actions_even_for_an_associated_desktop_entry() { + let attribution = NotificationAttribution::associated( + "Shadowed application", + "org.example.Shadowed", + "org.example.Shadowed", + "Shadows a system desktop entry", + AttributionClass::UserAssociated, + true, + "shadowed".to_string(), + ); + + assert!(!attribution.allows_application_actions()); +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index b387c33e9..48bb618df 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use chrono::Utc; use futures_util::TryStreamExt; -use unixnotis_core::{Action, Notification, NotificationImage, Urgency}; +use unixnotis_core::{ + Action, AttributionClass, Notification, NotificationAttribution, NotificationImage, Urgency, +}; use zbus::message::Type; use zbus::zvariant::OwnedValue; use zbus::{Connection, MatchRule, MessageStream}; @@ -99,13 +101,47 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { .expect_err("stale action generation must fail"); } +#[tokio::test] +async fn validated_action_rejects_a_conflicting_application_claim() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let id = { + let mut notification = action_notification(&sender, "open"); + notification.attribution = NotificationAttribution::conflict( + "Signal", + "application claim mismatch; source /tmp/fake", + "conflict:signal".to_string(), + ); + state + .store + .lock() + .await + .insert(notification, 0) + .notification + .id + }; + + ControlServer::new(state) + .invoke_validated_action(id, "open") + .await + .expect_err("conflicting attribution must not receive an action signal"); +} + fn action_notification(sender: &Connection, key: &str) -> Notification { Notification { id: 0, generation: 0, app_name: "ActionApp".to_string(), app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: NotificationAttribution::associated( + "ActionApp", + "org.example.ActionApp", + "org.example.ActionApp", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.ActionApp".to_string(), + ), summary: "Action".to_string(), body: String::new(), actions: vec![Action { diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 76f3f8bb7..7a52a6728 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -157,6 +157,10 @@ impl NotificationStore { pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { let notification = self.active.get(&id)?; + // Weak or conflicting provenance must not gain an application-directed signal + if !notification.attribution.allows_application_actions() { + return None; + } // Exact matching prevents a trusted control caller from inventing application actions notification .actions diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 01e8f6157..6013556aa 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -1,6 +1,9 @@ use std::sync::Arc; -use unixnotis_core::{Action, CloseReason, Config, InlineReply, InlineReplyPolicy}; +use unixnotis_core::{ + Action, AttributionClass, CloseReason, Config, InlineReply, InlineReplyPolicy, + NotificationAttribution, +}; use crate::store::test_support::{make_notification, make_store_with_limits}; use crate::store::NotificationStore; @@ -95,6 +98,15 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { fn active_action_target_requires_an_exact_action_on_the_live_generation() { let mut store = make_store_with_limits(12, 20); let mut notification = make_notification("action"); + notification.attribution = NotificationAttribution::associated( + "Action source", + "org.example.ActionSource", + "org.example.ActionSource", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.ActionSource".to_string(), + ); notification.actions.push(Action { key: "open".to_string(), label: "Open".to_string(), @@ -115,6 +127,36 @@ fn active_action_target_requires_an_exact_action_on_the_live_generation() { assert!(store.active_action_target(id, "open").is_none()); } +#[test] +fn active_action_target_denies_unknown_and_conflicting_senders() { + for attribution in [ + NotificationAttribution::unknown( + "Signal", + "source /tmp/fake", + "unknown:signal".to_string(), + ), + NotificationAttribution::conflict( + "Signal", + "source /tmp/fake", + "conflict:signal".to_string(), + ), + ] { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("untrusted action"); + notification.attribution = attribution; + notification.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + let id = store.insert(notification, 0).notification.id; + + assert!( + store.active_action_target(id, "default").is_none(), + "weak attribution should not expose application actions" + ); + } +} + #[test] fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { let mut store = make_store_with_limits(12, 20); From 4693d8f6806a6a5299e22d8baf0d12f1de633146 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 02:30:59 -0500 Subject: [PATCH 123/275] feat(popups): add trust-aware presentation kinds Summary: add trust-aware presentation kinds. Scope: popups. --- .../src/output/tests/notifications.rs | 1 + .../src/control/tests/events.rs | 1 + .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 1 + .../src/ui/notifications/model/tests/item.rs | 1 + .../row/notification/tests/support.rs | 1 + .../src/ui/notifications/row/tests/group.rs | 1 + .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + .../unixnotis-core/src/model/notification.rs | 8 +- .../src/model/tests/notification.rs | 5 +- .../src/dbus/runtime/tests/delivery.rs | 1 + crates/unixnotis-popups/src/ui/entry/build.rs | 335 ++++++------------ .../src/ui/entry/builders/common.rs | 179 ++++++++++ .../src/ui/entry/builders/communication.rs | 47 +++ .../src/ui/entry/builders/mod.rs | 57 +++ .../src/ui/entry/builders/tests/common.rs | 135 +++++++ .../src/ui/entry/builders/utility.rs | 54 +++ .../src/ui/entry/builders/warning.rs | 57 +++ .../unixnotis-popups/src/ui/entry/labels.rs | 52 --- crates/unixnotis-popups/src/ui/entry/mod.rs | 2 + .../src/ui/entry/presentation/kind.rs | 76 ++++ .../src/ui/entry/presentation/mod.rs | 13 + .../src/ui/entry/presentation/tests/kind.rs | 89 +++++ .../src/ui/entry/presentation/tests/mod.rs | 4 + .../ui/entry/presentation/tests/support.rs | 31 ++ .../src/ui/entry/presentation/tests/trust.rs | 113 ++++++ .../ui/entry/presentation/tests/view_model.rs | 206 +++++++++++ .../src/ui/entry/presentation/trust.rs | 89 +++++ .../src/ui/entry/presentation/view_model.rs | 151 ++++++++ .../src/ui/entry/tests/build.rs | 106 ++++-- .../src/ui/entry/tests/labels.rs | 32 +- crates/unixnotis-popups/src/ui/icon_state.rs | 41 +-- .../src/ui/icons/tests/resolver/support.rs | 1 + .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/constructor.rs | 68 +++- .../src/ui/state/tests/mutation.rs | 30 +- .../src/ui/tests/icon_state.rs | 91 ----- 38 files changed, 1609 insertions(+), 475 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/common.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/communication.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/mod.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/utility.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/warning.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/kind.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/mod.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/trust.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index fc69919ad..5211a5648 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -25,6 +25,7 @@ fn sample_notification() -> NotificationView { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, // CLI formatting only needs the lightweight transport fields image: NotificationImage::default(), } diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 864b93f29..dd82d72f2 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -16,6 +16,7 @@ fn notification(id: u32) -> NotificationView { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index 40417964f..89630cb70 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -83,6 +83,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage { image_path: path.to_string_lossy().into_owned(), ..NotificationImage::default() diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 43329f6ea..6fbe3543a 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -26,6 +26,7 @@ fn notification_view( urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image, } } diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 0274103f0..0a895ddbf 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -20,6 +20,7 @@ fn notification(id: u32) -> Rc { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index c816d2bbb..b3d591cc8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -31,6 +31,7 @@ pub(super) fn sample_notification() -> NotificationView { urgency: Urgency::Normal as u8, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 244ad656e..6426c77c9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -27,6 +27,7 @@ fn notification(app_name: &str) -> Rc { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }) } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index c72518580..67bdcb36f 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -24,6 +24,7 @@ fn make_view(is_transient: bool) -> NotificationView { urgency: 1, category: String::new(), is_transient, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } @@ -46,6 +47,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { urgency: 1, category: String::new(), is_transient, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index fd6617b53..ba08d363e 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -72,6 +72,7 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 9a845ac9e..300730b5d 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -89,6 +89,8 @@ impl Notification { category: self.category.clone().unwrap_or_default(), // Center and popup policy both need the transient bit to stay in sync is_transient: self.is_transient, + // Relative popup time needs the original commit time after reconnect and seed + received_at_unix_seconds: self.received_at.timestamp(), // UIs only need the text, actions, and image payload used for rendering image: self.image.clone(), // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small @@ -112,6 +114,8 @@ impl Notification { category: self.category.clone().unwrap_or_default(), // History policy still depends on the transient bit in panel rows is_transient: self.is_transient, + // List and popup views use the same stable wall-clock timestamp + received_at_unix_seconds: self.received_at.timestamp(), // List rows should avoid carrying raw image buffers across D-Bus image: self.image.for_listing(), // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small @@ -314,7 +318,7 @@ pub struct NotificationView { // Generation identifies the exact same-ID payload represented by this view pub generation: u64, // Lightweight fields used for UI display and filtering - // Intentionally omits daemon-only protocol flags and timestamps + // Intentionally omits daemon-only protocol flags and full timestamp objects pub app_name: String, // Authenticated badge identity and any mismatched caller-supplied brand claim pub attribution: NotificationAttribution, @@ -328,6 +332,8 @@ pub struct NotificationView { pub category: String, // Close handling needs this flag so history policy stays shared pub is_transient: bool, + // Unix seconds preserve the original receipt time across UI reconnects + pub received_at_unix_seconds: i64, // Image metadata intended for UI usage pub image: NotificationImage, } diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 34610e87c..003d07dde 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -use chrono::Utc; use zbus::zvariant::{serialized::Context, to_bytes, Value, LE}; use super::{Notification, NotificationImage}; @@ -47,7 +46,8 @@ fn notification_with_image(image: NotificationImage) -> Notification { suppress_sound: true, image, expire_timeout: 5000, - received_at: Utc::now(), + received_at: chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("fixed notification timestamp"), sender_name: Some(":1.42".to_string()), sender_pid: Some(1234), sender_start_time: Some(9000), @@ -88,6 +88,7 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { assert_eq!(view.actions.len(), 1); assert_eq!(view.urgency, Urgency::Critical.as_u8()); assert!(view.is_transient); + assert_eq!(view.received_at_unix_seconds, 1_700_000_000); assert!(view.image.has_image_data); } diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs index 1e54b1841..9c43119ff 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -18,6 +18,7 @@ fn candidate(generation: u64, should_show: bool) -> PopupCandidate { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }, should_show, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 5d2639801..84d7c5922 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -1,18 +1,15 @@ -//! Popup entry construction and UI action wiring +//! Popup entry lifecycle and high-level card assembly -use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; -use unixnotis_core::{hooks, Action, AttributionClass, NotificationView, Urgency}; +use unixnotis_core::{hooks, NotificationView}; use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; use super::super::UiState; +use super::builders::{build_action_row, build_close_button, build_popup_content}; use super::commands::try_send_command; -use super::labels::{ - clamp_label_text, has_visible_text, update_optional_label, POPUP_ACTION_LABEL_MAX_CHARS, - POPUP_APP_MAX_CHARS, POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, -}; +use super::presentation::PopupEntryViewModel; use crate::dbus::UiCommand; pub(in crate::ui) struct PopupEntry { @@ -39,8 +36,6 @@ impl PopupEntry { } } -const MAX_POPUP_ACTIONS: usize = 3; - impl UiState { pub(in crate::ui) fn build_popup_entry( &mut self, @@ -59,213 +54,23 @@ impl UiState { } pub(in crate::ui) fn build_popup_root(&mut self, notification: &NotificationView) -> gtk::Box { - // One vertical box owns the whole popup card layout - let root = gtk::Box::new(gtk::Orientation::Vertical, 6); - root.add_css_class("unixnotis-popup-card"); - // Use the live stack width when a row is built or rebuilt - let popup_width = self - .popup_stack - .width() - .max(self.popup_stack.width_request()) - .max(1); - root.set_size_request(popup_width, -1); - root.set_halign(Align::Fill); - root.set_hexpand(false); - // New roots stay hidden until visibility logic decides otherwise - root.set_visible(false); - let is_critical = notification.urgency == Urgency::Critical as u8; - if is_critical { - // Critical rows keep the shared urgency class at the root - root.add_css_class(hooks::shared_state::CRITICAL); - } - if matches!( - notification.attribution.class, - AttributionClass::Unknown | AttributionClass::Conflict - ) { - // Unknown claims receive a visible semantic border instead of trusted branding - root.add_css_class("unverified"); - } - let has_popup_actions = notification.actions.iter().any(popup_action_is_visible); - // State classes make popup theming less dependent on child selector tricks - set_class_state( - &root, - hooks::popup_card::HAS_SUMMARY, - has_visible_text(¬ification.summary), - ); - set_class_state( - &root, - hooks::popup_card::HAS_BODY, - has_visible_text(¬ification.body), - ); - set_class_state(&root, hooks::popup_card::HAS_ACTIONS, has_popup_actions); - - // Main row keeps the large badge beside one compact text column - let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); - main.add_css_class("unixnotis-popup-main"); - let content = gtk::Box::new(gtk::Orientation::Vertical, 2); - content.set_hexpand(true); - content.add_css_class("unixnotis-popup-content"); - - // Header keeps app identity and close control on one compact line - let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); - header.add_css_class("unixnotis-popup-header-row"); - if let Some(icon) = self.build_image_widget(notification) { - // Icon presence is exposed as a state class for theme rules - set_class_state(&root, hooks::popup_card::HAS_ICON, true); - icon.set_valign(Align::Center); - icon.set_halign(Align::Start); - icon.add_css_class("unixnotis-popup-icon"); - main.append(&icon); - } else { - // Missing icons also get a root class so themes can rebalance spacing - set_class_state(&root, hooks::popup_card::NO_ICON, true); - } - // Primary identity stays short while source evidence lives in a tooltip - let app = gtk::Label::new(Some(¬ification.attribution.display_name)); - app.set_xalign(0.0); - app.set_single_line_mode(true); - app.set_ellipsize(EllipsizeMode::End); - app.set_max_width_chars(POPUP_APP_MAX_CHARS as i32); - app.set_text( - clamp_label_text(¬ification.attribution.display_name, POPUP_APP_MAX_CHARS).as_ref(), - ); - if !notification.attribution.source_label.is_empty() { - app.set_tooltip_text(Some(¬ification.attribution.source_label)); - } - if notification.attribution.has_warning() { - app.add_css_class("unixnotis-attribution-warning"); - } - app.add_css_class("unixnotis-popup-header"); - - let close = gtk::Button::from_icon_name("window-close-symbolic"); - close.add_css_class("unixnotis-popup-close"); - close.set_halign(Align::End); - - // Close stays on the right edge even when the title text shrinks - header.append(&app); - header.append(&build_urgency_badge(is_critical)); - header.append(&build_popup_header_spacer()); - header.append(&close); - - // Source warnings remain visible instead of living only in a tooltip - let source = gtk::Label::new(None); - source.set_xalign(0.0); - source.set_wrap(true); - source.set_wrap_mode(WrapMode::WordChar); - source.set_lines(2); - source.add_css_class("unixnotis-popup-source"); - update_optional_label(&source, ¬ification.attribution.source_label, 96); - - // Summary stays short and collapses when the payload has no title - let summary = gtk::Label::new(Some(¬ification.summary)); - summary.set_xalign(0.0); - summary.set_wrap(true); - summary.set_wrap_mode(WrapMode::WordChar); - summary.set_ellipsize(EllipsizeMode::End); - summary.set_lines(2); - summary.set_max_width_chars(POPUP_SUMMARY_MAX_CHARS as i32); - summary.add_css_class("unixnotis-popup-summary"); - update_optional_label(&summary, ¬ification.summary, POPUP_SUMMARY_MAX_CHARS); - - // Body follows the same bounded layout rules as the summary - let body = gtk::Label::new(None); - body.set_xalign(0.0); - body.set_wrap(true); - body.set_wrap_mode(WrapMode::WordChar); - body.set_ellipsize(EllipsizeMode::End); - body.set_lines(3); - body.set_max_width_chars(POPUP_BODY_MAX_CHARS as i32); - body.add_css_class("unixnotis-popup-body"); - update_optional_label(&body, ¬ification.body, POPUP_BODY_MAX_CHARS); - - // Text rows stay beside the badge so compact cards do not grow around empty icon space - content.append(&header); - content.append(&source); - content.append(&summary); - content.append(&body); - - if let Some(image) = self.build_content_image_widget(notification) { - // Caller content stays in the body and never becomes the application badge - set_class_state(&root, hooks::popup_card::HAS_IMAGE, true); - image.set_halign(Align::Start); - image.add_css_class("unixnotis-popup-content-image"); - content.append(&image); - } - main.append(&content); - root.append(&main); - - // Action buttons are only built when the payload exposes actions - if has_popup_actions { - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); - actions.add_css_class("unixnotis-popup-actions"); - for action in notification - .actions - .iter() - .filter(|action| popup_action_is_visible(action)) - .take(MAX_POPUP_ACTIONS) - { - // Button labels are clamped before GTK measures them - let button = gtk::Button::with_label( - clamp_label_text(&action.label, POPUP_ACTION_LABEL_MAX_CHARS).as_ref(), - ); - button.add_css_class("unixnotis-popup-action"); - let action_key = action.key.clone(); - let tx = self.command_tx.clone(); - let id = notification.id; - button.connect_clicked(move |_| { - // Click handlers only enqueue the DBus command - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - actions.append(&button); - } + let view = PopupEntryViewModel::for_notification(notification); + let root = build_card_root(self, &view); + let close = build_close_button(); + let rendered = build_popup_content(self, notification, &view, &close); + + // Builder results feed stable state classes used by user themes + set_class_state(&root, hooks::popup_card::HAS_ICON, rendered.has_icon); + set_class_state(&root, hooks::popup_card::NO_ICON, !rendered.has_icon); + set_class_state(&root, hooks::popup_card::HAS_IMAGE, rendered.has_image); + root.append(&rendered.widget); + + if let Some(actions) = build_action_row(&self.command_tx, notification.id, &view) { root.append(&actions); } - // Close still targets the notification id even when the row is rebuilt - let id = notification.id; - let command_tx_close = self.command_tx.clone(); - close.connect_clicked(move |_| { - try_send_command(&command_tx_close, UiCommand::Dismiss(id)); - }); - - // Default action still fires from the rebuilt card body - let default_action = notification - .actions - .iter() - .find(|action| action.key == "default") - .map(|action| action.key.clone()); - if let Some(action_key) = default_action { - let gesture = gtk::GestureClick::new(); - // Default card actions only belong to plain card clicks - // Real buttons should keep their own handlers without also triggering the card action - gesture.set_button(1); - let root_weak = root.downgrade(); - let tx = self.command_tx.clone(); - gesture.connect_released(move |_, _, x, y| { - let Some(root) = root_weak.upgrade() else { - return; - }; - if picked_widget_blocks_default_action(root.pick(x, y, gtk::PickFlags::DEFAULT)) { - return; - } - // Card clicks mirror the default action button behavior - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - root.add_controller(gesture); - } - + connect_close_action(&close, notification.id, &self.command_tx); + connect_default_action(&root, notification.id, &view, &self.command_tx); root } @@ -292,8 +97,7 @@ impl UiState { let popup_stack = self.popup_stack.clone(); let popup_input_region = self.popup_input_region.clone(); revealer.connect_notify_local(Some("child-revealed"), move |_, _| { - // The first popup can finish revealing after the only earlier refresh ran - // Refresh again here so action rows do not inherit an old empty region + // Refresh after reveal so action rows never inherit an earlier empty input region refresh_popup_input_region(&popup_window, &popup_stack, &popup_input_region); }); @@ -301,26 +105,90 @@ impl UiState { } } -fn build_popup_header_spacer() -> gtk::Box { - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // Spacer width takes up the slack so the trailing button does not drift - // Plain halign on the button is not enough inside a horizontal box - spacer.set_hexpand(popup_header_spacer_expands()); - spacer +fn build_card_root(state: &UiState, view: &PopupEntryViewModel) -> gtk::Box { + let root = gtk::Box::new(gtk::Orientation::Vertical, 6); + root.add_css_class("unixnotis-popup-card"); + root.add_css_class(view.kind.css_class()); + root.add_css_class(view.trust.level.css_class()); + + // Use the live stack width when a row is built or rebuilt + let popup_width = state + .popup_stack + .width() + .max(state.popup_stack.width_request()) + .max(1); + root.set_size_request(popup_width, -1); + root.set_halign(Align::Fill); + root.set_hexpand(false); + // New roots stay hidden until visibility logic decides otherwise + root.set_visible(false); + + if view.critical { + root.add_css_class(hooks::shared_state::CRITICAL); + } + set_class_state( + &root, + hooks::popup_card::HAS_SUMMARY, + !view.title.trim().is_empty(), + ); + set_class_state(&root, hooks::popup_card::HAS_BODY, view.body.is_some()); + set_class_state( + &root, + hooks::popup_card::HAS_ACTIONS, + !view.actions.is_empty(), + ); + root } -fn build_urgency_badge(is_critical: bool) -> gtk::Label { - let badge = gtk::Label::new(Some("Critical")); - // The widget stays in the tree so header composition remains stable across payload variants - badge.add_css_class(hooks::urgency::BADGE); - badge.set_single_line_mode(true); - badge.set_visible(is_critical); - badge +fn connect_close_action( + close: >k::Button, + notification_id: u32, + command_tx: &tokio::sync::mpsc::Sender, +) { + let command_tx = command_tx.clone(); + close.connect_clicked(move |_| { + // Dismissal remains independent from application-owned action policy + try_send_command(&command_tx, UiCommand::Dismiss(notification_id)); + }); } -pub(super) const fn popup_header_spacer_expands() -> bool { - // Keep the alignment rule easy to test without constructing full GTK rows - true +fn connect_default_action( + root: >k::Box, + notification_id: u32, + view: &PopupEntryViewModel, + command_tx: &tokio::sync::mpsc::Sender, +) { + let Some(action_key) = view + .actions + .iter() + .find(|action| action.key == "default") + .map(|action| action.key.clone()) + else { + return; + }; + + let gesture = gtk::GestureClick::new(); + // Default card actions only belong to plain card clicks + gesture.set_button(1); + let root_weak = root.downgrade(); + let tx = command_tx.clone(); + gesture.connect_released(move |_, _, x, y| { + let Some(root) = root_weak.upgrade() else { + return; + }; + if picked_widget_blocks_default_action(root.pick(x, y, gtk::PickFlags::DEFAULT)) { + return; + } + // The presentation model already removed actions with weak provenance + try_send_command( + &tx, + UiCommand::InvokeAction { + id: notification_id, + action_key: action_key.clone(), + }, + ); + }); + root.add_controller(gesture); } fn picked_widget_blocks_default_action(mut widget: Option) -> bool { @@ -338,11 +206,6 @@ fn widget_type_blocks_default_action(widget_type: gtk::glib::Type) -> bool { widget_type.is_a(gtk::Button::static_type()) } -fn popup_action_is_visible(action: &Action) -> bool { - // Inline reply needs a text field, so it is available in the panel instead of popup buttons - action.key != "inline-reply" -} - fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { if enabled { // Skip duplicate adds so repeated rebuilds do not churn the class list diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs new file mode 100644 index 000000000..640fa4549 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -0,0 +1,179 @@ +//! Shared small primitives used by every popup kind + +use gtk::pango::{EllipsizeMode, WrapMode}; +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::{hooks, NotificationView}; + +use super::super::commands::try_send_command; +use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation}; +use crate::dbus::UiCommand; +use crate::ui::UiState; + +pub(super) struct IdentityHeader { + pub(super) widget: gtk::Box, + pub(super) has_icon: bool, +} + +pub(super) fn build_identity_header( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + close: >k::Button, + app_icon_size: Option, +) -> IdentityHeader { + let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); + header.add_css_class("unixnotis-popup-header-row"); + + let mut has_icon = false; + if let Some(size) = app_icon_size { + if let Some(icon) = state.build_app_icon_widget(notification, size) { + // Only daemon-associated badge inputs reach the quiet identity header + icon.set_valign(Align::Center); + icon.set_halign(Align::Start); + icon.add_css_class("unixnotis-popup-icon"); + icon.add_css_class("unixnotis-popup-app-icon"); + header.append(&icon); + has_icon = true; + } + } + + let app = gtk::Label::new(Some(&view.app_label)); + app.set_xalign(0.0); + app.set_single_line_mode(true); + app.set_ellipsize(EllipsizeMode::End); + app.add_css_class("unixnotis-popup-app-name"); + if let Some(details) = view.trust.details_label.as_deref() { + // Raw paths remain available on demand without entering normal card content + app.set_tooltip_text(Some(details)); + } + header.append(&app); + + if let Some(chip) = build_trust_chip(&view.trust) { + header.append(&chip); + } + + header.append(&build_header_spacer()); + header.append(&build_urgency_badge(view.critical)); + + let time = gtk::Label::new(Some(&view.timestamp_label)); + time.set_single_line_mode(true); + time.add_css_class("unixnotis-popup-time"); + header.append(&time); + header.append(close); + + IdentityHeader { + widget: header, + has_icon, + } +} + +pub(super) fn build_title_label(view: &PopupEntryViewModel) -> Option { + if view.title.trim().is_empty() { + return None; + } + + let title = gtk::Label::new(Some(&view.title)); + title.set_xalign(0.0); + title.set_wrap(true); + title.set_wrap_mode(WrapMode::WordChar); + title.set_ellipsize(EllipsizeMode::End); + title.set_lines(2); + title.add_css_class("unixnotis-popup-summary"); + Some(title) +} + +pub(super) fn build_body_label(view: &PopupEntryViewModel, line_limit: i32) -> Option { + let body_text = view.body.as_deref()?; + let body = gtk::Label::new(Some(body_text)); + body.set_xalign(0.0); + body.set_wrap(true); + body.set_wrap_mode(WrapMode::WordChar); + body.set_ellipsize(EllipsizeMode::End); + body.set_lines(line_limit); + body.add_css_class("unixnotis-popup-body"); + Some(body) +} + +pub(super) fn build_reply_note(view: &PopupEntryViewModel) -> Option { + if !view.trust.show_reply_unavailable { + return None; + } + + let note = gtk::Label::new(Some("Reply unavailable")); + note.set_xalign(0.0); + note.add_css_class("unixnotis-popup-footer-note"); + Some(note) +} + +pub(in crate::ui::entry) fn build_close_button() -> gtk::Button { + let close = gtk::Button::from_icon_name("window-close-symbolic"); + close.add_css_class("unixnotis-popup-close"); + close.set_halign(Align::End); + close.set_tooltip_text(Some("Dismiss notification")); + close +} + +pub(in crate::ui::entry) fn build_action_row( + command_tx: &tokio::sync::mpsc::Sender, + notification_id: u32, + view: &PopupEntryViewModel, +) -> Option { + if view.actions.is_empty() { + return None; + } + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + actions.add_css_class("unixnotis-popup-actions"); + for action in &view.actions { + let button = gtk::Button::with_label(&action.label); + button.add_css_class("unixnotis-popup-action"); + let action_key = action.key.clone(); + let tx = command_tx.clone(); + button.connect_clicked(move |_| { + // Click handlers only enqueue the exact action prepared by the presentation model + try_send_command( + &tx, + UiCommand::InvokeAction { + id: notification_id, + action_key: action_key.clone(), + }, + ); + }); + actions.append(&button); + } + Some(actions) +} + +fn build_urgency_badge(is_critical: bool) -> gtk::Label { + let badge = gtk::Label::new(Some("Critical")); + // The stable node keeps header spacing predictable across urgency changes + badge.add_css_class(hooks::urgency::BADGE); + badge.set_single_line_mode(true); + badge.set_visible(is_critical); + badge +} + +fn build_trust_chip(trust: &PopupTrustPresentation) -> Option { + let label = trust.short_label.as_deref()?; + let chip = gtk::Label::new(Some(label)); + chip.set_single_line_mode(true); + chip.add_css_class("unixnotis-popup-trust-chip"); + chip.add_css_class(trust.level.css_class()); + if let Some(details) = trust.details_label.as_deref() { + // Detailed evidence remains one hover or keyboard query away + chip.set_tooltip_text(Some(details)); + } + Some(chip) +} + +fn build_header_spacer() -> gtk::Box { + let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); + // The expanding spacer anchors time and close controls to the trailing edge + spacer.set_hexpand(true); + spacer +} + +#[cfg(test)] +#[path = "tests/common.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs new file mode 100644 index 000000000..4f9af1677 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs @@ -0,0 +1,47 @@ +//! Communication popup with quiet application identity and message-first hierarchy + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::common::{build_body_label, build_identity_header, build_reply_note, build_title_label}; +use super::{append_thumbnail, RenderedPopup}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +const COMMUNICATION_APP_ICON_SIZE: i32 = 20; + +pub(super) fn build_communication_popup( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + close: >k::Button, +) -> RenderedPopup { + let content = gtk::Box::new(gtk::Orientation::Vertical, 3); + content.add_css_class("unixnotis-popup-communication-content"); + + // Communication cards read as app identity, sender, then message preview + let header = build_identity_header( + state, + notification, + view, + close, + Some(COMMUNICATION_APP_ICON_SIZE), + ); + content.append(&header.widget); + if let Some(title) = build_title_label(view) { + content.append(&title); + } + if let Some(body) = build_body_label(view, 3) { + content.append(&body); + } + let has_image = append_thumbnail(state, notification, view, &content); + if let Some(note) = build_reply_note(view) { + content.append(¬e); + } + + RenderedPopup { + widget: content, + has_icon: header.has_icon, + has_image, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs new file mode 100644 index 000000000..06c647434 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -0,0 +1,57 @@ +//! Kind-specific GTK popup builders + +mod common; +mod communication; +mod utility; +mod warning; + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::presentation::{PopupEntryViewModel, PopupKind}; +use crate::ui::UiState; + +pub(super) use common::{build_action_row, build_close_button}; + +/// Result of building one kind-specific card body +pub(super) struct RenderedPopup { + pub(super) widget: gtk::Box, + pub(super) has_icon: bool, + pub(super) has_image: bool, +} + +pub(super) fn build_popup_content( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + close: >k::Button, +) -> RenderedPopup { + // Each layout owns its structure so future changes do not grow one conditional builder + match view.kind { + PopupKind::Communication => { + communication::build_communication_popup(state, notification, view, close) + } + PopupKind::Utility => utility::build_utility_popup(state, notification, view, close), + PopupKind::Warning => warning::build_warning_popup(state, notification, view, close), + } +} + +pub(super) fn append_thumbnail( + state: &UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + content: >k::Box, +) -> bool { + if view.thumbnail != super::presentation::ThumbnailKind::Content { + return false; + } + let Some(image) = state.build_content_image_widget(notification) else { + return false; + }; + + // Content images stay bounded and visually separate from the application badge + image.set_halign(gtk::Align::Start); + image.add_css_class("unixnotis-popup-content-image"); + content.append(&image); + true +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs new file mode 100644 index 000000000..97131576f --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -0,0 +1,135 @@ +use super::{ + build_action_row, build_body_label, build_close_button, build_header_spacer, build_reply_note, + build_title_label, build_urgency_badge, +}; +use gtk::prelude::*; +use unixnotis_core::{ + Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; + +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::PopupEntryViewModel; + +#[gtk::test] +fn popup_critical_badge_uses_shared_hook_and_visibility() { + let critical = build_urgency_badge(true); + let normal = build_urgency_badge(false); + + assert!(critical.has_css_class(unixnotis_core::hooks::urgency::BADGE)); + assert_eq!(critical.text().as_str(), "Critical"); + assert!(critical.get_visible()); + assert!(!normal.get_visible()); +} + +#[gtk::test] +fn title_and_body_builders_keep_text_classes_and_line_limits() { + let mut view = view_model(); + + let title = build_title_label(&view).expect("visible title"); + let body = build_body_label(&view, 3).expect("visible body"); + + assert_eq!(title.text().as_str(), "Primary title"); + assert!(title.has_css_class("unixnotis-popup-summary")); + assert_eq!(title.lines(), 2); + assert_eq!(body.text().as_str(), "Supporting body"); + assert!(body.has_css_class("unixnotis-popup-body")); + assert_eq!(body.lines(), 3); + + view.title.clear(); + view.body = None; + assert!(build_title_label(&view).is_none()); + assert!(build_body_label(&view, 3).is_none()); +} + +#[gtk::test] +fn reply_note_exists_only_when_the_policy_explanation_is_needed() { + let mut view = view_model(); + assert!(build_reply_note(&view).is_none()); + + view.trust.show_reply_unavailable = true; + let note = build_reply_note(&view).expect("reply unavailable note"); + + assert_eq!(note.text().as_str(), "Reply unavailable"); + assert!(note.has_css_class("unixnotis-popup-footer-note")); +} + +#[gtk::test] +fn close_button_and_header_spacer_keep_their_interaction_contracts() { + let close = build_close_button(); + let spacer = build_header_spacer(); + + assert!(close.has_css_class("unixnotis-popup-close")); + assert_eq!( + close.tooltip_text().as_deref(), + Some("Dismiss notification") + ); + assert!(spacer.hexpands()); +} + +#[gtk::test] +fn action_row_dispatches_the_prepared_action_identity() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let view = view_model_with_action(); + let row = build_action_row(&command_tx, 41, &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("action button"); + + button.emit_clicked(); + + match command_rx.try_recv().expect("queued action command") { + UiCommand::InvokeAction { id, action_key } => { + assert_eq!(id, 41); + assert_eq!(action_key, "default"); + } + command => panic!("unexpected command: {command:?}"), + } +} + +#[gtk::test] +fn empty_action_model_does_not_build_an_action_row() { + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + assert!(build_action_row(&command_tx, 41, &view_model()).is_none()); +} + +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel::for_notification_at(¬ification(), 1_000) +} + +fn view_model_with_action() -> PopupEntryViewModel { + let mut notification = notification(); + notification.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + PopupEntryViewModel::for_notification_at(¬ification, 1_000) +} + +fn notification() -> NotificationView { + NotificationView { + id: 41, + generation: 3, + app_name: "Example".to_string(), + attribution: NotificationAttribution::associated( + "Example", + "org.example.App", + "org.example.App", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.App".to_string(), + ), + summary: "Primary title".to_string(), + body: "Supporting body".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs new file mode 100644 index 000000000..2b387041d --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -0,0 +1,54 @@ +//! Compact utility popup for device, transfer, clipboard, and generic events + +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::NotificationView; + +use super::common::{build_body_label, build_identity_header, build_title_label}; +use super::{append_thumbnail, RenderedPopup}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +const UTILITY_ICON_SIZE: i32 = 24; + +pub(super) fn build_utility_popup( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + close: >k::Button, +) -> RenderedPopup { + let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); + main.add_css_class("unixnotis-popup-utility-content"); + + let has_icon = if let Some(icon) = state.build_app_icon_widget(notification, UTILITY_ICON_SIZE) + { + // Utility symbols support scanning without becoming the card's dominant object + icon.set_halign(Align::Start); + icon.set_valign(Align::Start); + icon.add_css_class("unixnotis-popup-icon"); + icon.add_css_class("unixnotis-popup-utility-icon"); + main.append(&icon); + true + } else { + false + }; + + let content = gtk::Box::new(gtk::Orientation::Vertical, 2); + content.set_hexpand(true); + let header = build_identity_header(state, notification, view, close, None); + content.append(&header.widget); + if let Some(title) = build_title_label(view) { + content.append(&title); + } + if let Some(body) = build_body_label(view, 2) { + content.append(&body); + } + let has_image = append_thumbnail(state, notification, view, &content); + main.append(&content); + + RenderedPopup { + widget: main, + has_icon, + has_image, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs new file mode 100644 index 000000000..1205df266 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs @@ -0,0 +1,57 @@ +//! Restrained warning popup for conflicting application identity + +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::NotificationView; + +use super::common::{build_body_label, build_identity_header, build_reply_note, build_title_label}; +use super::{append_thumbnail, RenderedPopup}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +const WARNING_ICON_SIZE: i32 = 20; + +pub(super) fn build_warning_popup( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + close: >k::Button, +) -> RenderedPopup { + let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); + main.add_css_class("unixnotis-popup-warning-content"); + + let has_icon = if let Some(icon) = state.build_app_icon_widget(notification, WARNING_ICON_SIZE) + { + // Conflict attribution supplies a daemon-owned generic badge instead of claimed branding + icon.set_halign(Align::Start); + icon.set_valign(Align::Start); + icon.add_css_class("unixnotis-popup-icon"); + icon.add_css_class("unixnotis-popup-warning-icon"); + main.append(&icon); + true + } else { + false + }; + + let content = gtk::Box::new(gtk::Orientation::Vertical, 3); + content.set_hexpand(true); + let header = build_identity_header(state, notification, view, close, None); + content.append(&header.widget); + if let Some(title) = build_title_label(view) { + content.append(&title); + } + if let Some(body) = build_body_label(view, 3) { + content.append(&body); + } + let has_image = append_thumbnail(state, notification, view, &content); + if let Some(note) = build_reply_note(view) { + content.append(¬e); + } + main.append(&content); + + RenderedPopup { + widget: main, + has_icon, + has_image, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/labels.rs b/crates/unixnotis-popups/src/ui/entry/labels.rs index 25cc1f698..7b36f8eb0 100644 --- a/crates/unixnotis-popups/src/ui/entry/labels.rs +++ b/crates/unixnotis-popups/src/ui/entry/labels.rs @@ -4,8 +4,6 @@ use std::borrow::Cow; -use gtk::prelude::*; - // Header/app title stays single-line and clipped at this length pub(super) const POPUP_APP_MAX_CHARS: usize = 64; // Summary is visually dominant but still bounded to avoid tall cards @@ -15,40 +13,6 @@ pub(super) const POPUP_BODY_MAX_CHARS: usize = 320; // Action labels stay short so button row width remains predictable pub(super) const POPUP_ACTION_LABEL_MAX_CHARS: usize = 14; -pub(super) struct OptionalLabelState<'a> { - // Empty rows should disappear instead of leaving stray spacing behind - pub(super) visible: bool, - // Reuse borrowed text when possible so empty checks stay cheap - pub(super) text: Cow<'a, str>, -} - -pub(super) fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { - // Build the layout decision first so empty-text handling stays identical - // for both summary and body rows - let state = optional_label_state(text, max_chars); - // Hidden labels collapse their space in the popup box - set_label_visible_if_changed(label, state.visible); - // Text assignment happens after the visibility decision so empty rows stay blank - set_label_text_if_changed(label, state.text.as_ref()); -} - -pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { - if !has_visible_text(text) { - // Empty text rows stay hidden so the card does not keep dead spacing - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - let text = clamp_label_text(text, max_chars); - OptionalLabelState { - // Clamped-empty text should collapse the row the same way raw empty text does - visible: has_visible_text(text.as_ref()), - // Clamp before the label sees the text so layout work stays bounded - text, - } -} - pub(super) fn has_visible_text(text: &str) -> bool { // Visibility depends on real content, not just raw string length // Space-only strings count as empty for popup layout purposes @@ -74,22 +38,6 @@ pub(super) fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { Cow::Borrowed(text) } -fn set_label_visible_if_changed(label: >k::Label, visible: bool) { - // Popup rows are refreshed often while the data stays the same - // Skip the setter when the row is already in the right state - if label.is_visible() != visible { - label.set_visible(visible); - } -} - -fn set_label_text_if_changed(label: >k::Label, text: &str) { - // Reapplying identical text still makes GTK walk the update path - // Compare first so stable popup rows stay quiet - if label.text().as_str() != text { - label.set_text(text); - } -} - #[cfg(test)] #[path = "tests/labels.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index ccf1b8a76..ca56c9f9b 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -1,7 +1,9 @@ //! Popup row construction and bounded label handling mod build; +mod builders; mod commands; mod labels; +mod presentation; pub(in crate::ui) use build::PopupEntry; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs new file mode 100644 index 000000000..75ec7f7a5 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs @@ -0,0 +1,76 @@ +//! Stable popup layout selection from protocol categories and trust state + +use unixnotis_core::NotificationView; + +use super::TrustLevel; + +/// Visual structure used for one popup +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::entry) enum PopupKind { + Communication, + Utility, + Warning, +} + +impl PopupKind { + pub(in crate::ui::entry) fn for_notification( + notification: &NotificationView, + trust_level: TrustLevel, + ) -> Self { + // Conflicting identity needs its own restrained warning layout + if trust_level == TrustLevel::Suspicious { + return Self::Warning; + } + + // Standard categories use class.specific, so only the first segment selects the layout + let category_class = notification + .category + .split('.') + .next() + .unwrap_or_default() + .trim(); + if communication_category_class(category_class) + || notification.inline_reply.available + || notification + .actions + .iter() + .any(|action| action.key == "inline-reply") + { + return Self::Communication; + } + + // Missing and vendor-specific categories stay compact instead of guessing from prose + Self::Utility + } + + pub(in crate::ui::entry) const fn css_class(self) -> &'static str { + match self { + Self::Communication => "communication", + Self::Utility => "utility", + Self::Warning => "warning", + } + } + + pub(in crate::ui::entry) const fn action_limit(self) -> usize { + match self { + Self::Communication => 3, + Self::Utility | Self::Warning => 1, + } + } +} + +fn communication_category_class(category_class: &str) -> bool { + // Freedesktop communication classes are extended with common vendor spellings + [ + "call", + "email", + "im", + "presence", + "chat", + "message", + "social", + "voicemail", + ] + .iter() + .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs new file mode 100644 index 000000000..9a73fb720 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs @@ -0,0 +1,13 @@ +//! Popup-only presentation model derived from daemon-owned notification evidence + +mod kind; +mod trust; +mod view_model; + +pub(in crate::ui::entry) use kind::PopupKind; +pub(in crate::ui::entry) use trust::{PopupTrustPresentation, TrustLevel}; +pub(in crate::ui::entry) use view_model::{PopupEntryViewModel, ThumbnailKind}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs new file mode 100644 index 000000000..a7bb70af7 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs @@ -0,0 +1,89 @@ +use unixnotis_core::{Action, AttributionClass, NotificationAttribution}; + +use super::super::{PopupKind, PopupTrustPresentation}; +use super::support::notification; + +#[test] +fn standard_communication_category_classes_select_the_communication_layout() { + for category in [ + "call.incoming", + "email.arrived", + "im.received", + "presence.online", + ] { + let mut view = notification(); + view.category = category.to_string(); + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!( + PopupKind::for_notification(&view, trust.level), + PopupKind::Communication, + "{category} should use the communication layout" + ); + } +} + +#[test] +fn utility_categories_and_missing_categories_select_the_compact_layout() { + for category in ["", "device.added", "network.connected", "transfer.complete"] { + let mut view = notification(); + view.category = category.to_string(); + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!( + PopupKind::for_notification(&view, trust.level), + PopupKind::Utility, + "{category:?} should use the utility layout" + ); + } +} + +#[test] +fn suspicious_provenance_overrides_a_communication_category() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::associated( + "Unknown application", + "", + "dialog-warning-symbolic", + "Claims to be Signal; source /tmp/fake", + AttributionClass::Conflict, + true, + "conflict:signal".to_string(), + ); + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!( + PopupKind::for_notification(&view, trust.level), + PopupKind::Warning + ); +} + +#[test] +fn either_reply_contract_selects_the_communication_layout() { + let mut metadata_reply = notification(); + metadata_reply.inline_reply.available = true; + let trust = PopupTrustPresentation::for_notification(&metadata_reply); + assert_eq!( + PopupKind::for_notification(&metadata_reply, trust.level), + PopupKind::Communication + ); + + let mut action_reply = notification(); + action_reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let trust = PopupTrustPresentation::for_notification(&action_reply); + assert_eq!( + PopupKind::for_notification(&action_reply, trust.level), + PopupKind::Communication + ); +} + +#[test] +fn each_popup_kind_keeps_its_intended_action_budget() { + assert_eq!(PopupKind::Communication.action_limit(), 3); + assert_eq!(PopupKind::Utility.action_limit(), 1); + assert_eq!(PopupKind::Warning.action_limit(), 1); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs new file mode 100644 index 000000000..aad083d3a --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs @@ -0,0 +1,4 @@ +mod kind; +mod support; +mod trust; +mod view_model; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs new file mode 100644 index 000000000..113a0856f --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs @@ -0,0 +1,31 @@ +use unixnotis_core::{ + AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + NotificationView, +}; + +pub(super) fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::associated( + "Example", + "org.example.App", + "org.example.App", + "/usr/bin/example", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.App".to_string(), + ), + summary: "Primary title".to_string(), + body: "Supporting body".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs new file mode 100644 index 000000000..068654daa --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -0,0 +1,113 @@ +use unixnotis_core::{Action, AttributionClass, InlineReplyPolicy, NotificationAttribution}; + +use super::super::{PopupTrustPresentation, TrustLevel}; +use super::support::notification; + +#[test] +fn protected_desktop_association_stays_verified_and_visually_quiet() { + let mut view = notification(); + view.inline_reply.available = true; + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Verified); + assert!(trust.short_label.is_none()); + assert!(trust.allow_reply); +} + +#[test] +fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { + let mut view = notification(); + view.attribution = NotificationAttribution::trusted_relay( + "Screenshot", + "Sent via /usr/bin/notify-send", + false, + "relay:screenshot".to_string(), + ); + view.inline_reply_policy = InlineReplyPolicy::Deny; + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::System); + assert_eq!(trust.short_label.as_deref(), Some("Command-line tool")); + assert_eq!( + trust.details_label.as_deref(), + Some("Sent via /usr/bin/notify-send") + ); + assert!(!trust.allow_reply); +} + +#[test] +fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { + let mut view = notification(); + view.attribution = NotificationAttribution::conflict( + "Signal", + "source /tmp/fake", + "conflict:signal".to_string(), + ); + view.inline_reply.available = true; + view.inline_reply_policy = InlineReplyPolicy::Deny; + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Suspicious); + assert_eq!(trust.short_label.as_deref(), Some("Suspicious")); + assert!(!trust.allow_reply); + assert!(trust.show_reply_unavailable); +} + +#[test] +fn user_writable_desktop_association_remains_unverified() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Local app", + "org.example.Local", + "org.example.Local", + "user desktop association", + AttributionClass::UserAssociated, + false, + "user-desktop:org.example.Local".to_string(), + ); + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Unverified); + assert_eq!(trust.short_label.as_deref(), Some("Unverified")); + assert!(!trust.show_reply_unavailable); +} + +#[test] +fn verified_identity_still_needs_both_a_reply_request_and_policy_permission() { + let mut denied = notification(); + denied.inline_reply.available = true; + denied.inline_reply_policy = InlineReplyPolicy::Deny; + let denied_trust = PopupTrustPresentation::for_notification(&denied); + assert!(!denied_trust.allow_reply); + assert!(denied_trust.show_reply_unavailable); + + let no_request = notification(); + let no_request_trust = PopupTrustPresentation::for_notification(&no_request); + assert!(!no_request_trust.allow_reply); + assert!(!no_request_trust.show_reply_unavailable); +} + +#[test] +fn only_the_exact_inline_reply_action_key_requests_reply_ui() { + let mut other_action = notification(); + other_action.actions.push(Action { + key: "reply-later".to_string(), + label: "Reply later".to_string(), + }); + let other_trust = PopupTrustPresentation::for_notification(&other_action); + assert!(!other_trust.allow_reply); + assert!(!other_trust.show_reply_unavailable); + + let mut inline_reply = notification(); + inline_reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let inline_trust = PopupTrustPresentation::for_notification(&inline_reply); + assert!(inline_trust.allow_reply); + assert!(!inline_trust.show_reply_unavailable); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs new file mode 100644 index 000000000..f89b7c063 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -0,0 +1,206 @@ +use unixnotis_core::{Action, AttributionClass, ImageData, NotificationAttribution}; + +use super::super::{PopupEntryViewModel, PopupKind, ThumbnailKind}; +use super::support::notification; + +#[test] +fn view_model_formats_relative_time_without_losing_original_age() { + let mut view = notification(); + view.received_at_unix_seconds = 1_000; + + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_030).timestamp_label, + "now" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_120).timestamp_label, + "2m" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 8_200).timestamp_label, + "2h" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "2d" + ); + + view.received_at_unix_seconds = 0; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "now" + ); + view.received_at_unix_seconds = 200_000; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "now" + ); +} + +#[test] +fn utility_layout_keeps_only_one_safe_action() { + let mut view = notification(); + view.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "folder".to_string(), + label: "Open folder".to_string(), + }, + ]; + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.kind, PopupKind::Utility); + assert_eq!(model.actions.len(), 1); + assert_eq!(model.actions[0].key, "default"); +} + +#[test] +fn weak_attribution_hides_every_application_directed_action() { + let mut view = notification(); + view.attribution = NotificationAttribution::unknown( + "Signal", + "source /tmp/fake", + "unknown:signal".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert!(model.actions.is_empty()); +} + +#[test] +fn square_icon_data_is_hidden_unless_the_category_is_media() { + let mut view = notification(); + view.image.has_image_data = true; + view.image.image_data = ImageData { + width: 64, + height: 64, + ..ImageData::default() + }; + + let utility = PopupEntryViewModel::for_notification_at(&view, 1_000); + assert_eq!(utility.thumbnail, ThumbnailKind::None); + + view.category = "image.photo".to_string(); + let media = PopupEntryViewModel::for_notification_at(&view, 1_000); + assert_eq!(media.thumbnail, ThumbnailKind::Content); +} + +#[test] +fn thumbnail_requires_real_image_data_or_a_nonempty_path() { + let mut view = notification(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::None + ); + + view.image.image_path = "/tmp/content.png".to_string(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn either_badge_source_match_suppresses_duplicate_decoration() { + let mut icon_match = notification(); + icon_match.attribution.badge_icon = "example".to_string(); + icon_match.image.has_image_data = true; + icon_match.image.icon_name = "example".to_string(); + icon_match.image.image_data = ImageData { + width: 160, + height: 90, + ..ImageData::default() + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&icon_match, 1_000).thumbnail, + ThumbnailKind::None + ); + + let mut path_match = icon_match; + path_match.image.icon_name = "different".to_string(); + path_match.image.image_path = "example".to_string(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&path_match, 1_000).thumbnail, + ThumbnailKind::None + ); + + let mut no_match = path_match; + no_match.image.image_path = "different".to_string(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&no_match, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn decorative_square_detection_uses_every_dimension_guard_and_exact_boundary() { + let mut view = notification(); + view.image.has_image_data = true; + + for (width, height, expected) in [ + (0, 0, ThumbnailKind::Content), + (96, 72, ThumbnailKind::Content), + (128, 128, ThumbnailKind::None), + (129, 129, ThumbnailKind::Content), + ] { + view.image.image_data = ImageData { + width, + height, + ..ImageData::default() + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + expected, + "{width}x{height} should have the intended decoration classification" + ); + } +} + +#[test] +fn square_path_content_is_not_mistaken_for_embedded_icon_data() { + let mut view = notification(); + view.image.image_path = "/tmp/content.png".to_string(); + view.image.image_data = ImageData { + width: 64, + height: 64, + ..ImageData::default() + }; + + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn conflicting_claim_uses_warning_layout_and_drops_actions() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::associated( + "Unknown application", + "", + "dialog-warning-symbolic", + "Claims to be Signal", + AttributionClass::Conflict, + true, + "conflict:signal".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.kind, PopupKind::Warning); + assert!(model.actions.is_empty()); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs new file mode 100644 index 000000000..9a125e4d3 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -0,0 +1,89 @@ +//! Human-scale trust state derived without exposing raw provenance in the card body + +use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationView}; + +/// Small set of trust states used by popup styling and interaction hints +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::entry) enum TrustLevel { + Verified, + Unverified, + Suspicious, + System, +} + +impl TrustLevel { + pub(in crate::ui::entry) const fn css_class(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Unverified => "unverified", + Self::Suspicious => "suspicious", + Self::System => "system", + } + } +} + +/// Safe visible trust text plus optional diagnostic detail for a tooltip +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::entry) struct PopupTrustPresentation { + pub(in crate::ui::entry) level: TrustLevel, + pub(in crate::ui::entry) short_label: Option, + pub(in crate::ui::entry) details_label: Option, + pub(in crate::ui::entry) allow_reply: bool, + pub(in crate::ui::entry) show_reply_unavailable: bool, +} + +impl PopupTrustPresentation { + pub(in crate::ui::entry) fn for_notification(notification: &NotificationView) -> Self { + let level = trust_level(notification); + let short_label = short_trust_label(level).map(str::to_string); + let details_label = nonempty_text(¬ification.attribution.source_label); + let has_reply = notification.inline_reply.available + || notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + let allow_reply = has_reply + && notification.inline_reply_policy == InlineReplyPolicy::Allow + && level == TrustLevel::Verified; + + Self { + level, + short_label, + details_label, + allow_reply, + // Explain a missing reply control only when the sender actually requested one + show_reply_unavailable: has_reply && !allow_reply, + } + } +} + +const fn trust_level(notification: &NotificationView) -> TrustLevel { + // Explicit conflicts outrank the weaker association class carried beside them + if notification.attribution.has_warning() { + return TrustLevel::Suspicious; + } + + match notification.attribution.class { + AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { + TrustLevel::Verified + } + AttributionClass::UserAssociated | AttributionClass::Unknown => TrustLevel::Unverified, + AttributionClass::TrustedRelay => TrustLevel::System, + AttributionClass::Conflict => TrustLevel::Suspicious, + } +} + +const fn short_trust_label(level: TrustLevel) -> Option<&'static str> { + match level { + // Verified application identity stays quiet unless a future theme opts into a marker + TrustLevel::Verified => None, + TrustLevel::Unverified => Some("Unverified"), + TrustLevel::Suspicious => Some("Suspicious"), + TrustLevel::System => Some("Command-line tool"), + } +} + +fn nonempty_text(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs new file mode 100644 index 000000000..826906783 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -0,0 +1,151 @@ +//! Bounded content and actions consumed by the kind-specific GTK builders + +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{Action, NotificationView, Urgency}; + +use super::{PopupKind, PopupTrustPresentation}; +use crate::ui::entry::labels::{ + clamp_label_text, has_visible_text, POPUP_ACTION_LABEL_MAX_CHARS, POPUP_APP_MAX_CHARS, + POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, +}; + +const DECORATIVE_SQUARE_IMAGE_MAX: i32 = 128; + +/// One safe application action prepared for a compact popup button +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::entry) struct ActionViewModel { + pub(in crate::ui::entry) key: String, + pub(in crate::ui::entry) label: String, +} + +/// Whether the payload contains a genuine content image worth showing +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::entry) enum ThumbnailKind { + None, + Content, +} + +/// Presentation data kept separate from raw attribution evidence +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::entry) struct PopupEntryViewModel { + pub(in crate::ui::entry) kind: PopupKind, + pub(in crate::ui::entry) app_label: String, + pub(in crate::ui::entry) timestamp_label: String, + pub(in crate::ui::entry) title: String, + pub(in crate::ui::entry) body: Option, + pub(in crate::ui::entry) thumbnail: ThumbnailKind, + pub(in crate::ui::entry) actions: Vec, + pub(in crate::ui::entry) trust: PopupTrustPresentation, + pub(in crate::ui::entry) critical: bool, +} + +impl PopupEntryViewModel { + pub(in crate::ui::entry) fn for_notification(notification: &NotificationView) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + Self::for_notification_at(notification, now) + } + + pub(in crate::ui::entry) fn for_notification_at( + notification: &NotificationView, + now: i64, + ) -> Self { + let trust = PopupTrustPresentation::for_notification(notification); + let kind = PopupKind::for_notification(notification, trust.level); + let actions = visible_actions(notification, kind); + + Self { + kind, + app_label: clamp_label_text( + ¬ification.attribution.display_name, + POPUP_APP_MAX_CHARS, + ) + .into_owned(), + timestamp_label: relative_time_label(notification.received_at_unix_seconds, now), + title: clamp_label_text(¬ification.summary, POPUP_SUMMARY_MAX_CHARS).into_owned(), + body: has_visible_text(¬ification.body) + .then(|| clamp_label_text(¬ification.body, POPUP_BODY_MAX_CHARS).into_owned()), + thumbnail: thumbnail_kind(notification), + actions, + trust, + critical: notification.urgency == Urgency::Critical as u8, + } + } +} + +fn visible_actions(notification: &NotificationView, kind: PopupKind) -> Vec { + // The daemon enforces the same boundary when a control client invokes an action + if !notification.attribution.allows_application_actions() { + return Vec::new(); + } + + notification + .actions + .iter() + .filter(|action| action.key != "inline-reply") + .take(kind.action_limit()) + .map(action_view_model) + .collect() +} + +fn action_view_model(action: &Action) -> ActionViewModel { + ActionViewModel { + key: action.key.clone(), + label: clamp_label_text(&action.label, POPUP_ACTION_LABEL_MAX_CHARS).into_owned(), + } +} + +fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { + let has_content = + notification.image.has_image_data || !notification.image.image_path.trim().is_empty(); + if !has_content { + return ThumbnailKind::None; + } + + let category_is_media = ["image", "media", "photo"].iter().any(|category| { + notification + .category + .split('.') + .next() + .unwrap_or_default() + .eq_ignore_ascii_case(category) + }); + if category_is_media { + return ThumbnailKind::Content; + } + + let badge = notification.attribution.badge_icon.trim(); + let source_matches_badge = !badge.is_empty() + && (notification.image.icon_name.trim() == badge + || notification.image.image_path.trim() == badge); + let image_data = ¬ification.image.image_data; + let looks_like_small_square_icon = notification.image.has_image_data + && image_data.width > 0 + && image_data.width == image_data.height + && image_data.width <= DECORATIVE_SQUARE_IMAGE_MAX; + + if source_matches_badge || looks_like_small_square_icon { + ThumbnailKind::None + } else { + ThumbnailKind::Content + } +} + +fn relative_time_label(received_at: i64, now: i64) -> String { + // Missing timestamps cannot produce a meaningful age + if received_at <= 0 { + return "now".to_string(); + } + + let age = now.saturating_sub(received_at).max(0); + match age { + 0..=59 => "now".to_string(), + 60..=3_599 => format!("{}m", age / 60), + 3_600..=86_399 => format!("{}h", age / 3_600), + _ => format!("{}d", age / 86_400), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index c5e83ea80..8b6d9cd3a 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -1,27 +1,13 @@ -use super::{ - build_urgency_badge, popup_action_is_visible, popup_header_spacer_expands, - widget_type_blocks_default_action, -}; +use super::{connect_close_action, connect_default_action, widget_type_blocks_default_action}; use gtk::glib::prelude::StaticType; use gtk::prelude::*; -use unixnotis_core::Action; - -#[test] -fn popup_header_spacer_expands_to_hold_close_alignment() { - // The spacer owns unused header width so the close button stays aligned - assert!(popup_header_spacer_expands()); -} +use unixnotis_core::{ + Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; -#[gtk::test] -fn popup_critical_badge_uses_shared_hook_and_visibility() { - let critical = build_urgency_badge(true); - let normal = build_urgency_badge(false); - - assert!(critical.has_css_class(unixnotis_core::hooks::urgency::BADGE)); - assert_eq!(critical.text().as_str(), "Critical"); - assert!(critical.get_visible()); - assert!(!normal.get_visible()); -} +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::PopupEntryViewModel; #[gtk::test] fn default_card_action_is_blocked_for_button_widgets() { @@ -35,17 +21,75 @@ fn default_card_action_is_allowed_for_plain_content_widgets() { assert!(!widget_type_blocks_default_action(gtk::Label::static_type())); } -#[test] -fn popup_actions_hide_inline_reply_but_keep_regular_buttons() { - let reply = Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }; - let open = Action { +#[gtk::test] +fn close_button_dispatches_only_the_notification_dismissal() { + let close = gtk::Button::new(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + connect_close_action(&close, 31, &command_tx); + + close.emit_clicked(); + + match command_rx.try_recv().expect("queued dismiss command") { + UiCommand::Dismiss(id) => assert_eq!(id, 31), + command => panic!("unexpected command: {command:?}"), + } +} + +#[gtk::test] +fn exact_default_action_adds_card_click_handling() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { key: "default".to_string(), label: "Open".to_string(), - }; + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.id, &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 1); +} + +#[gtk::test] +fn nondefault_action_does_not_make_the_whole_card_clickable() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { + key: "details".to_string(), + label: "Details".to_string(), + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.id, &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 0); +} - assert!(!popup_action_is_visible(&reply)); - assert!(popup_action_is_visible(&open)); +fn notification() -> NotificationView { + NotificationView { + id: 31, + generation: 1, + app_name: "Example".to_string(), + attribution: NotificationAttribution::associated( + "Example", + "org.example.App", + "org.example.App", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.App".to_string(), + ), + summary: "Example".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + } } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs b/crates/unixnotis-popups/src/ui/entry/tests/labels.rs index 554747738..e3a971e48 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/labels.rs @@ -1,37 +1,23 @@ -use super::{ - clamp_label_text, optional_label_state, POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, -}; +use super::{clamp_label_text, has_visible_text}; #[test] -fn summary_row_hides_when_text_is_empty() { - let state = optional_label_state("", POPUP_SUMMARY_MAX_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); +fn empty_text_has_no_visible_popup_content() { + assert!(!has_visible_text("")); } #[test] -fn body_row_hides_when_text_is_only_whitespace() { - let state = optional_label_state("\n\t ", POPUP_BODY_MAX_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); +fn whitespace_only_text_has_no_visible_popup_content() { + assert!(!has_visible_text("\n\t ")); } #[test] -fn zero_length_limit_hides_nonempty_text() { - let state = optional_label_state("hello", 0); - - assert!(!state.visible); - assert!(state.text.is_empty()); +fn zero_length_limit_returns_empty_text() { + assert!(clamp_label_text("hello", 0).is_empty()); } #[test] -fn visible_text_preserves_surrounding_whitespace() { - let state = optional_label_state(" hello ", POPUP_SUMMARY_MAX_CHARS); - - assert!(state.visible); - assert_eq!(state.text.as_ref(), " hello "); +fn nonempty_text_is_visible_even_with_surrounding_whitespace() { + assert!(has_visible_text(" hello ")); } #[test] diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index 6d0a33fa5..0fa5e8d7e 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -20,12 +20,9 @@ use super::UiState; const ICON_CACHE_MAX_ENTRIES: usize = 256; // Skip caching decoded textures above this size to avoid holding large buffers -const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1024 * 1024; -// Popup icon size is fixed so rows stay visually consistent across icon sources -const POPUP_APP_BADGE_SIZE: i32 = 44; +const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1_048_576; // Content stays visibly separate from the daemon-associated application badge -const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 72; -const DECORATIVE_SQUARE_IMAGE_MAX: i32 = 128; +const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 48; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); @@ -34,9 +31,6 @@ impl UiState { &self, notification: &NotificationView, ) -> Option { - if content_image_is_decorative(notification) { - return None; - } if let Some(texture) = image_data_texture(¬ification.image) { let widget = gtk::Image::from_paintable(Some(&texture)); set_popup_icon_size(&widget, POPUP_CONTENT_THUMBNAIL_SIZE); @@ -49,9 +43,10 @@ impl UiState { self.resolve_icon_widget(¬ification.image.image_path, POPUP_CONTENT_THUMBNAIL_SIZE) } - pub(super) fn build_image_widget( + pub(super) fn build_app_icon_widget( &mut self, notification: &NotificationView, + size: i32, ) -> Option { self.refresh_icon_sources_if_needed(); // Caller image hints are content, so the header resolves only authenticated badge inputs @@ -61,7 +56,7 @@ impl UiState { ); if let Some(cached) = self.icon_cache.get(&cache_key) { if let Some(icon_name) = &cached.resolved { - return self.resolve_icon_widget(icon_name, POPUP_APP_BADGE_SIZE); + return self.resolve_icon_widget(icon_name, size); } if negative_cache_is_fresh(cached.cached_at, Instant::now()) { return None; @@ -78,9 +73,7 @@ impl UiState { for candidate in &candidates { if let Some(icon_names) = self.desktop_icons.icons_for(candidate) { for icon_name in icon_names { - if let Some(widget) = - self.resolve_icon_widget(icon_name.as_str(), POPUP_APP_BADGE_SIZE) - { + if let Some(widget) = self.resolve_icon_widget(icon_name.as_str(), size) { resolved = Some((icon_name, widget)); break; } @@ -93,7 +86,7 @@ impl UiState { if resolved.is_none() { for candidate in candidates { - if let Some(widget) = self.resolve_icon_widget(&candidate, POPUP_APP_BADGE_SIZE) { + if let Some(widget) = self.resolve_icon_widget(&candidate, size) { resolved = Some((candidate, widget)); break; } @@ -210,26 +203,6 @@ impl UiState { } } -fn content_image_is_decorative(notification: &NotificationView) -> bool { - let category_is_media = notification.category.starts_with("image") - || notification.category.starts_with("media") - || notification.category.starts_with("photo"); - if category_is_media { - return false; - } - - let badge = notification.attribution.badge_icon.trim(); - let source_matches_badge = !badge.is_empty() - && (notification.image.icon_name.trim() == badge - || notification.image.image_path.trim() == badge); - let data = ¬ification.image.image_data; - let looks_like_small_square_icon = notification.image.has_image_data - && data.width > 0 - && data.width == data.height - && data.width <= DECORATIVE_SQUARE_IMAGE_MAX; - source_matches_badge || looks_like_small_square_icon -} - fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { now.saturating_duration_since(cached_at) < NEGATIVE_ICON_CACHE_TTL } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index e6af56476..8278fb724 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -18,6 +18,7 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage { icon_name: icon_name.to_string(), ..NotificationImage::default() diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 15e13ab2c..e61bd5a39 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -20,6 +20,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { urgency: urgency as u8, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index d708083ce..1a8349a94 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -43,6 +43,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }; @@ -115,12 +116,21 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { urgency: Urgency::Critical as u8, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }; let root = state.build_popup_root(¬ification); assert!(root.has_css_class(hooks::shared_state::CRITICAL)); + assert!(root.has_css_class(hooks::popup_card::HAS_SUMMARY)); + assert!(root.has_css_class(hooks::popup_card::HAS_BODY)); + assert!(!root.has_css_class(hooks::popup_card::HAS_ACTIONS)); + assert_ne!( + root.has_css_class(hooks::popup_card::HAS_ICON), + root.has_css_class(hooks::popup_card::NO_ICON) + ); + assert_eq!(root.height_request(), -1); assert!(visible_descendant_has_class( root.upcast_ref(), hooks::urgency::BADGE @@ -128,7 +138,7 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { } #[gtk::test] -fn unknown_attribution_builds_a_visible_unverified_state() { +fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupUnverifiedProbe") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -164,18 +174,72 @@ fn unknown_attribution_builds_a_visible_unverified_state() { urgency: Urgency::Normal as u8, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), }; let root = state.build_popup_root(¬ification); assert!(root.has_css_class("unverified")); - assert!(visible_descendant_has_text( + assert!(root.has_css_class("utility")); + assert!(visible_descendant_has_text(root.upcast_ref(), "Unverified")); + assert!(!visible_descendant_has_text( root.upcast_ref(), "Claims to be Signal" )); } +#[gtk::test] +fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupSuspiciousProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup suspicious probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-suspicious-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 4, + generation: 4, + app_name: "Signal".to_string(), + attribution: unixnotis_core::NotificationAttribution::conflict( + "Signal", + "application claim mismatch; source /tmp/fake", + "conflict:signal".to_string(), + ), + summary: "John Doe".to_string(), + body: "Are you free later?".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("warning")); + assert!(root.has_css_class("suspicious")); + assert!(visible_descendant_has_text(root.upcast_ref(), "Suspicious")); + assert!(!visible_descendant_has_text( + root.upcast_ref(), + "application claim mismatch; source /tmp/fake" + )); +} + fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { let mut child = widget.first_child(); while let Some(current) = child { diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 9000b6a11..6f61e7d2e 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -1,6 +1,6 @@ use gtk::prelude::*; use unixnotis_core::{ - CloseReason, Config, ImageData, NotificationImage, NotificationKey, NotificationView, + hooks, CloseReason, Config, ImageData, NotificationImage, NotificationKey, NotificationView, }; use unixnotis_ui::css::CssManager; @@ -103,17 +103,40 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { }; // Image categories retain real content even when the thumbnail is compact assert!(state.build_content_image_widget(&content).is_some()); + let content_root = state.build_popup_root(&content); + assert!(content_root.has_css_class(hooks::popup_card::HAS_IMAGE)); + assert!(descendant_has_class( + content_root.upcast_ref(), + "unixnotis-popup-content-image" + )); let mut missing_content = notification(9, 1, "missing"); missing_content.attribution.badge_icon.clear(); missing_content.attribution.desktop_id.clear(); // Empty content and badge sources must not create placeholder image widgets assert!(state.build_content_image_widget(&missing_content).is_none()); - assert!(state.build_image_widget(&missing_content).is_none()); + assert!(state.build_app_icon_widget(&missing_content, 20).is_none()); + let missing_root = state.build_popup_root(&missing_content); + assert!(!missing_root.has_css_class(hooks::popup_card::HAS_IMAGE)); + assert!(!descendant_has_class( + missing_root.upcast_ref(), + "unixnotis-popup-content-image" + )); // A daemon-selected badge remains independent from caller image content missing_content.attribution.badge_icon = "dialog-information".to_string(); - assert!(state.build_image_widget(&missing_content).is_some()); + assert!(state.build_app_icon_widget(&missing_content, 20).is_some()); +} + +fn descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.has_css_class(class_name) || descendant_has_class(¤t, class_name) { + return true; + } + child = current.next_sibling(); + } + false } fn popup_state(application_id: &str) -> UiState { @@ -147,6 +170,7 @@ fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { urgency: 1, category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), } } diff --git a/crates/unixnotis-popups/src/ui/tests/icon_state.rs b/crates/unixnotis-popups/src/ui/tests/icon_state.rs index 0d11b2b1d..877fb2188 100644 --- a/crates/unixnotis-popups/src/ui/tests/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/tests/icon_state.rs @@ -21,94 +21,3 @@ fn negative_icon_cache_handles_future_timestamp_without_panicking() { assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); } - -#[test] -fn small_square_image_data_is_suppressed_as_decorative_content() { - let mut notification = notification_with_image(); - notification.image.has_image_data = true; - notification.image.image_data.width = 96; - notification.image.image_data.height = 96; - - assert!(content_image_is_decorative(¬ification)); -} - -#[test] -fn media_category_keeps_a_small_square_content_thumbnail() { - for category in ["image.photo", "media.video", "photo"] { - let mut notification = notification_with_image(); - notification.category = category.to_string(); - notification.image.has_image_data = true; - notification.image.image_data.width = 96; - notification.image.image_data.height = 96; - - assert!( - !content_image_is_decorative(¬ification), - "{category} should preserve real content" - ); - } -} - -#[test] -fn content_source_matching_badge_is_suppressed_without_image_data() { - let mut notification = notification_with_image(); - notification.attribution.badge_icon = "signal".to_string(); - notification.image.icon_name = "signal".to_string(); - - assert!(content_image_is_decorative(¬ification)); -} - -#[test] -fn content_path_matching_badge_is_suppressed_without_image_data() { - let mut notification = notification_with_image(); - notification.attribution.badge_icon = "/usr/share/icons/signal.png".to_string(); - notification.image.image_path = "/usr/share/icons/signal.png".to_string(); - - assert!(content_image_is_decorative(¬ification)); -} - -#[test] -fn empty_badge_does_not_match_empty_content_sources() { - let notification = notification_with_image(); - - assert!(!content_image_is_decorative(¬ification)); -} - -#[test] -fn square_image_heuristic_requires_data_positive_dimensions_and_size_limit() { - let cases = [ - (false, 96, 96), - (true, 0, 0), - (true, 96, 72), - (true, 129, 129), - ]; - - for (has_image_data, width, height) in cases { - let mut notification = notification_with_image(); - notification.image.has_image_data = has_image_data; - notification.image.image_data.width = width; - notification.image.image_data.height = height; - - assert!( - !content_image_is_decorative(¬ification), - "data={has_image_data} width={width} height={height} should remain nondecorative" - ); - } -} - -fn notification_with_image() -> unixnotis_core::NotificationView { - unixnotis_core::NotificationView { - id: 1, - generation: 1, - app_name: "Example".to_string(), - attribution: unixnotis_core::NotificationAttribution::default(), - summary: "Summary".to_string(), - body: "Body".to_string(), - actions: Vec::new(), - inline_reply: unixnotis_core::InlineReply::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - urgency: 1, - category: String::new(), - is_transient: false, - image: unixnotis_core::NotificationImage::default(), - } -} From 39c43a57c40a8eb6fd4e21f87ba269185988de38 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 02:31:07 -0500 Subject: [PATCH 124/275] style(popups): refine compact native banners Summary: refine compact native banners. Scope: popups. --- crates/unixnotis-core/assets/popup.css | 103 ++++++++++++++---- .../unixnotis-core/src/embedded/tests/css.rs | 24 ++++ 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index e946a0ccd..00c28c109 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -1,6 +1,6 @@ /* UnixNotis popup theme */ -/* Shared close button styling for popup surfaces. */ +/* Shared close button styling for popup surfaces */ .unixnotis-popup-close { background: alpha(#ffffff, 0.045); border-radius: 999px; @@ -9,9 +9,9 @@ border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); padding: 3px; - min-width: 24px; + min-width: 26px; min-width: var(--unixnotis-popup-close-size); - min-height: 24px; + min-height: 26px; min-height: var(--unixnotis-popup-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.70); @@ -50,7 +50,7 @@ color: #ffffff; border-radius: 20px; border-radius: var(--unixnotis-popup-card-radius); - padding: 14px 16px; + padding: 14px; padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); border: 1px solid alpha(#ffffff, 0.10); box-shadow: @@ -58,25 +58,79 @@ 0 2px 8px -4px alpha(#000000, 0.36); } +.unixnotis-popup-card.utility { + padding-top: 12px; + padding-bottom: 12px; +} + +.unixnotis-popup-communication-content, +.unixnotis-popup-utility-content, +.unixnotis-popup-warning-content { + background: transparent; +} + .unixnotis-popup-header-row { - margin-bottom: 2px; + min-height: 20px; + margin-bottom: 1px; } -.unixnotis-popup-header { +.unixnotis-popup-app-name { color: alpha(#ffffff, 0.76); font-weight: 600; font-size: 12px; } +.unixnotis-popup-time { + color: alpha(#ffffff, 0.56); + font-weight: 400; + font-size: 11px; +} + +.unixnotis-popup-trust-chip { + border-radius: 999px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; +} + +.unixnotis-popup-trust-chip.unverified, +.unixnotis-popup-trust-chip.system { + background: alpha(#fbbf24, 0.10); + color: alpha(#fde68a, 0.84); + border: 1px solid alpha(#fbbf24, 0.20); +} + +.unixnotis-popup-trust-chip.suspicious { + background: alpha(#fb7185, 0.13); + color: #fecdd3; + border: 1px solid alpha(#fb7185, 0.34); +} + .unixnotis-popup-summary { font-weight: 700; font-size: 16px; margin-top: 1px; } +.unixnotis-popup-app-icon { + min-width: 20px; + min-height: 20px; +} + .unixnotis-popup-icon { - min-width: 44px; - min-height: 44px; + color: inherit; +} + +.unixnotis-popup-utility-icon { + min-width: 24px; + min-height: 24px; + margin-top: 1px; +} + +.unixnotis-popup-warning-icon { + min-width: 20px; + min-height: 20px; + margin-top: 1px; } .unixnotis-popup-body { @@ -86,20 +140,28 @@ margin-top: 2px; } -.unixnotis-popup-source { - color: alpha(#fbbf24, 0.86); - font-size: 12px; +.unixnotis-popup-footer-note { + color: alpha(#fbbf24, 0.72); + font-size: 11px; + margin-top: 2px; } .unixnotis-popup-content-image { - min-width: 72px; - min-height: 72px; + min-width: 48px; + min-height: 48px; margin-top: 6px; - border-radius: 10px; + border-radius: 9px; } .unixnotis-popup-card.unverified { - border-color: alpha(#fbbf24, 0.45); + border-color: alpha(#fbbf24, 0.24); +} + +.unixnotis-popup-card.suspicious { + border-color: alpha(#fb7185, 0.48); + box-shadow: + 0 12px 32px -12px alpha(#000000, 0.58), + inset 2px 0 alpha(#fb7185, 0.58); } .unixnotis-popup-actions { @@ -116,10 +178,6 @@ padding-top: calc(var(--unixnotis-popup-actions-gap) - 2px); } -.unixnotis-popup-card-no-icon .unixnotis-popup-header-row { - padding-left: 0; -} - .unixnotis-popup-action { background: alpha(#ffffff, 0.045); border-top: 1px solid alpha(#ffffff, 0.08); @@ -163,10 +221,13 @@ inset 3px 0 @unixnotis-critical-border; } -.unixnotis-popup-card.critical .unixnotis-popup-header { +.unixnotis-popup-card.critical .unixnotis-popup-app-name { color: @unixnotis-critical-text; } +.unixnotis-popup-card.critical .unixnotis-popup-app-icon, +.unixnotis-popup-card.critical .unixnotis-popup-utility-icon, +.unixnotis-popup-card.critical .unixnotis-popup-warning-icon, .unixnotis-popup-card.critical .unixnotis-popup-icon { color: @unixnotis-critical-icon; } @@ -174,4 +235,4 @@ .unixnotis-popup-card.critical .unixnotis-popup-summary { color: #ffffff; } -/* End of popup theme. */ +/* End of popup theme */ diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index f9d1566a0..edad747a4 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -131,3 +131,27 @@ fn critical_alert_assets_define_composed_popup_and_panel_states() { assert!(!DEFAULT_PANEL_CSS.contains("animation:")); assert!(!DEFAULT_POPUP_CSS.contains("animation:")); } + +#[test] +fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { + for selector in [ + ".unixnotis-popup-card.utility", + ".unixnotis-popup-communication-content", + ".unixnotis-popup-utility-content", + ".unixnotis-popup-warning-content", + ".unixnotis-popup-trust-chip.unverified", + ".unixnotis-popup-trust-chip.suspicious", + ".unixnotis-popup-time", + ] { + assert!( + DEFAULT_POPUP_CSS.contains(selector), + "popup CSS should retain {selector}" + ); + } + + // Default popups must not restore the old raw provenance body row + assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 20px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 24px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 48px")); +} From d599b895067e5a8f5311690c987c57e8243f5afd Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 13:50:53 -0500 Subject: [PATCH 125/275] fix(actions): deny unverified application callbacks Summary: deny unverified application callbacks. Scope: actions. --- .../unixnotis-core/src/model/attribution.rs | 31 +++++++++++++------ crates/unixnotis-core/src/model/mod.rs | 4 ++- .../src/model/tests/attribution.rs | 25 +++++++++------ crates/unixnotis-daemon/src/store/runtime.rs | 6 ++-- .../src/store/tests/runtime.rs | 11 ++++++- .../ui/entry/presentation/tests/view_model.rs | 22 +++++++++++++ .../src/ui/entry/presentation/view_model.rs | 4 +-- 7 files changed, 78 insertions(+), 25 deletions(-) diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index b31c6e248..4e05ff251 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -33,6 +33,14 @@ pub enum InlineReplyPolicy { Deny = 2, } +/// Backend policy for application-owned action signals +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ApplicationActionPolicy { + Allow, + Confirm, + Deny, +} + /// Application presentation derived by the daemon from sender and desktop metadata #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationAttribution { @@ -136,21 +144,24 @@ impl NotificationAttribution { self.warning } - /// Whether application-owned actions may be sent back to this notification source + /// Policy for application-owned actions derived from daemon attribution evidence #[must_use] - pub const fn allows_application_actions(&self) -> bool { + pub const fn application_action_policy(&self) -> ApplicationActionPolicy { // A warning means current evidence conflicts even when a weak association was found if self.warning { - return false; + return ApplicationActionPolicy::Deny; } - // Relay and unknown senders may display content but cannot receive trusted UI actions - matches!( - self.class, - AttributionClass::SystemAssociated - | AttributionClass::PortalAssociated - | AttributionClass::UserAssociated - ) + match self.class { + AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { + ApplicationActionPolicy::Allow + } + // Confirmation has no daemon-owned remembered-decision store in the first release + AttributionClass::UserAssociated + | AttributionClass::TrustedRelay + | AttributionClass::Unknown + | AttributionClass::Conflict => ApplicationActionPolicy::Deny, + } } } diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index 9a57f8112..aaee0284c 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -8,7 +8,9 @@ mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. -pub use attribution::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; +pub use attribution::{ + ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationAttribution, +}; pub use image::{ImageData, NotificationImage}; pub use notification::{Notification, NotificationKey, NotificationView}; pub use reply::InlineReply; diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 6565c998e..2206dafa6 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -1,4 +1,6 @@ -use super::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use super::{ + ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationAttribution, +}; use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; #[test] @@ -117,7 +119,6 @@ fn application_actions_require_a_non_conflicting_desktop_association() { for class in [ AttributionClass::SystemAssociated, AttributionClass::PortalAssociated, - AttributionClass::UserAssociated, ] { let attribution = NotificationAttribution::associated( "Associated", @@ -129,13 +130,15 @@ fn application_actions_require_a_non_conflicting_desktop_association() { "associated".to_string(), ); - assert!( - attribution.allows_application_actions(), - "{class:?} should allow application actions" + assert_eq!( + attribution.application_action_policy(), + ApplicationActionPolicy::Allow, + "{class:?} should allow application actions", ); } for class in [ + AttributionClass::UserAssociated, AttributionClass::TrustedRelay, AttributionClass::Unknown, AttributionClass::Conflict, @@ -150,9 +153,10 @@ fn application_actions_require_a_non_conflicting_desktop_association() { "weak".to_string(), ); - assert!( - !attribution.allows_application_actions(), - "{class:?} should deny application actions" + assert_eq!( + attribution.application_action_policy(), + ApplicationActionPolicy::Deny, + "{class:?} should deny application actions", ); } } @@ -169,5 +173,8 @@ fn warning_state_denies_actions_even_for_an_associated_desktop_entry() { "shadowed".to_string(), ); - assert!(!attribution.allows_application_actions()); + assert_eq!( + attribution.application_action_policy(), + ApplicationActionPolicy::Deny + ); } diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 7a52a6728..98b9fce5b 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use indexmap::IndexMap; use tracing::{debug, warn}; -use unixnotis_core::{Config, ControlState, Notification, NotificationView, PopupCandidate}; +use unixnotis_core::{ + ApplicationActionPolicy, Config, ControlState, Notification, NotificationView, PopupCandidate, +}; use super::dnd::{DndStateStore, DND_STATE_VERSION}; use super::model::NotificationStore; @@ -158,7 +160,7 @@ impl NotificationStore { pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { let notification = self.active.get(&id)?; // Weak or conflicting provenance must not gain an application-directed signal - if !notification.attribution.allows_application_actions() { + if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { return None; } // Exact matching prevents a trusted control caller from inventing application actions diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 6013556aa..1cc74ae09 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -128,8 +128,17 @@ fn active_action_target_requires_an_exact_action_on_the_live_generation() { } #[test] -fn active_action_target_denies_unknown_and_conflicting_senders() { +fn active_action_target_denies_every_unverified_sender_class() { for attribution in [ + NotificationAttribution::associated( + "User application", + "org.example.UserApplication", + "org.example.UserApplication", + "", + AttributionClass::UserAssociated, + false, + "user-desktop:org.example.UserApplication".to_string(), + ), NotificationAttribution::unknown( "Signal", "source /tmp/fake", diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index f89b7c063..ca257d268 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -76,6 +76,28 @@ fn weak_attribution_hides_every_application_directed_action() { assert!(model.actions.is_empty()); } +#[test] +fn user_associated_attribution_hides_application_directed_actions() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "User application", + "org.example.UserApplication", + "org.example.UserApplication", + "", + AttributionClass::UserAssociated, + false, + "user-desktop:org.example.UserApplication".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert!(model.actions.is_empty()); +} + #[test] fn square_icon_data_is_hidden_unless_the_category_is_media() { let mut view = notification(); diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 826906783..8b31f5651 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -2,7 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use unixnotis_core::{Action, NotificationView, Urgency}; +use unixnotis_core::{Action, ApplicationActionPolicy, NotificationView, Urgency}; use super::{PopupKind, PopupTrustPresentation}; use crate::ui::entry::labels::{ @@ -79,7 +79,7 @@ impl PopupEntryViewModel { fn visible_actions(notification: &NotificationView, kind: PopupKind) -> Vec { // The daemon enforces the same boundary when a control client invokes an action - if !notification.attribution.allows_application_actions() { + if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { return Vec::new(); } From 887efed984724348b7f753aba75ed30b9a8581d4 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 13:57:56 -0500 Subject: [PATCH 126/275] refactor(identity): verify generic launch evidence Summary: verify generic launch evidence. Scope: identity. --- .../identity/desktop_index/launch.rs | 159 ++---------- .../identity/desktop_index/mod.rs | 18 +- .../identity/desktop_index/model.rs | 50 +++- .../identity/desktop_index/program.rs | 11 - .../identity/desktop_index/record.rs | 18 +- .../identity/desktop_index/tests/launch.rs | 159 ++++++++++-- .../identity/desktop_index/tests/parsing.rs | 49 +++- .../desktop_index/tests/verification.rs | 31 +++ .../identity/desktop_index/tests/wrappers.rs | 69 +++++ .../identity/desktop_index/verification.rs | 186 ++++++++++++++ .../identity/desktop_index/wrappers.rs | 120 +++++++++ .../daemon/notifications/identity/resolver.rs | 241 +++++++++++------- .../daemon/notifications/identity/sender.rs | 94 +++++-- .../notifications/identity/tests/resolver.rs | 35 ++- .../identity/tests/resolver/association.rs | 65 ++++- .../identity/tests/resolver/runtime.rs | 44 +++- .../identity/tests/resolver/spoof.rs | 6 +- .../notifications/identity/tests/sender.rs | 49 +++- .../identity/tests/sender_cache.rs | 4 +- .../notifications/ingress/tests/payload.rs | 4 +- crates/unixnotis-daemon/src/main.rs | 1 - 21 files changed, 1078 insertions(+), 335 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs index 270305d4b..bcda9228d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs @@ -1,22 +1,21 @@ //! Desktop `Exec` template parsing and process-command matching -use std::collections::HashSet; use std::path::{Path, PathBuf}; use gio::prelude::AppInfoExt; -use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::executable::executable_evidence_for_path; use super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; +use super::program::resolve_program; +use super::wrappers::normalize_launch_command; const MAX_EXEC_TEMPLATE_BYTES: usize = 16 * 1024; const MAX_EXEC_TEMPLATE_ARGUMENTS: usize = 128; -const MAX_PROCESS_ARGUMENTS: usize = 256; pub(super) fn build_launch_spec( desktop: &gio::DesktopAppInfo, desktop_path: &Path, - executable: FileIdentity, -) -> Option { +) -> Option<(PathBuf, LaunchSpec)> { let template = desktop.string("Exec")?; if template.len() > MAX_EXEC_TEMPLATE_BYTES { return None; @@ -25,10 +24,13 @@ pub(super) fn build_launch_spec( if words.is_empty() || words.len() > MAX_EXEC_TEMPLATE_ARGUMENTS { return None; } + let normalized = normalize_launch_command(words).ok()?; + let executable_path = resolve_program(Path::new(&normalized.executable))?; + let executable = executable_evidence_for_path(&executable_path)?.identity; - let mut arguments = Vec::with_capacity(words.len().saturating_sub(1)); + let mut arguments = Vec::with_capacity(normalized.arguments.len()); let mut literal_files_are_system_managed = true; - for word in words.into_iter().skip(1) { + for word in normalized.arguments { let argument = match word.as_str() { "%f" => LaunchArgument::FieldCode(FieldCode::File), "%F" => LaunchArgument::FieldCode(FieldCode::Files), @@ -60,29 +62,16 @@ pub(super) fn build_launch_spec( arguments.push(argument); } - Some(LaunchSpec { - executable, - arguments, - literal_files_are_system_managed, - }) -} - -pub(super) fn launch_spec_matches_sender( - spec: &LaunchSpec, - sender_identity: FileIdentity, - cmdline: &[Vec], -) -> bool { - if !spec.executable.same_file(sender_identity) - || cmdline.is_empty() - || cmdline.len() > MAX_PROCESS_ARGUMENTS - { - return false; - } - if !literal_file_identities_are_current(spec) { - return false; - } - let mut visited = HashSet::new(); - match_arguments(&spec.arguments, &cmdline[1..], 0, 0, &mut visited) + Some(( + executable_path, + LaunchSpec { + executable, + arguments, + environment: normalized.environment, + wrappers: normalized.wrappers, + literal_files_are_system_managed, + }, + )) } fn literal_argument(value: Vec) -> LaunchArgument { @@ -117,116 +106,6 @@ fn percent_literal(word: &str) -> Option { Some(output) } -fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { - spec.arguments.iter().all(|argument| { - let LaunchArgument::Literal(LiteralArgument { - file: Some((path, expected)), - .. - }) = argument - else { - return true; - }; - executable_evidence_for_path(path).is_some_and(|evidence| { - evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() - }) - }) -} - -fn match_arguments( - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - if !visited.insert((template_index, actual_index)) { - return false; - } - let Some(argument) = template.get(template_index) else { - return actual_index == actual.len(); - }; - match argument { - LaunchArgument::Literal(literal) => { - actual.get(actual_index) == Some(&literal.value) - && match_arguments( - template, - actual, - template_index + 1, - actual_index + 1, - visited, - ) - } - LaunchArgument::OptionalIcon { name } => { - match_arguments(template, actual, template_index + 1, actual_index, visited) - || (actual - .get(actual_index) - .is_some_and(|value| value == b"--icon") - && actual - .get(actual_index + 1) - .is_some_and(|value| value == name.as_bytes()) - && match_arguments( - template, - actual, - template_index + 1, - actual_index + 2, - visited, - )) - } - LaunchArgument::FieldCode(code) => match_field_code( - *code, - template, - actual, - template_index, - actual_index, - visited, - ), - } -} - -fn match_field_code( - code: FieldCode, - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - let maximum = match code { - FieldCode::File | FieldCode::Url => 1, - FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), - }; - for count in 0..=maximum { - let values = actual - .get(actual_index..actual_index + count) - .unwrap_or_default(); - if !values.iter().all(|value| field_value_matches(code, value)) { - break; - } - if match_arguments( - template, - actual, - template_index + 1, - actual_index + count, - visited, - ) { - return true; - } - } - false -} - -fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { - if value.is_empty() || value.starts_with(b"-") { - return false; - } - match code { - FieldCode::File | FieldCode::Files => true, - FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) - .ok() - .is_some_and(|value| url::Url::parse(value).is_ok()), - } -} - #[cfg(test)] #[path = "tests/launch.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index 57fa97b14..fe962f433 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -8,25 +8,23 @@ mod program; mod record; mod refresh; mod scan; +mod verification; +mod wrappers; pub use model::DesktopIdentityIndex; pub(super) use model::DesktopRecord; +pub(super) use model::{LaunchFailure, LaunchVerification}; pub(super) use names::{normalize_desktop_id, normalize_name}; pub use refresh::spawn_desktop_index_refresh; pub use scan::DesktopIndexSnapshot; -pub(in crate::daemon::notifications::identity) fn record_launch_matches( +pub(in crate::daemon::notifications::identity) fn verify_record_launch( record: &DesktopRecord, + index: &DesktopIdentityIndex, sender_identity: super::FileIdentity, - cmdline: Option<&[Vec]>, -) -> bool { - match &record.launch_spec { - // Missing or unparsable Exec metadata cannot bind a process to an application - None => false, - Some(spec) => cmdline.is_some_and(|cmdline| { - launch::launch_spec_matches_sender(spec, sender_identity, cmdline) - }), - } + cmdline: &super::sender::CommandLineEvidence, +) -> LaunchVerification { + verification::verify_record_launch(record, index, sender_identity, cmdline) } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index 788c776a1..2b8ba4bf7 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -10,17 +10,19 @@ use super::names::normalize_name; pub(in crate::daemon::notifications::identity) struct LaunchSpec { pub(in crate::daemon::notifications::identity) executable: FileIdentity, pub(in crate::daemon::notifications::identity) arguments: Vec, + pub(in crate::daemon::notifications::identity) environment: Vec<(Vec, Vec)>, + pub(in crate::daemon::notifications::identity) wrappers: Vec, pub(in crate::daemon::notifications::identity) literal_files_are_system_managed: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications::identity) enum LaunchArgument { Literal(LiteralArgument), FieldCode(FieldCode), OptionalIcon { name: String }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications::identity) struct LiteralArgument { pub(in crate::daemon::notifications::identity) value: Vec, pub(in crate::daemon::notifications::identity) file: Option<(PathBuf, FileIdentity)>, @@ -34,6 +36,50 @@ pub(in crate::daemon::notifications::identity) enum FieldCode { Urls, } +/// Wrapper programs removed before application identity is evaluated +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchWrapper { + Env, +} + +/// Evidence that establishes which application a desktop record launches +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchAuthority { + DedicatedExecutable, + ProtectedPayload, + DynamicOnly, + Ambiguous, +} + +/// Positive launch identity retained for diagnostics and candidate ranking +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum VerifiedLaunch { + DedicatedExecutable, + ProtectedPayload, +} + +/// Stable reason for a launch decision that cannot authenticate the claim +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchFailure { + MissingCommandLine, + UnstructuredCommandLine, + UnsupportedWrapper, + AmbiguousDesktopAssociation, + DynamicOnlyContract, + ExecutableMismatch, + ProtectedPayloadMismatch, + RequiredArgumentMismatch, + DesktopClaimMismatch, +} + +/// Three-way launch result keeps missing evidence distinct from contradiction +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchVerification { + Verified(VerifiedLaunch), + InsufficientEvidence(LaunchFailure), + DefinitiveMismatch(LaunchFailure), +} + #[derive(Debug, Clone)] pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) id: String, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs index b2a9459c5..3f17cfa6c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs @@ -2,8 +2,6 @@ use std::path::{Path, PathBuf}; -use gio::prelude::AppInfoExt; - pub(super) fn resolve_program(program: &Path) -> Option { // Canonical paths are presentation data while device and inode carry the proof if program.is_absolute() { @@ -14,12 +12,3 @@ pub(super) fn resolve_program(program: &Path) -> Option { .map(|directory| directory.join(program)) .find_map(|candidate| candidate.canonicalize().ok()) } - -pub(in crate::daemon::notifications::identity) fn desktop_executable( - desktop: &gio::DesktopAppInfo, -) -> Option { - // GIO exposes a nullable executable for valid D-Bus-activated entries without Exec - desktop.commandline()?; - let executable = desktop.executable(); - (!executable.as_os_str().is_empty()).then_some(executable) -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index 8ff2eee16..c21730e68 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -9,7 +9,6 @@ use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::launch::build_launch_spec; use super::model::{DesktopIdentityIndex, DesktopRecord}; use super::names::{normalize_desktop_id, normalize_name}; -use super::program::{desktop_executable, resolve_program}; impl DesktopIdentityIndex { pub(super) fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { @@ -27,15 +26,16 @@ impl DesktopIdentityIndex { return; } let display_name = desktop.display_name().to_string(); - let desktop_program = desktop_executable(&desktop); - let executable_path = desktop_program.as_deref().and_then(resolve_program); - let executable_identity = executable_path - .as_deref() - .and_then(executable_evidence_for_path) - .map(|evidence| evidence.identity); + // Wrapper normalization finds the application executable instead of indexing env itself + let parsed_launch = build_launch_spec(&desktop, path); + let executable_path = parsed_launch + .as_ref() + .map(|(executable_path, _spec)| executable_path.clone()); + let executable_identity = parsed_launch + .as_ref() + .map(|(_executable_path, spec)| spec.executable); let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); - let launch_spec = - executable_identity.and_then(|identity| build_launch_spec(&desktop, path, identity)); + let launch_spec = parsed_launch.map(|(_executable_path, spec)| spec); // Every association needs a complete Exec contract instead of a runtime-name exception let association_eligible = launch_spec.is_some(); // System association requires protected metadata and a reproducible launch specification diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs index 46dbd84fa..6c5faed80 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -1,14 +1,146 @@ +use std::collections::HashSet; use std::fs; use std::path::Path; use super::super::launch::{ - build_launch_spec, field_value_matches, launch_spec_matches_sender, - MAX_EXEC_TEMPLATE_ARGUMENTS, MAX_EXEC_TEMPLATE_BYTES, MAX_PROCESS_ARGUMENTS, + build_launch_spec, MAX_EXEC_TEMPLATE_ARGUMENTS, MAX_EXEC_TEMPLATE_BYTES, +}; +use super::super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; +use crate::daemon::notifications::identity::executable::{ + executable_evidence_for_path, FileIdentity, }; -use super::super::model::{FieldCode, LaunchArgument, LaunchSpec}; -use crate::daemon::notifications::identity::executable::executable_evidence_for_path; use crate::test_support::TempRoot; +const MAX_PROCESS_ARGUMENTS: usize = 256; + +fn launch_spec_matches_sender( + spec: &LaunchSpec, + sender_identity: FileIdentity, + cmdline: &[Vec], +) -> bool { + if !spec.executable.same_file(sender_identity) + || cmdline.is_empty() + || cmdline.len() > MAX_PROCESS_ARGUMENTS + { + return false; + } + if !literal_file_identities_are_current(spec) { + return false; + } + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, &cmdline[1..], 0, 0, &mut visited) +} + +fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} + +fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + actual.get(actual_index) == Some(&literal.value) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 1, + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments(template, actual, template_index + 1, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index + 1) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 2, + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let values = actual + .get(actual_index..actual_index + count) + .unwrap_or_default(); + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index + 1, + actual_index + count, + visited, + ) { + return true; + } + } + false +} + +fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} + #[test] fn fixed_immutable_application_argument_is_matched_exactly() { let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); @@ -24,7 +156,7 @@ fn fixed_immutable_application_argument_is_matched_exactly() { ) .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let spec = build_launch_spec(&desktop, &path, shell.identity).expect("build launch spec"); + let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); assert!(launch_spec_matches_sender( &spec, @@ -48,7 +180,6 @@ fn fixed_immutable_application_argument_is_matched_exactly() { #[test] fn user_writable_literal_payload_cannot_support_a_system_association() { - let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); let root = TempRoot::new("launch-spec-user-payload"); let payload = root.join("application-script"); fs::write(&payload, "exit 0\n").expect("write user payload"); @@ -63,8 +194,8 @@ fn user_writable_literal_payload_cannot_support_a_system_association() { .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&desktop_path).expect("parse desktop entry"); - let spec = - build_launch_spec(&desktop, &desktop_path, shell.identity).expect("build launch spec"); + let (_executable_path, spec) = + build_launch_spec(&desktop, &desktop_path).expect("build launch spec"); assert!(!spec.literal_files_are_system_managed); } @@ -81,7 +212,7 @@ fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { ) .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let spec = build_launch_spec(&desktop, &path, executable.identity).expect("build launch spec"); + let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); assert!(launch_spec_matches_sender( &spec, @@ -114,8 +245,6 @@ fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { #[test] fn launch_spec_enforces_template_size_and_argument_limits_at_the_boundary() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); let root = TempRoot::new("launch-spec-limits"); let executable_prefix = "/usr/bin/true "; @@ -163,7 +292,7 @@ fn launch_spec_enforces_template_size_and_argument_limits_at_the_boundary() { .unwrap_or_else(|| panic!("parse {name} desktop entry")); assert_eq!( - build_launch_spec(&desktop, &path, executable.identity).is_some(), + build_launch_spec(&desktop, &path).is_some(), accepted, "{name}" ); @@ -172,8 +301,6 @@ fn launch_spec_enforces_template_size_and_argument_limits_at_the_boundary() { #[test] fn launch_spec_parses_every_supported_desktop_field_code() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); let root = TempRoot::new("launch-spec-field-codes"); let path = root.join("org.example.Fields.desktop"); fs::write( @@ -182,7 +309,7 @@ fn launch_spec_parses_every_supported_desktop_field_code() { ) .expect("write field-code desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let spec = build_launch_spec(&desktop, &path, executable.identity).expect("build launch spec"); + let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); assert!(matches!( spec.arguments[0], @@ -223,6 +350,8 @@ fn process_matcher_checks_identity_emptiness_and_argument_limits_independently() let spec = LaunchSpec { executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), literal_files_are_system_managed: true, }; let exact_limit = diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index 97b8bed3d..9125058b6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -1,7 +1,6 @@ use std::fs; -use std::path::Path; -use super::super::program::desktop_executable; +use super::super::model::{LaunchArgument, LaunchWrapper}; use super::super::DesktopIdentityIndex; use crate::test_support::TempRoot; @@ -14,10 +13,6 @@ fn dbus_activated_desktop_entry_without_exec_has_no_executable() { "[Desktop Entry]\nType=Application\nName=No Exec\nDBusActivatable=true\n", ) .expect("desktop entry without Exec"); - let desktop = gio::DesktopAppInfo::from_filename(&path).expect("valid desktop entry"); - - assert!(desktop_executable(&desktop).is_none()); - let mut index = DesktopIdentityIndex::default(); index.add_desktop_file(&path, true); assert_eq!(index.records.len(), 1); @@ -26,7 +21,7 @@ fn dbus_activated_desktop_entry_without_exec_has_no_executable() { } #[test] -fn desktop_entry_exec_is_reduced_by_gio_to_its_program() { +fn desktop_entry_exec_is_resolved_to_its_application_program() { let root = TempRoot::new("desktop-with-exec"); let path = root.join("org.example.True.desktop"); fs::write( @@ -34,12 +29,46 @@ fn desktop_entry_exec_is_reduced_by_gio_to_its_program() { "[Desktop Entry]\nType=Application\nName=True\nExec=/usr/bin/true %U\n", ) .expect("desktop entry with Exec"); - let desktop = gio::DesktopAppInfo::from_filename(&path).expect("valid desktop entry"); + let mut index = DesktopIdentityIndex::default(); + index.add_desktop_file(&path, true); + + assert_eq!( + index.records[0] + .executable_path + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new("true")) + ); +} + +#[test] +fn env_wrapped_desktop_entry_indexes_the_wrapped_application() { + let root = TempRoot::new("desktop-env-wrapper"); + let path = root.join("org.example.Wrapped.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Wrapped\nExec=/usr/bin/env FEATURE=1 /usr/bin/true --fixed %u\n", + ) + .expect("desktop entry with env wrapper"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + let record = &index.records[0]; assert_eq!( - desktop_executable(&desktop).as_deref(), - Some(Path::new("/usr/bin/true")) + record + .executable_path + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new("true")) ); + let spec = record.launch_spec.as_ref().expect("normalized launch spec"); + assert_eq!(spec.wrappers, [LaunchWrapper::Env]); + assert_eq!(spec.environment, [(b"FEATURE".to_vec(), b"1".to_vec())]); + assert!(matches!( + &spec.arguments[0], + LaunchArgument::Literal(argument) if argument.value == b"--fixed" + )); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs new file mode 100644 index 000000000..739c1c040 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -0,0 +1,31 @@ +use super::{is_dynamic_or_option, is_protected_payload}; +use crate::daemon::notifications::identity::desktop_index::model::{ + FieldCode, LaunchArgument, LiteralArgument, +}; + +#[test] +fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { + let dynamic = LaunchArgument::FieldCode(FieldCode::Files); + let option = LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }); + let payload = LaunchArgument::Literal(LiteralArgument { + value: b"/usr/share/example/app.bundle".to_vec(), + file: Some(( + "/usr/share/example/app.bundle".into(), + crate::daemon::notifications::identity::FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }, + )), + }); + + assert!(is_dynamic_or_option(&dynamic)); + assert!(is_dynamic_or_option(&option)); + assert!(!is_dynamic_or_option(&payload)); + assert!(!is_protected_payload(&dynamic)); + assert!(is_protected_payload(&payload)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs new file mode 100644 index 000000000..58d5feb66 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs @@ -0,0 +1,69 @@ +use super::{normalize_launch_command, ExecParseError}; +use crate::daemon::notifications::identity::desktop_index::model::LaunchWrapper; + +#[test] +fn env_wrapper_preserves_environment_and_exposes_the_application_command() { + let normalized = normalize_launch_command( + [ + "/usr/bin/env", + "-i", + "-u", + "OLD_VALUE", + "FEATURE=1", + "--", + "example-app", + "--fixed", + "%u", + ] + .into_iter() + .map(str::to_string) + .collect(), + ) + .expect("normalize env command"); + + assert_eq!(normalized.executable, "example-app"); + assert_eq!(normalized.arguments, ["--fixed", "%u"]); + assert_eq!( + normalized.environment, + vec![(b"FEATURE".to_vec(), b"1".to_vec())] + ); + assert_eq!(normalized.wrappers, [LaunchWrapper::Env]); +} + +#[test] +fn nested_env_wrappers_are_normalized_without_application_specific_rules() { + let normalized = normalize_launch_command( + ["env", "A=1", "/usr/bin/env", "B=2", "example-app"] + .into_iter() + .map(str::to_string) + .collect(), + ) + .expect("normalize nested env command"); + + assert_eq!(normalized.executable, "example-app"); + assert_eq!(normalized.environment.len(), 2); + assert_eq!( + normalized.wrappers, + [LaunchWrapper::Env, LaunchWrapper::Env] + ); +} + +#[test] +fn unsupported_or_incomplete_env_syntax_fails_closed() { + for (tokens, expected) in [ + ( + vec!["env".to_string(), "-S".to_string(), "app".to_string()], + ExecParseError::UnsupportedWrapper, + ), + ( + vec!["env".to_string(), "-u".to_string()], + ExecParseError::MalformedEnvCommand, + ), + ( + vec!["env".to_string(), "FEATURE=1".to_string()], + ExecParseError::MissingWrappedCommand, + ), + ] { + assert_eq!(normalize_launch_command(tokens), Err(expected)); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs new file mode 100644 index 000000000..80a26f0ec --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs @@ -0,0 +1,186 @@ +//! Evidence-based launch verification with explicit uncertainty and contradiction + +use std::collections::HashSet; +use std::path::Path; + +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::model::{ + DesktopIdentityIndex, DesktopRecord, LaunchArgument, LaunchAuthority, LaunchFailure, + LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, +}; +use super::names::normalize_desktop_id; + +pub(super) fn verify_record_launch( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + sender_identity: FileIdentity, + command_line: &CommandLineEvidence, +) -> LaunchVerification { + let Some(spec) = record.launch_spec.as_ref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + }; + if spec.wrappers.len() > 16 || spec.environment.len() > 128 { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + } + if !spec.executable.same_file(sender_identity) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); + } + if !literal_file_identities_are_current(spec) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ProtectedPayloadMismatch); + } + + match classify_launch_authority(record, index, spec) { + LaunchAuthority::DedicatedExecutable => verify_dedicated(command_line, spec), + LaunchAuthority::ProtectedPayload => verify_protected_payload(command_line, spec), + LaunchAuthority::DynamicOnly => { + LaunchVerification::InsufficientEvidence(LaunchFailure::DynamicOnlyContract) + } + LaunchAuthority::Ambiguous => { + LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation) + } + } +} + +fn classify_launch_authority( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> LaunchAuthority { + if spec.arguments.iter().any(is_protected_payload) { + return LaunchAuthority::ProtectedPayload; + } + + let distinct_ids = index + .records_for_executable(spec.executable) + .into_iter() + .filter(|candidate| !record.system_origin || candidate.system_origin) + .map(|candidate| normalize_desktop_id(&candidate.id)) + .collect::>(); + if distinct_ids.len() == 1 { + return LaunchAuthority::DedicatedExecutable; + } + + if spec.arguments.iter().all(is_dynamic_or_option) { + LaunchAuthority::DynamicOnly + } else { + LaunchAuthority::Ambiguous + } +} + +fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { + match command_line.quality { + // The live executable remains authoritative when argv memory is absent or rewritten + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable => { + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + } + CommandLineQuality::Structured => { + if required_fixed_arguments_present(spec, &command_line.argv) { + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + } else { + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + } + } + } +} + +fn verify_protected_payload( + command_line: &CommandLineEvidence, + spec: &LaunchSpec, +) -> LaunchVerification { + match command_line.quality { + CommandLineQuality::Unavailable | CommandLineQuality::Truncated => { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine); + } + CommandLineQuality::RewrittenProcessTitle => { + return LaunchVerification::InsufficientEvidence( + LaunchFailure::UnstructuredCommandLine, + ); + } + CommandLineQuality::Structured => {} + } + + let actual = command_line.argv.get(1..).unwrap_or_default(); + for argument in &spec.arguments { + let LaunchArgument::Literal(literal) = argument else { + continue; + }; + if literal.file.is_some() + && !actual + .iter() + .any(|value| literal_file_matches(literal, value)) + { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); + } + } + if !required_fixed_arguments_present(spec, &command_line.argv) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch); + } + + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) +} + +fn required_fixed_arguments_present(spec: &LaunchSpec, argv: &[Vec]) -> bool { + let actual = argv.get(1..).unwrap_or_default(); + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(literal) = argument else { + return true; + }; + if literal.file.is_some() { + actual + .iter() + .any(|value| literal_file_matches(literal, value)) + } else { + actual.iter().any(|value| value == &literal.value) + } + }) +} + +fn literal_file_matches(literal: &LiteralArgument, actual: &[u8]) -> bool { + let Some((_expected_path, expected_identity)) = literal.file.as_ref() else { + return false; + }; + let Ok(actual) = std::str::from_utf8(actual) else { + return false; + }; + executable_evidence_for_path(Path::new(actual)) + .is_some_and(|evidence| evidence.identity.same_file(*expected_identity)) +} + +fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} + +fn is_protected_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(LiteralArgument { + file: Some(_), + value, + }) if !value.starts_with(b"-") + ) +} + +fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { + match argument { + LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, + LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), + } +} + +#[cfg(test)] +#[path = "tests/verification.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs new file mode 100644 index 000000000..a4b57c987 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs @@ -0,0 +1,120 @@ +//! Generic launch-wrapper normalization before executable identity is resolved + +use super::model::LaunchWrapper; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NormalizedLaunchCommand { + pub(super) executable: String, + pub(super) arguments: Vec, + pub(super) environment: Vec<(Vec, Vec)>, + pub(super) wrappers: Vec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(super) enum ExecParseError { + EmptyCommand, + MalformedEnvCommand, + MissingWrappedCommand, + UnsupportedWrapper, +} + +pub(super) fn normalize_launch_command( + tokens: Vec, +) -> Result { + if tokens.is_empty() { + return Err(ExecParseError::EmptyCommand); + } + + let mut current = tokens; + let mut environment = Vec::new(); + let mut wrappers = Vec::new(); + while let Some(prefix) = unwrap_env(¤t)? { + // Each wrapper consumes a strict prefix and leaves one complete command + environment.extend(prefix.environment); + wrappers.push(prefix.wrapper); + current = prefix.remaining_command; + } + + let mut current = current.into_iter(); + let executable = current.next().ok_or(ExecParseError::EmptyCommand)?; + Ok(NormalizedLaunchCommand { + executable, + arguments: current.collect(), + environment, + wrappers, + }) +} + +struct NormalizedPrefix { + remaining_command: Vec, + environment: Vec<(Vec, Vec)>, + wrapper: LaunchWrapper, +} + +fn unwrap_env(tokens: &[String]) -> Result, ExecParseError> { + let Some(first) = tokens.first() else { + return Err(ExecParseError::EmptyCommand); + }; + if first != "env" && first != "/usr/bin/env" { + return Ok(None); + } + + let mut index = 1; + let mut environment = Vec::new(); + while let Some(token) = tokens.get(index) { + if token == "--" { + index += 1; + break; + } + if token == "-i" || token == "--ignore-environment" { + index += 1; + continue; + } + if token == "-u" { + if tokens.get(index + 1).is_none() { + return Err(ExecParseError::MalformedEnvCommand); + } + index += 2; + continue; + } + if token.starts_with("--unset=") { + index += 1; + continue; + } + if token.starts_with('-') { + // Options such as -S change tokenization and need a dedicated safe parser + return Err(ExecParseError::UnsupportedWrapper); + } + if let Some((name, value)) = parse_environment_assignment(token) { + environment.push((name.as_bytes().to_vec(), value.as_bytes().to_vec())); + index += 1; + continue; + } + break; + } + + if index >= tokens.len() { + return Err(ExecParseError::MissingWrappedCommand); + } + Ok(Some(NormalizedPrefix { + remaining_command: tokens[index..].to_vec(), + environment, + wrapper: LaunchWrapper::Env, + })) +} + +fn parse_environment_assignment(value: &str) -> Option<(&str, &str)> { + let (name, assigned) = value.split_once('=')?; + let mut characters = name.chars(); + let first = characters.next()?; + if !(first == '_' || first.is_ascii_alphabetic()) + || !characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + return None; + } + Some((name, assigned)) +} + +#[cfg(test)] +#[path = "tests/wrappers.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index e0e792814..82526fd30 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -7,8 +7,8 @@ use zbus::fdo::DBusProxy; use zbus::Connection; use super::desktop_index::{ - normalize_desktop_id, normalize_name, record_launch_matches, DesktopIdentityIndex, - DesktopRecord, + normalize_desktop_id, normalize_name, verify_record_launch, DesktopIdentityIndex, + DesktopRecord, LaunchFailure, LaunchVerification, }; use super::executable::{executable_evidence_for_path, FileIdentity}; use super::policy::inline_reply_policy; @@ -30,6 +30,12 @@ pub(in crate::daemon) struct AttributionResolution { #[derive(Clone, Copy)] struct VerifiedDesktopRecord<'record>(&'record DesktopRecord); +#[derive(Clone, Copy)] +struct CandidateVerification<'record> { + record: &'record DesktopRecord, + verification: LaunchVerification, +} + pub(in crate::daemon) fn unknown_reply_denied( claim: AppClaim<'_>, sender: &SenderMetadata, @@ -77,51 +83,39 @@ fn resolve_with_evidence( claim: AppClaim<'_>, sender: &SenderMetadata, index: &DesktopIdentityIndex, - owned_desktop_ids: &HashSet, + _owned_desktop_ids: &HashSet, ) -> AttributionResolution { - // An explicit desktop hint is accepted only when its executable is the sender file let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); - let mut desktop_hint_conflict = None; - if let Some(desktop_id) = desktop_entry.as_deref() { - let records = index.records_for_id(desktop_id); - if !records.is_empty() { - if claim.reported_name.trim().is_empty() && trusted_portal_path(sender, index).is_some() - { - // Portal backends forward a broker-verified app id as desktop-entry - return resolution_for_portal_record(records[0], sender, index); - } - if let Some(record) = records - .iter() - .find_map(|record| verify_record_sender(record, sender)) - { - return resolution_for_record(record, claim.reported_name, sender, index); - } - if records - .iter() - .any(|record| owned_desktop_ids.contains(&normalize_desktop_id(&record.id))) - { - // Session applications can request names, so ownership is context rather than proof - desktop_hint_conflict = Some("bus name ownership lacks executable association"); - } else { - // Packaging aliases may be stale, so exact executable evidence still gets a chance - desktop_hint_conflict = Some("desktop identity mismatch"); - } - } + let hint_records = desktop_entry + .as_deref() + .map_or_else(Vec::new, |desktop_id| index.records_for_id(desktop_id)); + if desktop_entry.is_some() + && !hint_records.is_empty() + && claim.reported_name.trim().is_empty() + && trusted_portal_path(sender, index).is_some() + { + // Portal backends forward a broker-verified app id as desktop-entry + return resolution_for_portal_record(hint_records[0], sender, index); + } + + // Hint and live-executable candidates are evaluated together so weak metadata cannot win early + let mut candidates = hint_records.clone(); + if let Some(identity) = sender.sender_executable_identity { + candidates.extend(index.records_for_executable(identity)); + } + candidates.dedup_by(|left, right| std::ptr::eq(*left, *right)); + let results = candidates + .iter() + .map(|record| CandidateVerification { + record, + verification: verify_record_sender(record, sender, index), + }) + .collect::>(); + if let Some(record) = strongest_verified_result(&results, claim.reported_name) { + return resolution_for_record(record, claim.reported_name, sender, index); } if let Some(identity) = sender.sender_executable_identity { - // Exact file association is stronger than every caller-controlled application name - let records = index.records_for_executable(identity); - if let Some(record) = verified_executable_record(&records, claim.reported_name, sender) { - return resolution_for_record(record, claim.reported_name, sender, index); - } - if records - .iter() - .any(|record| record.system_association && record_matches_sender(record, sender)) - { - // A known executable with a conflicting name must fail closed - return conflict_resolution(claim.reported_name, sender, "application claim mismatch"); - } if let Some(path) = index.trusted_relay_path(identity) { // Relay groups include both relay identity and the relayed claim let group_key = format!( @@ -139,11 +133,46 @@ fn resolve_with_evidence( } } - if let Some(reason) = desktop_hint_conflict { - return conflict_resolution(claim.reported_name, sender, reason); + let hint_is_definitive = !hint_records.is_empty() + && results + .iter() + .filter(|result| { + hint_records + .iter() + .any(|record| std::ptr::eq(*record, result.record)) + }) + .all(CandidateVerification::is_definitive_mismatch); + let matching_system_is_definitive = results.iter().any(|result| { + result.record.system_association + && result.record.claim_matches(claim.reported_name) + && result.is_definitive_mismatch() + }); + if hint_is_definitive || matching_system_is_definitive { + return conflict_resolution( + claim.reported_name, + sender, + launch_failure_label( + results + .iter() + .find(|result| result.is_definitive_mismatch()) + .map_or( + LaunchFailure::DesktopClaimMismatch, + CandidateVerification::failure, + ), + ), + ); } - if index.claim_matches_system_app(claim.reported_name) { + let matching_claim_has_insufficient_evidence = results.iter().any(|result| { + result.record.claim_matches(claim.reported_name) + && matches!( + result.verification, + LaunchVerification::InsufficientEvidence(_) + ) + }); + if index.claim_matches_system_app(claim.reported_name) + && !matching_claim_has_insufficient_evidence + { // Protected branding without the matching executable is an explicit conflict return conflict_resolution(claim.reported_name, sender, "executable identity mismatch"); } @@ -161,6 +190,71 @@ fn resolve_with_evidence( )) } +impl CandidateVerification<'_> { + const fn is_definitive_mismatch(&self) -> bool { + matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) + } + + const fn failure(&self) -> LaunchFailure { + match self.verification { + LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, + LaunchVerification::InsufficientEvidence(reason) + | LaunchVerification::DefinitiveMismatch(reason) => reason, + } + } +} + +fn strongest_verified_result<'record>( + results: &[CandidateVerification<'record>], + reported_name: &str, +) -> Option> { + let missing_name = reported_name.trim().is_empty(); + let mut verified = results.iter().filter(|result| { + matches!(result.verification, LaunchVerification::Verified(_)) + && (missing_name || result.record.claim_matches(reported_name)) + }); + let first = verified.next()?; + let mut preferred = first; + for candidate in verified { + let preferred_rank = record_trust_rank(preferred.record); + let candidate_rank = record_trust_rank(candidate.record); + if candidate_rank > preferred_rank { + preferred = candidate; + continue; + } + if candidate_rank == preferred_rank + && normalize_desktop_id(&candidate.record.id) + != normalize_desktop_id(&preferred.record.id) + { + // Equal-strength records for distinct applications remain ambiguous + return None; + } + } + Some(VerifiedDesktopRecord(preferred.record)) +} + +const fn record_trust_rank(record: &DesktopRecord) -> u8 { + if record.system_association { + 2 + } else { + 1 + } +} + +const fn launch_failure_label(reason: LaunchFailure) -> &'static str { + match reason { + LaunchFailure::MissingCommandLine => "missing command-line evidence", + LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", + LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", + LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", + LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", + LaunchFailure::ExecutableMismatch => "executable identity mismatch", + LaunchFailure::ProtectedPayloadMismatch => "protected application payload mismatch", + LaunchFailure::RequiredArgumentMismatch => "required launch argument mismatch", + LaunchFailure::DesktopClaimMismatch => "desktop claim mismatch", + } +} + fn resolution_for_portal_record( record: &DesktopRecord, sender: &SenderMetadata, @@ -274,67 +368,42 @@ const fn policy_resolution(attribution: NotificationAttribution) -> AttributionR } } -fn verified_executable_record<'record>( - records: &[&'record DesktopRecord], - reported_name: &str, +fn verify_record_sender( + record: &DesktopRecord, sender: &SenderMetadata, -) -> Option> { - let missing_name = reported_name.trim().is_empty(); - let mut matches = records.iter().filter_map(|record| { - (missing_name || record.claim_matches(reported_name)) - .then(|| verify_record_sender(record, sender)) - .flatten() - }); - let first = matches.next()?; - let first_id = normalize_desktop_id(&first.0.id); - let mut preferred = first; - - for candidate in matches { - // One executable cannot prove which of two distinct desktop applications sent the message - if normalize_desktop_id(&candidate.0.id) != first_id { - return None; - } - // Protected records win over duplicate user metadata for the same desktop id - if candidate.0.system_association && !preferred.0.system_association { - preferred = candidate; - } - } - Some(preferred) -} - -fn record_matches_sender(record: &DesktopRecord, sender: &SenderMetadata) -> bool { + index: &DesktopIdentityIndex, +) -> LaunchVerification { if !record.association_eligible { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); } let (Some(record_identity), Some(sender_identity)) = ( record.executable_identity, sender.sender_executable_identity, ) else { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); }; if !record_identity.same_file(sender_identity) { - return false; + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); } if record.system_association { // Cached inode equality cannot carry root ownership across inode reuse if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); } let Some(path) = record.executable_path.as_deref() else { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); }; // Reopen the installed path so stale index authority cannot outlive replacement let Some(current) = executable_evidence_for_path(path) else { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); }; if !current_system_identity_matches_sender(current.identity, sender_identity) { - return false; + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); } } - // Exact argv matching prevents any executable from acting as an implicit shared launcher - record_launch_matches(record, sender_identity, sender.sender_cmdline.as_deref()) + verify_record_launch(record, index, sender_identity, &sender.command_line) } const fn current_system_identity_matches_sender( @@ -347,14 +416,6 @@ const fn current_system_identity_matches_sender( && current.is_executable_regular() } -fn verify_record_sender<'record>( - record: &'record DesktopRecord, - sender: &SenderMetadata, -) -> Option> { - // This wrapper makes sender launch verification mandatory at every association call site - record_matches_sender(record, sender).then_some(VerifiedDesktopRecord(record)) -} - fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { // Unknown senders cannot merge into a trusted desktop group by copying its name let claim = normalize_name(reported_name); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index f84c16523..d47236d22 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -16,6 +16,21 @@ use super::{executable_evidence_for_pid, FileIdentity}; const MAX_PROCESS_CMDLINE_BYTES: u64 = 128 * 1024; const MAX_PROCESS_ARGUMENTS: usize = 256; +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum CommandLineQuality { + Structured, + RewrittenProcessTitle, + Truncated, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon::notifications) struct CommandLineEvidence { + pub(in crate::daemon::notifications) argv: Vec>, + pub(in crate::daemon::notifications) quality: CommandLineQuality, +} + #[derive(Debug, Clone, Default)] pub(in crate::daemon) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks @@ -28,8 +43,8 @@ pub(in crate::daemon) struct SenderMetadata { pub(in crate::daemon::notifications) sender_executable: Option, // Device and inode bind policy to the open running executable rather than its basename pub(in crate::daemon::notifications) sender_executable_identity: Option, - // NUL-delimited process arguments prove fixed desktop Exec literals for shared runtimes - pub(in crate::daemon::notifications) sender_cmdline: Option>>, + // Quality is explicit because processes may rewrite the visible procfs argument memory + pub(in crate::daemon::notifications) command_line: CommandLineEvidence, } pub(in crate::daemon) async fn resolve_sender_metadata( @@ -46,7 +61,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, - sender_cmdline: None, + command_line: CommandLineEvidence::default(), }; }; @@ -63,7 +78,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, - sender_cmdline: None, + command_line: CommandLineEvidence::default(), }; }; @@ -74,7 +89,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time: None, sender_executable: None, sender_executable_identity: None, - sender_cmdline: None, + command_line: CommandLineEvidence::default(), }; }; @@ -82,11 +97,14 @@ pub(in crate::daemon) async fn resolve_sender_metadata( let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); let (sender_start_time, process_evidence) = sender_pid.map_or((None, None), |pid| { let start_before = read_process_start_time(pid); - let evidence = (executable_evidence_for_pid(pid), read_process_cmdline(pid)); + let executable = executable_evidence_for_pid(pid); + let command_line = read_process_cmdline(pid, executable.as_ref()); + let evidence = (executable, command_line); let start_after = read_process_start_time(pid); stable_process_evidence(start_before, Some(evidence), start_after) }); - let (executable_evidence, sender_cmdline) = process_evidence.unwrap_or((None, None)); + let (executable_evidence, command_line) = + process_evidence.unwrap_or_else(|| (None, CommandLineEvidence::default())); let sender_executable = executable_evidence .as_ref() .map(|evidence| evidence.canonical_path.display().to_string()); @@ -98,7 +116,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_start_time, sender_executable, sender_executable_identity, - sender_cmdline, + command_line, }; // Failed lookups remain retryable instead of becoming persistent unknown identities if metadata.sender_start_time.is_some() && metadata.sender_executable_identity.is_some() { @@ -117,14 +135,14 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen // Refresh every process-derived field before a security-sensitive association decision let start_before = read_process_start_time(pid); let executable = executable_evidence_for_pid(pid); - let cmdline = read_process_cmdline(pid); + let command_line = read_process_cmdline(pid, executable.as_ref()); let start_after = read_process_start_time(pid); if !process_lifetime_matches(start_before, expected_start, start_after) { // Stale cache entries retain bus context but lose all application identity authority refreshed.sender_start_time = None; refreshed.sender_executable = None; refreshed.sender_executable_identity = None; - refreshed.sender_cmdline = None; + refreshed.command_line = CommandLineEvidence::default(); return refreshed; } @@ -132,7 +150,7 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen .as_ref() .map(|evidence| evidence.canonical_path.display().to_string()); refreshed.sender_executable_identity = executable.map(|evidence| evidence.identity); - refreshed.sender_cmdline = cmdline; + refreshed.command_line = command_line; refreshed } @@ -153,15 +171,32 @@ fn read_process_start_time(pid: u32) -> Option { } #[cfg(target_os = "linux")] -fn read_process_cmdline(pid: u32) -> Option>> { +fn read_process_cmdline( + pid: u32, + executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { let path = format!("/proc/{pid}/cmdline"); let mut bytes = Vec::new(); - File::open(path) - .ok()? + let Some(file) = File::open(path).ok() else { + return CommandLineEvidence::default(); + }; + if file .take(MAX_PROCESS_CMDLINE_BYTES + 1) .read_to_end(&mut bytes) - .ok()?; - parse_process_cmdline(bytes) + .is_err() + { + return CommandLineEvidence::default(); + } + if bytes.len() as u64 > MAX_PROCESS_CMDLINE_BYTES { + return CommandLineEvidence { + argv: Vec::new(), + quality: CommandLineQuality::Truncated, + }; + } + let Some(argv) = parse_process_cmdline(bytes) else { + return CommandLineEvidence::default(); + }; + classify_command_line(argv, executable) } #[cfg(target_os = "linux")] @@ -187,8 +222,31 @@ fn read_process_start_time(_pid: u32) -> Option { } #[cfg(not(target_os = "linux"))] -fn read_process_cmdline(_pid: u32) -> Option>> { - None +fn read_process_cmdline( + _pid: u32, + _executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { + CommandLineEvidence::default() +} + +fn classify_command_line( + argv: Vec>, + executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { + let rewritten = executable.is_some_and(|executable| { + argv.as_slice().first().is_some_and(|value| { + let prefix = executable.canonical_path.as_os_str().as_encoded_bytes(); + value.starts_with(prefix) && value.iter().any(u8::is_ascii_whitespace) + }) && argv.len() == 1 + }); + CommandLineEvidence { + argv, + quality: if rewritten { + CommandLineQuality::RewrittenProcessTitle + } else { + CommandLineQuality::Structured + }, + } } fn stable_process_evidence( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 8f67b16ec..2c1e5c04b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -5,9 +5,10 @@ use unixnotis_core::{AttributionClass, InlineReplyPolicy}; use super::*; use crate::daemon::notifications::identity::desktop_index::model::{ - ExecutableIdentity, LaunchArgument, LaunchSpec, LiteralArgument, + ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, }; use crate::daemon::notifications::identity::desktop_index::{DesktopIdentityIndex, DesktopRecord}; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; use crate::daemon::notifications::identity::FileIdentity; trait DesktopRecordFixture { @@ -46,6 +47,8 @@ impl DesktopRecordFixture for DesktopRecord { launch_spec: Some(LaunchSpec { executable: identity, arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), literal_files_are_system_managed: true, }), names: HashSet::from([normalize_name(display_name)]), @@ -67,6 +70,8 @@ impl DesktopRecordFixture for DesktopRecord { }) }) .collect(), + environment: Vec::new(), + wrappers: Vec::new(), literal_files_are_system_managed: true, }); self @@ -124,19 +129,23 @@ fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { sender_name: Some(":1.42".to_string()), sender_executable: Some(path.to_string()), sender_executable_identity: Some(identity), - sender_cmdline: Some(vec![path.as_bytes().to_vec()]), + command_line: CommandLineEvidence { + argv: vec![path.as_bytes().to_vec()], + quality: CommandLineQuality::Structured, + }, ..SenderMetadata::default() } } fn sender_with_arguments(path: &str, identity: FileIdentity, arguments: &[&str]) -> SenderMetadata { let mut metadata = sender(path, identity); - metadata.sender_cmdline = Some( - std::iter::once(path) + metadata.command_line = CommandLineEvidence { + argv: std::iter::once(path) .chain(arguments.iter().copied()) .map(|argument| argument.as_bytes().to_vec()) .collect(), - ); + quality: CommandLineQuality::Structured, + }; metadata } @@ -153,6 +162,22 @@ fn installed_system_executable() -> (String, FileIdentity) { (path.display().to_string(), evidence.identity) } +fn verified_executable_record<'record>( + records: &[&'record DesktopRecord], + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option> { + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: verify_record_sender(record, sender, index), + }) + .collect::>(); + strongest_verified_result(&results, reported_name) +} + #[path = "resolver/association.rs"] mod association; #[path = "resolver/portal.rs"] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs index cea0a963e..50bcca66b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs @@ -65,6 +65,61 @@ fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } +#[test] +fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.App", "Example App", &app_path, app_identity) + .with_launch_literals(&["--fixed"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender_with_arguments( + &app_path, + app_identity, + &["--display-backend=x11", "--fixed", "--tray"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); +} + +#[test] +fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.App", "Example App", &app_path, app_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut rewritten = sender(&app_path, app_identity); + rewritten.command_line = CommandLineEvidence { + argv: vec![format!("{app_path} --runtime-flag").into_bytes()], + quality: CommandLineQuality::RewrittenProcessTitle, + }; + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &rewritten, + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.class, + AttributionClass::SystemAssociated + ); + assert_ne!(resolution.attribution.class, AttributionClass::Conflict); +} + #[test] fn verified_executable_recovers_from_stale_desktop_hint() { let (signal_path, signal_identity) = installed_system_executable(); @@ -145,8 +200,9 @@ fn duplicate_desktop_id_prefers_the_protected_record() { let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); let records = index.records_for_executable(app_identity); - let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) - .expect("duplicate desktop id should keep one verified record"); + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate desktop id should keep one verified record"); assert!(verified.0.system_association); assert_eq!(verified.0.badge_icon, "protected-signal"); @@ -162,8 +218,9 @@ fn duplicate_protected_desktop_id_keeps_stable_index_order() { let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); let records = index.records_for_executable(app_identity); - let verified = verified_executable_record(&records, "", &sender(&app_path, app_identity)) - .expect("duplicate protected records should keep one verified record"); + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate protected records should keep one verified record"); assert_eq!(verified.0.badge_icon, "first-signal"); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs index 169f342d1..672f4d494 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs @@ -172,7 +172,7 @@ fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { } #[test] -fn unavailable_process_command_line_fails_closed() { +fn dedicated_executable_remains_verified_when_command_line_is_unavailable() { let (launcher_path, launcher_identity) = installed_system_executable(); let record = system_record( "org.example.CommandLine", @@ -182,7 +182,7 @@ fn unavailable_process_command_line_fails_closed() { ); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let mut missing_command_line = sender(&launcher_path, launcher_identity); - missing_command_line.sender_cmdline = None; + missing_command_line.command_line = CommandLineEvidence::default(); let resolution = resolve_with_evidence( AppClaim { @@ -194,11 +194,11 @@ fn unavailable_process_command_line_fails_closed() { &HashSet::new(), ); - assert_ne!( + assert_eq!( resolution.attribution.class, AttributionClass::SystemAssociated ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } #[test] @@ -332,3 +332,39 @@ fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { assert_eq!(resolution.attribution.class, AttributionClass::Unknown); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } + +#[test] +fn dynamic_only_contract_is_unverified_instead_of_suspicious() { + let runtime_identity = identity(70, 700, 0); + let mut first = system_record( + "org.example.Dynamic", + "Dynamic App", + "/usr/bin/runtime", + runtime_identity, + ); + first + .launch_spec + .as_mut() + .expect("dynamic launch spec") + .arguments = vec![LaunchArgument::FieldCode(FieldCode::Files)]; + let second = system_record( + "org.example.Other", + "Other App", + "/usr/bin/runtime", + runtime_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Dynamic App", + desktop_entry: Some("org.example.Dynamic"), + }, + &sender_with_arguments("/usr/bin/runtime", runtime_identity, &["/tmp/payload"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs index 3ea29db57..6211bd7c5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs @@ -218,7 +218,7 @@ fn malicious_notify_send_basename_is_not_a_trusted_relay() { } #[test] -fn owned_dbus_application_name_cannot_replace_executable_association() { +fn owned_dbus_application_name_without_executable_evidence_remains_unverified() { let app_identity = identity(4, 40, 0); let mut record = DesktopRecord::fixture( "org.example.App", @@ -242,12 +242,12 @@ fn owned_dbus_application_name_cannot_replace_executable_association() { &owned, ); - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.attribution.class, AttributionClass::Unknown); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert!(resolution .attribution .source_label - .contains("bus name ownership lacks executable association")); + .contains("/usr/lib/example-launcher")); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 3ffcd8950..7989bed71 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -36,15 +36,15 @@ fn process_cmdline_parser_preserves_argument_boundaries_and_rejects_truncation() async fn process_metadata_helpers_read_current_process_on_linux() { let pid = std::process::id(); - let exe = executable_evidence_for_pid(pid) - .map(|evidence| evidence.canonical_path) - .expect("current process executable should be readable"); - assert!(exe.is_absolute()); + let executable = + executable_evidence_for_pid(pid).expect("current process executable should be readable"); + assert!(executable.canonical_path.is_absolute()); let start_time = read_process_start_time(pid).expect("current process start time should exist"); assert!(start_time > 1); - let cmdline = read_process_cmdline(pid).expect("current process cmdline should exist"); - assert!(!cmdline.is_empty()); + let command_line = read_process_cmdline(pid, Some(&executable)); + assert_eq!(command_line.quality, CommandLineQuality::Structured); + assert!(!command_line.argv.is_empty()); } #[test] @@ -82,7 +82,11 @@ fn security_refresh_reloads_current_process_evidence() { assert_eq!(refreshed.sender_start_time, Some(start_time)); assert!(refreshed.sender_executable_identity.is_some()); - assert!(refreshed.sender_cmdline.is_some()); + assert_eq!( + refreshed.command_line.quality, + CommandLineQuality::Structured + ); + assert!(!refreshed.command_line.argv.is_empty()); } #[cfg(target_os = "linux")] @@ -102,7 +106,10 @@ fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { uid: 0, mode: 0o100_755, }), - sender_cmdline: Some(vec![b"/usr/bin/trusted-app".to_vec()]), + command_line: CommandLineEvidence { + argv: vec![b"/usr/bin/trusted-app".to_vec()], + quality: CommandLineQuality::Structured, + }, ..SenderMetadata::default() }; @@ -111,7 +118,31 @@ fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { assert!(refreshed.sender_start_time.is_none()); assert!(refreshed.sender_executable.is_none()); assert!(refreshed.sender_executable_identity.is_none()); - assert!(refreshed.sender_cmdline.is_none()); + assert_eq!( + refreshed.command_line.quality, + CommandLineQuality::Unavailable + ); + assert!(refreshed.command_line.argv.is_empty()); +} + +#[test] +fn rewritten_process_title_is_kept_as_unstructured_evidence() { + let executable = super::super::executable::ExecutableEvidence { + canonical_path: "/opt/example/example-app".into(), + identity: FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }, + }; + let evidence = classify_command_line( + vec![b"/opt/example/example-app --runtime-flag".to_vec()], + Some(&executable), + ); + + assert_eq!(evidence.quality, CommandLineQuality::RewrittenProcessTitle); + assert_eq!(evidence.argv.len(), 1); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index bfef10854..5d61b1f1c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -1,5 +1,5 @@ use super::{SenderMetadataCache, MAX_CACHED_SENDERS}; -use crate::daemon::notifications::identity::sender::SenderMetadata; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, SenderMetadata}; fn metadata(sender: &str, pid: u32) -> SenderMetadata { SenderMetadata { @@ -8,7 +8,7 @@ fn metadata(sender: &str, pid: u32) -> SenderMetadata { sender_start_time: Some(u64::from(pid)), sender_executable: Some(format!("/usr/bin/app-{pid}")), sender_executable_identity: None, - sender_cmdline: None, + command_line: CommandLineEvidence::default(), } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index 438432632..629e87f1b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -28,7 +28,7 @@ fn build_notification_clamps_summary_and_body_sizes() { sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), sender_executable_identity: None, - sender_cmdline: None, + ..SenderMetadata::default() }, attribution: unixnotis_core::NotificationAttribution::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, @@ -54,7 +54,7 @@ fn build_notification_strips_display_spoofing_controls() { sender_start_time: Some(77), sender_executable: Some("/usr/bin/test-app".to_string()), sender_executable_identity: None, - sender_cmdline: None, + ..SenderMetadata::default() }, attribution: unixnotis_core::NotificationAttribution::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index dba49d910..719a5ff0b 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -11,7 +11,6 @@ clippy::ref_option, clippy::significant_drop_tightening, clippy::struct_excessive_bools, - clippy::struct_field_names, clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps, clippy::unused_async, From 8d6ac3706ec2b23e589ab1873e856f0a08de2f34 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 14:15:19 -0500 Subject: [PATCH 127/275] feat(diagnostics): expose structured attribution reasons Summary: expose structured attribution reasons. Scope: diagnostics. --- crates/noticenterctl/src/cli/command.rs | 4 + crates/noticenterctl/src/dbus/client.rs | 19 +- crates/noticenterctl/src/dbus/commands.rs | 11 +- .../noticenterctl/src/dbus/tests/commands.rs | 4 + .../noticenterctl/src/dbus/tests/support.rs | 18 +- .../noticenterctl/src/output/diagnostics.rs | 156 +++++++++++++++ crates/noticenterctl/src/output/mod.rs | 2 + .../unixnotis-core/src/control/diagnostics.rs | 21 +++ crates/unixnotis-core/src/control/mod.rs | 2 + .../src/control/notification.rs | 27 ++- crates/unixnotis-core/src/control/proxy.rs | 7 +- .../src/control/tests/notification.rs | 40 ++++ .../unixnotis-core/src/model/diagnostics.rs | 67 +++++++ crates/unixnotis-core/src/model/mod.rs | 5 + .../unixnotis-core/src/model/notification.rs | 4 + .../src/model/tests/diagnostics.rs | 41 ++++ .../src/model/tests/notification.rs | 1 + .../src/daemon/control/query.rs | 20 +- .../src/daemon/control/server.rs | 12 +- .../src/daemon/control/tests/action.rs | 1 + .../src/daemon/control/tests/reply.rs | 1 + .../src/daemon/control/tests/server.rs | 1 + .../identity/desktop_index/mod.rs | 2 +- .../daemon/notifications/identity/resolver.rs | 178 ++++++++++++------ .../identity/resolver/diagnostics.rs | 98 ++++++++++ .../notifications/identity/tests/resolver.rs | 5 +- .../identity/tests/resolver/association.rs | 12 ++ .../identity/tests/resolver/runtime.rs | 20 +- .../identity/tests/resolver/spoof.rs | 4 + .../daemon/notifications/ingress/payload.rs | 7 +- .../notifications/ingress/tests/payload.rs | 8 + .../src/daemon/notifications/server/flow.rs | 13 ++ .../daemon/notifications/server/tests/flow.rs | 1 + .../state/tests/notification_lifecycle.rs | 1 + crates/unixnotis-daemon/src/store/model.rs | 13 +- crates/unixnotis-daemon/src/store/runtime.rs | 33 +++- .../src/store/test_support.rs | 1 + .../src/store/tests/runtime.rs | 36 +++- crates/unixnotis-daemon/src/tests/expire.rs | 1 + .../src/dbus/runtime/delivery.rs | 4 +- .../src/dbus/runtime/tests/delivery.rs | 10 +- 41 files changed, 827 insertions(+), 84 deletions(-) create mode 100644 crates/noticenterctl/src/output/diagnostics.rs create mode 100644 crates/unixnotis-core/src/control/diagnostics.rs create mode 100644 crates/unixnotis-core/src/control/tests/notification.rs create mode 100644 crates/unixnotis-core/src/model/diagnostics.rs create mode 100644 crates/unixnotis-core/src/model/tests/diagnostics.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index ff45a289c..d3b741cf7 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -38,6 +38,10 @@ pub enum Command { Dismiss { id: u32, }, + // Explain application identity and popup suppression for one active notification + ExplainNotification { + id: u32, + }, // List active notifications; full output requires diagnostic mode ListActive { #[arg(long)] diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index 90ff113a5..6a672c0e6 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -2,7 +2,9 @@ use std::future::Future; use std::pin::Pin; use anyhow::Result; -use unixnotis_core::{ControlProxy, InhibitorInfo, NotificationView, PanelDebugLevel}; +use unixnotis_core::{ + ControlProxy, InhibitorInfo, NotificationDiagnosticsView, NotificationView, PanelDebugLevel, +}; use super::timeout::run_control_call; @@ -35,6 +37,12 @@ pub trait ControlClient { // Remove one notification by its id fn dismiss(&self, id: u32) -> ControlFuture<'_, ()>; + // Fetch structured attribution and popup state for one active notification + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec>; + // Fetch the notifications that are active right now fn list_active(&self) -> ControlFuture<'_, Vec>; @@ -104,6 +112,15 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::dismiss(self, id))) } + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec> { + Box::pin(run_control_call( + ControlProxy::get_notification_diagnostics(self, id), + )) + } + fn list_active(&self) -> ControlFuture<'_, Vec> { // Ask for the current active notifications and return them as view-friendly data Box::pin(run_control_call(ControlProxy::list_active(self))) diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index 0e9e91336..148b3677c 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -4,8 +4,8 @@ use unixnotis_core::util; use crate::cli::{Command, DndState}; use crate::debug_logs::follow_debug_logs; use crate::output::{ - allow_full_output, print_inhibitors, print_notifications, warn_full_requires_diagnostic, - write_stderr, write_stdout, + allow_full_output, print_inhibitors, print_notification_diagnostics, print_notifications, + warn_full_requires_diagnostic, write_stderr, write_stdout, }; use super::client::ControlClient; @@ -57,6 +57,13 @@ pub(super) async fn handle_command_with_debug_logs( // Dismiss targets a single notification by id client.dismiss(id).await?; } + Command::ExplainNotification { id } => { + let mut diagnostics = client.notification_diagnostics(id).await?; + let view = diagnostics + .pop() + .ok_or_else(|| anyhow::anyhow!("notification {id} is not active"))?; + print_notification_diagnostics(&view)?; + } Command::ListActive { full } => { let diagnostic_mode = util::diagnostic_mode(); let allow_full = allow_full_output(full, diagnostic_mode); diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs index 72a49a92c..7c19d9f3a 100644 --- a/crates/noticenterctl/src/dbus/tests/commands.rs +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -182,6 +182,10 @@ async fn timed_dnd_dispatch_rejects_non_on_state_without_calling_control() { async fn notification_commands_dispatch_to_matching_control_calls() { let cases = [ (Command::Dismiss { id: 7 }, RecordedCall::Dismiss(7)), + ( + Command::ExplainNotification { id: 8 }, + RecordedCall::NotificationDiagnostics(8), + ), ( Command::ListActive { full: false }, RecordedCall::ListActive, diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs index 616e593c3..164ea948e 100644 --- a/crates/noticenterctl/src/dbus/tests/support.rs +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -1,6 +1,8 @@ use std::cell::RefCell; -use unixnotis_core::{InhibitorInfo, NotificationView, PanelDebugLevel}; +use unixnotis_core::{ + InhibitorInfo, NotificationDiagnosticsView, NotificationView, PanelDebugLevel, +}; use super::super::client::{ControlClient, ControlFuture}; @@ -14,6 +16,7 @@ pub(super) enum RecordedCall { ClearActive, ClearHistory, Dismiss(u32), + NotificationDiagnostics(u32), ListActive, ListHistory, SetDnd(bool), @@ -95,6 +98,19 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::Dismiss(id), ()) } + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec> { + self.record( + RecordedCall::NotificationDiagnostics(id), + vec![NotificationDiagnosticsView { + id, + ..NotificationDiagnosticsView::default() + }], + ) + } + fn list_active(&self) -> ControlFuture<'_, Vec> { self.record(RecordedCall::ListActive, Vec::new()) } diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs new file mode 100644 index 000000000..7fbeb8595 --- /dev/null +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -0,0 +1,156 @@ +//! Human-readable notification attribution and popup diagnostics + +use std::fmt::Write; + +use anyhow::Result; +use unixnotis_core::{ + CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + NotificationDiagnosticsView, PopupAdmissionView, RecordTrust, +}; + +use super::write_stdout; + +pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result<()> { + let diagnostics = &view.attribution; + let mut output = String::new(); + writeln!(output, "Notification: {}:{}", view.id, view.generation)?; + writeln!( + output, + "Application claim: {}", + value_or_none(&diagnostics.claimed_name) + )?; + writeln!( + output, + "Claimed desktop entry: {}", + value_or_none(&diagnostics.claimed_desktop_entry) + )?; + writeln!( + output, + "Sender executable: {}", + value_or_none(&diagnostics.sender_executable) + )?; + writeln!( + output, + "Matched desktop ID: {}", + value_or_none(&diagnostics.matched_desktop_id) + )?; + writeln!( + output, + "Record origin: {}", + record_trust(diagnostics.record_trust) + )?; + writeln!( + output, + "Launch authority: {}", + launch_authority(diagnostics.launch_authority) + )?; + writeln!( + output, + "Command line: {}", + command_line_quality(diagnostics.command_line_quality) + )?; + writeln!( + output, + "Identity result: {}", + verification(diagnostics.verification) + )?; + writeln!( + output, + "Identity reason: {}", + value_or_none(&diagnostics.reason) + )?; + writeln!(output, "Stored: {}", yes_no(view.stored))?; + writeln!( + output, + "Popup: {}", + if view.popup_admission.should_show() { + "allowed" + } else { + "suppressed" + } + )?; + writeln!( + output, + "Popup reason: {}", + popup_admission(view.popup_admission) + )?; + writeln!( + output, + "Renderer process: {}", + if view.renderer_process_running { + "running" + } else { + "unavailable" + } + )?; + writeln!(output, "Renderer ready: {}", yes_no(view.renderer_ready))?; + writeln!( + output, + "Configured max visible: {}", + view.configured_max_visible + )?; + write_stdout(&output) +} + +fn value_or_none(value: &str) -> &str { + if value.trim().is_empty() { + "none" + } else { + value + } +} + +const fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +const fn record_trust(value: RecordTrust) -> &'static str { + match value { + RecordTrust::None => "none", + RecordTrust::Portal => "portal", + RecordTrust::System => "system", + RecordTrust::User => "user", + } +} + +const fn launch_authority(value: LaunchAuthorityView) -> &'static str { + match value { + LaunchAuthorityView::None => "none", + LaunchAuthorityView::DedicatedExecutable => "dedicated executable", + LaunchAuthorityView::ProtectedPayload => "protected payload", + LaunchAuthorityView::DynamicOnly => "dynamic-only contract", + LaunchAuthorityView::Ambiguous => "ambiguous", + } +} + +const fn command_line_quality(value: CommandLineQualityView) -> &'static str { + match value { + CommandLineQualityView::Structured => "structured", + CommandLineQualityView::RewrittenProcessTitle => "rewritten process title", + CommandLineQualityView::Truncated => "truncated", + CommandLineQualityView::Unavailable => "unavailable", + } +} + +const fn verification(value: LaunchVerificationView) -> &'static str { + match value { + LaunchVerificationView::Verified => "verified", + LaunchVerificationView::InsufficientEvidence => "unverified", + LaunchVerificationView::DefinitiveMismatch => "suspicious", + } +} + +const fn popup_admission(value: PopupAdmissionView) -> &'static str { + match value { + PopupAdmissionView::Show => "show", + PopupAdmissionView::Rule => "rule", + PopupAdmissionView::Dnd => "DND", + PopupAdmissionView::Inhibitor => "inhibitor", + PopupAdmissionView::RendererUnavailable => "renderer unavailable", + PopupAdmissionView::RendererDisabled => "renderer disabled", + } +} diff --git a/crates/noticenterctl/src/output/mod.rs b/crates/noticenterctl/src/output/mod.rs index c4e76aae3..248d34ff0 100644 --- a/crates/noticenterctl/src/output/mod.rs +++ b/crates/noticenterctl/src/output/mod.rs @@ -1,10 +1,12 @@ //! Output formatting helpers for noticenterctl +mod diagnostics; mod error; mod gate; mod notifications; mod writer; +pub use diagnostics::print_notification_diagnostics; pub use error::format_cli_error; pub use gate::{allow_full_output, warn_full_requires_diagnostic}; pub use notifications::{print_inhibitors, print_notifications}; diff --git a/crates/unixnotis-core/src/control/diagnostics.rs b/crates/unixnotis-core/src/control/diagnostics.rs new file mode 100644 index 000000000..9ba1deb2c --- /dev/null +++ b/crates/unixnotis-core/src/control/diagnostics.rs @@ -0,0 +1,21 @@ +//! Read-only notification explanation returned by the control service + +use serde::{Deserialize, Serialize}; +use zbus::zvariant::Type; + +use crate::AttributionDiagnostics; + +use super::PopupAdmissionView; + +/// One active notification and the state that controls its popup rendering +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct NotificationDiagnosticsView { + pub id: u32, + pub generation: u64, + pub stored: bool, + pub attribution: AttributionDiagnostics, + pub popup_admission: PopupAdmissionView, + pub renderer_process_running: bool, + pub renderer_ready: bool, + pub configured_max_visible: u32, +} diff --git a/crates/unixnotis-core/src/control/mod.rs b/crates/unixnotis-core/src/control/mod.rs index 8f4f4fb50..bbd5f3ea0 100644 --- a/crates/unixnotis-core/src/control/mod.rs +++ b/crates/unixnotis-core/src/control/mod.rs @@ -1,6 +1,7 @@ //! D-Bus control interface types and proxy definitions mod constants; +mod diagnostics; mod notification; mod panel; mod policy; @@ -8,6 +9,7 @@ mod proxy; mod state; pub use constants::*; +pub use diagnostics::*; pub use notification::*; pub use panel::*; pub use policy::*; diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index e3fa3ff06..8afcda194 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -16,9 +16,34 @@ pub enum CloseReason { Undefined = 4, } +/// Current reason a stored notification may or may not become a popup +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum PopupAdmissionView { + Show = 0, + Rule = 1, + Dnd = 2, + Inhibitor = 3, + #[default] + RendererUnavailable = 4, + RendererDisabled = 5, +} + +impl PopupAdmissionView { + /// Whether the current admission permits popup rendering + #[must_use] + pub const fn should_show(self) -> bool { + matches!(self, Self::Show) + } +} + /// One atomic popup payload and its current admission decision #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Type)] pub struct PopupCandidate { pub notification: NotificationView, - pub should_show: bool, + pub admission: PopupAdmissionView, } + +#[cfg(test)] +#[path = "tests/notification.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index b32e915db..01ac29924 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -8,7 +8,7 @@ use zbus::proxy; -use crate::{NotificationView, PopupCandidate}; +use crate::{NotificationDiagnosticsView, NotificationView, PopupCandidate}; use super::{ CloseReason, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, PopupGateState, @@ -35,6 +35,11 @@ trait Control { fn get_active_notification(&self, id: u32) -> zbus::Result>; /// Fetch one current popup payload and admission decision atomically fn get_popup_candidate(&self, id: u32) -> zbus::Result>; + /// Explain attribution and popup admission for one active notification + fn get_notification_diagnostics( + &self, + id: u32, + ) -> zbus::Result>; /// Open the control center panel fn open_panel(&self) -> zbus::Result<()>; /// Open the control center panel with debug logging diff --git a/crates/unixnotis-core/src/control/tests/notification.rs b/crates/unixnotis-core/src/control/tests/notification.rs new file mode 100644 index 000000000..20580094d --- /dev/null +++ b/crates/unixnotis-core/src/control/tests/notification.rs @@ -0,0 +1,40 @@ +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +use super::PopupAdmissionView; + +#[test] +fn popup_admission_wire_values_remain_stable_and_complete() { + for (admission, expected) in [ + (PopupAdmissionView::Show, 0_u8), + (PopupAdmissionView::Rule, 1), + (PopupAdmissionView::Dnd, 2), + (PopupAdmissionView::Inhibitor, 3), + (PopupAdmissionView::RendererUnavailable, 4), + (PopupAdmissionView::RendererDisabled, 5), + ] { + let encoded = to_bytes(Context::new_dbus(LE, 0), &admission) + .expect("popup admission should serialize"); + + assert_eq!(encoded.bytes(), &[expected]); + } + + assert_eq!(PopupAdmissionView::signature(), u8::signature()); +} + +#[test] +fn only_show_admission_permits_popup_rendering() { + assert!(PopupAdmissionView::Show.should_show()); + + for admission in [ + PopupAdmissionView::Rule, + PopupAdmissionView::Dnd, + PopupAdmissionView::Inhibitor, + PopupAdmissionView::RendererUnavailable, + PopupAdmissionView::RendererDisabled, + ] { + assert!( + !admission.should_show(), + "{admission:?} should keep the popup hidden", + ); + } +} diff --git a/crates/unixnotis-core/src/model/diagnostics.rs b/crates/unixnotis-core/src/model/diagnostics.rs new file mode 100644 index 000000000..c4e769a8d --- /dev/null +++ b/crates/unixnotis-core/src/model/diagnostics.rs @@ -0,0 +1,67 @@ +//! Structured application-attribution evidence for diagnostic clients + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use zbus::zvariant::Type; + +/// Trust level of the desktop record selected by launch verification +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum RecordTrust { + #[default] + None = 0, + Portal = 1, + System = 2, + User = 3, +} + +/// Evidence that establishes or weakens one desktop launch association +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum LaunchAuthorityView { + #[default] + None = 0, + DedicatedExecutable = 1, + ProtectedPayload = 2, + DynamicOnly = 3, + Ambiguous = 4, +} + +/// Reliability of the argument boundaries read for the sender process +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum CommandLineQualityView { + Structured = 0, + RewrittenProcessTitle = 1, + Truncated = 2, + #[default] + Unavailable = 3, +} + +/// Summary of the strongest launch-verification result +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum LaunchVerificationView { + Verified = 0, + #[default] + InsufficientEvidence = 1, + DefinitiveMismatch = 2, +} + +/// Bounded evidence retained for notification explanation and debug logs +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct AttributionDiagnostics { + pub claimed_name: String, + pub claimed_desktop_entry: String, + pub sender_executable: String, + pub matched_desktop_id: String, + pub record_trust: RecordTrust, + pub launch_authority: LaunchAuthorityView, + pub command_line_quality: CommandLineQualityView, + pub verification: LaunchVerificationView, + pub reason: String, +} + +#[cfg(test)] +#[path = "tests/diagnostics.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index aaee0284c..b97c5e701 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -2,6 +2,7 @@ // Keep the public model surface small by splitting large helpers into files. mod attribution; +mod diagnostics; mod image; mod notification; mod reply; @@ -11,6 +12,10 @@ mod types; pub use attribution::{ ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationAttribution, }; +pub use diagnostics::{ + AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + RecordTrust, +}; pub use image::{ImageData, NotificationImage}; pub use notification::{Notification, NotificationKey, NotificationView}; pub use reply::InlineReply; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 300730b5d..898efc7a7 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; use super::attribution::{InlineReplyPolicy, NotificationAttribution}; +use super::diagnostics::AttributionDiagnostics; use super::image::NotificationImage; use super::reply::InlineReply; use super::types::{Action, Urgency}; @@ -31,6 +32,8 @@ pub struct Notification { pub app_icon: String, // Daemon-resolved application association stays stable for the notification lifetime pub attribution: NotificationAttribution, + // Structured evidence is retained for authenticated explanation requests + pub attribution_diagnostics: AttributionDiagnostics, // User-facing content as provided by the sender pub summary: String, pub body: String, @@ -135,6 +138,7 @@ impl Notification { app_name: self.app_name.clone(), app_icon: self.app_icon.clone(), attribution: self.attribution.clone(), + attribution_diagnostics: self.attribution_diagnostics.clone(), summary: self.summary.clone(), body: self.body.clone(), actions: self.actions.clone(), diff --git a/crates/unixnotis-core/src/model/tests/diagnostics.rs b/crates/unixnotis-core/src/model/tests/diagnostics.rs new file mode 100644 index 000000000..bb663d7ef --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/diagnostics.rs @@ -0,0 +1,41 @@ +use zbus::zvariant::{serialized::Context, to_bytes, LE}; + +use super::{ + AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + RecordTrust, +}; + +#[test] +fn attribution_diagnostics_round_trip_every_evidence_dimension() { + let diagnostics = AttributionDiagnostics { + claimed_name: "Example".to_string(), + claimed_desktop_entry: "org.example.App".to_string(), + sender_executable: "/opt/example/app".to_string(), + matched_desktop_id: "org.example.App".to_string(), + record_trust: RecordTrust::System, + launch_authority: LaunchAuthorityView::ProtectedPayload, + command_line_quality: CommandLineQualityView::RewrittenProcessTitle, + verification: LaunchVerificationView::InsufficientEvidence, + reason: "unstructured command-line evidence".to_string(), + }; + + let encoded = + to_bytes(Context::new_dbus(LE, 0), &diagnostics).expect("serialize attribution evidence"); + let decoded = encoded + .deserialize::() + .expect("deserialize attribution evidence") + .0; + + assert_eq!(decoded, diagnostics); +} + +#[test] +fn diagnostic_wire_enums_reject_unknown_values() { + let encoded = + to_bytes(Context::new_dbus(LE, 0), &u8::MAX).expect("serialize unknown evidence value"); + + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); +} diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 003d07dde..3e18d4814 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -29,6 +29,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { false, "desktop:org.example.Mail".to_string(), ), + attribution_diagnostics: crate::AttributionDiagnostics::default(), summary: "Subject".to_string(), body: "Body".to_string(), actions: vec![Action { diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 015e8a774..32cd66e85 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -2,7 +2,9 @@ //! //! Keeps read-only control methods grouped outside the main interface file -use unixnotis_core::{ControlState, InhibitorInfo, NotificationView, PopupCandidate}; +use unixnotis_core::{ + ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationView, PopupCandidate, +}; use zbus::message::Header; use super::ControlServer; @@ -72,6 +74,22 @@ impl ControlServer { Ok(store.popup_candidate(id).into_iter().collect()) } + pub(super) async fn query_notification_diagnostics( + &self, + id: u32, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Process evidence and notification content share the normal control authorization gate + self.authorize_control_call(header, "GetNotificationDiagnostics") + .await?; + let health = self.state.ui_health(); + let store = self.state.store.lock().await; + Ok(store + .notification_diagnostics(id, &health) + .into_iter() + .collect()) + } + pub(super) async fn query_inhibitors( &self, header: &Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index d29dd96cd..c6d75d7dc 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use unixnotis_core::{ - CloseReason, ControlState, InhibitorInfo, NotificationKey, NotificationView, PanelDebugLevel, - PanelRequest, PopupCandidate, PopupGateState, UiHealth, + CloseReason, ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationKey, + NotificationView, PanelDebugLevel, PanelRequest, PopupCandidate, PopupGateState, UiHealth, }; use zbus::message::Header; use zbus::{interface, SignalContext}; @@ -116,6 +116,14 @@ impl ControlServer { self.query_popup_candidate(id, &header).await } + async fn get_notification_diagnostics( + &self, + id: u32, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_notification_diagnostics(id, &header).await + } + async fn open_panel(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.request_panel_command(&header, "OpenPanel", PanelRequest::open()) .await diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index 48bb618df..b0f3c1a96 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -142,6 +142,7 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { false, "system-desktop:org.example.ActionApp".to_string(), ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "Action".to_string(), body: String::new(), actions: vec![Action { diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 5397d4830..630db2088 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -286,6 +286,7 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { app_name: "Messages".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "New message".to_string(), body: "Are you coming?".to_string(), actions: vec![unixnotis_core::Action { diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 013539a78..13158cbce 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -17,6 +17,7 @@ fn notification(summary: &str) -> Notification { app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index fe962f433..a249f6aff 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -13,7 +13,7 @@ mod wrappers; pub use model::DesktopIdentityIndex; pub(super) use model::DesktopRecord; -pub(super) use model::{LaunchFailure, LaunchVerification}; +pub(super) use model::{LaunchFailure, LaunchVerification, VerifiedLaunch}; pub(super) use names::{normalize_desktop_id, normalize_name}; pub use refresh::spawn_desktop_index_refresh; pub use scan::DesktopIndexSnapshot; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index 82526fd30..c29d9ceb7 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -2,18 +2,25 @@ use std::collections::HashSet; -use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use unixnotis_core::{ + AttributionClass, AttributionDiagnostics, InlineReplyPolicy, NotificationAttribution, + RecordTrust, +}; use zbus::fdo::DBusProxy; use zbus::Connection; use super::desktop_index::{ normalize_desktop_id, normalize_name, verify_record_launch, DesktopIdentityIndex, - DesktopRecord, LaunchFailure, LaunchVerification, + DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, }; use super::executable::{executable_evidence_for_path, FileIdentity}; use super::policy::inline_reply_policy; use super::sender::{refresh_sender_security_evidence, SenderMetadata}; +mod diagnostics; + +use diagnostics::{launch_failure_label, with_diagnostics}; + const MAX_DESKTOP_ID_BYTES: usize = 256; #[derive(Clone, Copy)] @@ -24,11 +31,12 @@ pub(in crate::daemon) struct AppClaim<'a> { pub(in crate::daemon) struct AttributionResolution { pub(in crate::daemon) attribution: NotificationAttribution, + pub(in crate::daemon) diagnostics: AttributionDiagnostics, pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, } #[derive(Clone, Copy)] -struct VerifiedDesktopRecord<'record>(&'record DesktopRecord); +struct VerifiedDesktopRecord<'record>(&'record DesktopRecord, VerifiedLaunch); #[derive(Clone, Copy)] struct CandidateVerification<'record> { @@ -45,14 +53,22 @@ pub(in crate::daemon) fn unknown_reply_denied( || reason.to_string(), |path| format!("{reason}; source {path}"), ); - AttributionResolution { + let resolution = AttributionResolution { attribution: NotificationAttribution::unknown( claim.reported_name, &source, unknown_group_key(claim.reported_name, sender), ), + diagnostics: AttributionDiagnostics::default(), inline_reply_policy: InlineReplyPolicy::Deny, - } + }; + with_diagnostics( + resolution, + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine), + ) } pub(in crate::daemon) async fn resolve_attribution( @@ -95,7 +111,16 @@ fn resolve_with_evidence( && trusted_portal_path(sender, index).is_some() { // Portal backends forward a broker-verified app id as desktop-entry - return resolution_for_portal_record(hint_records[0], sender, index); + let mut resolution = with_diagnostics( + resolution_for_portal_record(hint_records[0], sender, index), + claim, + sender, + Some(hint_records[0]), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.record_trust = RecordTrust::Portal; + resolution.diagnostics.reason = "verified portal application identity".to_string(); + return resolution; } // Hint and live-executable candidates are evaluated together so weak metadata cannot win early @@ -112,27 +137,29 @@ fn resolve_with_evidence( }) .collect::>(); if let Some(record) = strongest_verified_result(&results, claim.reported_name) { - return resolution_for_record(record, claim.reported_name, sender, index); + return with_diagnostics( + resolution_for_record(record, claim.reported_name, sender, index), + claim, + sender, + Some(record.0), + LaunchVerification::Verified(record.1), + ); } - if let Some(identity) = sender.sender_executable_identity { - if let Some(path) = index.trusted_relay_path(identity) { - // Relay groups include both relay identity and the relayed claim - let group_key = format!( - "relay:{}:{}", - identity.group_fragment(), - normalize_name(claim.reported_name) - ); - let attribution = NotificationAttribution::trusted_relay( - claim.reported_name, - &format!("Sent via {}", path.display()), - index.claim_matches_system_app(claim.reported_name), - group_key, - ); - return policy_resolution(attribution); - } + if let Some(resolution) = trusted_relay_resolution(claim, sender, index) { + return resolution; } + resolve_unverified_candidates(claim, sender, index, &hint_records, &results) +} + +fn resolve_unverified_candidates( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + hint_records: &[&DesktopRecord], + results: &[CandidateVerification<'_>], +) -> AttributionResolution { let hint_is_definitive = !hint_records.is_empty() && results .iter() @@ -148,18 +175,19 @@ fn resolve_with_evidence( && result.is_definitive_mismatch() }); if hint_is_definitive || matching_system_is_definitive { - return conflict_resolution( - claim.reported_name, + let mismatch = results + .iter() + .find(|result| result.is_definitive_mismatch()); + let failure = mismatch.map_or( + LaunchFailure::DesktopClaimMismatch, + CandidateVerification::failure, + ); + return with_diagnostics( + conflict_resolution(claim.reported_name, sender, launch_failure_label(failure)), + claim, sender, - launch_failure_label( - results - .iter() - .find(|result| result.is_definitive_mismatch()) - .map_or( - LaunchFailure::DesktopClaimMismatch, - CandidateVerification::failure, - ), - ), + mismatch.map(|result| result.record), + LaunchVerification::DefinitiveMismatch(failure), ); } @@ -174,7 +202,13 @@ fn resolve_with_evidence( && !matching_claim_has_insufficient_evidence { // Protected branding without the matching executable is an explicit conflict - return conflict_resolution(claim.reported_name, sender, "executable identity mismatch"); + return with_diagnostics( + conflict_resolution(claim.reported_name, sender, "executable identity mismatch"), + claim, + sender, + None, + LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + ); } let source = sender @@ -183,11 +217,57 @@ fn resolve_with_evidence( .map(|path| format!("Source: {path}")) .unwrap_or_default(); let group_key = unknown_group_key(claim.reported_name, sender); - policy_resolution(NotificationAttribution::unknown( + let insufficient = results.iter().find(|result| { + result.record.claim_matches(claim.reported_name) + && matches!( + result.verification, + LaunchVerification::InsufficientEvidence(_) + ) + }); + with_diagnostics( + policy_resolution(NotificationAttribution::unknown( + claim.reported_name, + &source, + group_key, + )), + claim, + sender, + insufficient.map(|result| result.record), + insufficient.map_or( + LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation), + |result| result.verification, + ), + ) +} + +fn trusted_relay_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option { + let identity = sender.sender_executable_identity?; + let path = index.trusted_relay_path(identity)?; + // Relay groups include both relay identity and the relayed claim + let group_key = format!( + "relay:{}:{}", + identity.group_fragment(), + normalize_name(claim.reported_name) + ); + let attribution = NotificationAttribution::trusted_relay( claim.reported_name, - &source, + &format!("Sent via {}", path.display()), + index.claim_matches_system_app(claim.reported_name), group_key, - )) + ); + let mut resolution = with_diagnostics( + policy_resolution(attribution), + claim, + sender, + None, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.reason = "verified trusted relay executable".to_string(); + Some(resolution) } impl CandidateVerification<'_> { @@ -230,7 +310,10 @@ fn strongest_verified_result<'record>( return None; } } - Some(VerifiedDesktopRecord(preferred.record)) + let LaunchVerification::Verified(launch) = preferred.verification else { + return None; + }; + Some(VerifiedDesktopRecord(preferred.record, launch)) } const fn record_trust_rank(record: &DesktopRecord) -> u8 { @@ -241,20 +324,6 @@ const fn record_trust_rank(record: &DesktopRecord) -> u8 { } } -const fn launch_failure_label(reason: LaunchFailure) -> &'static str { - match reason { - LaunchFailure::MissingCommandLine => "missing command-line evidence", - LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", - LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", - LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", - LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", - LaunchFailure::ExecutableMismatch => "executable identity mismatch", - LaunchFailure::ProtectedPayloadMismatch => "protected application payload mismatch", - LaunchFailure::RequiredArgumentMismatch => "required launch argument mismatch", - LaunchFailure::DesktopClaimMismatch => "desktop claim mismatch", - } -} - fn resolution_for_portal_record( record: &DesktopRecord, sender: &SenderMetadata, @@ -360,11 +429,12 @@ fn conflict_resolution( )) } -const fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { +fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { // Interaction policy remains separate so presentation changes cannot enable replies AttributionResolution { inline_reply_policy: inline_reply_policy(attribution.class), attribution, + diagnostics: AttributionDiagnostics::default(), } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs new file mode 100644 index 000000000..589ea3813 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs @@ -0,0 +1,98 @@ +//! Conversion from daemon launch evidence into stable diagnostic wire values + +use unixnotis_core::{ + AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + RecordTrust, +}; + +use super::{ + AppClaim, AttributionResolution, DesktopRecord, LaunchFailure, LaunchVerification, + SenderMetadata, VerifiedLaunch, +}; +use crate::daemon::notifications::identity::sender::CommandLineQuality; + +pub(super) fn with_diagnostics( + mut resolution: AttributionResolution, + claim: AppClaim<'_>, + sender: &SenderMetadata, + record: Option<&DesktopRecord>, + verification: LaunchVerification, +) -> AttributionResolution { + let (verification_view, launch_authority, reason) = match verification { + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::DedicatedExecutable, + "verified by dedicated executable identity", + ), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::ProtectedPayload, + "verified by executable and protected payload identity", + ), + LaunchVerification::InsufficientEvidence(failure) => ( + LaunchVerificationView::InsufficientEvidence, + launch_authority_for_failure(failure), + launch_failure_label(failure), + ), + LaunchVerification::DefinitiveMismatch(failure) => ( + LaunchVerificationView::DefinitiveMismatch, + launch_authority_for_failure(failure), + launch_failure_label(failure), + ), + }; + resolution.diagnostics = AttributionDiagnostics { + claimed_name: claim.reported_name.to_string(), + claimed_desktop_entry: claim.desktop_entry.unwrap_or_default().to_string(), + sender_executable: sender.sender_executable.clone().unwrap_or_default(), + matched_desktop_id: record.map_or_else(String::new, |record| record.id.clone()), + record_trust: record.map_or(RecordTrust::None, |record| { + if record.system_origin { + RecordTrust::System + } else { + RecordTrust::User + } + }), + launch_authority, + command_line_quality: command_line_quality_view(sender.command_line.quality), + verification: verification_view, + reason: reason.to_string(), + }; + resolution +} + +pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str { + match reason { + LaunchFailure::MissingCommandLine => "missing command-line evidence", + LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", + LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", + LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", + LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", + LaunchFailure::ExecutableMismatch => "executable identity mismatch", + LaunchFailure::ProtectedPayloadMismatch => "protected application payload mismatch", + LaunchFailure::RequiredArgumentMismatch => "required launch argument mismatch", + LaunchFailure::DesktopClaimMismatch => "desktop claim mismatch", + } +} + +const fn launch_authority_for_failure(failure: LaunchFailure) -> LaunchAuthorityView { + match failure { + LaunchFailure::DynamicOnlyContract => LaunchAuthorityView::DynamicOnly, + LaunchFailure::AmbiguousDesktopAssociation => LaunchAuthorityView::Ambiguous, + LaunchFailure::ProtectedPayloadMismatch + | LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine => LaunchAuthorityView::ProtectedPayload, + LaunchFailure::ExecutableMismatch + | LaunchFailure::RequiredArgumentMismatch + | LaunchFailure::DesktopClaimMismatch + | LaunchFailure::UnsupportedWrapper => LaunchAuthorityView::None, + } +} + +const fn command_line_quality_view(quality: CommandLineQuality) -> CommandLineQualityView { + match quality { + CommandLineQuality::Structured => CommandLineQualityView::Structured, + CommandLineQuality::RewrittenProcessTitle => CommandLineQualityView::RewrittenProcessTitle, + CommandLineQuality::Truncated => CommandLineQualityView::Truncated, + CommandLineQuality::Unavailable => CommandLineQualityView::Unavailable, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 2c1e5c04b..2ba59226b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -1,7 +1,10 @@ use std::collections::HashSet; use std::path::PathBuf; -use unixnotis_core::{AttributionClass, InlineReplyPolicy}; +use unixnotis_core::{ + AttributionClass, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, + LaunchVerificationView, +}; use super::*; use crate::daemon::notifications::identity::desktop_index::model::{ diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs index 50bcca66b..078481e05 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs @@ -118,6 +118,18 @@ fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { AttributionClass::SystemAssociated ); assert_ne!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!( + resolution.diagnostics.command_line_quality, + CommandLineQualityView::RewrittenProcessTitle + ); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::Verified + ); + assert_eq!( + resolution.diagnostics.launch_authority, + LaunchAuthorityView::DedicatedExecutable + ); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs index 672f4d494..dcfbf5a50 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs @@ -335,11 +335,11 @@ fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { #[test] fn dynamic_only_contract_is_unverified_instead_of_suspicious() { - let runtime_identity = identity(70, 700, 0); + let (runtime_path, runtime_identity) = installed_system_executable(); let mut first = system_record( "org.example.Dynamic", "Dynamic App", - "/usr/bin/runtime", + &runtime_path, runtime_identity, ); first @@ -350,7 +350,7 @@ fn dynamic_only_contract_is_unverified_instead_of_suspicious() { let second = system_record( "org.example.Other", "Other App", - "/usr/bin/runtime", + &runtime_path, runtime_identity, ); let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); @@ -360,11 +360,23 @@ fn dynamic_only_contract_is_unverified_instead_of_suspicious() { reported_name: "Dynamic App", desktop_entry: Some("org.example.Dynamic"), }, - &sender_with_arguments("/usr/bin/runtime", runtime_identity, &["/tmp/payload"]), + &sender_with_arguments(&runtime_path, runtime_identity, &["/tmp/payload"]), &index, &HashSet::new(), ); assert_eq!(resolution.attribution.class, AttributionClass::Unknown); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); + assert_eq!( + resolution.diagnostics.launch_authority, + LaunchAuthorityView::DynamicOnly + ); + assert_eq!( + resolution.diagnostics.matched_desktop_id, + "org.example.Dynamic" + ); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs index 6211bd7c5..e9ba5d233 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs @@ -102,6 +102,10 @@ fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { assert_eq!(resolution.attribution.class, AttributionClass::Conflict); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::DefinitiveMismatch + ); assert_ne!( resolution.attribution.group_key, "desktop:org.signal.Signal" diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index 608fb03fd..f0a4d571a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use unixnotis_core::{ - util, Action, Config, InlineReply, InlineReplyPolicy, Notification, NotificationAttribution, - NotificationImage, Urgency, + util, Action, AttributionDiagnostics, Config, InlineReply, InlineReplyPolicy, Notification, + NotificationAttribution, NotificationImage, Urgency, }; use zbus::zvariant::{OwnedValue, Value}; @@ -28,6 +28,7 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) hints: HashMap, pub(in crate::daemon::notifications) sender: SenderMetadata, pub(in crate::daemon::notifications) attribution: NotificationAttribution, + pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, pub(in crate::daemon::notifications) inline_reply_policy: InlineReplyPolicy, pub(in crate::daemon::notifications) expire_timeout: i32, } @@ -44,6 +45,7 @@ pub(in crate::daemon::notifications) fn build_notification( hints, sender, attribution, + attribution_diagnostics, inline_reply_policy, expire_timeout, } = input; @@ -89,6 +91,7 @@ pub(in crate::daemon::notifications) fn build_notification( }, app_icon: util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), attribution, + attribution_diagnostics, // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid // Fold very long unbroken runs so renderer width remains bounded summary: util::fold_text_for_layout( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index 629e87f1b..4ff05646e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -31,6 +31,7 @@ fn build_notification_clamps_summary_and_body_sizes() { ..SenderMetadata::default() }, attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -57,6 +58,7 @@ fn build_notification_strips_display_spoofing_controls() { ..SenderMetadata::default() }, attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -103,6 +105,7 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { false, "desktop:org.example.Messages".to_string(), ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, expire_timeout: 0, }); @@ -133,6 +136,7 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( "source /usr/bin/unknown-client", "executable:1:2".to_string(), ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -165,6 +169,7 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { "", "unknown:messages".to_string(), ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -199,6 +204,7 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { hints, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, expire_timeout: 0, }); @@ -311,6 +317,7 @@ fn resolve_expiration_respects_protocol_and_config_rules() { app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), @@ -366,6 +373,7 @@ fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_ app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 09e93fa98..c03b0a806 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -157,6 +157,18 @@ impl NotificationServer { "notification application claim conflicts with sender evidence" ); } + debug!( + claim = %resolution.diagnostics.claimed_name, + desktop_entry = %resolution.diagnostics.claimed_desktop_entry, + sender_executable = %resolution.diagnostics.sender_executable, + matched_desktop_id = %resolution.diagnostics.matched_desktop_id, + record_origin = ?resolution.diagnostics.record_trust, + launch_authority = ?resolution.diagnostics.launch_authority, + cmdline_quality = ?resolution.diagnostics.command_line_quality, + verification = ?resolution.diagnostics.verification, + reason = %resolution.diagnostics.reason, + "notification attribution decided" + ); // Build a safe notification record from untrusted wire data build_notification(NotificationInput { @@ -168,6 +180,7 @@ impl NotificationServer { hints: input.hints, sender, attribution: resolution.attribution, + attribution_diagnostics: resolution.diagnostics, inline_reply_policy: resolution.inline_reply_policy, expire_timeout: input.expire_timeout, }) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 769a75816..ab0e5e04b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -27,6 +27,7 @@ fn notification_with_id(id: u32) -> Arc { app_name: "app".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "summary".to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index fe1588195..202e107ce 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -15,6 +15,7 @@ fn notification(summary: &str) -> Notification { app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index d0a4c6a30..fa5629a1a 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Instant; use indexmap::IndexMap; -use unixnotis_core::{Config, Notification, NotificationKey}; +use unixnotis_core::{Config, Notification, NotificationKey, PopupAdmissionView}; use super::dnd::DndStateStore; use super::inhibitors::Inhibitor; @@ -73,6 +73,17 @@ impl PopupAdmission { pub const fn should_show(self) -> bool { matches!(self, Self::Show) } + + pub const fn to_view(self) -> PopupAdmissionView { + match self { + Self::Show => PopupAdmissionView::Show, + Self::Suppressed(PopupSuppressionReason::Rule) => PopupAdmissionView::Rule, + Self::Suppressed(PopupSuppressionReason::Dnd) => PopupAdmissionView::Dnd, + Self::Suppressed( + PopupSuppressionReason::Inhibitor | PopupSuppressionReason::DropAllInhibitor, + ) => PopupAdmissionView::Inhibitor, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 98b9fce5b..c115c8b1e 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use indexmap::IndexMap; use tracing::{debug, warn}; use unixnotis_core::{ - ApplicationActionPolicy, Config, ControlState, Notification, NotificationView, PopupCandidate, + ApplicationActionPolicy, Config, ControlState, Notification, NotificationDiagnosticsView, + NotificationView, PopupAdmissionView, PopupCandidate, UiHealth, }; use super::dnd::{DndStateStore, DND_STATE_VERSION}; @@ -140,7 +141,35 @@ impl NotificationStore { let notification = self.active.get(&id)?; Some(PopupCandidate { notification: notification.to_view(), - should_show: self.popup_admission(notification).should_show(), + admission: self.popup_admission(notification).to_view(), + }) + } + + pub fn notification_diagnostics( + &self, + id: u32, + ui_health: &UiHealth, + ) -> Option { + let notification = self.active.get(&id)?; + let stored_admission = self.popup_admission(notification).to_view(); + let popup_admission = if stored_admission != PopupAdmissionView::Show { + stored_admission + } else if ui_health.popups_process_running && ui_health.popups_ready { + PopupAdmissionView::Show + } else { + PopupAdmissionView::RendererUnavailable + }; + + Some(NotificationDiagnosticsView { + id, + generation: notification.generation, + stored: true, + attribution: notification.attribution_diagnostics.clone(), + popup_admission, + renderer_process_running: ui_health.popups_process_running, + renderer_ready: ui_health.popups_ready, + configured_max_visible: u32::try_from(self.config.popups.max_visible) + .unwrap_or(u32::MAX), }) } diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs index f7c1111a2..777301f86 100644 --- a/crates/unixnotis-daemon/src/store/test_support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -25,6 +25,7 @@ pub(in crate::store) fn make_notification(summary: &str) -> Notification { app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 1cc74ae09..b93e8fd61 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use unixnotis_core::{ Action, AttributionClass, CloseReason, Config, InlineReply, InlineReplyPolicy, - NotificationAttribution, + NotificationAttribution, PopupAdmissionView, }; use crate::store::test_support::{make_notification, make_store_with_limits}; @@ -46,7 +46,7 @@ fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { assert_eq!(candidate.notification.generation, replacement.generation); assert_eq!(candidate.notification.summary, "rule suppressed"); - assert!(!candidate.should_show); + assert_eq!(candidate.admission, PopupAdmissionView::Rule); } #[test] @@ -64,7 +64,37 @@ fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { assert_eq!(candidate.notification.generation, replacement.generation); assert_eq!(candidate.notification.summary, "dnd suppressed"); - assert!(!candidate.should_show); + assert_eq!(candidate.admission, PopupAdmissionView::Dnd); +} + +#[test] +fn notification_diagnostics_report_renderer_and_store_admission_separately() { + let mut store = make_store_with_limits(10, 10); + let visible = store.insert(make_notification("visible"), 0).notification; + let unavailable = store + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) + .expect("active notification diagnostics"); + + assert_eq!( + unavailable.popup_admission, + PopupAdmissionView::RendererUnavailable + ); + assert!(!unavailable.renderer_process_running); + assert!(!unavailable.renderer_ready); + + store.set_dnd(true); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + let suppressed = store + .notification_diagnostics(visible.id, &ready) + .expect("DND diagnostics"); + + assert_eq!(suppressed.popup_admission, PopupAdmissionView::Dnd); + assert!(suppressed.renderer_process_running); + assert!(suppressed.renderer_ready); } #[test] diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 5023a30bf..1edb861d6 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -323,6 +323,7 @@ fn make_notification(summary: &str) -> Notification { app_name: "TestApp".to_string(), app_icon: String::new(), attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), diff --git a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs index 312b36424..2e9dcbc1a 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs @@ -44,12 +44,12 @@ pub(super) fn popup_event( if is_add { Some(UiEvent::NotificationAdded( candidate.notification, - candidate.should_show, + candidate.admission.should_show(), )) } else { Some(UiEvent::NotificationUpdated( candidate.notification, - candidate.should_show, + candidate.admission.should_show(), )) } } diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs index 9c43119ff..775b7a3b3 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -1,9 +1,9 @@ -use unixnotis_core::{NotificationImage, NotificationView, PopupCandidate}; +use unixnotis_core::{NotificationImage, NotificationView, PopupAdmissionView, PopupCandidate}; use super::super::delivery::popup_event; use crate::dbus::UiEvent; -fn candidate(generation: u64, should_show: bool) -> PopupCandidate { +fn candidate(generation: u64, admission: PopupAdmissionView) -> PopupCandidate { PopupCandidate { notification: NotificationView { id: 7, @@ -21,20 +21,20 @@ fn candidate(generation: u64, should_show: bool) -> PopupCandidate { received_at_unix_seconds: 0, image: NotificationImage::default(), }, - should_show, + admission, } } #[test] fn old_allowed_signal_cannot_display_new_suppressed_replacement() { - let event = popup_event(vec![candidate(2, false)], 1, true); + let event = popup_event(vec![candidate(2, PopupAdmissionView::Rule)], 1, true); assert!(event.is_none()); } #[test] fn current_suppressed_replacement_is_delivered_as_hidden_update() { - let event = popup_event(vec![candidate(2, false)], 2, false); + let event = popup_event(vec![candidate(2, PopupAdmissionView::Dnd)], 2, false); assert!(matches!( event, From 181ea45f6e7c2b822a441f7ce02b9ed4c6d87d26 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 14:27:55 -0500 Subject: [PATCH 128/275] feat(popups): preserve actions and add bounded inline replies Summary: preserve actions and add bounded inline replies. Scope: popups. --- .../unixnotis-center/src/control/commands.rs | 9 +- crates/unixnotis-center/src/control/model.rs | 4 +- .../src/control/tests/commands.rs | 1 + .../src/control/tests/model.rs | 1 + .../row/notification/reply/binding.rs | 5 + .../row/notification/reply/lifecycle.rs | 10 +- .../row/notification/reply/state.rs | 3 + .../notification/reply/tests/submission.rs | 4 +- crates/unixnotis-core/assets/popup.css | 27 +++ crates/unixnotis-core/src/control/proxy.rs | 2 +- .../src/daemon/control/reply.rs | 21 +- .../src/daemon/control/server.rs | 3 +- .../src/daemon/control/tests/reply.rs | 100 +++++--- .../src/daemon/control/tests/server.rs | 2 +- crates/unixnotis-daemon/src/store/runtime.rs | 7 +- .../src/store/tests/runtime.rs | 42 ++-- crates/unixnotis-popups/src/dbus/commands.rs | 19 +- .../src/dbus/tests/commands.rs | 19 ++ .../unixnotis-popups/src/dbus/tests/types.rs | 21 ++ crates/unixnotis-popups/src/dbus/types.rs | 33 ++- crates/unixnotis-popups/src/ui/entry/build.rs | 14 +- .../src/ui/entry/builders/common.rs | 85 +++++-- .../src/ui/entry/builders/mod.rs | 2 + .../src/ui/entry/builders/reply/lifecycle.rs | 101 ++++++++ .../src/ui/entry/builders/reply/mod.rs | 9 + .../src/ui/entry/builders/reply/tests/mod.rs | 105 +++++++++ .../src/ui/entry/builders/reply/widget.rs | 223 ++++++++++++++++++ .../src/ui/entry/builders/tests/common.rs | 29 ++- .../src/ui/entry/presentation/mod.rs | 4 +- .../src/ui/entry/presentation/tests/trust.rs | 23 +- .../ui/entry/presentation/tests/view_model.rs | 43 ++-- .../src/ui/entry/presentation/trust.rs | 22 +- .../src/ui/entry/presentation/view_model.rs | 65 +++-- 33 files changed, 898 insertions(+), 160 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index 13cea69c7..f7f27c165 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -24,8 +24,13 @@ pub async fn handle_command( UiCommand::InvokeAction { id, action_key } => { timed_dbus_call(proxy.invoke_action(id, &action_key)).await } - UiCommand::Reply { id, text, outcome } => { - let result = timed_dbus_call(proxy.reply_notification(id, &text)).await; + UiCommand::Reply { + id, + generation, + text, + outcome, + } => { + let result = timed_dbus_call(proxy.reply_notification(id, generation, &text)).await; let reply_result = match &result { Ok(()) => Ok(()), Err(err) => Err(err.to_string()), diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index 393f33fda..6ed0fb195 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -49,6 +49,7 @@ pub enum UiCommand { }, Reply { id: u32, + generation: u64, text: String, outcome: tokio::sync::oneshot::Sender>, }, @@ -67,9 +68,10 @@ impl fmt::Debug for UiCommand { .field("id", id) .field("action_key", action_key) .finish(), - Self::Reply { id, .. } => formatter + Self::Reply { id, generation, .. } => formatter .debug_struct("Reply") .field("id", id) + .field("generation", generation) // Typed message content must never enter diagnostic logs .field("text", &"[redacted]") .finish_non_exhaustive(), diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index ce770ed17..18c713853 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -69,6 +69,7 @@ fn enqueue_offline_command_rejects_live_reply_text_and_reports_failure() { &mut offline, UiCommand::Reply { id: 7, + generation: 11, text: "Still there?".to_string(), outcome, } diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index 1911b3376..4d4a06c80 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -16,6 +16,7 @@ fn reply_command_debug_output_redacts_the_typed_message() { let (outcome, _result) = tokio::sync::oneshot::channel(); let command = UiCommand::Reply { id: 9, + generation: 12, text: "private reply text".to_string(), outcome, }; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs index def416d41..3e369025f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs @@ -35,6 +35,11 @@ pub(in super::super) fn configure_inline_reply( } // Unavailable policies also clear the command target used by click handlers widgets.state.bound_id.set(if available { id } else { 0 }); + widgets.state.bound_generation.set(if available { + notification.generation + } else { + 0 + }); if !available { // History and ordinary actions never expose a stale reply field return; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs index 2c765d8a3..a7055c529 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs @@ -24,8 +24,14 @@ pub(super) fn submit_reply( // Trim once so UI validation and the transmitted payload use the same content let text = entry.text().trim().to_string(); let id = state.bound_id.get(); + let generation = state.bound_generation.get(); // replace(true) closes the race between Enter and a near-simultaneous click - if id == 0 || text.is_empty() || text.len() > MAX_REPLY_BYTES || state.submitted.replace(true) { + if id == 0 + || generation == 0 + || text.is_empty() + || text.len() > MAX_REPLY_BYTES + || state.submitted.replace(true) + { return; } let current_attempt = state.attempt.get().wrapping_add(1); @@ -40,6 +46,7 @@ pub(super) fn submit_reply( command_tx, UiCommand::Reply { id, + generation, text, outcome: outcome_tx, }, @@ -56,6 +63,7 @@ pub(super) fn submit_reply( .await .unwrap_or_else(|_| Err("notification service did not return a result".to_string())); if result_state.bound_id.get() != id + || result_state.bound_generation.get() != generation || result_state.attempt.get() != current_attempt || !result_state.submitted.get() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs index 8e6c2bcbe..a27dc51b7 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs @@ -13,6 +13,8 @@ pub(super) const INLINE_REPLY_TRANSITION_MS: u32 = 250; pub(super) struct ReplyState { // Numeric identity is retained for the command sent to the daemon pub(super) bound_id: Rc>, + // Commit generation prevents a recycled identifier from receiving an older draft + pub(super) bound_generation: Rc>, // One shared gate covers button and Enter submissions pub(super) submitted: Rc>, // Attempt identity keeps delayed outcomes tied to one exact submission @@ -23,6 +25,7 @@ impl ReplyState { pub(super) fn new() -> Self { Self { bound_id: Rc::new(Cell::new(0)), + bound_generation: Rc::new(Cell::new(0)), submitted: Rc::new(Cell::new(false)), attempt: Rc::new(Cell::new(0)), } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs index 1e78545f5..ac2a3021f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs @@ -43,7 +43,9 @@ fn inline_reply_submit_sends_text_once_and_hides_after_success() { row.inline_reply.entry.emit_activate(); row.inline_reply.send_button.emit_clicked(); - let UiCommand::Reply { id, text, outcome } = command_rx.try_recv().expect("reply command") + let UiCommand::Reply { + id, text, outcome, .. + } = command_rx.try_recv().expect("reply command") else { panic!("expected inline reply command"); }; diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 00c28c109..09ba728e7 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -207,6 +207,33 @@ box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } +.unixnotis-popup-action-overflow { + min-width: 32px; + padding-left: 7px; + padding-right: 7px; +} + +.unixnotis-popup-action-overflow-list { + padding: 6px; +} + +.unixnotis-popup-inline-reply { + margin-top: 8px; +} + +.unixnotis-popup-reply-entry { + min-height: 30px; + border-radius: 10px; + padding-left: 9px; + padding-right: 9px; +} + +.unixnotis-popup-reply-error { + color: alpha(#fb7185, 0.88); + font-size: 11px; + margin-top: 2px; +} + /* Critical state composes after the ordinary card and interaction rules */ .unixnotis-popup-card.critical { background-image: linear-gradient( diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 01ac29924..0462cf7cb 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -65,7 +65,7 @@ trait Control { /// Invoke an action key for a notification fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; /// Submit text for an explicitly advertised inline-reply action - fn reply_notification(&self, id: u32, reply_text: &str) -> zbus::Result<()>; + fn reply_notification(&self, id: u32, generation: u64, reply_text: &str) -> zbus::Result<()>; /// Clear active notifications and saved history fn clear_all(&self) -> zbus::Result<()>; /// Clear active notifications without deleting saved history diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index c3f6e73d3..137882fff 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -18,15 +18,19 @@ impl ControlServer { pub(super) async fn submit_inline_reply( &self, id: u32, + generation: u64, reply_text: &str, ) -> zbus::fdo::Result<()> { - self.submit_inline_reply_with_post_emit(id, reply_text, || std::future::ready(())) - .await + self.submit_inline_reply_with_post_emit(id, generation, reply_text, || { + std::future::ready(()) + }) + .await } async fn submit_inline_reply_with_post_emit( &self, id: u32, + generation: u64, reply_text: &str, post_emit: F, ) -> zbus::fdo::Result<()> @@ -39,11 +43,14 @@ impl ControlServer { let target = { // Keep the Arc so later cleanup can distinguish a same-ID replacement let store = self.state.store.lock().await; - store.active_inline_reply_target(id).ok_or_else(|| { - zbus::fdo::Error::InvalidArgs( - "notification is not live or does not support inline reply".to_string(), - ) - })? + store + .active_inline_reply_target(id, generation) + .ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification generation is stale or does not support inline reply" + .to_string(), + ) + })? }; let destination = self.reply_destination(&target).await?; diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index c6d75d7dc..d47e564ff 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -217,12 +217,13 @@ impl ControlServer { pub(super) async fn reply_notification( &self, id: u32, + generation: u64, reply_text: &str, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ReplyNotification") .await?; - self.submit_inline_reply(id, reply_text).await + self.submit_inline_reply(id, generation, reply_text).await } pub(super) async fn clear_all( diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 630db2088..23de95a65 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -60,16 +60,16 @@ async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state, &sender).await; - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(false, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; ControlServer::new(state.clone()) - .submit_inline_reply(id, " On my way ") + .submit_inline_reply(id, generation, " On my way ") .await .expect("submit live inline reply"); @@ -85,16 +85,16 @@ async fn submit_inline_reply_keeps_resident_notification_live() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state, &sender).await; - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(true, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; ControlServer::new(state.clone()) - .submit_inline_reply(id, "Another update") + .submit_inline_reply(id, generation, "Another update") .await .expect("submit resident inline reply"); @@ -104,6 +104,36 @@ async fn submit_inline_reply_keeps_resident_notification_live() { assert_eq!(state.store.lock().await.list_active().len(), 1); } +#[tokio::test] +async fn stale_reply_generation_cannot_target_a_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let (id, old_generation, replacement_generation) = { + let mut store = state.store.lock().await; + let original = store + .insert(reply_notification(false, &sender), 0) + .notification; + let replacement = store + .insert(reply_notification(false, &sender), original.id) + .notification; + (original.id, original.generation, replacement.generation) + }; + + let error = ControlServer::new(state.clone()) + .submit_inline_reply(id, old_generation, "stale draft") + .await + .expect_err("stale reply generation must be rejected"); + + assert!(error.to_string().contains("generation is stale")); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active"); + assert_eq!(active.generation, replacement_generation); +} + #[tokio::test] async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { let state = daemon_state_for_test(false).await; @@ -118,16 +148,16 @@ async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { ]; for message in messages { - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(true, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; ControlServer::new(state.clone()) - .submit_inline_reply(id, &message) + .submit_inline_reply(id, generation, &message) .await .expect("submit exact reply text"); @@ -142,18 +172,18 @@ async fn reply_listener_replacement_survives_generation_safe_dismissal() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state, &sender).await; - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(false, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; let replacement_state = state.clone(); let replacement_sender = sender.clone(); ControlServer::new(state.clone()) - .submit_inline_reply_with_post_emit(id, "yes", move || async move { + .submit_inline_reply_with_post_emit(id, generation, "yes", move || async move { // This models the sender updating the same row while handling the reply signal let (signal_id, text) = next_reply_signal(&mut stream).await; assert_eq!((signal_id, text.as_str()), (id, "yes")); @@ -179,17 +209,17 @@ async fn reply_listener_close_removes_replied_notification_without_history() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state, &sender).await; - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(false, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; let closing_state = state.clone(); ControlServer::new(state.clone()) - .submit_inline_reply_with_post_emit(id, "yes", move || async move { + .submit_inline_reply_with_post_emit(id, generation, "yes", move || async move { let (signal_id, text) = next_reply_signal(&mut stream).await; assert_eq!((signal_id, text.as_str()), (id, "yes")); closing_state @@ -209,12 +239,12 @@ async fn reply_listener_close_removes_replied_notification_without_history() { async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(false, &sender), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; let sender_name = sender.unique_name().expect("sender unique name").clone(); sender.close().await.expect("close sender connection"); @@ -237,7 +267,7 @@ async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { .expect("bus should release the closed sender name"); let error = ControlServer::new(state.clone()) - .submit_inline_reply(id, "Anyone there?") + .submit_inline_reply(id, generation, "Anyone there?") .await .expect_err("closed sender must reject replies"); @@ -259,16 +289,16 @@ async fn inline_reply_signal_reaches_owner_but_not_unrelated_observer() { let observer = Connection::session().await.expect("observer session bus"); let mut owner_stream = reply_signal_stream(&state, &owner).await; let mut observer_stream = reply_signal_stream(&state, &observer).await; - let id = { + let (id, generation) = { let mut store = state.store.lock().await; - store + let notification = store .insert(reply_notification(true, &owner), 0) - .notification - .id + .notification; + (notification.id, notification.generation) }; ControlServer::new(state) - .submit_inline_reply(id, "private reply") + .submit_inline_reply(id, generation, "private reply") .await .expect("submit owner reply"); diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 13158cbce..7b072d5fa 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -225,7 +225,7 @@ async fn inline_reply_rejects_unauthorized_sender_before_live_state_lookup() { let message = control_header_message("ReplyNotification"); server - .reply_notification(7, "private text", message.header()) + .reply_notification(7, 1, "private text", message.header()) .await .expect_err("unauthorized inline reply should fail"); } diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index c115c8b1e..e6bcd0b79 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -173,7 +173,11 @@ impl NotificationStore { }) } - pub fn active_inline_reply_target(&self, id: u32) -> Option> { + pub fn active_inline_reply_target( + &self, + id: u32, + generation: u64, + ) -> Option> { let notification = self.active.get(&id)?; // Both fields must agree so malformed internal data cannot widen reply access let has_reply_action = notification @@ -181,6 +185,7 @@ impl NotificationStore { .iter() .any(|action| action.key == "inline-reply"); (notification.inline_reply.available + && notification.generation == generation && notification.inline_reply_policy == unixnotis_core::InlineReplyPolicy::Allow && has_reply_action) .then(|| Arc::clone(notification)) diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index b93e8fd61..a42e1364c 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -100,10 +100,7 @@ fn notification_diagnostics_report_renderer_and_store_admission_separately() { #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { let mut store = make_store_with_limits(12, 20); - let ordinary_id = store - .insert(make_notification("ordinary"), 0) - .notification - .id; + let ordinary = store.insert(make_notification("ordinary"), 0).notification; let mut reply = make_notification("reply"); reply.inline_reply = InlineReply { available: true, @@ -114,14 +111,19 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let reply_id = store.insert(reply, 0).notification.id; + let reply = store.insert(reply, 0).notification; - assert!(store.active_inline_reply_target(ordinary_id).is_none()); + assert!(store + .active_inline_reply_target(ordinary.id, ordinary.generation) + .is_none()); let target = store - .active_inline_reply_target(reply_id) + .active_inline_reply_target(reply.id, reply.generation) .expect("reply target"); - assert_eq!(target.id, reply_id); + assert_eq!(target.id, reply.id); assert!(!target.is_resident); + assert!(store + .active_inline_reply_target(reply.id, reply.generation.saturating_sub(1)) + .is_none()); } #[test] @@ -206,19 +208,21 @@ fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { label: "Reply".to_string(), }); reply.is_resident = true; - let id = store.insert(reply, 0).notification.id; + let reply = store.insert(reply, 0).notification; assert!( store - .active_inline_reply_target(id) + .active_inline_reply_target(reply.id, reply.generation) .expect("resident reply target") .is_resident ); - store.close(id, CloseReason::Expired); + store.close(reply.id, CloseReason::Expired); - assert!(store.active_inline_reply_target(id).is_none()); - assert!(store.list_history().iter().any(|view| view.id == id)); + assert!(store + .active_inline_reply_target(reply.id, reply.generation) + .is_none()); + assert!(store.list_history().iter().any(|view| view.id == reply.id)); } #[test] @@ -226,9 +230,11 @@ fn inline_reply_metadata_without_the_protocol_action_is_rejected() { let mut store = make_store_with_limits(12, 20); let mut malformed = make_notification("metadata only"); malformed.inline_reply.available = true; - let id = store.insert(malformed, 0).notification.id; + let malformed = store.insert(malformed, 0).notification; - assert!(store.active_inline_reply_target(id).is_none()); + assert!(store + .active_inline_reply_target(malformed.id, malformed.generation) + .is_none()); } #[test] @@ -241,7 +247,9 @@ fn inline_reply_policy_denies_a_complete_reply_action() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let id = store.insert(notification, 0).notification.id; + let notification = store.insert(notification, 0).notification; - assert!(store.active_inline_reply_target(id).is_none()); + assert!(store + .active_inline_reply_target(notification.id, notification.generation) + .is_none()); } diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index 960823395..45e19618d 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -13,6 +13,17 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu UiCommand::InvokeAction { id, action_key } => { timed_dbus_call(proxy.invoke_action(id, &action_key)).await } + UiCommand::Reply { + id, + generation, + text, + outcome, + } => { + let result = timed_dbus_call(proxy.reply_notification(id, generation, &text)).await; + let reply_result = result.as_ref().map_err(ToString::to_string).copied(); + let _ = outcome.send(reply_result); + result + } UiCommand::Shutdown(_) => Ok(()), } } @@ -21,8 +32,12 @@ pub fn drain_offline_commands( command_rx: &mut mpsc::Receiver, ) -> Option> { while let Ok(command) = command_rx.try_recv() { - if let UiCommand::Shutdown(acknowledgement) = command { - return Some(acknowledgement); + match command { + UiCommand::Shutdown(acknowledgement) => return Some(acknowledgement), + UiCommand::Reply { outcome, .. } => { + let _ = outcome.send(Err("notification service is unavailable".to_string())); + } + UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } => {} } // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index c813f84ec..7838f61b5 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -44,3 +44,22 @@ fn drain_offline_commands_returns_shutdown_acknowledgement() { .recv() .expect("receive shutdown acknowledgement"); } + +#[test] +fn drain_offline_commands_reports_reply_delivery_failure() { + let (tx, mut rx) = mpsc::channel(1); + let (outcome, result) = tokio::sync::oneshot::channel(); + tx.try_send(UiCommand::Reply { + id: 10, + generation: 12, + text: "Keep this private".to_string(), + outcome, + }) + .expect("reply command should queue"); + + assert!(drain_offline_commands(&mut rx).is_none()); + assert_eq!( + result.blocking_recv().expect("reply result"), + Err("notification service is unavailable".to_string()) + ); +} diff --git a/crates/unixnotis-popups/src/dbus/tests/types.rs b/crates/unixnotis-popups/src/dbus/tests/types.rs index b40e82f94..694cbe1b0 100644 --- a/crates/unixnotis-popups/src/dbus/tests/types.rs +++ b/crates/unixnotis-popups/src/dbus/tests/types.rs @@ -22,6 +22,27 @@ fn shutdown_command_preserves_the_cleanup_acknowledgement() { .expect("receive shutdown acknowledgement"); } +#[test] +fn reply_debug_output_redacts_private_message_text() { + let (outcome, _result) = tokio::sync::oneshot::channel(); + let command = UiCommand::Reply { + id: 17, + generation: 23, + text: "private reply content".to_string(), + outcome, + }; + let rendered = format!("{command:?}"); + + assert!( + !rendered.contains("private reply content"), + "reply text must not enter debug output" + ); + assert!( + rendered.contains(""), + "debug output should make redaction explicit" + ); +} + #[test] fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 27d86c555..85b8f38f7 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -24,14 +24,43 @@ pub enum UiEvent { } /// Commands sent from GTK handlers to the D-Bus runtime -#[derive(Debug, Clone)] pub enum UiCommand { Dismiss(u32), - InvokeAction { id: u32, action_key: String }, + InvokeAction { + id: u32, + action_key: String, + }, + Reply { + id: u32, + generation: u64, + text: String, + outcome: tokio::sync::oneshot::Sender>, + }, // A synchronous acknowledgement lets GTK wait for MarkPopupsNotReady before process exit Shutdown(std::sync::mpsc::SyncSender<()>), } +impl std::fmt::Debug for UiCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Dismiss(id) => formatter.debug_tuple("Dismiss").field(id).finish(), + Self::InvokeAction { id, action_key } => formatter + .debug_struct("InvokeAction") + .field("id", id) + .field("action_key", action_key) + .finish(), + Self::Reply { id, generation, .. } => formatter + .debug_struct("Reply") + .field("id", id) + .field("generation", generation) + // Reply text is private message content and must never enter debug logs + .field("text", &"") + .finish_non_exhaustive(), + Self::Shutdown(_) => formatter.write_str("Shutdown(..)"), + } + } +} + #[cfg(test)] #[path = "tests/types.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 84d7c5922..9f1f4cd9d 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -7,7 +7,9 @@ use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; use super::super::UiState; -use super::builders::{build_action_row, build_close_button, build_popup_content}; +use super::builders::{ + build_action_row, build_close_button, build_inline_reply, build_popup_content, +}; use super::commands::try_send_command; use super::presentation::PopupEntryViewModel; use crate::dbus::UiCommand; @@ -65,6 +67,9 @@ impl UiState { set_class_state(&root, hooks::popup_card::HAS_IMAGE, rendered.has_image); root.append(&rendered.widget); + if let Some(reply) = build_inline_reply(notification, &view, &self.command_tx) { + root.append(&reply); + } if let Some(actions) = build_action_row(&self.command_tx, notification.id, &view) { root.append(&actions); } @@ -135,7 +140,9 @@ fn build_card_root(state: &UiState, view: &PopupEntryViewModel) -> gtk::Box { set_class_state( &root, hooks::popup_card::HAS_ACTIONS, - !view.actions.is_empty(), + view.trust.reply == super::presentation::ReplyPresentation::Available + || !view.primary_actions.is_empty() + || !view.overflow_actions.is_empty(), ); root } @@ -159,8 +166,9 @@ fn connect_default_action( command_tx: &tokio::sync::mpsc::Sender, ) { let Some(action_key) = view - .actions + .primary_actions .iter() + .chain(&view.overflow_actions) .find(|action| action.key == "default") .map(|action| action.key.clone()) else { diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 640fa4549..e3f26c3fd 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -6,7 +6,7 @@ use gtk::Align; use unixnotis_core::{hooks, NotificationView}; use super::super::commands::try_send_command; -use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation}; +use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; use crate::dbus::UiCommand; use crate::ui::UiState; @@ -96,7 +96,7 @@ pub(super) fn build_body_label(view: &PopupEntryViewModel, line_limit: i32) -> O } pub(super) fn build_reply_note(view: &PopupEntryViewModel) -> Option { - if !view.trust.show_reply_unavailable { + if view.trust.reply != ReplyPresentation::Unavailable { return None; } @@ -119,32 +119,79 @@ pub(in crate::ui::entry) fn build_action_row( notification_id: u32, view: &PopupEntryViewModel, ) -> Option { - if view.actions.is_empty() { + if view.primary_actions.is_empty() && view.overflow_actions.is_empty() { return None; } let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); actions.add_css_class("unixnotis-popup-actions"); - for action in &view.actions { - let button = gtk::Button::with_label(&action.label); - button.add_css_class("unixnotis-popup-action"); - let action_key = action.key.clone(); - let tx = command_tx.clone(); - button.connect_clicked(move |_| { - // Click handlers only enqueue the exact action prepared by the presentation model - try_send_command( - &tx, - UiCommand::InvokeAction { - id: notification_id, - action_key: action_key.clone(), - }, - ); - }); - actions.append(&button); + for action in &view.primary_actions { + actions.append(&build_action_button( + command_tx, + notification_id, + action, + None, + )); + } + if !view.overflow_actions.is_empty() { + actions.append(&build_overflow_menu(command_tx, notification_id, view)); } Some(actions) } +fn build_action_button( + command_tx: &tokio::sync::mpsc::Sender, + notification_id: u32, + action: &super::super::presentation::ActionViewModel, + popover: Option<>k::Popover>, +) -> gtk::Button { + let button = gtk::Button::with_label(&action.label); + button.add_css_class("unixnotis-popup-action"); + let action_key = action.key.clone(); + let tx = command_tx.clone(); + let popover = popover.cloned(); + button.connect_clicked(move |_| { + // Menus close before the exact daemon-validated action is queued + if let Some(popover) = &popover { + popover.popdown(); + } + try_send_command( + &tx, + UiCommand::InvokeAction { + id: notification_id, + action_key: action_key.clone(), + }, + ); + }); + button +} + +fn build_overflow_menu( + command_tx: &tokio::sync::mpsc::Sender, + notification_id: u32, + view: &PopupEntryViewModel, +) -> gtk::MenuButton { + let menu = gtk::MenuButton::new(); + menu.set_icon_name("view-more-symbolic"); + menu.set_tooltip_text(Some("More actions")); + menu.add_css_class("unixnotis-popup-action-overflow"); + + let popover = gtk::Popover::new(); + let list = gtk::Box::new(gtk::Orientation::Vertical, 4); + list.add_css_class("unixnotis-popup-action-overflow-list"); + for action in &view.overflow_actions { + list.append(&build_action_button( + command_tx, + notification_id, + action, + Some(&popover), + )); + } + popover.set_child(Some(&list)); + menu.set_popover(Some(&popover)); + menu +} + fn build_urgency_badge(is_critical: bool) -> gtk::Label { let badge = gtk::Label::new(Some("Critical")); // The stable node keeps header spacing predictable across urgency changes diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 06c647434..99214bef0 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -2,6 +2,7 @@ mod common; mod communication; +mod reply; mod utility; mod warning; @@ -12,6 +13,7 @@ use super::presentation::{PopupEntryViewModel, PopupKind}; use crate::ui::UiState; pub(super) use common::{build_action_row, build_close_button}; +pub(in crate::ui::entry) use reply::build_inline_reply; /// Result of building one kind-specific card body pub(super) struct RenderedPopup { diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs new file mode 100644 index 000000000..7f8354bb5 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs @@ -0,0 +1,101 @@ +//! Reply submission guards shared by button and keyboard activation + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; + +use crate::dbus::UiCommand; +use crate::ui::entry::commands::try_send_command; + +pub(super) const MAX_REPLY_BYTES: usize = 4 * 1024; +pub(super) const MAX_REPLY_CHARS: i32 = 4 * 1024; + +pub(super) struct ReplySubmission<'widget> { + pub(super) id: u32, + pub(super) generation: u64, + pub(super) entry: &'widget gtk::Entry, + pub(super) revealer: &'widget gtk::Revealer, + pub(super) send: &'widget gtk::Button, + pub(super) error: &'widget gtk::Label, + pub(super) submitted: &'widget Rc>, + pub(super) command_tx: &'widget tokio::sync::mpsc::Sender, +} + +pub(super) fn submit_reply(submission: ReplySubmission<'_>) { + let Some(text) = bounded_reply_text(&submission.entry.text()) else { + return; + }; + // One shared cell closes the near-simultaneous Enter and click race + if submission.submitted.replace(true) { + return; + } + + submission.entry.set_sensitive(false); + submission.send.set_sensitive(false); + submission.error.set_visible(false); + let (outcome, result) = tokio::sync::oneshot::channel(); + try_send_command( + submission.command_tx, + UiCommand::Reply { + id: submission.id, + generation: submission.generation, + text, + outcome, + }, + ); + + let entry = submission.entry.clone(); + let revealer = submission.revealer.clone(); + let send = submission.send.clone(); + let error = submission.error.clone(); + let submitted = Rc::clone(submission.submitted); + gtk::glib::MainContext::default().spawn_local(async move { + let result = result.await; + // Keep both activation paths locked until the daemon returns the final result + submitted.set(false); + entry.set_sensitive(true); + match result { + Ok(Ok(())) => { + // Successful delivery clears local text and returns to the compact card + entry.set_text(""); + send.set_sensitive(false); + error.set_visible(false); + revealer.set_reveal_child(false); + } + Ok(Err(message)) => { + // A transport or daemon rejection keeps the draft available for correction + error.set_text(&message); + error.set_visible(true); + send.set_sensitive(bounded_reply_text(&entry.text()).is_some()); + entry.grab_focus(); + } + Err(_) => { + error.set_text("Notification service did not return a reply result"); + error.set_visible(true); + send.set_sensitive(bounded_reply_text(&entry.text()).is_some()); + entry.grab_focus(); + } + } + }); +} + +pub(super) fn bounded_reply_text(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && value.len() <= MAX_REPLY_BYTES && !value.contains(['\0', '\r', '\n'])) + .then(|| value.to_string()) +} + +pub(super) fn cancel_reply( + entry: >k::Entry, + revealer: >k::Revealer, + error: >k::Label, + submitted: &Cell, +) { + if submitted.get() { + return; + } + entry.set_text(""); + error.set_visible(false); + revealer.set_reveal_child(false); +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs new file mode 100644 index 000000000..7b0a5a4c6 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs @@ -0,0 +1,9 @@ +//! Bounded inline reply editor for verified communication notifications + +mod lifecycle; +mod widget; + +pub(in crate::ui::entry) use widget::build_inline_reply; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs new file mode 100644 index 000000000..794467c69 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs @@ -0,0 +1,105 @@ +use gtk::prelude::*; +use unixnotis_core::{ + Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; + +use super::super::build_inline_reply; +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::PopupEntryViewModel; + +#[gtk::test] +fn reply_button_reveals_editor_without_sending_and_submission_keeps_generation() { + let mut notification = notification(); + notification.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let widget = + build_inline_reply(¬ification, &view, &command_tx).expect("verified reply editor"); + let reveal = widget + .first_child() + .and_downcast::() + .expect("reply button"); + let revealer = widget + .last_child() + .and_downcast::() + .expect("reply revealer"); + + reveal.emit_clicked(); + assert!(revealer.reveals_child()); + assert!(command_rx.try_recv().is_err()); + + let form = revealer + .child() + .and_downcast::() + .expect("reply form"); + let input_row = form + .first_child() + .and_downcast::() + .expect("reply input row"); + let entry = input_row + .first_child() + .and_downcast::() + .expect("reply entry"); + entry.set_text("On my way"); + entry.emit_activate(); + + let UiCommand::Reply { + id, + generation, + text, + .. + } = command_rx.try_recv().expect("reply command") + else { + panic!("expected reply command"); + }; + assert_eq!(id, notification.id); + assert_eq!(generation, notification.generation); + assert_eq!(text, "On my way"); +} + +#[gtk::test] +fn unverified_notification_never_builds_a_reply_editor() { + let mut notification = notification(); + notification.inline_reply.available = true; + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + + assert!(build_inline_reply(¬ification, &view, &command_tx).is_none()); +} + +fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::associated( + "Example", + "org.example.App", + "org.example.App", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.App".to_string(), + ), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs new file mode 100644 index 000000000..fd03e059d --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs @@ -0,0 +1,223 @@ +//! GTK construction and input wiring for one popup reply editor + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::lifecycle::{ + bounded_reply_text, cancel_reply, submit_reply, ReplySubmission, MAX_REPLY_CHARS, +}; +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; + +pub(in crate::ui::entry) fn build_inline_reply( + notification: &NotificationView, + view: &PopupEntryViewModel, + command_tx: &tokio::sync::mpsc::Sender, +) -> Option { + if view.kind != PopupKind::Communication || view.trust.reply != ReplyPresentation::Available { + return None; + } + + let root = gtk::Box::new(gtk::Orientation::Vertical, 4); + root.add_css_class("unixnotis-popup-inline-reply"); + let reveal = gtk::Button::with_label(reply_label(notification)); + reveal.add_css_class("unixnotis-popup-action"); + root.append(&reveal); + + let revealer = gtk::Revealer::new(); + revealer.set_reveal_child(false); + revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_transition_duration(200); + let form = gtk::Box::new(gtk::Orientation::Vertical, 4); + let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_max_length(MAX_REPLY_CHARS); + entry.set_placeholder_text(Some(reply_placeholder(notification))); + entry.add_css_class("unixnotis-popup-reply-entry"); + let send = gtk::Button::with_label(reply_submit_label(notification)); + send.set_sensitive(false); + send.add_css_class("unixnotis-popup-action"); + let cancel = gtk::Button::with_label("Cancel"); + cancel.add_css_class("unixnotis-popup-action"); + let error = gtk::Label::new(None); + error.set_xalign(0.0); + error.set_wrap(true); + error.set_visible(false); + error.add_css_class("unixnotis-popup-reply-error"); + + input_row.append(&entry); + input_row.append(&send); + input_row.append(&cancel); + form.append(&input_row); + form.append(&error); + revealer.set_child(Some(&form)); + root.append(&revealer); + + let submitted = Rc::new(Cell::new(false)); + connect_reveal(&reveal, &revealer, &entry, &submitted); + connect_validation(&entry, &send, &error, &submitted); + connect_submission( + notification, + &entry, + &revealer, + &send, + &error, + &submitted, + command_tx, + ); + connect_cancel(&entry, &revealer, &cancel, &error, &submitted); + + Some(root) +} + +fn connect_reveal( + button: >k::Button, + revealer: >k::Revealer, + entry: >k::Entry, + submitted: &Rc>, +) { + let revealer = revealer.clone(); + let entry = entry.clone(); + let submitted = Rc::clone(submitted); + button.connect_clicked(move |_| { + // Opening the editor is local-only and never sends an application signal + if submitted.get() { + return; + } + revealer.set_reveal_child(true); + entry.grab_focus(); + }); +} + +fn connect_validation( + entry: >k::Entry, + send: >k::Button, + error: >k::Label, + submitted: &Rc>, +) { + let send = send.clone(); + let error = error.clone(); + let submitted = Rc::clone(submitted); + entry.connect_changed(move |entry| { + error.set_visible(false); + let valid = bounded_reply_text(&entry.text()).is_some(); + send.set_sensitive(valid && !submitted.get()); + entry.set_tooltip_text( + (!valid && !entry.text().trim().is_empty()) + .then_some("Reply text must be one line and no larger than 4 KiB"), + ); + }); +} + +fn connect_submission( + notification: &NotificationView, + entry: >k::Entry, + revealer: >k::Revealer, + send: >k::Button, + error: >k::Label, + submitted: &Rc>, + command_tx: &tokio::sync::mpsc::Sender, +) { + let click_entry = entry.clone(); + let click_revealer = revealer.clone(); + let click_send = send.clone(); + let click_error = error.clone(); + let click_submitted = Rc::clone(submitted); + let click_tx = command_tx.clone(); + let id = notification.id; + let generation = notification.generation; + send.connect_clicked(move |_| { + submit_reply(ReplySubmission { + id, + generation, + entry: &click_entry, + revealer: &click_revealer, + send: &click_send, + error: &click_error, + submitted: &click_submitted, + command_tx: &click_tx, + }); + }); + + let activate_revealer = revealer.clone(); + let activate_send = send.clone(); + let activate_error = error.clone(); + let activate_submitted = Rc::clone(submitted); + let activate_tx = command_tx.clone(); + entry.connect_activate(move |entry| { + submit_reply(ReplySubmission { + id, + generation, + entry, + revealer: &activate_revealer, + send: &activate_send, + error: &activate_error, + submitted: &activate_submitted, + command_tx: &activate_tx, + }); + }); +} + +fn connect_cancel( + entry: >k::Entry, + revealer: >k::Revealer, + cancel: >k::Button, + error: >k::Label, + submitted: &Rc>, +) { + let cancel_entry = entry.clone(); + let cancel_revealer = revealer.clone(); + let cancel_error = error.clone(); + let cancel_submitted = Rc::clone(submitted); + cancel.connect_clicked(move |_| { + cancel_reply( + &cancel_entry, + &cancel_revealer, + &cancel_error, + &cancel_submitted, + ); + }); + + let key_revealer = revealer.clone(); + let key_error = error.clone(); + let key_submitted = Rc::clone(submitted); + let controller = gtk::EventControllerKey::new(); + controller.connect_key_pressed(move |controller, key, _, _| { + if key != gtk::gdk::Key::Escape { + return gtk::glib::Propagation::Proceed; + } + if let Some(entry) = controller.widget().and_downcast::() { + cancel_reply(&entry, &key_revealer, &key_error, &key_submitted); + } + gtk::glib::Propagation::Stop + }); + entry.add_controller(controller); +} + +fn reply_label(notification: &NotificationView) -> &str { + if notification.inline_reply.label.trim().is_empty() { + "Reply" + } else { + ¬ification.inline_reply.label + } +} + +fn reply_placeholder(notification: &NotificationView) -> &str { + if notification.inline_reply.placeholder.trim().is_empty() { + "Write a reply" + } else { + ¬ification.inline_reply.placeholder + } +} + +fn reply_submit_label(notification: &NotificationView) -> &str { + if notification.inline_reply.submit_label.trim().is_empty() { + "Send" + } else { + ¬ification.inline_reply.submit_label + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 97131576f..1407ae31c 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -9,7 +9,7 @@ use unixnotis_core::{ }; use crate::dbus::UiCommand; -use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::entry::presentation::{PopupEntryViewModel, ReplyPresentation}; #[gtk::test] fn popup_critical_badge_uses_shared_hook_and_visibility() { @@ -47,7 +47,7 @@ fn reply_note_exists_only_when_the_policy_explanation_is_needed() { let mut view = view_model(); assert!(build_reply_note(&view).is_none()); - view.trust.show_reply_unavailable = true; + view.trust.reply = ReplyPresentation::Unavailable; let note = build_reply_note(&view).expect("reply unavailable note"); assert_eq!(note.text().as_str(), "Reply unavailable"); @@ -88,6 +88,31 @@ fn action_row_dispatches_the_prepared_action_identity() { } } +#[gtk::test] +fn extra_safe_action_builds_a_compact_overflow_menu() { + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut notification = notification(); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "folder".to_string(), + label: "Open folder".to_string(), + }, + ]; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, 41, &view).expect("action row"); + let menu = row + .last_child() + .and_downcast::() + .expect("overflow menu"); + + assert_eq!(menu.icon_name().as_deref(), Some("view-more-symbolic")); + assert!(menu.popover().is_some()); +} + #[gtk::test] fn empty_action_model_does_not_build_an_action_row() { let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs index 9a73fb720..d6c4d6c10 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs @@ -5,8 +5,8 @@ mod trust; mod view_model; pub(in crate::ui::entry) use kind::PopupKind; -pub(in crate::ui::entry) use trust::{PopupTrustPresentation, TrustLevel}; -pub(in crate::ui::entry) use view_model::{PopupEntryViewModel, ThumbnailKind}; +pub(in crate::ui::entry) use trust::{PopupTrustPresentation, ReplyPresentation, TrustLevel}; +pub(in crate::ui::entry) use view_model::{ActionViewModel, PopupEntryViewModel, ThumbnailKind}; #[cfg(test)] #[path = "tests/mod.rs"] diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index 068654daa..3e7d7500b 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -1,6 +1,6 @@ use unixnotis_core::{Action, AttributionClass, InlineReplyPolicy, NotificationAttribution}; -use super::super::{PopupTrustPresentation, TrustLevel}; +use super::super::{PopupTrustPresentation, ReplyPresentation, TrustLevel}; use super::support::notification; #[test] @@ -12,7 +12,7 @@ fn protected_desktop_association_stays_verified_and_visually_quiet() { assert_eq!(trust.level, TrustLevel::Verified); assert!(trust.short_label.is_none()); - assert!(trust.allow_reply); + assert_eq!(trust.reply, ReplyPresentation::Available); } #[test] @@ -34,7 +34,7 @@ fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { trust.details_label.as_deref(), Some("Sent via /usr/bin/notify-send") ); - assert!(!trust.allow_reply); + assert_eq!(trust.reply, ReplyPresentation::Hidden); } #[test] @@ -52,8 +52,7 @@ fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { assert_eq!(trust.level, TrustLevel::Suspicious); assert_eq!(trust.short_label.as_deref(), Some("Suspicious")); - assert!(!trust.allow_reply); - assert!(trust.show_reply_unavailable); + assert_eq!(trust.reply, ReplyPresentation::Unavailable); } #[test] @@ -73,7 +72,7 @@ fn user_writable_desktop_association_remains_unverified() { assert_eq!(trust.level, TrustLevel::Unverified); assert_eq!(trust.short_label.as_deref(), Some("Unverified")); - assert!(!trust.show_reply_unavailable); + assert_eq!(trust.reply, ReplyPresentation::Hidden); } #[test] @@ -82,13 +81,11 @@ fn verified_identity_still_needs_both_a_reply_request_and_policy_permission() { denied.inline_reply.available = true; denied.inline_reply_policy = InlineReplyPolicy::Deny; let denied_trust = PopupTrustPresentation::for_notification(&denied); - assert!(!denied_trust.allow_reply); - assert!(denied_trust.show_reply_unavailable); + assert_eq!(denied_trust.reply, ReplyPresentation::Unavailable); let no_request = notification(); let no_request_trust = PopupTrustPresentation::for_notification(&no_request); - assert!(!no_request_trust.allow_reply); - assert!(!no_request_trust.show_reply_unavailable); + assert_eq!(no_request_trust.reply, ReplyPresentation::Hidden); } #[test] @@ -99,8 +96,7 @@ fn only_the_exact_inline_reply_action_key_requests_reply_ui() { label: "Reply later".to_string(), }); let other_trust = PopupTrustPresentation::for_notification(&other_action); - assert!(!other_trust.allow_reply); - assert!(!other_trust.show_reply_unavailable); + assert_eq!(other_trust.reply, ReplyPresentation::Hidden); let mut inline_reply = notification(); inline_reply.actions.push(Action { @@ -108,6 +104,5 @@ fn only_the_exact_inline_reply_action_key_requests_reply_ui() { label: "Reply".to_string(), }); let inline_trust = PopupTrustPresentation::for_notification(&inline_reply); - assert!(inline_trust.allow_reply); - assert!(!inline_trust.show_reply_unavailable); + assert_eq!(inline_trust.reply, ReplyPresentation::Available); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index ca257d268..60949ee78 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -38,7 +38,7 @@ fn view_model_formats_relative_time_without_losing_original_age() { } #[test] -fn utility_layout_keeps_only_one_safe_action() { +fn utility_layout_moves_extra_safe_actions_into_overflow() { let mut view = notification(); view.actions = vec![ Action { @@ -54,8 +54,10 @@ fn utility_layout_keeps_only_one_safe_action() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Utility); - assert_eq!(model.actions.len(), 1); - assert_eq!(model.actions[0].key, "default"); + assert_eq!(model.primary_actions.len(), 1); + assert_eq!(model.primary_actions[0].key, "default"); + assert_eq!(model.overflow_actions.len(), 1); + assert_eq!(model.overflow_actions[0].key, "folder"); } #[test] @@ -73,7 +75,8 @@ fn weak_attribution_hides_every_application_directed_action() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); - assert!(model.actions.is_empty()); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); } #[test] @@ -95,12 +98,14 @@ fn user_associated_attribution_hides_application_directed_actions() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); - assert!(model.actions.is_empty()); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); } #[test] -fn square_icon_data_is_hidden_unless_the_category_is_media() { +fn communication_avatar_is_not_suppressed_as_decoration() { let mut view = notification(); + view.category = "im.received".to_string(); view.image.has_image_data = true; view.image.image_data = ImageData { width: 64, @@ -108,12 +113,10 @@ fn square_icon_data_is_hidden_unless_the_category_is_media() { ..ImageData::default() }; - let utility = PopupEntryViewModel::for_notification_at(&view, 1_000); - assert_eq!(utility.thumbnail, ThumbnailKind::None); - - view.category = "image.photo".to_string(); - let media = PopupEntryViewModel::for_notification_at(&view, 1_000); - assert_eq!(media.thumbnail, ThumbnailKind::Content); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); } #[test] @@ -164,16 +167,11 @@ fn either_badge_source_match_suppresses_duplicate_decoration() { } #[test] -fn decorative_square_detection_uses_every_dimension_guard_and_exact_boundary() { +fn image_dimensions_alone_never_prove_badge_duplication() { let mut view = notification(); view.image.has_image_data = true; - for (width, height, expected) in [ - (0, 0, ThumbnailKind::Content), - (96, 72, ThumbnailKind::Content), - (128, 128, ThumbnailKind::None), - (129, 129, ThumbnailKind::Content), - ] { + for (width, height) in [(0, 0), (64, 64), (96, 72), (128, 128), (129, 129)] { view.image.image_data = ImageData { width, height, @@ -181,8 +179,8 @@ fn decorative_square_detection_uses_every_dimension_guard_and_exact_boundary() { }; assert_eq!( PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, - expected, - "{width}x{height} should have the intended decoration classification" + ThumbnailKind::Content, + "{width}x{height} should remain real notification content" ); } } @@ -224,5 +222,6 @@ fn conflicting_claim_uses_warning_layout_and_drops_actions() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Warning); - assert!(model.actions.is_empty()); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs index 9a125e4d3..20d4565f6 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -11,6 +11,14 @@ pub(in crate::ui::entry) enum TrustLevel { System, } +/// Inline reply state kept separate from application-owned actions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::entry) enum ReplyPresentation { + Hidden, + Available, + Unavailable, +} + impl TrustLevel { pub(in crate::ui::entry) const fn css_class(self) -> &'static str { match self { @@ -28,8 +36,7 @@ pub(in crate::ui::entry) struct PopupTrustPresentation { pub(in crate::ui::entry) level: TrustLevel, pub(in crate::ui::entry) short_label: Option, pub(in crate::ui::entry) details_label: Option, - pub(in crate::ui::entry) allow_reply: bool, - pub(in crate::ui::entry) show_reply_unavailable: bool, + pub(in crate::ui::entry) reply: ReplyPresentation, } impl PopupTrustPresentation { @@ -45,14 +52,19 @@ impl PopupTrustPresentation { let allow_reply = has_reply && notification.inline_reply_policy == InlineReplyPolicy::Allow && level == TrustLevel::Verified; + let reply = if allow_reply { + ReplyPresentation::Available + } else if has_reply { + ReplyPresentation::Unavailable + } else { + ReplyPresentation::Hidden + }; Self { level, short_label, details_label, - allow_reply, - // Explain a missing reply control only when the sender actually requested one - show_reply_unavailable: has_reply && !allow_reply, + reply, } } } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 8b31f5651..8b5aea51f 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -10,8 +10,6 @@ use crate::ui::entry::labels::{ POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, }; -const DECORATIVE_SQUARE_IMAGE_MAX: i32 = 128; - /// One safe application action prepared for a compact popup button #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::ui::entry) struct ActionViewModel { @@ -35,7 +33,8 @@ pub(in crate::ui::entry) struct PopupEntryViewModel { pub(in crate::ui::entry) title: String, pub(in crate::ui::entry) body: Option, pub(in crate::ui::entry) thumbnail: ThumbnailKind, - pub(in crate::ui::entry) actions: Vec, + pub(in crate::ui::entry) primary_actions: Vec, + pub(in crate::ui::entry) overflow_actions: Vec, pub(in crate::ui::entry) trust: PopupTrustPresentation, pub(in crate::ui::entry) critical: bool, } @@ -56,7 +55,7 @@ impl PopupEntryViewModel { ) -> Self { let trust = PopupTrustPresentation::for_notification(notification); let kind = PopupKind::for_notification(notification, trust.level); - let actions = visible_actions(notification, kind); + let (primary_actions, overflow_actions) = visible_actions(notification, kind); Self { kind, @@ -70,26 +69,31 @@ impl PopupEntryViewModel { body: has_visible_text(¬ification.body) .then(|| clamp_label_text(¬ification.body, POPUP_BODY_MAX_CHARS).into_owned()), thumbnail: thumbnail_kind(notification), - actions, + primary_actions, + overflow_actions, trust, critical: notification.urgency == Urgency::Critical as u8, } } } -fn visible_actions(notification: &NotificationView, kind: PopupKind) -> Vec { +fn visible_actions( + notification: &NotificationView, + kind: PopupKind, +) -> (Vec, Vec) { // The daemon enforces the same boundary when a control client invokes an action if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { - return Vec::new(); + return (Vec::new(), Vec::new()); } - notification + let mut actions = notification .actions .iter() .filter(|action| action.key != "inline-reply") - .take(kind.action_limit()) .map(action_view_model) - .collect() + .collect::>(); + let overflow_actions = actions.split_off(actions.len().min(kind.action_limit())); + (actions, overflow_actions) } fn action_view_model(action: &Action) -> ActionViewModel { @@ -118,23 +122,42 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { return ThumbnailKind::Content; } - let badge = notification.attribution.badge_icon.trim(); - let source_matches_badge = !badge.is_empty() - && (notification.image.icon_name.trim() == badge - || notification.image.image_path.trim() == badge); - let image_data = ¬ification.image.image_data; - let looks_like_small_square_icon = notification.image.has_image_data - && image_data.width > 0 - && image_data.width == image_data.height - && image_data.width <= DECORATIVE_SQUARE_IMAGE_MAX; - - if source_matches_badge || looks_like_small_square_icon { + if image_source_matches_authenticated_badge(notification) { ThumbnailKind::None } else { ThumbnailKind::Content } } +fn image_source_matches_authenticated_badge(notification: &NotificationView) -> bool { + let badge = notification.attribution.badge_icon.trim(); + if badge.is_empty() { + return false; + } + if notification.image.icon_name.trim() == badge { + return true; + } + + let image_path = notification.image.image_path.trim(); + if image_path.is_empty() { + return false; + } + if image_path == badge { + return true; + } + + // Canonical file identity handles symlink aliases without guessing from dimensions + let badge_path = std::path::Path::new(badge); + let image_path = std::path::Path::new(image_path); + if !badge_path.is_absolute() || !image_path.is_absolute() { + return false; + } + let Some(badge_path) = std::fs::canonicalize(badge_path).ok() else { + return false; + }; + std::fs::canonicalize(image_path).is_ok_and(|path| path == badge_path) +} + fn relative_time_label(received_at: i64, now: i64) -> String { // Missing timestamps cannot produce a meaningful age if received_at <= 0 { From fdf06f0312e0f9310d96cb6a3675b2eac9644242 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 14:30:07 -0500 Subject: [PATCH 129/275] fix(theme): preserve edits racing stock migration Summary: preserve edits racing stock migration. Scope: theme. --- .../config/loading/io/tests/theme_stock.rs | 34 ++++++- .../src/config/loading/io/theme_stock.rs | 90 +++++++++++++++++-- 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs index 244316394..35166f196 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs @@ -4,7 +4,8 @@ use std::fs; use std::io; use super::super::theme_stock::{ - migrate_known_stock_file, migrate_stock_file_with_writer, stock_backup_path, + migrate_known_stock_file, migrate_stock_file_with_writer, replace_file_if_snapshot_matches, + stock_backup_path, }; use super::support::test_root; @@ -73,7 +74,7 @@ fn interrupted_replacement_keeps_complete_legacy_file_and_backup() { CURRENT_STOCK, &digest, BACKUP_TAG, - |_path, _contents| { + |_path, _contents, _snapshot| { Err(io::Error::new( io::ErrorKind::Interrupted, "test interruption", @@ -88,6 +89,35 @@ fn interrupted_replacement_keeps_complete_legacy_file_and_backup() { let _ = fs::remove_dir_all(root); } +#[test] +fn file_edited_after_backup_is_preserved_instead_of_migrated() { + let root = test_root("stock-migration-concurrent-edit"); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + fs::write(&target, OLD_STOCK).expect("legacy stock"); + let digest = blake3::hash(OLD_STOCK).to_hex().to_string(); + let edited = b"/* user edit during migration */\n"; + + let migrated = migrate_stock_file_with_writer( + &target, + CURRENT_STOCK, + &digest, + BACKUP_TAG, + |path, contents, snapshot| { + // This hook models an editor winning the race after the backup completes + fs::write(path, edited)?; + replace_file_if_snapshot_matches(path, contents, snapshot) + }, + ) + .expect("concurrent migration check"); + + assert!(!migrated); + assert_eq!(fs::read(&target).expect("edited theme"), edited); + let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); + assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); + let _ = fs::remove_dir_all(root); +} + #[test] fn matching_existing_backup_allows_a_retried_migration() { let root = test_root("stock-migration-retry"); diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs index 64ec91d20..35eb8178e 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs @@ -1,10 +1,12 @@ //! Exact-byte migration for stock theme assets that shipped with older releases -use std::io; +use std::io::{self, Read}; +use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use crate::filesystem::{ - read_regular_file_bounded, regular_file_contents_equal, write_file_atomic_preserving_mode, + open_regular_file, regular_file_contents_equal, write_file_atomic_preserving_mode, write_file_if_missing, }; use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; @@ -21,6 +23,15 @@ const LEGACY_WIDGETS_DIGEST: &str = const LEGACY_MEDIA_DIGEST: &str = "f3618bdaf411d4b018cb9aa1688c9be0880a5bdc0016fdb5e35d8ec798ae6b36"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct FileSnapshot { + device: u64, + inode: u64, + size: u64, + modified: SystemTime, + digest: blake3::Hash, +} + pub(super) fn migrate_known_stock_themes(paths: &ThemePaths) -> Result<(), ConfigError> { // Each file migrates independently so one customized layer never changes another layer migrate_known_stock_file( @@ -55,7 +66,7 @@ pub(super) fn migrate_known_stock_file( current_stock, legacy_digest, backup_tag, - |target, contents| write_file_atomic_preserving_mode(target, contents, 0o644), + replace_file_if_snapshot_matches, ) } @@ -64,13 +75,13 @@ pub(super) fn migrate_stock_file_with_writer( current_stock: &[u8], legacy_digest: &str, backup_tag: &str, - replace_file: impl FnOnce(&Path, &[u8]) -> io::Result<()>, + replace_file: impl FnOnce(&Path, &[u8], &FileSnapshot) -> io::Result, ) -> Result { // Unknown, unreadable, and oversized files remain user-owned and untouched - let Ok(existing) = read_regular_file_bounded(path, MAX_STOCK_THEME_BYTES) else { + let Ok((original, existing)) = inspect_stock_file(path) else { return Ok(false); }; - if blake3::hash(&existing).to_hex().as_str() != legacy_digest { + if original.digest.to_hex().as_str() != legacy_digest { return Ok(false); } @@ -80,11 +91,74 @@ pub(super) fn migrate_stock_file_with_writer( return Ok(false); }; - // Atomic replacement keeps the prior complete file visible if publication is interrupted - replace_file(path, current_stock).map_err(|error| migration_error(path, &error))?; + // The replacement boundary rechecks the exact object and bytes that were backed up + replace_file(path, current_stock, &original).map_err(|error| migration_error(path, &error)) +} + +pub(super) fn replace_file_if_snapshot_matches( + path: &Path, + current_stock: &[u8], + original: &FileSnapshot, +) -> io::Result { + let (current, _contents) = inspect_stock_file(path)?; + if ¤t != original { + // A concurrent edit always wins over automatic stock migration + return Ok(false); + } + + // Atomic publication keeps either the complete old file or complete new file visible + write_file_atomic_preserving_mode(path, current_stock, 0o644)?; Ok(true) } +fn inspect_stock_file(path: &Path) -> io::Result<(FileSnapshot, Vec)> { + let mut file = open_regular_file(path)?; + let before = file.metadata()?; + if before.len() > MAX_STOCK_THEME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stock theme exceeds migration size limit", + )); + } + + let capacity = usize::try_from(before.len()) + .map_err(|_error| io::Error::new(io::ErrorKind::InvalidData, "theme size is invalid"))?; + let mut contents = Vec::with_capacity(capacity); + file.by_ref() + .take(MAX_STOCK_THEME_BYTES.saturating_add(1)) + .read_to_end(&mut contents)?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_STOCK_THEME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "stock theme exceeds migration size limit", + )); + } + + let after = file.metadata()?; + let before_snapshot = snapshot_for_metadata(&before, &contents)?; + let after_snapshot = snapshot_for_metadata(&after, &contents)?; + if before_snapshot != after_snapshot { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "stock theme changed while it was being inspected", + )); + } + Ok((after_snapshot, contents)) +} + +fn snapshot_for_metadata( + metadata: &std::fs::Metadata, + contents: &[u8], +) -> io::Result { + Ok(FileSnapshot { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified: metadata.modified()?, + digest: blake3::hash(contents), + }) +} + fn reserve_stock_backup( path: &Path, backup_tag: &str, From 93eb3bebbbc75b4dc7f6c609b1345212a79bfd60 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 14:36:45 -0500 Subject: [PATCH 130/275] style(popups): add rounded cards and trust badges Summary: add rounded cards and trust badges. Scope: popups. --- Cargo.lock | 10 ++++ Cargo.toml | 1 + crates/unixnotis-popups/Cargo.toml | 3 ++ crates/unixnotis-popups/build.rs | 8 ++++ .../icons/unixnotis-app-unknown-symbolic.svg | 3 ++ .../unixnotis-shield-warning-symbolic.svg | 3 ++ .../icons/unixnotis-system-symbolic.svg | 3 ++ .../icons/unixnotis-terminal-symbolic.svg | 3 ++ .../resources/resources.gresource.xml | 9 ++++ crates/unixnotis-popups/src/app/command.rs | 2 + crates/unixnotis-popups/src/app/mod.rs | 1 + crates/unixnotis-popups/src/app/resources.rs | 9 ++++ crates/unixnotis-popups/src/ui/entry/build.rs | 11 +++-- .../src/ui/entry/builders/common.rs | 5 +- crates/unixnotis-popups/src/ui/entry/mod.rs | 1 + .../src/ui/entry/presentation/mod.rs | 3 +- .../src/ui/entry/presentation/trust.rs | 2 +- crates/unixnotis-popups/src/ui/mod.rs | 1 + .../src/ui/popups/mutation.rs | 19 +++++--- .../src/ui/popups/visibility.rs | 29 ++++++++---- .../unixnotis-popups/src/ui/semantic_icons.rs | 26 +++++++++++ .../src/ui/state/tests/constructor.rs | 46 +++++++++++++++++++ .../src/ui/tests/semantic_icons.rs | 23 ++++++++++ 23 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 crates/unixnotis-popups/build.rs create mode 100644 crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg create mode 100644 crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg create mode 100644 crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg create mode 100644 crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg create mode 100644 crates/unixnotis-popups/resources/resources.gresource.xml create mode 100644 crates/unixnotis-popups/src/app/resources.rs create mode 100644 crates/unixnotis-popups/src/ui/semantic_icons.rs create mode 100644 crates/unixnotis-popups/src/ui/tests/semantic_icons.rs diff --git a/Cargo.lock b/Cargo.lock index e7e7d1a5a..7f9c1478d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "glib-build-tools" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86aebe63bb050d4918cb1d629880cb35fcba7ccda6f6fc0ec1beffdaa1b9d5c3" +dependencies = [ + "gio", +] + [[package]] name = "glib-macros" version = "0.21.5" @@ -3673,6 +3682,7 @@ dependencies = [ "gdk4-wayland", "gio", "glib", + "glib-build-tools", "gtk4", "gtk4-layer-shell", "image", diff --git a/Cargo.toml b/Cargo.toml index 38a317414..b88556523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ zbus = { version = "4", default-features = false, features = ["tokio"] } gio = "0.21" gdk4-wayland = { version = "0.10.3", features = ["v4_18"] } glib = "0.21" +glib-build-tools = "0.21" gtk = { package = "gtk4", version = "0.10", features = ["v4_18"] } gtk4-layer-shell = "0.7.1" indexmap = "2" diff --git a/crates/unixnotis-popups/Cargo.toml b/crates/unixnotis-popups/Cargo.toml index 016065b9f..2f5a18d6b 100644 --- a/crates/unixnotis-popups/Cargo.toml +++ b/crates/unixnotis-popups/Cargo.toml @@ -22,5 +22,8 @@ zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } unixnotis-ui = { path = "../unixnotis-ui" } +[build-dependencies] +glib-build-tools.workspace = true + [dev-dependencies] proptest.workspace = true diff --git a/crates/unixnotis-popups/build.rs b/crates/unixnotis-popups/build.rs new file mode 100644 index 000000000..0fd866b12 --- /dev/null +++ b/crates/unixnotis-popups/build.rs @@ -0,0 +1,8 @@ +fn main() { + // Compile controlled semantic icons into the binary so desktop themes cannot replace meaning + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "unixnotis-popups.gresource", + ); +} diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg b/crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg new file mode 100644 index 000000000..416cbbbe6 --- /dev/null +++ b/crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg b/crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg new file mode 100644 index 000000000..43433398f --- /dev/null +++ b/crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg b/crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg new file mode 100644 index 000000000..5ccbd65d1 --- /dev/null +++ b/crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg b/crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg new file mode 100644 index 000000000..12132a1b3 --- /dev/null +++ b/crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-popups/resources/resources.gresource.xml b/crates/unixnotis-popups/resources/resources.gresource.xml new file mode 100644 index 000000000..517e512db --- /dev/null +++ b/crates/unixnotis-popups/resources/resources.gresource.xml @@ -0,0 +1,9 @@ + + + + icons/unixnotis-app-unknown-symbolic.svg + icons/unixnotis-shield-warning-symbolic.svg + icons/unixnotis-terminal-symbolic.svg + icons/unixnotis-system-symbolic.svg + + diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index a4488a321..d3a34353f 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -16,6 +16,7 @@ use unixnotis_ui::css::{self, CssKind}; use crate::{dbus, ui}; use super::reload::{start_reload_timer, ReloadGate}; +use super::resources; use super::runtime::handle_ui_event; use super::startup::{init_tracing, is_wayland_session, load_config, ConfigSource}; @@ -30,6 +31,7 @@ pub struct Args { } pub fn run(args: Args) -> Result<()> { + resources::register()?; // Load and validate config before GTK starts so startup failures stay clear let (config, config_path, config_source) = load_config(&args).context("load config")?; init_tracing(&config); diff --git a/crates/unixnotis-popups/src/app/mod.rs b/crates/unixnotis-popups/src/app/mod.rs index 157aff64c..00868a207 100644 --- a/crates/unixnotis-popups/src/app/mod.rs +++ b/crates/unixnotis-popups/src/app/mod.rs @@ -2,6 +2,7 @@ mod command; mod reload; +pub mod resources; mod runtime; mod startup; diff --git a/crates/unixnotis-popups/src/app/resources.rs b/crates/unixnotis-popups/src/app/resources.rs new file mode 100644 index 000000000..e0babbefe --- /dev/null +++ b/crates/unixnotis-popups/src/app/resources.rs @@ -0,0 +1,9 @@ +//! Process-wide registration for bundled popup resources + +use anyhow::Context; + +pub fn register() -> anyhow::Result<()> { + // Registration happens before GTK activation so every card sees the same icon assets + gio::resources_register_include!("unixnotis-popups.gresource") + .context("register bundled popup resources") +} diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 9f1f4cd9d..ca5365760 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -92,9 +92,14 @@ impl UiState { revealer.set_transition_type(gtk::RevealerTransitionType::Crossfade); revealer.set_transition_duration(200); } - // The shared primitive clips the full styled popup instead of approximating the corners - let plate = CutCorner::new(root, self.config.theme.notification_corners); - revealer.set_child(Some(&plate)); + if self.config.theme.notification_corners.is_active() { + // Explicit diagonal cuts still use the shared clipping primitive + let plate = CutCorner::new(root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } else { + // The default card relies on GTK CSS rounding without a custom snapshot wrapper + revealer.set_child(Some(root)); + } // Visibility is driven centrally so only rows inside max_visible animate in revealer.set_reveal_child(false); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index e3f26c3fd..fa338517a 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -8,6 +8,7 @@ use unixnotis_core::{hooks, NotificationView}; use super::super::commands::try_send_command; use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; use crate::dbus::UiCommand; +use crate::ui::semantic_icons::build_semantic_badge; use crate::ui::UiState; pub(super) struct IdentityHeader { @@ -27,7 +28,9 @@ pub(super) fn build_identity_header( let mut has_icon = false; if let Some(size) = app_icon_size { - if let Some(icon) = state.build_app_icon_widget(notification, size) { + let icon = build_semantic_badge(view.trust.level, size) + .or_else(|| state.build_app_icon_widget(notification, size)); + if let Some(icon) = icon { // Only daemon-associated badge inputs reach the quiet identity header icon.set_valign(Align::Center); icon.set_halign(Align::Start); diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index ca56c9f9b..e57a9beeb 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -7,3 +7,4 @@ mod labels; mod presentation; pub(in crate::ui) use build::PopupEntry; +pub(in crate::ui) use presentation::TrustLevel; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs index d6c4d6c10..2f23dab25 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs @@ -5,7 +5,8 @@ mod trust; mod view_model; pub(in crate::ui::entry) use kind::PopupKind; -pub(in crate::ui::entry) use trust::{PopupTrustPresentation, ReplyPresentation, TrustLevel}; +pub(in crate::ui) use trust::TrustLevel; +pub(in crate::ui::entry) use trust::{PopupTrustPresentation, ReplyPresentation}; pub(in crate::ui::entry) use view_model::{ActionViewModel, PopupEntryViewModel, ThumbnailKind}; #[cfg(test)] diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs index 20d4565f6..c2b8e0def 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -4,7 +4,7 @@ use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationView}; /// Small set of trust states used by popup styling and interaction hints #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::ui::entry) enum TrustLevel { +pub(in crate::ui) enum TrustLevel { Verified, Unverified, Suspicious, diff --git a/crates/unixnotis-popups/src/ui/mod.rs b/crates/unixnotis-popups/src/ui/mod.rs index a0db07dbf..e965be21b 100644 --- a/crates/unixnotis-popups/src/ui/mod.rs +++ b/crates/unixnotis-popups/src/ui/mod.rs @@ -6,6 +6,7 @@ mod entry; mod icon_state; mod icons; mod popups; +mod semantic_icons; mod state; mod window; diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 4647d9e3e..b0db8dffe 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -179,14 +179,19 @@ impl UiState { if old_root.has_css_class("unixnotis-popup-visible") { new_root.add_css_class("unixnotis-popup-visible"); } - if let Some(plate) = revealer.child().and_downcast::() { - // Preserve the reveal animation while swapping only the clipped card contents - plate.set_child(Some(&new_root)); - plate.set_corners(self.config.theme.notification_corners); + if self.config.theme.notification_corners.is_active() { + if let Some(plate) = revealer.child().and_downcast::() { + // Preserve the reveal animation while swapping only the clipped card contents + plate.set_child(Some(&new_root)); + plate.set_corners(self.config.theme.notification_corners); + } else { + // Enabling experimental cuts replaces the ordinary card wrapper on rebuild + let plate = CutCorner::new(&new_root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } } else { - // Older in-memory rows cannot normally reach this branch, but rebuilding stays safe - let plate = CutCorner::new(&new_root, self.config.theme.notification_corners); - revealer.set_child(Some(&plate)); + // Disabling experimental cuts restores the native rounded card shape + revealer.set_child(Some(&new_root)); } if let Some(entry) = self.popups.get_mut(&id) { diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index 90d3d07d9..e551164bd 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -72,14 +72,27 @@ impl UiState { continue; }; root.set_size_request(popup_width, -1); - if let Some(plate) = entry - .revealer - .as_ref() - .and_then(gtk::Revealer::child) - .and_downcast::() - { - // Theme corner changes apply to existing visible popups immediately - plate.set_corners(self.config.theme.notification_corners); + let Some(revealer) = entry.revealer.as_ref() else { + continue; + }; + let plate = revealer.child().and_downcast::(); + match (self.config.theme.notification_corners.is_active(), plate) { + (true, Some(plate)) => { + // Active cut geometry updates without rebuilding the full card + plate.set_corners(self.config.theme.notification_corners); + } + (true, None) => { + // Detach the ordinary card before moving it under the opt-in clipper + revealer.set_child(gtk::Widget::NONE); + let plate = CutCorner::new(root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } + (false, Some(plate)) => { + // Return the card to the revealer before dropping the disabled clipper + plate.set_child(gtk::Widget::NONE); + revealer.set_child(Some(root)); + } + (false, None) => {} } } // Re-run visibility so max_visible changes take effect right away diff --git a/crates/unixnotis-popups/src/ui/semantic_icons.rs b/crates/unixnotis-popups/src/ui/semantic_icons.rs new file mode 100644 index 000000000..5e677d75c --- /dev/null +++ b/crates/unixnotis-popups/src/ui/semantic_icons.rs @@ -0,0 +1,26 @@ +//! Daemon-controlled badges for uncertain and non-application identities + +use gtk::prelude::*; + +use super::entry::TrustLevel; + +const RESOURCE_ROOT: &str = "/com/unixnotis/Popups/icons"; + +pub(super) fn build_semantic_badge(level: TrustLevel, size: i32) -> Option { + let file = match level { + // Verified applications always retain the authenticated desktop badge + TrustLevel::Verified => return None, + TrustLevel::Unverified => "unixnotis-app-unknown-symbolic.svg", + TrustLevel::Suspicious => "unixnotis-shield-warning-symbolic.svg", + TrustLevel::System => "unixnotis-terminal-symbolic.svg", + }; + let image = gtk::Image::from_resource(&format!("{RESOURCE_ROOT}/{file}")); + let size = size.max(1); + image.set_pixel_size(size); + image.set_size_request(size, size); + Some(image) +} + +#[cfg(test)] +#[path = "tests/semantic_icons.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 1a8349a94..4c2d10f14 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -59,6 +59,52 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { assert_eq!(plate.child().as_ref(), Some(root.upcast_ref())); } +#[gtk::test] +fn default_popup_entry_uses_the_native_rounded_card_without_a_clipper() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupRoundedCardTest") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup rounded-card test application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-rounded-card"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 1, + generation: 1, + app_name: "Demo".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + }; + + let entry = state.build_popup_entry(¬ification); + let root = entry.root.expect("popup entry should keep its styled root"); + let child = entry + .revealer + .and_then(|revealer| revealer.child()) + .expect("popup revealer should contain its card"); + + assert_eq!(child, root.upcast::()); +} + #[gtk::test] fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { let app = gtk::Application::builder() diff --git a/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs b/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs new file mode 100644 index 000000000..be6d38a17 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs @@ -0,0 +1,23 @@ +use super::super::entry::TrustLevel; +use super::super::semantic_icons::build_semantic_badge; + +#[gtk::test] +fn uncertain_trust_states_use_bundled_semantic_resources() { + super::super::super::app::resources::register().expect("register popup resources"); + + for level in [ + TrustLevel::Unverified, + TrustLevel::Suspicious, + TrustLevel::System, + ] { + let image = build_semantic_badge(level, 20).expect("semantic badge"); + + assert!(image.paintable().is_some(), "bundled badge should load"); + assert_eq!(image.pixel_size(), 20); + } +} + +#[gtk::test] +fn verified_identity_does_not_replace_the_authenticated_badge() { + assert!(build_semantic_badge(TrustLevel::Verified, 20).is_none()); +} From dbde45436261fbd8b22dc120ad78d604a1cdf524 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 14:44:07 -0500 Subject: [PATCH 131/275] refactor(ui): share notification presentation policy Summary: share notification presentation policy. Scope: ui. --- .../src/ui/notifications/row/group.rs | 18 +- .../row/notification/tests/support.rs | 1 + .../row/notification/update/actions.rs | 87 +++--- .../row/notification/update/row.rs | 15 +- .../row/notification/update/tests/actions.rs | 28 ++ .../row/notification/update/thumbnail.rs | 6 +- .../row/notification/update/visual.rs | 10 + .../unixnotis-popups/src/ui/entry/labels.rs | 43 --- crates/unixnotis-popups/src/ui/entry/mod.rs | 1 - .../src/ui/entry/presentation/kind.rs | 77 +---- .../src/ui/entry/presentation/tests/trust.rs | 6 +- .../src/ui/entry/presentation/trust.rs | 105 +------ .../src/ui/entry/presentation/view_model.rs | 154 ++-------- .../src/ui/entry/tests/labels.rs | 26 -- crates/unixnotis-ui/src/lib.rs | 1 + crates/unixnotis-ui/src/presentation/build.rs | 275 ++++++++++++++++++ crates/unixnotis-ui/src/presentation/mod.rs | 19 ++ .../src/presentation/tests/mod.rs | 5 + .../src/presentation/tests/presentation.rs | 96 ++++++ .../src/presentation/tests/support.rs | 31 ++ .../src/presentation/tests/text.rs | 18 ++ crates/unixnotis-ui/src/presentation/text.rs | 30 ++ crates/unixnotis-ui/src/presentation/types.rs | 126 ++++++++ 23 files changed, 740 insertions(+), 438 deletions(-) delete mode 100644 crates/unixnotis-popups/src/ui/entry/labels.rs delete mode 100644 crates/unixnotis-popups/src/ui/entry/tests/labels.rs create mode 100644 crates/unixnotis-ui/src/presentation/build.rs create mode 100644 crates/unixnotis-ui/src/presentation/mod.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/mod.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/presentation.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/support.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/text.rs create mode 100644 crates/unixnotis-ui/src/presentation/text.rs create mode 100644 crates/unixnotis-ui/src/presentation/types.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index c90aeddeb..c2b88216b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -10,6 +10,7 @@ use gtk::pango; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{css::hooks, util}; +use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use crate::control::UiEvent; @@ -117,7 +118,11 @@ pub(in crate::ui::notifications) fn update_group_row( let display_name = data .notification .as_ref() - .map(|notification| notification.attribution.display_name.clone()) + .map(|notification| { + NotificationPresentation::from_view(notification) + .identity + .primary_label + }) .filter(|name| !name.is_empty()) .unwrap_or_else(|| data.group_key.to_string()); // Display application presentation while the daemon identity key drives grouping behavior @@ -137,17 +142,16 @@ pub(in crate::ui::notifications) fn update_group_row( *group.group_key.borrow_mut() = data.group_key.clone(); if let Some(notification) = data.notification.as_ref() { - if notification.attribution.source_label.is_empty() { + let presentation = NotificationPresentation::from_view(notification); + if presentation.trust.details_label.is_none() { group.title.set_tooltip_text(None); - } else { - group - .title - .set_tooltip_text(Some(¬ification.attribution.source_label)); + } else if let Some(details) = presentation.trust.details_label.as_deref() { + group.title.set_tooltip_text(Some(details)); } set_class_state( root, "unixnotis-attribution-warning", - notification.attribution.has_warning(), + presentation.trust.level == TrustLevel::Suspicious, ); let scale = root.scale_factor(); // Group headers use the associated badge path instead of caller content images diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index b3d591cc8..dc5007192 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -20,6 +20,7 @@ pub(super) fn sample_notification() -> NotificationView { attribution: unixnotis_core::NotificationAttribution { display_name: "demo".to_string(), badge_icon: "demo".to_string(), + class: unixnotis_core::AttributionClass::SystemAssociated, group_key: "test:demo".to_string(), ..unixnotis_core::NotificationAttribution::default() }, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index b440be8ef..7e70708b8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -7,7 +7,8 @@ use std::time::Duration; use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; -use unixnotis_core::{InlineReplyPolicy, NotificationView}; +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{NotificationPresentation, ReplyPresentation}; use crate::control::UiCommand; use crate::ui::panel::behavior::input::ClickCooldown; @@ -31,16 +32,23 @@ pub(super) fn update_actions( notification: &Rc, is_active: bool, ) { + let presentation = NotificationPresentation::from_view(notification); configure_inline_reply(&row.inline_reply, notification, is_active); + let safe_actions = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .collect::>(); // Fast path skips button rebuilding when the action set is unchanged { let cached = row.action_cache.borrow(); let reply_cached = row.reply_cache.borrow(); if row.action_cache_id.get() == notification.id - && cached.len() == notification.actions.len() + && cached.len() == safe_actions.len() && cached .iter() - .zip(notification.actions.iter()) + .zip(&safe_actions) .all(|((key, label), action)| key == &action.key && label == &action.label) && reply_cached.0 == notification.inline_reply && reply_cached.1 == notification.inline_reply_policy @@ -54,8 +62,8 @@ pub(super) fn update_actions( // Cache the current action signature for the next update cycle let mut cached = row.action_cache.borrow_mut(); cached.clear(); - cached.reserve(notification.actions.len()); - for action in ¬ification.actions { + cached.reserve(safe_actions.len()); + for action in &safe_actions { cached.push((action.key.clone(), action.label.clone())); } row.action_cache_id.set(notification.id); @@ -70,36 +78,32 @@ pub(super) fn update_actions( while let Some(child) = row.actions_box.first_child() { row.actions_box.remove(&child); } - if visible_action_count(notification, is_active) == 0 { + if visible_action_count_from(&presentation, is_active) == 0 { return; } - let mut reply_button_added = false; - for action in ¬ification.actions { - if action.key == "inline-reply" { - if reply_button_added - || !is_active - || !notification.inline_reply.available - || notification.inline_reply_policy != InlineReplyPolicy::Allow - { - continue; - } - reply_button_added = true; - let label = if !notification.inline_reply.label.is_empty() { - notification.inline_reply.label.as_str() - } else if !action.label.is_empty() { - action.label.as_str() - } else { - "Reply" - }; - let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); - button.add_css_class("unixnotis-panel-action"); - button.add_css_class("unixnotis-notification-action"); - connect_inline_reply_button(&button, &row.inline_reply); - row.actions_box.append(&button); - continue; - } + if is_active && presentation.trust.reply == ReplyPresentation::Available { + let action_label = notification + .actions + .iter() + .find(|action| action.key == "inline-reply") + .map(|action| action.label.as_str()) + .unwrap_or_default(); + let label = if !notification.inline_reply.label.is_empty() { + notification.inline_reply.label.as_str() + } else if !action_label.is_empty() { + action_label + } else { + "Reply" + }; + let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + connect_inline_reply_button(&button, &row.inline_reply); + row.actions_box.append(&button); + } + for action in safe_actions { // Bound action text before GTK measures the button let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); button.add_css_class("unixnotis-panel-action"); @@ -127,17 +131,14 @@ pub(super) fn update_actions( } pub(super) fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { - let regular = notification - .actions - .iter() - .filter(|action| action.key != "inline-reply") - .count(); - let reply = is_active - && notification.inline_reply.available - && notification.inline_reply_policy == InlineReplyPolicy::Allow - && notification - .actions - .iter() - .any(|action| action.key == "inline-reply"); + visible_action_count_from( + &NotificationPresentation::from_view(notification), + is_active, + ) +} + +fn visible_action_count_from(presentation: &NotificationPresentation, is_active: bool) -> usize { + let regular = presentation.actions.primary.len() + presentation.actions.overflow.len(); + let reply = is_active && presentation.trust.reply == ReplyPresentation::Available; regular + usize::from(reply) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 226584c87..dbad54ebf 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -2,6 +2,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; +use unixnotis_ui::presentation::NotificationPresentation; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -27,6 +28,7 @@ pub(in crate::ui::notifications) fn update_notification_row( return; }; let notification = notification_snapshot.as_ref(); + let presentation = NotificationPresentation::from_view(notification); let has_actions = visible_action_count(notification, data.is_active) > 0; let has_thumbnail = data.presentation.show_thumbnail && notification_has_thumbnail(notification); @@ -34,15 +36,14 @@ pub(in crate::ui::notifications) fn update_notification_row( apply_visual_state(row, data, notification, has_actions, has_thumbnail); update_notification_text( row, - ¬ification.attribution.display_name, - ¬ification.summary, - ¬ification.body, + &presentation.identity.primary_label, + &presentation.title, + presentation.body.as_deref().unwrap_or_default(), ); - if notification.attribution.source_label.is_empty() { + if presentation.trust.details_label.is_none() { row.app_label.set_tooltip_text(None); - } else { - row.app_label - .set_tooltip_text(Some(¬ification.attribution.source_label)); + } else if let Some(details) = presentation.trust.details_label.as_deref() { + row.app_label.set_tooltip_text(Some(details)); } update_metadata_labels(row, data, notification); row.notify_id.set(notification.id); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index a86eaa3fb..110682ab8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -91,6 +91,34 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { ); } +#[gtk::test] +fn unverified_panel_row_hides_application_actions_like_the_popup() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unknown( + "Claimed application", + "unverified sender", + "unknown:claimed".to_string(), + ); + notification.actions = vec![Action { + key: "default".to_string(), + label: "Open".to_string(), + }]; + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 0); + assert!(row.card.has_css_class("unverified")); +} + #[gtk::test] fn reply_action_cache_tracks_allow_and_deny_policy_transitions() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index 6f6758443..9db5e4432 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -1,7 +1,11 @@ //! Thumbnail source decisions for notification rows use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{NotificationPresentation, ThumbnailKind}; pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> bool { - notification.image.has_image_data || !notification.image.image_path.trim().is_empty() + NotificationPresentation::from_view(notification) + .media + .thumbnail + == ThumbnailKind::Content } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index d4afbba2b..ceec5b494 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -2,6 +2,7 @@ use gtk::prelude::*; use unixnotis_core::{hooks, NotificationView, Urgency}; +use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use super::super::super::super::item::RowData; use super::super::state::NotificationRowWidgets; @@ -29,11 +30,20 @@ pub(super) fn apply_visual_state( has_thumbnail: bool, ) { let card = &row.card; + let presentation = NotificationPresentation::from_view(notification); let is_critical = notification.urgency == Urgency::Critical as u8; // Theme changes update recycled rows without rebuilding the GTK child tree row.card_plate.set_corners(data.presentation.card_corners); // Explicit state updates prevent recycled rows from retaining stale classes set_class_state(card, hooks::shared_state::CRITICAL, is_critical); + for (level, class_name) in [ + (TrustLevel::Verified, "verified"), + (TrustLevel::Unverified, "unverified"), + (TrustLevel::Suspicious, "suspicious"), + (TrustLevel::System, "system"), + ] { + set_class_state(card, class_name, presentation.trust.level == level); + } set_widget_visible_if_changed(&row.urgency_badge, is_critical); set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); set_class_state(card, hooks::shared_state::STACKED, data.stacked); diff --git a/crates/unixnotis-popups/src/ui/entry/labels.rs b/crates/unixnotis-popups/src/ui/entry/labels.rs deleted file mode 100644 index 7b36f8eb0..000000000 --- a/crates/unixnotis-popups/src/ui/entry/labels.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Popup text sizing and empty-row handling -//! -//! Keeps label rules in one place so summary and body rows stay consistent - -use std::borrow::Cow; - -// Header/app title stays single-line and clipped at this length -pub(super) const POPUP_APP_MAX_CHARS: usize = 64; -// Summary is visually dominant but still bounded to avoid tall cards -pub(super) const POPUP_SUMMARY_MAX_CHARS: usize = 120; -// Body keeps enough context while preventing oversized popup growth -pub(super) const POPUP_BODY_MAX_CHARS: usize = 320; -// Action labels stay short so button row width remains predictable -pub(super) const POPUP_ACTION_LABEL_MAX_CHARS: usize = 14; - -pub(super) fn has_visible_text(text: &str) -> bool { - // Visibility depends on real content, not just raw string length - // Space-only strings count as empty for popup layout purposes - text.chars().any(|ch| !ch.is_whitespace()) -} - -pub(super) fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { - if max_chars == 0 { - // Zero means the caller wants an intentionally blank label - return Cow::Borrowed(""); - } - // char_indices preserves UTF-8 boundaries during truncation - for (chars, (idx, _)) in text.char_indices().enumerate() { - if chars == max_chars { - // Keep one glyph slot for the ellipsis instead of splitting the codepoint - let mut clamped = String::with_capacity(idx + 3); - clamped.push_str(&text[..idx]); - clamped.push('…'); - return Cow::Owned(clamped); - } - } - // Borrow the original text when no clamp is needed - Cow::Borrowed(text) -} - -#[cfg(test)] -#[path = "tests/labels.rs"] -mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index e57a9beeb..03dbaaaf7 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -3,7 +3,6 @@ mod build; mod builders; mod commands; -mod labels; mod presentation; pub(in crate::ui) use build::PopupEntry; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs index 75ec7f7a5..0d2c73b59 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs @@ -1,76 +1,3 @@ -//! Stable popup layout selection from protocol categories and trust state +//! Popup naming for the shared notification content hierarchy -use unixnotis_core::NotificationView; - -use super::TrustLevel; - -/// Visual structure used for one popup -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::ui::entry) enum PopupKind { - Communication, - Utility, - Warning, -} - -impl PopupKind { - pub(in crate::ui::entry) fn for_notification( - notification: &NotificationView, - trust_level: TrustLevel, - ) -> Self { - // Conflicting identity needs its own restrained warning layout - if trust_level == TrustLevel::Suspicious { - return Self::Warning; - } - - // Standard categories use class.specific, so only the first segment selects the layout - let category_class = notification - .category - .split('.') - .next() - .unwrap_or_default() - .trim(); - if communication_category_class(category_class) - || notification.inline_reply.available - || notification - .actions - .iter() - .any(|action| action.key == "inline-reply") - { - return Self::Communication; - } - - // Missing and vendor-specific categories stay compact instead of guessing from prose - Self::Utility - } - - pub(in crate::ui::entry) const fn css_class(self) -> &'static str { - match self { - Self::Communication => "communication", - Self::Utility => "utility", - Self::Warning => "warning", - } - } - - pub(in crate::ui::entry) const fn action_limit(self) -> usize { - match self { - Self::Communication => 3, - Self::Utility | Self::Warning => 1, - } - } -} - -fn communication_category_class(category_class: &str) -> bool { - // Freedesktop communication classes are extended with common vendor spellings - [ - "call", - "email", - "im", - "presence", - "chat", - "message", - "social", - "voicemail", - ] - .iter() - .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) -} +pub(in crate::ui::entry) use unixnotis_ui::presentation::NotificationKind as PopupKind; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index 3e7d7500b..62337bdf2 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -7,6 +7,10 @@ use super::support::notification; fn protected_desktop_association_stays_verified_and_visually_quiet() { let mut view = notification(); view.inline_reply.available = true; + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); let trust = PopupTrustPresentation::for_notification(&view); @@ -104,5 +108,5 @@ fn only_the_exact_inline_reply_action_key_requests_reply_ui() { label: "Reply".to_string(), }); let inline_trust = PopupTrustPresentation::for_notification(&inline_reply); - assert_eq!(inline_trust.reply, ReplyPresentation::Available); + assert_eq!(inline_trust.reply, ReplyPresentation::Unavailable); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs index c2b8e0def..c1d1017f4 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -1,101 +1,6 @@ -//! Human-scale trust state derived without exposing raw provenance in the card body +//! Popup naming for shared trust and reply presentation -use unixnotis_core::{AttributionClass, InlineReplyPolicy, NotificationView}; - -/// Small set of trust states used by popup styling and interaction hints -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::ui) enum TrustLevel { - Verified, - Unverified, - Suspicious, - System, -} - -/// Inline reply state kept separate from application-owned actions -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::ui::entry) enum ReplyPresentation { - Hidden, - Available, - Unavailable, -} - -impl TrustLevel { - pub(in crate::ui::entry) const fn css_class(self) -> &'static str { - match self { - Self::Verified => "verified", - Self::Unverified => "unverified", - Self::Suspicious => "suspicious", - Self::System => "system", - } - } -} - -/// Safe visible trust text plus optional diagnostic detail for a tooltip -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::ui::entry) struct PopupTrustPresentation { - pub(in crate::ui::entry) level: TrustLevel, - pub(in crate::ui::entry) short_label: Option, - pub(in crate::ui::entry) details_label: Option, - pub(in crate::ui::entry) reply: ReplyPresentation, -} - -impl PopupTrustPresentation { - pub(in crate::ui::entry) fn for_notification(notification: &NotificationView) -> Self { - let level = trust_level(notification); - let short_label = short_trust_label(level).map(str::to_string); - let details_label = nonempty_text(¬ification.attribution.source_label); - let has_reply = notification.inline_reply.available - || notification - .actions - .iter() - .any(|action| action.key == "inline-reply"); - let allow_reply = has_reply - && notification.inline_reply_policy == InlineReplyPolicy::Allow - && level == TrustLevel::Verified; - let reply = if allow_reply { - ReplyPresentation::Available - } else if has_reply { - ReplyPresentation::Unavailable - } else { - ReplyPresentation::Hidden - }; - - Self { - level, - short_label, - details_label, - reply, - } - } -} - -const fn trust_level(notification: &NotificationView) -> TrustLevel { - // Explicit conflicts outrank the weaker association class carried beside them - if notification.attribution.has_warning() { - return TrustLevel::Suspicious; - } - - match notification.attribution.class { - AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { - TrustLevel::Verified - } - AttributionClass::UserAssociated | AttributionClass::Unknown => TrustLevel::Unverified, - AttributionClass::TrustedRelay => TrustLevel::System, - AttributionClass::Conflict => TrustLevel::Suspicious, - } -} - -const fn short_trust_label(level: TrustLevel) -> Option<&'static str> { - match level { - // Verified application identity stays quiet unless a future theme opts into a marker - TrustLevel::Verified => None, - TrustLevel::Unverified => Some("Unverified"), - TrustLevel::Suspicious => Some("Suspicious"), - TrustLevel::System => Some("Command-line tool"), - } -} - -fn nonempty_text(value: &str) -> Option { - let value = value.trim(); - (!value.is_empty()).then(|| value.to_string()) -} +pub(in crate::ui) use unixnotis_ui::presentation::TrustLevel; +pub(in crate::ui::entry) use unixnotis_ui::presentation::{ + ReplyPresentation, TrustPresentation as PopupTrustPresentation, +}; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 8b5aea51f..9c1b40ca5 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -1,30 +1,17 @@ -//! Bounded content and actions consumed by the kind-specific GTK builders +//! Popup adapter over the shared non-GTK notification presentation use std::time::{SystemTime, UNIX_EPOCH}; -use unixnotis_core::{Action, ApplicationActionPolicy, NotificationView, Urgency}; +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::NotificationPresentation; use super::{PopupKind, PopupTrustPresentation}; -use crate::ui::entry::labels::{ - clamp_label_text, has_visible_text, POPUP_ACTION_LABEL_MAX_CHARS, POPUP_APP_MAX_CHARS, - POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, -}; -/// One safe application action prepared for a compact popup button -#[derive(Debug, Clone, PartialEq, Eq)] -pub(in crate::ui::entry) struct ActionViewModel { - pub(in crate::ui::entry) key: String, - pub(in crate::ui::entry) label: String, -} - -/// Whether the payload contains a genuine content image worth showing -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::ui::entry) enum ThumbnailKind { - None, - Content, -} +pub(in crate::ui::entry) use unixnotis_ui::presentation::{ + ActionView as ActionViewModel, ThumbnailKind, +}; -/// Presentation data kept separate from raw attribution evidence +/// Popup field names retained as a thin adapter for the kind-specific GTK builders #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::ui::entry) struct PopupEntryViewModel { pub(in crate::ui::entry) kind: PopupKind, @@ -53,122 +40,21 @@ impl PopupEntryViewModel { notification: &NotificationView, now: i64, ) -> Self { - let trust = PopupTrustPresentation::for_notification(notification); - let kind = PopupKind::for_notification(notification, trust.level); - let (primary_actions, overflow_actions) = visible_actions(notification, kind); + Self::from_shared(NotificationPresentation::from_view_at(notification, now)) + } + fn from_shared(shared: NotificationPresentation) -> Self { Self { - kind, - app_label: clamp_label_text( - ¬ification.attribution.display_name, - POPUP_APP_MAX_CHARS, - ) - .into_owned(), - timestamp_label: relative_time_label(notification.received_at_unix_seconds, now), - title: clamp_label_text(¬ification.summary, POPUP_SUMMARY_MAX_CHARS).into_owned(), - body: has_visible_text(¬ification.body) - .then(|| clamp_label_text(¬ification.body, POPUP_BODY_MAX_CHARS).into_owned()), - thumbnail: thumbnail_kind(notification), - primary_actions, - overflow_actions, - trust, - critical: notification.urgency == Urgency::Critical as u8, + kind: shared.kind, + app_label: shared.identity.primary_label, + timestamp_label: shared.timestamp, + title: shared.title, + body: shared.body, + thumbnail: shared.media.thumbnail, + primary_actions: shared.actions.primary, + overflow_actions: shared.actions.overflow, + trust: shared.trust, + critical: shared.critical, } } } - -fn visible_actions( - notification: &NotificationView, - kind: PopupKind, -) -> (Vec, Vec) { - // The daemon enforces the same boundary when a control client invokes an action - if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { - return (Vec::new(), Vec::new()); - } - - let mut actions = notification - .actions - .iter() - .filter(|action| action.key != "inline-reply") - .map(action_view_model) - .collect::>(); - let overflow_actions = actions.split_off(actions.len().min(kind.action_limit())); - (actions, overflow_actions) -} - -fn action_view_model(action: &Action) -> ActionViewModel { - ActionViewModel { - key: action.key.clone(), - label: clamp_label_text(&action.label, POPUP_ACTION_LABEL_MAX_CHARS).into_owned(), - } -} - -fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { - let has_content = - notification.image.has_image_data || !notification.image.image_path.trim().is_empty(); - if !has_content { - return ThumbnailKind::None; - } - - let category_is_media = ["image", "media", "photo"].iter().any(|category| { - notification - .category - .split('.') - .next() - .unwrap_or_default() - .eq_ignore_ascii_case(category) - }); - if category_is_media { - return ThumbnailKind::Content; - } - - if image_source_matches_authenticated_badge(notification) { - ThumbnailKind::None - } else { - ThumbnailKind::Content - } -} - -fn image_source_matches_authenticated_badge(notification: &NotificationView) -> bool { - let badge = notification.attribution.badge_icon.trim(); - if badge.is_empty() { - return false; - } - if notification.image.icon_name.trim() == badge { - return true; - } - - let image_path = notification.image.image_path.trim(); - if image_path.is_empty() { - return false; - } - if image_path == badge { - return true; - } - - // Canonical file identity handles symlink aliases without guessing from dimensions - let badge_path = std::path::Path::new(badge); - let image_path = std::path::Path::new(image_path); - if !badge_path.is_absolute() || !image_path.is_absolute() { - return false; - } - let Some(badge_path) = std::fs::canonicalize(badge_path).ok() else { - return false; - }; - std::fs::canonicalize(image_path).is_ok_and(|path| path == badge_path) -} - -fn relative_time_label(received_at: i64, now: i64) -> String { - // Missing timestamps cannot produce a meaningful age - if received_at <= 0 { - return "now".to_string(); - } - - let age = now.saturating_sub(received_at).max(0); - match age { - 0..=59 => "now".to_string(), - 60..=3_599 => format!("{}m", age / 60), - 3_600..=86_399 => format!("{}h", age / 3_600), - _ => format!("{}d", age / 86_400), - } -} diff --git a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs b/crates/unixnotis-popups/src/ui/entry/tests/labels.rs deleted file mode 100644 index e3a971e48..000000000 --- a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs +++ /dev/null @@ -1,26 +0,0 @@ -use super::{clamp_label_text, has_visible_text}; - -#[test] -fn empty_text_has_no_visible_popup_content() { - assert!(!has_visible_text("")); -} - -#[test] -fn whitespace_only_text_has_no_visible_popup_content() { - assert!(!has_visible_text("\n\t ")); -} - -#[test] -fn zero_length_limit_returns_empty_text() { - assert!(clamp_label_text("hello", 0).is_empty()); -} - -#[test] -fn nonempty_text_is_visible_even_with_surrounding_whitespace() { - assert!(has_visible_text(" hello ")); -} - -#[test] -fn clamp_preserves_utf8_boundaries_and_adds_ellipsis() { - assert_eq!(clamp_label_text("éclair", 2).as_ref(), "éc…"); -} diff --git a/crates/unixnotis-ui/src/lib.rs b/crates/unixnotis-ui/src/lib.rs index 5e62c5e94..4593a6e2c 100644 --- a/crates/unixnotis-ui/src/lib.rs +++ b/crates/unixnotis-ui/src/lib.rs @@ -11,5 +11,6 @@ pub mod css; mod cut_corner; pub mod icons; +pub mod presentation; pub use cut_corner::CutCorner; diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs new file mode 100644 index 000000000..e9ce96148 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -0,0 +1,275 @@ +//! Derivation of one shared notification presentation snapshot + +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{ + Action, ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationView, Urgency, +}; + +use super::text::{ + clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, + BODY_LABEL_MAX_CHARS, SUMMARY_LABEL_MAX_CHARS, +}; +use super::types::{ + ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, + NotificationKind, ReplyPresentation, ThumbnailKind, TrustLevel, TrustPresentation, +}; + +/// Complete non-GTK notification presentation shared by popup and panel adapters +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationPresentation { + pub kind: NotificationKind, + pub trust: TrustPresentation, + pub identity: IdentityPresentation, + pub title: String, + pub body: Option, + pub timestamp: String, + pub media: MediaPresentation, + pub actions: ActionPresentation, + pub critical: bool, +} + +impl NotificationPresentation { + #[must_use] + pub fn from_view(notification: &NotificationView) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + Self::from_view_at(notification, now) + } + + #[must_use] + pub fn from_view_at(notification: &NotificationView, now: i64) -> Self { + let trust = trust_presentation(notification); + let kind = notification_kind(notification, trust.level); + let identity = identity_presentation(notification, trust.level); + + Self { + kind, + trust, + identity, + title: clamp_label_text(¬ification.summary, SUMMARY_LABEL_MAX_CHARS).into_owned(), + body: has_visible_text(¬ification.body) + .then(|| clamp_label_text(¬ification.body, BODY_LABEL_MAX_CHARS).into_owned()), + timestamp: relative_time_label(notification.received_at_unix_seconds, now), + media: MediaPresentation { + thumbnail: thumbnail_kind(notification), + }, + actions: visible_actions(notification, kind), + critical: notification.urgency == Urgency::Critical as u8, + } + } +} + +pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresentation { + let level = trust_level(notification); + let short_label = match level { + TrustLevel::Verified => None, + TrustLevel::Unverified => Some("Unverified".to_string()), + TrustLevel::Suspicious => Some("Suspicious".to_string()), + TrustLevel::System => Some("Command-line tool".to_string()), + }; + let details_label = nonempty_text(¬ification.attribution.source_label); + let has_reply_action = notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + let has_reply_request = notification.inline_reply.available || has_reply_action; + let reply = if has_reply_action + && notification.inline_reply.available + && notification.inline_reply_policy == InlineReplyPolicy::Allow + && level == TrustLevel::Verified + { + ReplyPresentation::Available + } else if has_reply_request { + ReplyPresentation::Unavailable + } else { + ReplyPresentation::Hidden + }; + + TrustPresentation { + level, + short_label, + details_label, + reply, + } +} + +const fn trust_level(notification: &NotificationView) -> TrustLevel { + if notification.attribution.has_warning() { + return TrustLevel::Suspicious; + } + match notification.attribution.class { + AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { + TrustLevel::Verified + } + AttributionClass::UserAssociated | AttributionClass::Unknown => TrustLevel::Unverified, + AttributionClass::TrustedRelay => TrustLevel::System, + AttributionClass::Conflict => TrustLevel::Suspicious, + } +} + +fn identity_presentation( + notification: &NotificationView, + level: TrustLevel, +) -> IdentityPresentation { + let primary_label = + clamp_label_text(¬ification.attribution.display_name, APP_LABEL_MAX_CHARS).into_owned(); + let secondary_claim = (level == TrustLevel::Suspicious) + .then(|| claimed_identity(¬ification.attribution.source_label)) + .flatten(); + let badge = match level { + TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, + TrustLevel::Unverified => BadgePresentation::UnknownApplication, + TrustLevel::Suspicious => BadgePresentation::SuspiciousApplication, + TrustLevel::System => BadgePresentation::CommandLine, + }; + IdentityPresentation { + primary_label, + secondary_claim, + badge, + } +} + +fn claimed_identity(source: &str) -> Option { + let claim = source + .split(';') + .next() + .map(str::trim) + .filter(|value| value.starts_with("Claims to be "))?; + Some(claim.to_string()) +} + +pub(super) fn notification_kind( + notification: &NotificationView, + trust_level: TrustLevel, +) -> NotificationKind { + if trust_level == TrustLevel::Suspicious { + return NotificationKind::Warning; + } + let category_class = notification + .category + .split('.') + .next() + .unwrap_or_default() + .trim(); + if communication_category_class(category_class) + || notification.inline_reply.available + || notification + .actions + .iter() + .any(|action| action.key == "inline-reply") + { + NotificationKind::Communication + } else { + NotificationKind::Utility + } +} + +fn communication_category_class(category_class: &str) -> bool { + [ + "call", + "email", + "im", + "presence", + "chat", + "message", + "social", + "voicemail", + ] + .iter() + .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) +} + +fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> ActionPresentation { + if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { + return ActionPresentation::default(); + } + let mut actions = notification + .actions + .iter() + .filter(|action| action.key != "inline-reply") + .map(action_view) + .collect::>(); + let overflow = actions.split_off(actions.len().min(kind.action_limit())); + ActionPresentation { + primary: actions, + overflow, + } +} + +fn action_view(action: &Action) -> ActionView { + ActionView { + key: action.key.clone(), + label: clamp_label_text(&action.label, ACTION_LABEL_MAX_CHARS).into_owned(), + } +} + +fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { + let has_content = + notification.image.has_image_data || !notification.image.image_path.trim().is_empty(); + if !has_content { + return ThumbnailKind::None; + } + let category_is_media = ["image", "media", "photo"].iter().any(|category| { + notification + .category + .split('.') + .next() + .unwrap_or_default() + .eq_ignore_ascii_case(category) + }); + if category_is_media || !image_source_matches_authenticated_badge(notification) { + ThumbnailKind::Content + } else { + ThumbnailKind::None + } +} + +fn image_source_matches_authenticated_badge(notification: &NotificationView) -> bool { + let badge = notification.attribution.badge_icon.trim(); + if badge.is_empty() { + return false; + } + if notification.image.icon_name.trim() == badge { + return true; + } + let image_path = notification.image.image_path.trim(); + if image_path.is_empty() { + return false; + } + if image_path == badge { + return true; + } + + // Canonical identity handles symlink aliases without treating dimensions as evidence + let badge_path = std::path::Path::new(badge); + let image_path = std::path::Path::new(image_path); + if !badge_path.is_absolute() || !image_path.is_absolute() { + return false; + } + let Some(badge_path) = std::fs::canonicalize(badge_path).ok() else { + return false; + }; + std::fs::canonicalize(image_path).is_ok_and(|path| path == badge_path) +} + +fn relative_time_label(received_at: i64, now: i64) -> String { + if received_at <= 0 { + return "now".to_string(); + } + let age = now.saturating_sub(received_at).max(0); + match age { + 0..=59 => "now".to_string(), + 60..=3_599 => format!("{}m", age / 60), + 3_600..=86_399 => format!("{}h", age / 3_600), + _ => format!("{}d", age / 86_400), + } +} + +fn nonempty_text(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs new file mode 100644 index 000000000..466bd76b2 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -0,0 +1,19 @@ +//! Shared notification presentation decisions for popup and panel clients + +mod build; +mod text; +mod types; + +pub use build::NotificationPresentation; +pub use text::{ + clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, + BODY_LABEL_MAX_CHARS, SUMMARY_LABEL_MAX_CHARS, +}; +pub use types::{ + ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, + NotificationKind, ReplyPresentation, ThumbnailKind, TrustLevel, TrustPresentation, +}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/tests/mod.rs b/crates/unixnotis-ui/src/presentation/tests/mod.rs new file mode 100644 index 000000000..0bb91928e --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/mod.rs @@ -0,0 +1,5 @@ +//! Shared notification presentation regression tests + +mod presentation; +mod support; +mod text; diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs new file mode 100644 index 000000000..1210c5a6a --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -0,0 +1,96 @@ +use unixnotis_core::{Action, AttributionClass, ImageData, NotificationAttribution}; + +use super::super::{ + BadgePresentation, NotificationKind, NotificationPresentation, ReplyPresentation, + ThumbnailKind, TrustLevel, +}; +use super::support::notification; + +#[test] +fn shared_model_keeps_verified_communication_content_and_actions_consistent() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + ]; + view.image.has_image_data = true; + view.image.image_data = ImageData { + width: 64, + height: 64, + ..ImageData::default() + }; + + let presentation = NotificationPresentation::from_view_at(&view, 1_120); + + assert_eq!(presentation.kind, NotificationKind::Communication); + assert_eq!(presentation.trust.level, TrustLevel::Verified); + assert_eq!(presentation.trust.reply, ReplyPresentation::Available); + assert_eq!( + presentation.identity.badge, + BadgePresentation::AuthenticatedApplication + ); + assert_eq!(presentation.media.thumbnail, ThumbnailKind::Content); + assert_eq!(presentation.actions.primary.len(), 1); + assert!(presentation.actions.overflow.is_empty()); + assert_eq!(presentation.timestamp, "2m"); +} + +#[test] +fn shared_model_downgrades_conflicts_and_denies_application_interaction() { + let mut view = notification(); + view.attribution = NotificationAttribution::conflict( + "Known application", + "sender executable differs", + "conflict:known".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.kind, NotificationKind::Warning); + assert_eq!(presentation.trust.level, TrustLevel::Suspicious); + assert_eq!( + presentation.identity.badge, + BadgePresentation::SuspiciousApplication + ); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + +#[test] +fn shared_model_keeps_user_association_unverified_and_noninteractive() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Local application", + "org.example.Local", + "org.example.Local", + "", + AttributionClass::UserAssociated, + false, + "user:local".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unverified); + assert_eq!( + presentation.identity.badge, + BadgePresentation::UnknownApplication + ); + assert!(presentation.actions.primary.is_empty()); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/support.rs b/crates/unixnotis-ui/src/presentation/tests/support.rs new file mode 100644 index 000000000..a3db512b6 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/support.rs @@ -0,0 +1,31 @@ +use unixnotis_core::{ + AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + NotificationView, +}; + +pub(super) fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::associated( + "Example", + "org.example.App", + "org.example.App", + "", + AttributionClass::SystemAssociated, + false, + "system-desktop:org.example.App".to_string(), + ), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + } +} diff --git a/crates/unixnotis-ui/src/presentation/tests/text.rs b/crates/unixnotis-ui/src/presentation/tests/text.rs new file mode 100644 index 000000000..fa1bdaeae --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/text.rs @@ -0,0 +1,18 @@ +use super::super::{clamp_label_text, has_visible_text}; + +#[test] +fn blank_text_has_no_visible_notification_content() { + assert!(!has_visible_text("")); + assert!(!has_visible_text("\n\t ")); +} + +#[test] +fn nonempty_text_remains_visible_with_surrounding_whitespace() { + assert!(has_visible_text(" hello ")); +} + +#[test] +fn shared_clamp_preserves_utf8_and_zero_limit_semantics() { + assert!(clamp_label_text("hello", 0).is_empty()); + assert_eq!(clamp_label_text("éclair", 2).as_ref(), "éc…"); +} diff --git a/crates/unixnotis-ui/src/presentation/text.rs b/crates/unixnotis-ui/src/presentation/text.rs new file mode 100644 index 000000000..9e88fedd5 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/text.rs @@ -0,0 +1,30 @@ +//! Shared text limits that keep both notification surfaces bounded + +use std::borrow::Cow; + +pub const APP_LABEL_MAX_CHARS: usize = 64; +pub const SUMMARY_LABEL_MAX_CHARS: usize = 120; +pub const BODY_LABEL_MAX_CHARS: usize = 320; +pub const ACTION_LABEL_MAX_CHARS: usize = 20; + +#[must_use] +pub fn has_visible_text(text: &str) -> bool { + text.chars().any(|character| !character.is_whitespace()) +} + +#[must_use] +pub fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { + if max_chars == 0 { + return Cow::Borrowed(""); + } + // Character boundaries retain valid UTF-8 for untrusted notification strings + for (characters, (index, _)) in text.char_indices().enumerate() { + if characters == max_chars { + let mut clamped = String::with_capacity(index + 3); + clamped.push_str(&text[..index]); + clamped.push('…'); + return Cow::Owned(clamped); + } + } + Cow::Borrowed(text) +} diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs new file mode 100644 index 000000000..343d4f0e0 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -0,0 +1,126 @@ +//! Plain presentation types shared without GTK widget ownership + +/// Stable content hierarchy selected from protocol and trust evidence +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationKind { + Communication, + Utility, + Warning, +} + +impl NotificationKind { + #[must_use] + pub fn for_notification( + notification: &unixnotis_core::NotificationView, + trust_level: TrustLevel, + ) -> Self { + super::build::notification_kind(notification, trust_level) + } + + #[must_use] + pub const fn action_limit(self) -> usize { + match self { + Self::Communication => 3, + Self::Utility | Self::Warning => 1, + } + } + + #[must_use] + pub const fn css_class(self) -> &'static str { + match self { + Self::Communication => "communication", + Self::Utility => "utility", + Self::Warning => "warning", + } + } +} + +/// Human-scale trust state shown consistently by every notification client +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustLevel { + Verified, + Unverified, + Suspicious, + System, +} + +impl TrustLevel { + #[must_use] + pub const fn css_class(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Unverified => "unverified", + Self::Suspicious => "suspicious", + Self::System => "system", + } + } +} + +/// Controlled badge source selected from daemon-owned identity evidence +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BadgePresentation { + AuthenticatedApplication, + UnknownApplication, + SuspiciousApplication, + CommandLine, + System, +} + +/// Inline reply state kept separate from application-owned actions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyPresentation { + Hidden, + Available, + Unavailable, +} + +/// Safe visible trust text plus optional diagnostic detail +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustPresentation { + pub level: TrustLevel, + pub short_label: Option, + pub details_label: Option, + pub reply: ReplyPresentation, +} + +impl TrustPresentation { + #[must_use] + pub fn for_notification(notification: &unixnotis_core::NotificationView) -> Self { + super::build::trust_presentation(notification) + } +} + +/// Identity content owned by a group or notification header +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityPresentation { + pub primary_label: String, + pub secondary_claim: Option, + pub badge: BadgePresentation, +} + +/// One daemon-approved application action +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionView { + pub key: String, + pub label: String, +} + +/// Compact actions split without silently dropping safe overflow +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ActionPresentation { + pub primary: Vec, + pub overflow: Vec, +} + +/// Whether a notification contains genuine bounded content media +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThumbnailKind { + None, + Content, +} + +/// Shared media decision independent from GTK decoding +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MediaPresentation { + pub thumbnail: ThumbnailKind, +} From 60bc7431b830be813fb2cb5f8d7b57135e406901 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 15:07:37 -0500 Subject: [PATCH 132/275] refactor(ui): simplify groups and shared security badges Summary: simplify groups and shared security badges. Scope: ui. --- Cargo.lock | 2 +- .../ui/notifications/model/tests/grouping.rs | 6 +- .../src/ui/notifications/row/group.rs | 13 +- .../notifications/row/notification/build.rs | 37 +---- .../notifications/row/notification/state.rs | 8 +- .../row/notification/update/row.rs | 19 ++- .../row/notification/update/tests/mod.rs | 2 - .../row/notification/update/tests/stack.rs | 49 ------ .../row/notification/update/tests/state.rs | 33 +++- .../row/notification/update/visual.rs | 22 +-- .../src/ui/notifications/row/tests/group.rs | 4 + .../src/ui/notifications/store/blocks.rs | 52 +++---- .../src/ui/notifications/store/mutation.rs | 7 +- .../ui/notifications/store/tests/blocks.rs | 20 +-- .../ui/notifications/store/tests/update.rs | 85 ++++------ crates/unixnotis-core/assets/panel.css | 42 +---- crates/unixnotis-core/assets/popup.css | 6 + .../config/loading/io/tests/theme_stock.rs | 31 +++- .../src/config/loading/io/theme_stock.rs | 2 +- .../src/css/hooks/tests/hooks.rs | 26 ++-- .../desktop_index/tests/verification.rs | 147 +++++++++++++++++- .../identity/desktop_index/tests/wrappers.rs | 45 ++++++ .../src/store/tests/runtime.rs | 27 ++++ crates/unixnotis-popups/Cargo.toml | 3 - crates/unixnotis-popups/build.rs | 8 - crates/unixnotis-popups/src/app/command.rs | 8 +- crates/unixnotis-popups/src/app/mod.rs | 1 - crates/unixnotis-popups/src/app/resources.rs | 9 -- .../src/ui/entry/builders/common.rs | 14 +- .../src/ui/entry/builders/utility.rs | 6 +- .../src/ui/entry/builders/warning.rs | 14 +- crates/unixnotis-popups/src/ui/entry/mod.rs | 1 - .../src/ui/entry/presentation/mod.rs | 1 - .../src/ui/entry/presentation/tests/trust.rs | 3 +- .../ui/entry/presentation/tests/view_model.rs | 4 + .../src/ui/entry/presentation/trust.rs | 1 - .../src/ui/entry/presentation/view_model.rs | 4 + crates/unixnotis-popups/src/ui/mod.rs | 1 - .../unixnotis-popups/src/ui/semantic_icons.rs | 26 ---- .../src/ui/tests/semantic_icons.rs | 23 --- crates/unixnotis-ui/Cargo.toml | 3 + crates/unixnotis-ui/build.rs | 8 + .../icons/unixnotis-app-unknown-symbolic.svg | 0 .../unixnotis-shield-warning-symbolic.svg | 0 .../icons/unixnotis-system-symbolic.svg | 0 .../icons/unixnotis-terminal-symbolic.svg | 0 .../resources/resources.gresource.xml | 2 +- .../unixnotis-ui/src/presentation/badges.rs | 48 ++++++ crates/unixnotis-ui/src/presentation/mod.rs | 2 + .../src/presentation/tests/badges.rs | 21 +++ .../src/presentation/tests/presentation.rs | 69 +++++++- 51 files changed, 596 insertions(+), 369 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs delete mode 100644 crates/unixnotis-popups/build.rs delete mode 100644 crates/unixnotis-popups/src/app/resources.rs delete mode 100644 crates/unixnotis-popups/src/ui/semantic_icons.rs delete mode 100644 crates/unixnotis-popups/src/ui/tests/semantic_icons.rs create mode 100644 crates/unixnotis-ui/build.rs rename crates/{unixnotis-popups => unixnotis-ui}/resources/icons/unixnotis-app-unknown-symbolic.svg (100%) rename crates/{unixnotis-popups => unixnotis-ui}/resources/icons/unixnotis-shield-warning-symbolic.svg (100%) rename crates/{unixnotis-popups => unixnotis-ui}/resources/icons/unixnotis-system-symbolic.svg (100%) rename crates/{unixnotis-popups => unixnotis-ui}/resources/icons/unixnotis-terminal-symbolic.svg (100%) rename crates/{unixnotis-popups => unixnotis-ui}/resources/resources.gresource.xml (92%) create mode 100644 crates/unixnotis-ui/src/presentation/badges.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/badges.rs diff --git a/Cargo.lock b/Cargo.lock index 7f9c1478d..1b3877892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3682,7 +3682,6 @@ dependencies = [ "gdk4-wayland", "gio", "glib", - "glib-build-tools", "gtk4", "gtk4-layer-shell", "image", @@ -3699,6 +3698,7 @@ dependencies = [ name = "unixnotis-ui" version = "1.2.0" dependencies = [ + "glib-build-tools", "gtk4", "notify", "serde", diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs index dc999d65d..d43ee1e3f 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs @@ -72,14 +72,14 @@ fn expected_list_len_tracks_collapsed_expanded_and_filtered_groups() { list.flush_rebuild(); let terminal = list.entries.get(&2).expect("terminal").app_key.clone(); - assert_eq!(list.expected_list_len(), 4); + assert_eq!(list.expected_list_len(), 3); list.group_expanded.insert(terminal, true); - assert_eq!(list.expected_list_len(), 5); + assert_eq!(list.expected_list_len(), 4); assert!(list.set_filter_query("browser")); list.flush_rebuild(); - assert_eq!(list.expected_list_len(), 2); + assert_eq!(list.expected_list_len(), 1); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index c2b88216b..1edc220da 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -10,7 +10,7 @@ use gtk::pango; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{css::hooks, util}; -use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; +use unixnotis_ui::presentation::{build_semantic_badge, NotificationPresentation, TrustLevel}; use crate::control::UiEvent; @@ -153,9 +153,14 @@ pub(in crate::ui::notifications) fn update_group_row( "unixnotis-attribution-warning", presentation.trust.level == TrustLevel::Suspicious, ); - let scale = root.scale_factor(); - // Group headers use the associated badge path instead of caller content images - icon_resolver.apply_badge(&group.icon, notification.as_ref(), 18, scale); + if let Some(image) = build_semantic_badge(presentation.identity.badge, 18) { + group.icon.set_paintable(image.paintable().as_ref()); + group.icon.set_visible(true); + } else { + let scale = root.scale_factor(); + // Verified groups keep authenticated application art from the shared resolver + icon_resolver.apply_badge(&group.icon, notification.as_ref(), 18, scale); + } set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); } else { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 7aee5a5f7..ed7540118 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -18,17 +18,6 @@ use crate::ui::try_send_command; use super::reply::build_inline_reply; use super::state::NotificationRowWidgets; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum StackLayer { - Back, - Middle, - Foreground, -} - -// Later GTK siblings paint above earlier siblings when card margins overlap -pub(super) const STACK_LAYER_ORDER: [StackLayer; 3] = - [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground]; - pub(in crate::ui::notifications) fn build_notification_row( command_tx: mpsc::Sender, ) -> (gtk::Box, NotificationRowWidgets) { @@ -176,17 +165,8 @@ pub(in crate::ui::notifications) fn build_notification_row( // The wrapper clips the complete styled card while the inner box keeps all CSS hooks let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); - let stack_ghost_1 = build_stack_ghost(1); - let stack_ghost_2 = build_stack_ghost(2); - - // The explicit plan makes paint order reviewable without starting GTK in unit tests - for layer in STACK_LAYER_ORDER { - match layer { - StackLayer::Back => root.append(&stack_ghost_2), - StackLayer::Middle => root.append(&stack_ghost_1), - StackLayer::Foreground => root.append(&card_plate), - } - } + // One content card keeps grouped rows calm without decorative fake stack layers + root.append(&card_plate); let notify_id = Rc::new(Cell::new(0)); // Close click always targets the latest id assigned to this row @@ -210,8 +190,6 @@ pub(in crate::ui::notifications) fn build_notification_row( NotificationRowWidgets { card, card_plate, - stack_ghost_1, - stack_ghost_2, icon, app_label, urgency_badge, @@ -238,14 +216,3 @@ pub(in crate::ui::notifications) fn build_notification_row( }, ) } - -fn build_stack_ghost(depth: u8) -> gtk::Box { - let ghost = gtk::Box::new(gtk::Orientation::Vertical, 0); - // The real card and its shadows share theme hooks for consistent colors - ghost.add_css_class("unixnotis-panel-card"); - ghost.add_css_class("unixnotis-stack-ghost"); - ghost.add_css_class(&format!("unixnotis-stack-ghost-{depth}")); - ghost.set_hexpand(true); - ghost.set_visible(false); - ghost -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index acae61b9d..4614ff8d2 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -7,6 +7,7 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation}; use super::reply::InlineReplyWidgets; @@ -15,9 +16,6 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing pub(super) card_plate: unixnotis_ui::CutCorner, - // Internal stack depth cards keep collapsed stacks in the same row update - pub(super) stack_ghost_1: gtk::Box, - pub(super) stack_ghost_2: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, // App name text shown beside the icon @@ -78,6 +76,7 @@ pub(in crate::ui::notifications) struct IconSignature { // Header badges depend only on daemon-associated attribution inputs badge_icon: String, desktop_id: String, + presentation: BadgePresentation, } impl IconSignature { @@ -87,6 +86,9 @@ impl IconSignature { Self { badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), + presentation: NotificationPresentation::from_view(notification) + .identity + .badge, } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index dbad54ebf..3c9932b4d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -2,7 +2,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; -use unixnotis_ui::presentation::NotificationPresentation; +use unixnotis_ui::presentation::{build_semantic_badge, NotificationPresentation}; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -29,6 +29,7 @@ pub(in crate::ui::notifications) fn update_notification_row( }; let notification = notification_snapshot.as_ref(); let presentation = NotificationPresentation::from_view(notification); + let show_identity = !data.stacked && !data.expanded; let has_actions = visible_action_count(notification, data.is_active) > 0; let has_thumbnail = data.presentation.show_thumbnail && notification_has_thumbnail(notification); @@ -52,11 +53,21 @@ pub(in crate::ui::notifications) fn update_notification_row( // Text and action changes must not restart an unchanged icon pipeline let next_sig = IconSignature::from(notification); let mut sig_guard = row.icon_sig.borrow_mut(); - if sig_guard.as_ref() != Some(&next_sig) { - let scale = row.card.scale_factor(); - icon_resolver.apply_badge(&row.icon, notification, 22, scale); + if show_identity && sig_guard.as_ref() != Some(&next_sig) { + if let Some(image) = build_semantic_badge(presentation.identity.badge, 22) { + row.icon.set_paintable(image.paintable().as_ref()); + row.icon.set_visible(true); + } else { + let scale = row.card.scale_factor(); + // Verified rows keep authenticated application art from the shared resolver + icon_resolver.apply_badge(&row.icon, notification, 22, scale); + } *sig_guard = Some(next_sig); + } else if !show_identity { + *sig_guard = None; } + set_widget_visible_if_changed(&row.icon, show_identity); + set_widget_visible_if_changed(&row.app_label, show_identity); if has_thumbnail { // Reapply visible thumbnails so config reloads cannot leave stale previews let scale = row.card.scale_factor(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs index b73bcac91..53594a22d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -3,7 +3,6 @@ mod actions; mod labels; mod metadata; -mod stack; mod state; mod thumbnail; @@ -14,4 +13,3 @@ pub(super) use super::metadata::{ }; pub(super) use super::row::update_notification_row; pub(super) use super::thumbnail::notification_has_thumbnail; -pub(super) use super::visual::{stack_ghost_visibility, StackGhostVisibility}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs deleted file mode 100644 index 6c5bfa548..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/stack.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Collapsed notification-stack composition tests - -use super::super::super::build::{StackLayer, STACK_LAYER_ORDER}; -use super::{stack_ghost_visibility, StackGhostVisibility}; - -#[test] -fn notification_stack_places_readable_card_above_rear_layers() { - // The foreground must remain last because later GTK siblings paint on top - assert_eq!( - STACK_LAYER_ORDER, - [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground] - ); -} - -#[test] -fn two_notification_stack_uses_non_overlapping_back_slot() { - assert_eq!( - stack_ghost_visibility(1), - StackGhostVisibility { - middle: false, - back: true, - } - ); -} - -#[test] -fn three_notification_stack_uses_both_rear_slots() { - assert_eq!( - stack_ghost_visibility(2), - StackGhostVisibility { - middle: true, - back: true, - } - ); - - // Larger groups remain capped to the same two visual depth layers - assert_eq!(stack_ghost_visibility(u8::MAX), stack_ghost_visibility(2)); -} - -#[test] -fn single_notification_stack_hides_both_rear_slots() { - assert_eq!( - stack_ghost_visibility(0), - StackGhostVisibility { - middle: false, - back: false, - } - ); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 008e73810..1194a0f96 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -7,11 +7,26 @@ use unixnotis_core::{hooks, Action, CutCorners, NotificationMetadataConfig, Urge use crate::ui::icons::IconResolver; +use super::super::super::state::IconSignature; use super::super::super::test_support::{ notification_row, row_data, sample_notification, RowFlags, }; use super::update_notification_row; +#[test] +fn icon_signature_changes_when_trust_presentation_changes() { + let verified = sample_notification(); + let mut suspicious = verified.clone(); + // Keep resolver inputs unchanged to isolate the trust-state regression + suspicious.attribution.warning = true; + + assert_ne!( + IconSignature::from(&verified), + IconSignature::from(&suspicious), + "trust changes must refresh a recycled row badge" + ); +} + #[gtk::test] fn update_notification_row_applies_state_classes_and_text() { let (_root, row) = notification_row(); @@ -36,15 +51,15 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row.card.has_css_class(hooks::shared_state::STACKED)); assert!(row.card.has_css_class(hooks::panel_card::GROUP_COLLAPSED)); assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); - assert!(row.stack_ghost_1.get_visible()); - assert!(row.stack_ghost_2.get_visible()); + assert!(!row.app_label.get_visible()); + assert!(!row.icon.get_visible()); assert!(row.urgency_badge.get_visible()); assert_eq!(row.urgency_badge.text().as_str(), "Critical"); assert_eq!(row.app_label.text().as_str(), "demo"); assert_eq!(row.summary_label.text().as_str(), "summary"); assert_eq!(row.body_label.text().as_str(), "body"); assert_eq!(row.notify_id.get(), 1); - assert!(row.icon_sig.borrow().is_some()); + assert!(row.icon_sig.borrow().is_none()); } #[gtk::test] @@ -64,6 +79,18 @@ fn recycled_panel_row_hides_critical_badge_after_urgency_returns_to_normal() { assert!(!row.urgency_badge.get_visible()); } +#[gtk::test] +fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.app_label.get_visible()); + assert_eq!(row.app_label.text().as_str(), "demo"); +} + #[gtk::test] fn update_notification_row_shows_metadata_lanes_and_footer_state() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index ceec5b494..47178511f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -8,20 +8,6 @@ use super::super::super::super::item::RowData; use super::super::state::NotificationRowWidgets; use super::labels::has_visible_text; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct StackGhostVisibility { - pub(super) middle: bool, - pub(super) back: bool, -} - -pub(super) const fn stack_ghost_visibility(stack_depth: u8) -> StackGhostVisibility { - // A single rear layer uses the back slot because it starts without overlap - StackGhostVisibility { - middle: stack_depth >= 2, - back: stack_depth >= 1, - } -} - pub(super) fn apply_visual_state( row: &NotificationRowWidgets, data: &RowData, @@ -47,15 +33,11 @@ pub(super) fn apply_visual_state( set_widget_visible_if_changed(&row.urgency_badge, is_critical); set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); set_class_state(card, hooks::shared_state::STACKED, data.stacked); - set_class_state(card, hooks::panel_card::GROUPED, true); + let grouped = data.stacked || data.expanded; + set_class_state(card, hooks::panel_card::GROUPED, grouped); set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); - // Rear layers occupy fixed paint slots with different overlap rules - let ghost_visibility = stack_ghost_visibility(data.stack_depth); - set_widget_visible_if_changed(&row.stack_ghost_1, ghost_visibility.middle); - set_widget_visible_if_changed(&row.stack_ghost_2, ghost_visibility.back); - set_class_state( card, hooks::panel_card::HAS_SUMMARY, diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 6426c77c9..b91961430 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -109,6 +109,10 @@ fn update_group_row_keeps_conflict_warning_out_of_the_title() { .title .tooltip_text() .is_some_and(|text| text.contains("Trusted Brand"))); + assert!( + widgets.icon.paintable().is_some(), + "conflicting identity should use a controlled warning badge" + ); assert!(root.has_css_class("unixnotis-attribution-warning")); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 689d1cc15..1a83e5528 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -20,31 +20,30 @@ impl NotificationList { return (Vec::new(), Vec::new()); }; - // Cached header objects preserve GTK bindings across incremental rebuilds - let header = self.group_headers.entry(key.clone()).or_insert_with(|| { - RowItem::new(RowData::group_header( + let mut items = Vec::new(); + let mut keys = Vec::new(); + if ids.len() > 1 { + // Multi-item groups own one shared application identity header + let header = self.group_headers.entry(key.clone()).or_insert_with(|| { + RowItem::new(RowData::group_header( + key.clone(), + ids.len(), + expanded, + first_entry.view.clone(), + )) + }); + header.update(RowData::group_header( key.clone(), ids.len(), expanded, first_entry.view.clone(), - )) - }); - header.update(RowData::group_header( - key.clone(), - ids.len(), - expanded, - first_entry.view.clone(), - )); - - let mut items = Vec::new(); - let mut keys = Vec::new(); - items.push(header.clone()); - keys.push(RowKey::GroupHeader { group: key.clone() }); + )); + items.push(header.clone()); + keys.push(RowKey::GroupHeader { group: key.clone() }); + } - // Collapsed groups render one notification row - // The row owns stack decoration so GTK does not virtualize it separately + // Collapsed groups render the newest content row under their shared header let stacked = !expanded && ids.len() > 1; - let stack_depth = collapsed_stack_depth(ids.len(), expanded); for (index, id) in ids.iter().enumerate() { if !expanded && index > 0 { break; @@ -64,7 +63,7 @@ impl NotificationList { entry.app_key.clone(), entry.view.clone(), stacked, - stack_depth, + 0, expanded, entry.is_active, presentation, @@ -82,7 +81,10 @@ impl NotificationList { ids: &[u32], ) -> usize { let expanded = self.group_expanded.get(key).copied().unwrap_or(false); - let mut len = 1; // header + if ids.len() <= 1 { + return usize::from(!ids.is_empty()); + } + let mut len = 1; // shared header if expanded { len += ids.len(); } else if !ids.is_empty() { @@ -147,14 +149,6 @@ impl NotificationList { } } -fn collapsed_stack_depth(count: usize, expanded: bool) -> u8 { - if expanded { - return 0; - } - // One extra notification shows one shadow, larger stacks cap at two - count.saturating_sub(1).min(2) as u8 -} - pub(in crate::ui::notifications) fn common_prefix_suffix( current: &[RowKey], next: &[RowKey], diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 4171ff69d..92c466360 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -91,11 +91,6 @@ impl NotificationList { .unwrap_or(false); let group_len = self.grouped_cache.get(&entry.app_key).map_or(0, Vec::len); let stacked = collapsed_group_is_stacked(expanded, group_len); - let stack_depth = if expanded { - 0 - } else { - group_len.saturating_sub(1).min(2) as u8 - }; let presentation = super::item::RowPresentation { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, @@ -109,7 +104,7 @@ impl NotificationList { entry.app_key.clone(), entry.view.clone(), stacked, - stack_depth, + 0, expanded, entry.is_active, presentation, diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index 6efb09fc6..a05edc1b1 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -2,23 +2,11 @@ use std::rc::Rc; use gio::prelude::ListModelExt; -use super::{collapsed_stack_depth, common_prefix_suffix}; +use super::common_prefix_suffix; use crate::ui::notifications::item::{RowData, RowItem}; use crate::ui::notifications::model::types::{GroupRange, RowKey}; use crate::ui::notifications::test_support as support; -#[test] -fn collapsed_stack_depth_caps_at_two() { - assert_eq!(collapsed_stack_depth(1, false), 0); - assert_eq!(collapsed_stack_depth(2, false), 1); - assert_eq!(collapsed_stack_depth(4, false), 2); -} - -#[test] -fn collapsed_stack_depth_is_zero_when_expanded() { - assert_eq!(collapsed_stack_depth(4, true), 0); -} - #[test] fn common_prefix_suffix_finds_stable_edges() { let group = Rc::::from("terminal"); @@ -88,7 +76,7 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert!(!header.expanded); let visible = items[1].data(); assert!(visible.stacked); - assert_eq!(visible.stack_depth, 2); + assert_eq!(visible.stack_depth, 0); assert!(!visible.expanded); } @@ -101,8 +89,8 @@ fn build_group_block_keeps_single_collapsed_notification_unstacked() { let (items, _keys) = list.build_group_block(&key, &ids); - assert_eq!(items.len(), 2); - let visible = items[1].data(); + assert_eq!(items.len(), 1); + let visible = items[0].data(); assert!(!visible.stacked); assert_eq!(visible.stack_depth, 0); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs index 4e52ee75c..12c811356 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs @@ -137,7 +137,7 @@ fn flush_rebuild_builds_seeded_rows_and_hides_empty_overlay() { list.flush_rebuild(); assert!(!list.needs_rebuild()); - assert_eq!(list.store.n_items(), 2); + assert_eq!(list.store.n_items(), 1); assert!(!list.empty_overlay.get_visible()); } @@ -153,23 +153,15 @@ fn flush_rebuild_filters_existing_list_with_minimal_middle_splice() { ); list.flush_rebuild(); let browser = list.entries.get(&2).expect("browser").app_key.clone(); - assert_eq!(list.store.n_items(), 4); + assert_eq!(list.store.n_items(), 2); assert!(list.set_filter_query("browser")); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 2); - assert_eq!( - list.current_keys, - vec![ - RowKey::GroupHeader { - group: browser.clone() - }, - RowKey::Notification { id: 2 }, - ] - ); + assert_eq!(list.store.n_items(), 1); + assert_eq!(list.current_keys, vec![RowKey::Notification { id: 2 }]); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&browser].len, 2); + assert_eq!(list.group_ranges[&browser].len, 1); } #[gtk::test] @@ -224,23 +216,17 @@ fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 4); + assert_eq!(list.store.n_items(), 2); assert_eq!( list.current_keys, vec![ - RowKey::GroupHeader { - group: editor.clone() - }, RowKey::Notification { id: 3 }, - RowKey::GroupHeader { - group: terminal.clone() - }, RowKey::Notification { id: 1 }, ] ); assert!(!list.group_ranges.contains_key(&browser)); assert_eq!(list.group_ranges[&editor].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].start, 1); assert!(!list.interned.iter().any(|key| key.as_ref() == "stale")); } @@ -262,13 +248,10 @@ fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { list.toggle_group(terminal.as_ref()); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 5); + assert_eq!(list.store.n_items(), 4); assert_eq!( list.current_keys, vec![ - RowKey::GroupHeader { - group: browser.clone() - }, RowKey::Notification { id: 3 }, RowKey::GroupHeader { group: terminal.clone() @@ -278,15 +261,21 @@ fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { ] ); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&browser].len, 2); - assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&browser].len, 1); + assert_eq!(list.group_ranges[&terminal].start, 1); assert_eq!(list.group_ranges[&terminal].len, 3); } #[gtk::test] fn flush_rebuild_refreshes_dirty_group_even_when_span_is_stable() { let mut list = support::make_list(); - list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + Vec::new(), + ); list.flush_rebuild(); let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); let view = list.entries.get(&1).expect("terminal").view.clone(); @@ -298,7 +287,7 @@ fn flush_rebuild_refreshes_dirty_group_even_when_span_is_stable() { list.flush_rebuild(); assert_eq!(list.store.n_items(), 2); - assert_eq!(header.data().count, 1); + assert_eq!(header.data().count, 2); assert_eq!(list.group_ranges[&terminal].start, 0); assert_eq!(list.group_ranges[&terminal].len, 2); } @@ -316,10 +305,10 @@ fn flush_rebuild_places_multiple_pending_dirty_groups_before_kept_group() { let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); let browser = list.entries.get(&2).expect("browser").app_key.clone(); let editor = list.entries.get(&3).expect("editor").app_key.clone(); - assert_eq!(list.store.n_items(), 6); + assert_eq!(list.store.n_items(), 3); assert_eq!(list.group_ranges[&editor].start, 0); - assert_eq!(list.group_ranges[&browser].start, 2); - assert_eq!(list.group_ranges[&terminal].start, 4); + assert_eq!(list.group_ranges[&browser].start, 1); + assert_eq!(list.group_ranges[&terminal].start, 2); } #[gtk::test] @@ -341,19 +330,11 @@ fn flush_rebuild_removes_empty_dirty_group_and_keeps_following_ranges_valid() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 2); - assert_eq!( - list.current_keys, - vec![ - RowKey::GroupHeader { - group: terminal.clone() - }, - RowKey::Notification { id: 1 }, - ] - ); + assert_eq!(list.store.n_items(), 1); + assert_eq!(list.current_keys, vec![RowKey::Notification { id: 1 }]); assert!(!list.group_ranges.contains_key(&browser)); assert_eq!(list.group_ranges[&terminal].start, 0); - assert_eq!(list.group_ranges[&terminal].len, 2); + assert_eq!(list.group_ranges[&terminal].len, 1); } #[gtk::test] @@ -374,9 +355,9 @@ fn flush_rebuild_restores_missing_range_with_full_rebuild_fallback() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 4); + assert_eq!(list.store.n_items(), 2); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].start, 1); } #[gtk::test] @@ -395,8 +376,8 @@ fn flush_rebuild_restores_store_length_with_full_rebuild_fallback() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 4); - assert_eq!(list.current_keys.len(), 4); + assert_eq!(list.store.n_items(), 2); + assert_eq!(list.current_keys.len(), 2); } #[gtk::test] @@ -410,20 +391,14 @@ fn flush_rebuild_batches_new_dirty_groups_before_kept_groups() { let browser = list.entries.get(&2).expect("browser").app_key.clone(); let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); - assert_eq!(list.store.n_items(), 4); + assert_eq!(list.store.n_items(), 2); assert_eq!( list.current_keys, vec![ - RowKey::GroupHeader { - group: browser.clone() - }, RowKey::Notification { id: 2 }, - RowKey::GroupHeader { - group: terminal.clone() - }, RowKey::Notification { id: 1 }, ] ); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].start, 1); } diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index fd0ff1729..d0cf32418 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -329,10 +329,14 @@ entry selection { min-height: 28px; min-height: var(--unixnotis-panel-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; + opacity: 0; + transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } +.unixnotis-panel-card:hover .unixnotis-panel-close, +.unixnotis-panel-close:focus, .unixnotis-panel-close:hover { + opacity: 1; background: alpha(#fb7185, 0.16); border-color: alpha(#fb7185, 0.45); color: #fb7185; @@ -426,10 +430,6 @@ entry selection { margin-left: 2px; } -.unixnotis-panel-card-grouped { - margin-left: 8px; -} - /* * Notification cards (panel) */ @@ -453,13 +453,10 @@ entry selection { } .unixnotis-panel-card.stacked { - /* One focused shadow keeps the foreground content visually above the rear layers */ - box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); } .unixnotis-panel-card-group-collapsed { - /* Pull the foreground over the middle layer while leaving its rounded top visible */ - margin-top: -58px; margin-bottom: 8px; } @@ -467,33 +464,6 @@ entry selection { margin-bottom: var(--unixnotis-panel-card-gap); } -.unixnotis-stack-ghost { - /* Full card silhouettes preserve the stack shape if card colors are customized */ - background: #172238; - border-radius: 16px; - padding: 0; - min-height: 68px; - opacity: 1; - margin-left: 10px; - margin-right: 10px; - margin-top: -58px; - margin-bottom: 0; - border: 1px solid alpha(#9bb8e8, 0.18); - box-shadow: none; -} - -.unixnotis-stack-ghost-2 { - background: #121c2f; - min-height: 68px; - opacity: 1; - margin-left: 20px; - margin-right: 20px; - margin-top: 0; - margin-bottom: 0; - border-color: alpha(#9bb8e8, 0.14); - border-radius: 16px; -} - .unixnotis-panel-card.active { background-image: linear-gradient(135deg, alpha(#1a2e50, 0.93), alpha(#111627, 0.96)); border-top: 1px solid alpha(#ffffff, 0.12); diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 09ba728e7..6af7a4505 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -146,6 +146,12 @@ margin-top: 2px; } +.unixnotis-popup-secondary-claim { + color: alpha(#fbbf24, 0.82); + font-size: 12px; + margin-top: 1px; +} + .unixnotis-popup-content-image { min-width: 48px; min-height: 48px; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs index 35166f196..7c75f204f 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs @@ -5,7 +5,7 @@ use std::io; use super::super::theme_stock::{ migrate_known_stock_file, migrate_stock_file_with_writer, replace_file_if_snapshot_matches, - stock_backup_path, + stock_backup_path, MAX_STOCK_THEME_BYTES, }; use super::support::test_root; @@ -162,6 +162,35 @@ fn conflicting_existing_backup_uses_a_new_suffix_without_overwriting() { let _ = fs::remove_dir_all(root); } +#[test] +fn stock_migration_size_limit_accepts_exact_boundary_and_rejects_one_more_byte() { + let exact = vec![b'a'; usize::try_from(MAX_STOCK_THEME_BYTES).expect("test size")]; + let oversized = + vec![b'b'; usize::try_from(MAX_STOCK_THEME_BYTES.saturating_add(1)).expect("test size")]; + + for (name, contents, expected) in [ + ("exact-size-stock", exact, true), + ("oversized-stock", oversized, false), + ] { + let root = test_root(name); + fs::create_dir_all(&root).expect("theme root"); + let target = root.join("panel.css"); + fs::write(&target, &contents).expect("stock fixture"); + let digest = blake3::hash(&contents).to_hex().to_string(); + + let migrated = migrate_known_stock_file(&target, CURRENT_STOCK, &digest, BACKUP_TAG) + .expect("size boundary migration"); + + assert_eq!(migrated, expected, "{name}"); + if expected { + assert_eq!(fs::read(&target).expect("migrated stock"), CURRENT_STOCK); + } else { + assert_eq!(fs::read(&target).expect("preserved stock"), contents); + } + let _ = fs::remove_dir_all(root); + } +} + fn migrate(target: &std::path::Path, legacy: &[u8]) -> Result { let digest = blake3::hash(legacy).to_hex().to_string(); migrate_known_stock_file(target, CURRENT_STOCK, &digest, BACKUP_TAG) diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs index 35eb8178e..9ec8ce3a5 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs @@ -13,7 +13,7 @@ use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; use super::{ConfigError, ThemePaths}; -const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; +pub(super) const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; const MAX_BACKUP_COLLISION_RETRIES: u8 = 8; const LEGACY_BACKUP_TAG: &str = "unixnotis-stock-9ca42584"; const LEGACY_PANEL_DIGEST: &str = diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index a66e80d27..132eb8a57 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -242,9 +242,9 @@ fn stock_panel_css_targets_real_group_card_hooks() { let css = crate::theme::DEFAULT_PANEL_CSS; // Group headers and notification cards are sibling ListView rows, not nested widgets - // Stock CSS must target direct card hooks so grouped spacing actually applies - assert!(css.contains(&format!(".{}", panel_card::GROUPED))); + // Stock CSS targets explicit collapsed and expanded content states assert!(css.contains(&format!(".{}", panel_card::GROUP_COLLAPSED))); + assert!(css.contains(&format!(".{}", panel_card::GROUP_EXPANDED))); // These selectors belonged to an older nested-card idea and do not match the real tree assert!(!css.contains("unixnotis-group-cards")); @@ -253,17 +253,19 @@ fn stock_panel_css_targets_real_group_card_hooks() { } #[test] -fn stock_panel_css_uses_two_overlapping_full_card_stack_layers() { +fn stock_panel_css_avoids_decorative_stack_ghosts_and_negative_overlap() { let css = crate::theme::DEFAULT_PANEL_CSS; - // Full-height rear layers overlap so themes retain a coherent card silhouette - assert!(css.contains(".unixnotis-stack-ghost")); - assert!(css.contains("min-height: 68px;")); - assert!(css.contains("margin-left: 10px;")); - assert!(css.contains("margin-top: -58px;")); + assert!(!css.contains(".unixnotis-stack-ghost")); + assert!(!css.contains("margin-top: -58px;")); +} + +#[test] +fn stock_panel_close_control_stays_quiet_until_hover_or_focus() { + let css = crate::theme::DEFAULT_PANEL_CSS; - // The back layer narrows again and starts the stack without a negative offset - assert!(css.contains(".unixnotis-stack-ghost-2")); - assert!(css.contains("margin-left: 20px;")); - assert!(css.contains("margin-top: 0;")); + assert!(css.contains(".unixnotis-panel-close {\n")); + assert!(css.contains("opacity: 0;")); + assert!(css.contains(".unixnotis-panel-card:hover .unixnotis-panel-close")); + assert!(css.contains(".unixnotis-panel-close:focus")); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs index 739c1c040..ad2074890 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -1,7 +1,16 @@ -use super::{is_dynamic_or_option, is_protected_payload}; +use std::collections::HashSet; +use std::path::Path; + +use super::{ + is_dynamic_or_option, is_protected_payload, literal_file_identities_are_current, + literal_file_matches, verify_protected_payload, verify_record_launch, +}; use crate::daemon::notifications::identity::desktop_index::model::{ - FieldCode, LaunchArgument, LiteralArgument, + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, + LaunchVerification, LaunchWrapper, LiteralArgument, VerifiedLaunch, }; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; #[test] fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { @@ -29,3 +38,137 @@ fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { assert!(!is_protected_payload(&dynamic)); assert!(is_protected_payload(&payload)); } + +#[test] +fn protected_payload_verification_requires_current_file_identity_and_fixed_arguments() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let other = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other system payload"); + let payload_argument = LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + }; + let spec = LaunchSpec { + executable: shell.identity, + arguments: vec![ + LaunchArgument::Literal(payload_argument.clone()), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + assert!(literal_file_matches(&payload_argument, b"/usr/bin/true")); + assert!(!literal_file_matches(&payload_argument, b"/usr/bin/false")); + assert!(!literal_file_matches(&payload_argument, &[0xff])); + assert!(literal_file_identities_are_current(&spec)); + + let mut stale_spec = spec.clone(); + let LaunchArgument::Literal(stale_payload) = &mut stale_spec.arguments[0] else { + panic!("payload fixture should remain literal"); + }; + stale_payload.file = Some(("/usr/bin/true".into(), other.identity)); + assert!(!literal_file_identities_are_current(&stale_spec)); + + let verified = structured_command(&["/usr/bin/sh", "/usr/bin/true", "--fixed"]); + assert_eq!( + verify_protected_payload(&verified, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); + + let wrong_payload = structured_command(&["/usr/bin/sh", "/usr/bin/false", "--fixed"]); + assert_eq!( + verify_protected_payload(&wrong_payload, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) + ); + + let missing_argument = structured_command(&["/usr/bin/sh", "/usr/bin/true"]); + assert_eq!( + verify_protected_payload(&missing_argument, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { + for (wrapper_count, environment_count, expected) in [ + ( + 16, + 0, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 17, + 0, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ( + 0, + 128, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 0, + 129, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: Vec::new(), + environment: std::iter::repeat_n((b"A".to_vec(), b"1".to_vec()), environment_count) + .collect(), + wrappers: std::iter::repeat_n(LaunchWrapper::Env, wrapper_count).collect(), + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Boundary".to_string(), + display_name: "Boundary".to_string(), + badge_icon: "boundary".to_string(), + executable_path: Some("/usr/bin/true".into()), + executable_identity: Some(executable.identity), + desktop_identity: None, + system_origin: true, + system_association: true, + association_eligible: true, + dbus_activatable: false, + launch_spec: Some(spec), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Boundary") + .into_iter() + .next() + .expect("indexed boundary record"); + + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&["/usr/bin/true"]), + ), + expected, + "wrapper_count={wrapper_count}, environment_count={environment_count}" + ); + } +} + +fn structured_command(arguments: &[&str]) -> CommandLineEvidence { + CommandLineEvidence { + argv: arguments + .iter() + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + quality: CommandLineQuality::Structured, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs index 58d5feb66..694c97a3d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs @@ -67,3 +67,48 @@ fn unsupported_or_incomplete_env_syntax_fails_closed() { assert_eq!(normalize_launch_command(tokens), Err(expected)); } } + +#[test] +fn each_supported_env_control_advances_to_the_wrapped_command() { + for tokens in [ + vec!["env", "-i", "example-app"], + vec!["env", "--ignore-environment", "example-app"], + vec!["env", "-u", "OLD_VALUE", "example-app"], + vec!["env", "--unset=OLD_VALUE", "example-app"], + vec!["env", "--", "example-app"], + ] { + let normalized = + normalize_launch_command(tokens.into_iter().map(str::to_string).collect::>()) + .expect("supported env control should expose wrapped command"); + + assert_eq!(normalized.executable, "example-app"); + assert!(normalized.arguments.is_empty()); + } +} + +#[test] +fn environment_names_follow_portable_identifier_rules() { + for accepted in ["A=1", "_A=1", "A_1=value", "A="] { + let normalized = normalize_launch_command(vec![ + "env".to_string(), + accepted.to_string(), + "example-app".to_string(), + ]) + .expect("portable environment assignment"); + + assert_eq!(normalized.environment.len(), 1, "{accepted}"); + } + + for rejected in ["1A=value", "=value", "A-B=value", "A.B=value"] { + let normalized = normalize_launch_command(vec![ + "env".to_string(), + rejected.to_string(), + "example-app".to_string(), + ]) + .expect("invalid assignment becomes the wrapped command"); + + assert_eq!(normalized.executable, rejected, "{rejected}"); + assert_eq!(normalized.arguments, ["example-app"], "{rejected}"); + assert!(normalized.environment.is_empty(), "{rejected}"); + } +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index a42e1364c..599c85374 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -97,6 +97,33 @@ fn notification_diagnostics_report_renderer_and_store_admission_separately() { assert!(suppressed.renderer_ready); } +#[test] +fn notification_diagnostics_require_both_renderer_process_and_readiness() { + let mut store = make_store_with_limits(10, 10); + let visible = store.insert(make_notification("visible"), 0).notification; + + for (process_running, ready, expected) in [ + (false, false, PopupAdmissionView::RendererUnavailable), + (true, false, PopupAdmissionView::RendererUnavailable), + (false, true, PopupAdmissionView::RendererUnavailable), + (true, true, PopupAdmissionView::Show), + ] { + let health = unixnotis_core::UiHealth { + popups_process_running: process_running, + popups_ready: ready, + ..unixnotis_core::UiHealth::default() + }; + let diagnostics = store + .notification_diagnostics(visible.id, &health) + .expect("active notification diagnostics"); + + assert_eq!( + diagnostics.popup_admission, expected, + "process_running={process_running}, ready={ready}" + ); + } +} + #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { let mut store = make_store_with_limits(12, 20); diff --git a/crates/unixnotis-popups/Cargo.toml b/crates/unixnotis-popups/Cargo.toml index 2f5a18d6b..016065b9f 100644 --- a/crates/unixnotis-popups/Cargo.toml +++ b/crates/unixnotis-popups/Cargo.toml @@ -22,8 +22,5 @@ zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } unixnotis-ui = { path = "../unixnotis-ui" } -[build-dependencies] -glib-build-tools.workspace = true - [dev-dependencies] proptest.workspace = true diff --git a/crates/unixnotis-popups/build.rs b/crates/unixnotis-popups/build.rs deleted file mode 100644 index 0fd866b12..000000000 --- a/crates/unixnotis-popups/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -fn main() { - // Compile controlled semantic icons into the binary so desktop themes cannot replace meaning - glib_build_tools::compile_resources( - &["resources"], - "resources/resources.gresource.xml", - "unixnotis-popups.gresource", - ); -} diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index d3a34353f..6429f78d5 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -11,12 +11,14 @@ use glib::MainContext; use gtk::prelude::*; use tracing::{info, warn}; use unixnotis_core::Config; -use unixnotis_ui::css::{self, CssKind}; +use unixnotis_ui::{ + css::{self, CssKind}, + presentation::register_semantic_badges, +}; use crate::{dbus, ui}; use super::reload::{start_reload_timer, ReloadGate}; -use super::resources; use super::runtime::handle_ui_event; use super::startup::{init_tracing, is_wayland_session, load_config, ConfigSource}; @@ -31,7 +33,7 @@ pub struct Args { } pub fn run(args: Args) -> Result<()> { - resources::register()?; + register_semantic_badges().map_err(anyhow::Error::msg)?; // Load and validate config before GTK starts so startup failures stay clear let (config, config_path, config_source) = load_config(&args).context("load config")?; init_tracing(&config); diff --git a/crates/unixnotis-popups/src/app/mod.rs b/crates/unixnotis-popups/src/app/mod.rs index 00868a207..157aff64c 100644 --- a/crates/unixnotis-popups/src/app/mod.rs +++ b/crates/unixnotis-popups/src/app/mod.rs @@ -2,7 +2,6 @@ mod command; mod reload; -pub mod resources; mod runtime; mod startup; diff --git a/crates/unixnotis-popups/src/app/resources.rs b/crates/unixnotis-popups/src/app/resources.rs deleted file mode 100644 index e0babbefe..000000000 --- a/crates/unixnotis-popups/src/app/resources.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Process-wide registration for bundled popup resources - -use anyhow::Context; - -pub fn register() -> anyhow::Result<()> { - // Registration happens before GTK activation so every card sees the same icon assets - gio::resources_register_include!("unixnotis-popups.gresource") - .context("register bundled popup resources") -} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index fa338517a..6d1baa794 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -4,11 +4,11 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; use unixnotis_core::{hooks, NotificationView}; +use unixnotis_ui::presentation::build_semantic_badge; use super::super::commands::try_send_command; use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; use crate::dbus::UiCommand; -use crate::ui::semantic_icons::build_semantic_badge; use crate::ui::UiState; pub(super) struct IdentityHeader { @@ -28,7 +28,7 @@ pub(super) fn build_identity_header( let mut has_icon = false; if let Some(size) = app_icon_size { - let icon = build_semantic_badge(view.trust.level, size) + let icon = build_semantic_badge(view.badge, size) .or_else(|| state.build_app_icon_widget(notification, size)); if let Some(icon) = icon { // Only daemon-associated badge inputs reach the quiet identity header @@ -109,6 +109,16 @@ pub(super) fn build_reply_note(view: &PopupEntryViewModel) -> Option Some(note) } +pub(super) fn build_secondary_claim(view: &PopupEntryViewModel) -> Option { + let text = view.secondary_claim.as_deref()?; + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + label.set_wrap(true); + label.set_wrap_mode(WrapMode::WordChar); + label.add_css_class("unixnotis-popup-secondary-claim"); + Some(label) +} + pub(in crate::ui::entry) fn build_close_button() -> gtk::Button { let close = gtk::Button::from_icon_name("window-close-symbolic"); close.add_css_class("unixnotis-popup-close"); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs index 2b387041d..6b24cd414 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -3,6 +3,7 @@ use gtk::prelude::*; use gtk::Align; use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::build_semantic_badge; use super::common::{build_body_label, build_identity_header, build_title_label}; use super::{append_thumbnail, RenderedPopup}; @@ -20,8 +21,9 @@ pub(super) fn build_utility_popup( let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); main.add_css_class("unixnotis-popup-utility-content"); - let has_icon = if let Some(icon) = state.build_app_icon_widget(notification, UTILITY_ICON_SIZE) - { + let icon = build_semantic_badge(view.badge, UTILITY_ICON_SIZE) + .or_else(|| state.build_app_icon_widget(notification, UTILITY_ICON_SIZE)); + let has_icon = if let Some(icon) = icon { // Utility symbols support scanning without becoming the card's dominant object icon.set_halign(Align::Start); icon.set_valign(Align::Start); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs index 1205df266..2e7807cab 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs @@ -3,8 +3,12 @@ use gtk::prelude::*; use gtk::Align; use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::build_semantic_badge; -use super::common::{build_body_label, build_identity_header, build_reply_note, build_title_label}; +use super::common::{ + build_body_label, build_identity_header, build_reply_note, build_secondary_claim, + build_title_label, +}; use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; @@ -20,8 +24,9 @@ pub(super) fn build_warning_popup( let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); main.add_css_class("unixnotis-popup-warning-content"); - let has_icon = if let Some(icon) = state.build_app_icon_widget(notification, WARNING_ICON_SIZE) - { + let icon = build_semantic_badge(view.badge, WARNING_ICON_SIZE) + .or_else(|| state.build_app_icon_widget(notification, WARNING_ICON_SIZE)); + let has_icon = if let Some(icon) = icon { // Conflict attribution supplies a daemon-owned generic badge instead of claimed branding icon.set_halign(Align::Start); icon.set_valign(Align::Start); @@ -37,6 +42,9 @@ pub(super) fn build_warning_popup( content.set_hexpand(true); let header = build_identity_header(state, notification, view, close, None); content.append(&header.widget); + if let Some(claim) = build_secondary_claim(view) { + content.append(&claim); + } if let Some(title) = build_title_label(view) { content.append(&title); } diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index 03dbaaaf7..76744541e 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -6,4 +6,3 @@ mod commands; mod presentation; pub(in crate::ui) use build::PopupEntry; -pub(in crate::ui) use presentation::TrustLevel; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs index 2f23dab25..3b88e0fbd 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs @@ -5,7 +5,6 @@ mod trust; mod view_model; pub(in crate::ui::entry) use kind::PopupKind; -pub(in crate::ui) use trust::TrustLevel; pub(in crate::ui::entry) use trust::{PopupTrustPresentation, ReplyPresentation}; pub(in crate::ui::entry) use view_model::{ActionViewModel, PopupEntryViewModel, ThumbnailKind}; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index 62337bdf2..b697ae6c9 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -1,6 +1,7 @@ use unixnotis_core::{Action, AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use unixnotis_ui::presentation::TrustLevel; -use super::super::{PopupTrustPresentation, ReplyPresentation, TrustLevel}; +use super::super::{PopupTrustPresentation, ReplyPresentation}; use super::support::notification; #[test] diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 60949ee78..a110b7ba5 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -222,6 +222,10 @@ fn conflicting_claim_uses_warning_layout_and_drops_actions() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Warning); + assert_eq!( + model.secondary_claim.as_deref(), + Some("Claims to be Signal") + ); assert!(model.primary_actions.is_empty()); assert!(model.overflow_actions.is_empty()); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs index c1d1017f4..73a12339a 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -1,6 +1,5 @@ //! Popup naming for shared trust and reply presentation -pub(in crate::ui) use unixnotis_ui::presentation::TrustLevel; pub(in crate::ui::entry) use unixnotis_ui::presentation::{ ReplyPresentation, TrustPresentation as PopupTrustPresentation, }; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 9c1b40ca5..992f26328 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -16,6 +16,8 @@ pub(in crate::ui::entry) use unixnotis_ui::presentation::{ pub(in crate::ui::entry) struct PopupEntryViewModel { pub(in crate::ui::entry) kind: PopupKind, pub(in crate::ui::entry) app_label: String, + pub(in crate::ui::entry) secondary_claim: Option, + pub(in crate::ui::entry) badge: unixnotis_ui::presentation::BadgePresentation, pub(in crate::ui::entry) timestamp_label: String, pub(in crate::ui::entry) title: String, pub(in crate::ui::entry) body: Option, @@ -47,6 +49,8 @@ impl PopupEntryViewModel { Self { kind: shared.kind, app_label: shared.identity.primary_label, + secondary_claim: shared.identity.secondary_claim, + badge: shared.identity.badge, timestamp_label: shared.timestamp, title: shared.title, body: shared.body, diff --git a/crates/unixnotis-popups/src/ui/mod.rs b/crates/unixnotis-popups/src/ui/mod.rs index e965be21b..a0db07dbf 100644 --- a/crates/unixnotis-popups/src/ui/mod.rs +++ b/crates/unixnotis-popups/src/ui/mod.rs @@ -6,7 +6,6 @@ mod entry; mod icon_state; mod icons; mod popups; -mod semantic_icons; mod state; mod window; diff --git a/crates/unixnotis-popups/src/ui/semantic_icons.rs b/crates/unixnotis-popups/src/ui/semantic_icons.rs deleted file mode 100644 index 5e677d75c..000000000 --- a/crates/unixnotis-popups/src/ui/semantic_icons.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Daemon-controlled badges for uncertain and non-application identities - -use gtk::prelude::*; - -use super::entry::TrustLevel; - -const RESOURCE_ROOT: &str = "/com/unixnotis/Popups/icons"; - -pub(super) fn build_semantic_badge(level: TrustLevel, size: i32) -> Option { - let file = match level { - // Verified applications always retain the authenticated desktop badge - TrustLevel::Verified => return None, - TrustLevel::Unverified => "unixnotis-app-unknown-symbolic.svg", - TrustLevel::Suspicious => "unixnotis-shield-warning-symbolic.svg", - TrustLevel::System => "unixnotis-terminal-symbolic.svg", - }; - let image = gtk::Image::from_resource(&format!("{RESOURCE_ROOT}/{file}")); - let size = size.max(1); - image.set_pixel_size(size); - image.set_size_request(size, size); - Some(image) -} - -#[cfg(test)] -#[path = "tests/semantic_icons.rs"] -mod tests; diff --git a/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs b/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs deleted file mode 100644 index be6d38a17..000000000 --- a/crates/unixnotis-popups/src/ui/tests/semantic_icons.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::super::entry::TrustLevel; -use super::super::semantic_icons::build_semantic_badge; - -#[gtk::test] -fn uncertain_trust_states_use_bundled_semantic_resources() { - super::super::super::app::resources::register().expect("register popup resources"); - - for level in [ - TrustLevel::Unverified, - TrustLevel::Suspicious, - TrustLevel::System, - ] { - let image = build_semantic_badge(level, 20).expect("semantic badge"); - - assert!(image.paintable().is_some(), "bundled badge should load"); - assert_eq!(image.pixel_size(), 20); - } -} - -#[gtk::test] -fn verified_identity_does_not_replace_the_authenticated_badge() { - assert!(build_semantic_badge(TrustLevel::Verified, 20).is_none()); -} diff --git a/crates/unixnotis-ui/Cargo.toml b/crates/unixnotis-ui/Cargo.toml index fed91057f..e10cb2008 100644 --- a/crates/unixnotis-ui/Cargo.toml +++ b/crates/unixnotis-ui/Cargo.toml @@ -13,6 +13,9 @@ serde.workspace = true serde_json.workspace = true url.workspace = true +[build-dependencies] +glib-build-tools.workspace = true + [[bin]] name = "unixnotis-css-validate" path = "src/bin/css_validate.rs" diff --git a/crates/unixnotis-ui/build.rs b/crates/unixnotis-ui/build.rs new file mode 100644 index 000000000..af2e0fc89 --- /dev/null +++ b/crates/unixnotis-ui/build.rs @@ -0,0 +1,8 @@ +fn main() { + // Compile security badges once so every UI client renders the same controlled symbols + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "unixnotis-ui.gresource", + ); +} diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg similarity index 100% rename from crates/unixnotis-popups/resources/icons/unixnotis-app-unknown-symbolic.svg rename to crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-shield-warning-symbolic.svg similarity index 100% rename from crates/unixnotis-popups/resources/icons/unixnotis-shield-warning-symbolic.svg rename to crates/unixnotis-ui/resources/icons/unixnotis-shield-warning-symbolic.svg diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-system-symbolic.svg similarity index 100% rename from crates/unixnotis-popups/resources/icons/unixnotis-system-symbolic.svg rename to crates/unixnotis-ui/resources/icons/unixnotis-system-symbolic.svg diff --git a/crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-terminal-symbolic.svg similarity index 100% rename from crates/unixnotis-popups/resources/icons/unixnotis-terminal-symbolic.svg rename to crates/unixnotis-ui/resources/icons/unixnotis-terminal-symbolic.svg diff --git a/crates/unixnotis-popups/resources/resources.gresource.xml b/crates/unixnotis-ui/resources/resources.gresource.xml similarity index 92% rename from crates/unixnotis-popups/resources/resources.gresource.xml rename to crates/unixnotis-ui/resources/resources.gresource.xml index 517e512db..76d3a0def 100644 --- a/crates/unixnotis-popups/resources/resources.gresource.xml +++ b/crates/unixnotis-ui/resources/resources.gresource.xml @@ -1,6 +1,6 @@ - + icons/unixnotis-app-unknown-symbolic.svg icons/unixnotis-shield-warning-symbolic.svg icons/unixnotis-terminal-symbolic.svg diff --git a/crates/unixnotis-ui/src/presentation/badges.rs b/crates/unixnotis-ui/src/presentation/badges.rs new file mode 100644 index 000000000..401d8863d --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/badges.rs @@ -0,0 +1,48 @@ +//! Controlled security badges shared by every GTK notification client + +use std::sync::OnceLock; + +use gtk::prelude::*; + +use super::BadgePresentation; + +const RESOURCE_ROOT: &str = "/com/unixnotis/Ui/icons"; + +/// Registers bundled badge resources once for the current process +/// +/// # Errors +/// +/// Returns the original registration error when GTK cannot load the compiled resource +pub fn register_semantic_badges() -> Result<(), String> { + static REGISTRATION: OnceLock> = OnceLock::new(); + // One cached result keeps repeated GTK startup and test initialization deterministic + REGISTRATION + .get_or_init(|| { + gtk::gio::resources_register_include!("unixnotis-ui.gresource") + .map_err(|error| format!("register bundled UI resources: {error}")) + }) + .clone() +} + +/// Builds a daemon-controlled badge when authenticated application art is not allowed +#[must_use] +pub fn build_semantic_badge(badge: BadgePresentation, size: i32) -> Option { + register_semantic_badges().ok()?; + let file = match badge { + // Verified applications retain the authenticated desktop badge + BadgePresentation::AuthenticatedApplication => return None, + BadgePresentation::UnknownApplication => "unixnotis-app-unknown-symbolic.svg", + BadgePresentation::SuspiciousApplication => "unixnotis-shield-warning-symbolic.svg", + BadgePresentation::CommandLine => "unixnotis-terminal-symbolic.svg", + BadgePresentation::System => "unixnotis-system-symbolic.svg", + }; + let image = gtk::Image::from_resource(&format!("{RESOURCE_ROOT}/{file}")); + let size = size.max(1); + image.set_pixel_size(size); + image.set_size_request(size, size); + Some(image) +} + +#[cfg(test)] +#[path = "tests/badges.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs index 466bd76b2..e44db502b 100644 --- a/crates/unixnotis-ui/src/presentation/mod.rs +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -1,9 +1,11 @@ //! Shared notification presentation decisions for popup and panel clients +mod badges; mod build; mod text; mod types; +pub use badges::{build_semantic_badge, register_semantic_badges}; pub use build::NotificationPresentation; pub use text::{ clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, diff --git a/crates/unixnotis-ui/src/presentation/tests/badges.rs b/crates/unixnotis-ui/src/presentation/tests/badges.rs new file mode 100644 index 000000000..73893a2db --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/badges.rs @@ -0,0 +1,21 @@ +use super::super::{build_semantic_badge, BadgePresentation}; + +#[gtk::test] +fn uncertain_identity_badges_load_from_controlled_resources() { + for badge in [ + BadgePresentation::UnknownApplication, + BadgePresentation::SuspiciousApplication, + BadgePresentation::CommandLine, + BadgePresentation::System, + ] { + let image = build_semantic_badge(badge, 20).expect("semantic badge should exist"); + + assert!(image.paintable().is_some(), "bundled badge should load"); + assert_eq!(image.pixel_size(), 20); + } +} + +#[gtk::test] +fn authenticated_identity_keeps_the_application_badge() { + assert!(build_semantic_badge(BadgePresentation::AuthenticatedApplication, 20).is_none()); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 1210c5a6a..48e0c8a5f 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -1,4 +1,6 @@ -use unixnotis_core::{Action, AttributionClass, ImageData, NotificationAttribution}; +use unixnotis_core::{ + Action, AttributionClass, ImageData, InlineReplyPolicy, NotificationAttribution, Urgency, +}; use super::super::{ BadgePresentation, NotificationKind, NotificationPresentation, ReplyPresentation, @@ -64,6 +66,10 @@ fn shared_model_downgrades_conflicts_and_denies_application_interaction() { presentation.identity.badge, BadgePresentation::SuspiciousApplication ); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("Claims to be Known application") + ); assert!(presentation.actions.primary.is_empty()); assert!(presentation.actions.overflow.is_empty()); } @@ -94,3 +100,64 @@ fn shared_model_keeps_user_association_unverified_and_noninteractive() { ); assert!(presentation.actions.primary.is_empty()); } + +#[test] +fn shared_model_requires_every_reply_authorization_condition() { + let cases = [ + (false, false, InlineReplyPolicy::Deny, false), + (true, false, InlineReplyPolicy::Allow, false), + (false, true, InlineReplyPolicy::Allow, false), + (true, true, InlineReplyPolicy::Deny, false), + (true, true, InlineReplyPolicy::Allow, true), + ]; + + for (has_action, metadata_available, policy, expected_available) in cases { + let mut view = notification(); + view.inline_reply.available = metadata_available; + view.inline_reply_policy = policy; + if has_action { + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + } + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + let expected = if expected_available { + ReplyPresentation::Available + } else if has_action || metadata_available { + ReplyPresentation::Unavailable + } else { + ReplyPresentation::Hidden + }; + + assert_eq!( + presentation.trust.reply, expected, + "has_action={has_action}, metadata_available={metadata_available}, policy={policy:?}" + ); + } +} + +#[test] +fn shared_model_requires_verified_identity_and_exact_critical_urgency() { + let mut view = notification(); + view.inline_reply.available = true; + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + + view.attribution.class = AttributionClass::UserAssociated; + let unverified = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(unverified.trust.reply, ReplyPresentation::Unavailable); + assert!(!unverified.critical); + + view.attribution.class = AttributionClass::SystemAssociated; + view.urgency = Urgency::Critical as u8; + let critical = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(critical.trust.reply, ReplyPresentation::Available); + assert!(critical.critical); + + view.urgency = (Urgency::Critical as u8).saturating_add(1); + assert!(!NotificationPresentation::from_view_at(&view, 1_000).critical); +} From 2490639052408d6cc0e719a8f7906359a4fabed3 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:22:08 -0500 Subject: [PATCH 133/275] fix(identity): enforce ordered runtime launch contracts Summary: enforce ordered runtime launch contracts. Scope: identity. --- .../desktop_index/tests/verification.rs | 349 +++++++++++++++++- .../identity/desktop_index/verification.rs | 285 ++++++++++++-- .../identity/desktop_index/wrappers.rs | 18 +- .../notifications/identity/tests/resolver.rs | 21 ++ .../identity/tests/resolver/association.rs | 5 +- .../identity/tests/resolver/runtime.rs | 20 +- 6 files changed, 639 insertions(+), 59 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs index ad2074890..f26dfb693 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -2,12 +2,14 @@ use std::collections::HashSet; use std::path::Path; use super::{ + classify_launch_authority, executable_contract_is_dedicated, field_value_matches, is_dynamic_or_option, is_protected_payload, literal_file_identities_are_current, - literal_file_matches, verify_protected_payload, verify_record_launch, + literal_file_matches, match_ordered_dedicated_contract, match_ordered_exec_contract, + verify_dedicated, verify_protected_payload, verify_record_launch, MAX_PROCESS_ARGUMENTS, }; use crate::daemon::notifications::identity::desktop_index::model::{ - DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, - LaunchVerification, LaunchWrapper, LiteralArgument, VerifiedLaunch, + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, + LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, VerifiedLaunch, }; use crate::daemon::notifications::identity::executable::executable_evidence_for_path; use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; @@ -94,6 +96,180 @@ fn protected_payload_verification_requires_current_file_identity_and_fixed_argum ); } +#[test] +fn trusted_payload_cannot_be_used_as_a_decoy_argument() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let spec = LaunchSpec { + executable: runtime.identity, + arguments: vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let sender = structured_command(&["/usr/bin/sh", "/usr/bin/false", "/usr/bin/true"]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch), + "a protected file after the active payload must not authenticate the runtime" + ); +} + +#[test] +fn ordered_contract_preserves_repeated_literals_and_field_positions() { + let spec = LaunchSpec { + executable: executable_evidence_for_path(Path::new("/usr/bin/true")) + .expect("system executable") + .identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"safe".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + assert!(match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"safe".to_vec(), + b"--mode".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"--mode".to_vec(), + b"safe".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); +} + +#[test] +fn dedicated_contract_does_not_accept_reordered_fixed_options() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--first".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--second".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--first", "--second"]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--second", "--first"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--display=x11", + "--first", + "--tray", + "--second", + "--verbose", + ]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--first", + "/tmp/unexpected-payload", + "--second", + ]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { + for field_code in [FieldCode::Files, FieldCode::Urls] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "Runtime application".to_string(), + badge_icon: "runtime".to_string(), + executable_path: Some("/usr/bin/true".into()), + executable_identity: Some(executable.identity), + desktop_identity: None, + system_origin: true, + system_association: true, + association_eligible: true, + dbus_activatable: false, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("single indexed runtime"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a single dynamic record must remain non-authoritative for {field_code:?}" + ); + } +} + #[test] fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { for (wrapper_count, environment_count, expected) in [ @@ -163,6 +339,173 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() } } +#[test] +fn dedicated_authority_rejects_each_open_ended_positional_contract() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + for (arguments, expected) in [ + (Vec::new(), true), + (vec![LaunchArgument::FieldCode(FieldCode::File)], false), + ( + vec![LaunchArgument::Literal(LiteralArgument { + value: b"runtime-selected-payload".to_vec(), + file: None, + })], + false, + ), + ] { + let spec = LaunchSpec { + executable: executable.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record_for_spec("org.example.DedicatedBoundary", &spec)); + let record = index + .records_for_id("org.example.DedicatedBoundary") + .into_iter() + .next() + .expect("indexed dedicated boundary record"); + + assert_eq!( + executable_contract_is_dedicated(record, &index, &spec), + expected, + "arguments={:?}", + spec.arguments + ); + } +} + +#[test] +fn protected_payload_accepts_exactly_the_bounded_argument_limit() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let mut arguments = vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })]; + arguments.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| { + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }) + })); + let spec = LaunchSpec { + executable: runtime.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let mut argv = vec![b"/usr/bin/sh".to_vec(), b"/usr/bin/true".to_vec()]; + argv.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| b"--fixed".to_vec())); + let command = CommandLineEvidence { + argv, + quality: CommandLineQuality::Structured, + }; + + assert_eq!(command.argv.len().saturating_sub(1), MAX_PROCESS_ARGUMENTS); + assert_eq!( + verify_protected_payload(&command, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); +} + +#[test] +fn optional_icon_contract_preserves_its_flag_and_value_relationship() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![ + LaunchArgument::OptionalIcon { + name: "example-icon".to_string(), + }, + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + assert_optional_icon_contract(match_ordered_exec_contract, &spec, "protected"); + assert_optional_icon_contract(match_ordered_dedicated_contract, &spec, "dedicated"); +} + +#[test] +fn field_values_reject_empty_options_and_malformed_urls() { + assert!(!field_value_matches(FieldCode::File, b"")); + assert!(!field_value_matches(FieldCode::Files, b"--runtime-option")); + assert!(field_value_matches(FieldCode::File, b"relative-file")); + assert!(field_value_matches( + FieldCode::Url, + b"https://example.invalid/item" + )); + assert!(!field_value_matches(FieldCode::Urls, b"not a URL")); + assert!(!field_value_matches(FieldCode::Url, &[0xff])); +} + +type ContractMatcher = fn(&LaunchSpec, &[Vec]) -> bool; + +fn assert_optional_icon_contract(matcher: ContractMatcher, spec: &LaunchSpec, label: &str) { + for (actual, expected) in [ + (vec![b"--fixed".to_vec()], true), + ( + vec![ + b"--icon".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + true, + ), + ( + vec![ + b"--badge".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + ( + vec![ + b"--icon".to_vec(), + b"other-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + (vec![b"--icon".to_vec(), b"--fixed".to_vec()], false), + ] { + assert_eq!( + matcher(spec, &actual), + expected, + "{label}: actual={actual:?}" + ); + } +} + +fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { + DesktopRecord { + id: id.to_string(), + display_name: "Contract application".to_string(), + badge_icon: "contract".to_string(), + executable_path: Some("/usr/bin/true".into()), + executable_identity: Some(spec.executable), + desktop_identity: None, + system_origin: true, + system_association: true, + association_eligible: true, + dbus_activatable: false, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + } +} + fn structured_command(arguments: &[&str]) -> CommandLineEvidence { CommandLineEvidence { argv: arguments diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs index 80a26f0ec..bb9dab3f1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs @@ -6,11 +6,13 @@ use std::path::Path; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::super::sender::{CommandLineEvidence, CommandLineQuality}; use super::model::{ - DesktopIdentityIndex, DesktopRecord, LaunchArgument, LaunchAuthority, LaunchFailure, + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, }; use super::names::normalize_desktop_id; +const MAX_PROCESS_ARGUMENTS: usize = 256; + pub(super) fn verify_record_launch( record: &DesktopRecord, index: &DesktopIdentityIndex, @@ -51,21 +53,45 @@ fn classify_launch_authority( return LaunchAuthority::ProtectedPayload; } + // A caller-selected file or URL can change what a shared runtime executes + // Uniqueness in the desktop index cannot turn that open-ended selector into app identity + if !spec.arguments.is_empty() && spec.arguments.iter().all(is_dynamic_or_option) { + return LaunchAuthority::DynamicOnly; + } + + if executable_contract_is_dedicated(record, index, spec) { + return LaunchAuthority::DedicatedExecutable; + } + + LaunchAuthority::Ambiguous +} + +fn executable_contract_is_dedicated( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> bool { let distinct_ids = index .records_for_executable(spec.executable) .into_iter() .filter(|candidate| !record.system_origin || candidate.system_origin) .map(|candidate| normalize_desktop_id(&candidate.id)) .collect::>(); - if distinct_ids.len() == 1 { - return LaunchAuthority::DedicatedExecutable; - } - if spec.arguments.iter().all(is_dynamic_or_option) { - LaunchAuthority::DynamicOnly - } else { - LaunchAuthority::Ambiguous - } + // A dedicated executable contract has no unresolved positional selector + // Fixed positional values on a shared runtime could select code just like a file field + distinct_ids.len() == 1 + && !spec + .arguments + .iter() + .any(|argument| matches!(argument, LaunchArgument::FieldCode(_))) + && !spec.arguments.iter().any(|argument| { + matches!( + argument, + LaunchArgument::Literal(literal) + if !literal.value.starts_with(b"-") && literal.file.is_none() + ) + }) } fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { @@ -77,7 +103,10 @@ fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> La LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) } CommandLineQuality::Structured => { - if required_fixed_arguments_present(spec, &command_line.argv) { + let actual = command_line.argv.get(1..).unwrap_or_default(); + if actual.len() <= MAX_PROCESS_ARGUMENTS + && match_ordered_dedicated_contract(spec, actual) + { LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) } else { LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) @@ -103,39 +132,223 @@ fn verify_protected_payload( } let actual = command_line.argv.get(1..).unwrap_or_default(); - for argument in &spec.arguments { - let LaunchArgument::Literal(literal) = argument else { - continue; - }; - if literal.file.is_some() - && !actual - .iter() - .any(|value| literal_file_matches(literal, value)) - { - return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); - } + if actual.len() > MAX_PROCESS_ARGUMENTS { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnstructuredCommandLine); + } + if match_ordered_exec_contract(spec, actual) { + return LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload); } - if !required_fixed_arguments_present(spec, &command_line.argv) { - return LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch); + + // A protected file in another argv slot is a decoy, not supporting evidence + // Missing or replaced protected files are equally definitive for structured argv + if protected_payload_position_mismatch(spec, actual) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); } - LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) } -fn required_fixed_arguments_present(spec: &LaunchSpec, argv: &[Vec]) -> bool { - let actual = argv.get(1..).unwrap_or_default(); - spec.arguments.iter().all(|argument| { - let LaunchArgument::Literal(literal) = argument else { - return true; - }; - if literal.file.is_some() { +fn match_ordered_dedicated_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_dedicated_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +fn match_dedicated_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + // Only standalone runtime switches are non-authoritative after the fixed contract + return actual[actual_index..] + .iter() + .all(|value| value.starts_with(b"-")); + }; + let next_template = template_index.saturating_add(1); + let matches_expected = match argument { + LaunchArgument::Literal(literal) => { actual - .iter() - .any(|value| literal_file_matches(literal, value)) - } else { - actual.iter().any(|value| value == &literal.value) + .get(actual_index) + .is_some_and(|value| value == &literal.value) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(1), + visited, + ) } - }) + LaunchArgument::OptionalIcon { name } => { + match_dedicated_arguments(template, actual, next_template, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(2), + visited, + )) + } + // Dynamic selectors prevent dedicated classification before matching begins + LaunchArgument::FieldCode(_) => false, + }; + if matches_expected { + return true; + } + + // Unknown positional values can select content, so only skip one self-contained option + actual + .get(actual_index) + .is_some_and(|value| value.starts_with(b"-") && value != b"--icon") + && match_dedicated_arguments( + template, + actual, + template_index, + actual_index.saturating_add(1), + visited, + ) +} + +fn match_ordered_exec_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + let matches = if literal.file.is_some() { + actual + .get(actual_index) + .is_some_and(|value| literal_file_matches(literal, value)) + } else { + actual.get(actual_index) == Some(&literal.value) + }; + matches + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(1), + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index, + visited, + ) || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(2), + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + +fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} + +fn protected_payload_position_mismatch(spec: &LaunchSpec, actual: &[Vec]) -> bool { + spec.arguments + .iter() + .enumerate() + .filter_map(|(index, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + is_protected_payload(argument).then_some((index, literal)) + }) + .any(|(index, literal)| { + !actual + .get(index) + .is_some_and(|value| literal_file_matches(literal, value)) + }) } fn literal_file_matches(literal: &LiteralArgument, actual: &[u8]) -> bool { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs index a4b57c987..b6c7dd1f4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs @@ -63,22 +63,22 @@ fn unwrap_env(tokens: &[String]) -> Result, ExecParseEr let mut environment = Vec::new(); while let Some(token) = tokens.get(index) { if token == "--" { - index += 1; + advance_index(&mut index, 1)?; break; } if token == "-i" || token == "--ignore-environment" { - index += 1; + advance_index(&mut index, 1)?; continue; } if token == "-u" { if tokens.get(index + 1).is_none() { return Err(ExecParseError::MalformedEnvCommand); } - index += 2; + advance_index(&mut index, 2)?; continue; } if token.starts_with("--unset=") { - index += 1; + advance_index(&mut index, 1)?; continue; } if token.starts_with('-') { @@ -87,7 +87,7 @@ fn unwrap_env(tokens: &[String]) -> Result, ExecParseEr } if let Some((name, value)) = parse_environment_assignment(token) { environment.push((name.as_bytes().to_vec(), value.as_bytes().to_vec())); - index += 1; + advance_index(&mut index, 1)?; continue; } break; @@ -103,6 +103,14 @@ fn unwrap_env(tokens: &[String]) -> Result, ExecParseEr })) } +fn advance_index(index: &mut usize, amount: usize) -> Result<(), ExecParseError> { + // Checked progress prevents malformed input from wrapping the parser cursor + *index = index + .checked_add(amount) + .ok_or(ExecParseError::MalformedEnvCommand)?; + Ok(()) +} + fn parse_environment_assignment(value: &str) -> Option<(&str, &str)> { let (name, assigned) = value.split_once('=')?; let mut characters = name.chars(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 2ba59226b..0bd0a3ddb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -25,6 +25,8 @@ trait DesktopRecordFixture { ) -> Self; fn with_launch_literals(self, arguments: &[&str]) -> Self; + + fn with_protected_launch_file(self, path: &str, identity: FileIdentity) -> Self; } impl DesktopRecordFixture for DesktopRecord { @@ -79,6 +81,25 @@ impl DesktopRecordFixture for DesktopRecord { }); self } + + fn with_protected_launch_file(mut self, path: &str, identity: FileIdentity) -> Self { + let spec = self + .launch_spec + .as_mut() + .expect("launch fixture needs a launch specification"); + let literal = spec + .arguments + .iter_mut() + .find_map(|argument| match argument { + LaunchArgument::Literal(literal) if literal.value == path.as_bytes() => { + Some(literal) + } + _ => None, + }) + .expect("protected launch path must exist in the fixture contract"); + literal.file = Some((PathBuf::from(path), identity)); + self + } } trait DesktopIdentityIndexFixture { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs index 078481e05..ae73525ca 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs @@ -68,8 +68,7 @@ fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract #[test] fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { let (app_path, app_identity) = installed_system_executable(); - let record = system_record("org.example.App", "Example App", &app_path, app_identity) - .with_launch_literals(&["--fixed"]); + let record = system_record("org.example.App", "Example App", &app_path, app_identity); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_with_evidence( @@ -80,7 +79,7 @@ fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { &sender_with_arguments( &app_path, app_identity, - &["--display-backend=x11", "--fixed", "--tray"], + &["--display-backend=x11", "--tray"], ), &index, &HashSet::new(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs index dcfbf5a50..df6560dcd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs @@ -108,13 +108,15 @@ fn java_cannot_associate_a_different_jar() { #[test] fn matching_fixed_system_application_argument_allows_association() { let (runtime_path, runtime_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); let record = system_record( "org.example.ScriptApp", "Script App", &runtime_path, runtime_identity, ) - .with_launch_literals(&["/usr/share/script-app/main.py"]); + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_with_evidence( @@ -122,11 +124,7 @@ fn matching_fixed_system_application_argument_allows_association() { reported_name: "Script App", desktop_entry: Some("org.example.ScriptApp"), }, - &sender_with_arguments( - &runtime_path, - runtime_identity, - &["/usr/share/script-app/main.py"], - ), + &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), &index, &HashSet::new(), ); @@ -277,13 +275,15 @@ fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { #[test] fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { let (runtime_path, runtime_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); let record = system_record( "org.example.PasswordManager", "Example Password Manager", &runtime_path, runtime_identity, ) - .with_launch_literals(&["/usr/share/password-manager/main.py"]); + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_with_evidence( @@ -291,11 +291,7 @@ fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { reported_name: "Example Password Manager", desktop_entry: None, }, - &sender_with_arguments( - &runtime_path, - runtime_identity, - &["/usr/share/password-manager/main.py"], - ), + &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), &index, &HashSet::new(), ); From f59db437f2b59b02e739905e7d371b637b906db0 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:23:25 -0500 Subject: [PATCH 134/275] fix(notifications): bind lifecycle decisions to generations Summary: bind lifecycle decisions to generations. Scope: notifications. --- .../noticenterctl/src/output/diagnostics.rs | 22 ++- .../src/output/tests/notifications.rs | 1 + .../unixnotis-core/src/control/diagnostics.rs | 4 +- .../src/control/notification.rs | 23 +++ crates/unixnotis-core/src/control/proxy.rs | 11 ++ .../src/control/tests/notification.rs | 20 ++- .../unixnotis-core/src/model/notification.rs | 16 ++ .../src/daemon/control/action.rs | 42 +++-- .../src/daemon/control/popup.rs | 22 +++ .../src/daemon/control/query.rs | 2 +- .../src/daemon/control/server.rs | 37 ++++ .../src/daemon/control/tests/action.rs | 50 +++++- .../src/daemon/control/tests/server.rs | 72 ++++++++ .../src/daemon/notifications/server/flow.rs | 11 ++ .../daemon/state/notification_lifecycle.rs | 37 +++- .../state/tests/notification_lifecycle.rs | 59 +++++++ crates/unixnotis-daemon/src/store/model.rs | 6 +- .../src/store/notifications/history.rs | 31 +++- .../src/store/notifications/insertion.rs | 10 +- .../src/store/notifications/lifecycle.rs | 44 ++++- .../src/store/notifications/tests/history.rs | 24 +++ .../store/notifications/tests/lifecycle.rs | 21 +++ crates/unixnotis-daemon/src/store/runtime.rs | 152 ++++++++++++++--- .../src/store/tests/runtime.rs | 160 +++++++++++++++++- 24 files changed, 811 insertions(+), 66 deletions(-) diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs index 7fbeb8595..186412d6a 100644 --- a/crates/noticenterctl/src/output/diagnostics.rs +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -5,7 +5,7 @@ use std::fmt::Write; use anyhow::Result; use unixnotis_core::{ CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, - NotificationDiagnosticsView, PopupAdmissionView, RecordTrust, + NotificationDiagnosticsView, PopupAdmissionView, PopupDeliveryStage, RecordTrust, }; use super::write_stdout; @@ -89,9 +89,29 @@ pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Res "Configured max visible: {}", view.configured_max_visible )?; + writeln!( + output, + "Decision time (Unix ms): {}", + view.decided_at_unix_ms + )?; + writeln!( + output, + "Delivery stage: {}", + popup_delivery_stage(view.delivery_stage) + )?; write_stdout(&output) } +const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { + match value { + PopupDeliveryStage::Suppressed => "suppressed", + PopupDeliveryStage::Admitted => "admitted", + PopupDeliveryStage::FanoutFailed => "fanout failed", + PopupDeliveryStage::RendererFetched => "renderer fetched", + PopupDeliveryStage::Rendered => "rendered", + } +} + fn value_or_none(value: &str) -> &str { if value.trim().is_empty() { "none" diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 5211a5648..2716068d8 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -28,6 +28,7 @@ fn sample_notification() -> NotificationView { received_at_unix_seconds: 0, // CLI formatting only needs the lightweight transport fields image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-core/src/control/diagnostics.rs b/crates/unixnotis-core/src/control/diagnostics.rs index 9ba1deb2c..d016bc8e8 100644 --- a/crates/unixnotis-core/src/control/diagnostics.rs +++ b/crates/unixnotis-core/src/control/diagnostics.rs @@ -5,7 +5,7 @@ use zbus::zvariant::Type; use crate::AttributionDiagnostics; -use super::PopupAdmissionView; +use super::{PopupAdmissionView, PopupDeliveryStage}; /// One active notification and the state that controls its popup rendering #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -18,4 +18,6 @@ pub struct NotificationDiagnosticsView { pub renderer_process_running: bool, pub renderer_ready: bool, pub configured_max_visible: u32, + pub decided_at_unix_ms: i64, + pub delivery_stage: PopupDeliveryStage, } diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index 8afcda194..71101bf4a 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -37,6 +37,29 @@ impl PopupAdmissionView { } } +/// Furthest delivery stage reached by one committed popup decision +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum PopupDeliveryStage { + #[default] + Suppressed = 0, + Admitted = 1, + FanoutFailed = 2, + RendererFetched = 3, + Rendered = 4, +} + +/// Immutable arrival decision plus later delivery progress for one generation +#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Type)] +pub struct PopupDecisionRecord { + pub admission_at_commit: PopupAdmissionView, + pub renderer_process_running_at_commit: bool, + pub renderer_ready_at_commit: bool, + pub max_visible_at_commit: u32, + pub decided_at_unix_ms: i64, + pub delivery_stage: PopupDeliveryStage, +} + /// One atomic popup payload and its current admission decision #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Type)] pub struct PopupCandidate { diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 0462cf7cb..5b4f864bf 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -62,8 +62,17 @@ trait Control { fn list_inhibitors(&self) -> zbus::Result>; /// Remove a notification by identifier fn dismiss(&self, id: u32) -> zbus::Result<()>; + /// Remove only the exact notification generation represented by a UI row + fn dismiss_generation(&self, id: u32, generation: u64) -> zbus::Result<()>; /// Invoke an action key for a notification fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; + /// Invoke an action only for the exact notification generation represented by a UI row + fn invoke_action_generation( + &self, + id: u32, + generation: u64, + action_key: &str, + ) -> zbus::Result<()>; /// Submit text for an explicitly advertised inline-reply action fn reply_notification(&self, id: u32, generation: u64, reply_text: &str) -> zbus::Result<()>; /// Clear active notifications and saved history @@ -82,6 +91,8 @@ trait Control { /// Clear popup readiness during orderly shutdown without activating the daemon #[zbus(no_autostart)] fn mark_popups_not_ready(&self) -> zbus::Result<()>; + /// Confirm that the popup renderer materialized one exact generation + fn mark_popup_rendered(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] fn notification_added(&self, id: u32, generation: u64) -> zbus::Result<()>; diff --git a/crates/unixnotis-core/src/control/tests/notification.rs b/crates/unixnotis-core/src/control/tests/notification.rs index 20580094d..5096ffdcf 100644 --- a/crates/unixnotis-core/src/control/tests/notification.rs +++ b/crates/unixnotis-core/src/control/tests/notification.rs @@ -1,6 +1,6 @@ use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; -use super::PopupAdmissionView; +use super::{PopupAdmissionView, PopupDeliveryStage}; #[test] fn popup_admission_wire_values_remain_stable_and_complete() { @@ -21,6 +21,24 @@ fn popup_admission_wire_values_remain_stable_and_complete() { assert_eq!(PopupAdmissionView::signature(), u8::signature()); } +#[test] +fn popup_delivery_stage_wire_values_remain_stable_and_complete() { + for (stage, expected) in [ + (PopupDeliveryStage::Suppressed, 0_u8), + (PopupDeliveryStage::Admitted, 1), + (PopupDeliveryStage::FanoutFailed, 2), + (PopupDeliveryStage::RendererFetched, 3), + (PopupDeliveryStage::Rendered, 4), + ] { + let encoded = to_bytes(Context::new_dbus(LE, 0), &stage) + .expect("popup delivery stage should serialize"); + + assert_eq!(encoded.bytes(), &[expected]); + } + + assert_eq!(PopupDeliveryStage::signature(), u8::signature()); +} + #[test] fn only_show_admission_permits_popup_rendering() { assert!(PopupAdmissionView::Show.should_show()); diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 898efc7a7..0925f41eb 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -12,6 +12,7 @@ use super::image::NotificationImage; use super::reply::InlineReply; use super::types::{Action, Urgency}; use crate::util::{fold_text_for_layout, MAX_DISPLAY_TOKEN_WIDTH}; +use crate::PopupDecisionRecord; /// Exact identity of one committed notification payload #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq, Hash)] @@ -96,6 +97,7 @@ impl Notification { received_at_unix_seconds: self.received_at.timestamp(), // UIs only need the text, actions, and image payload used for rendering image: self.image.clone(), + popup_decision: PopupDecisionRecord::default(), // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -121,6 +123,7 @@ impl Notification { received_at_unix_seconds: self.received_at.timestamp(), // List rows should avoid carrying raw image buffers across D-Bus image: self.image.for_listing(), + popup_decision: PopupDecisionRecord::default(), // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -340,6 +343,19 @@ pub struct NotificationView { pub received_at_unix_seconds: i64, // Image metadata intended for UI usage pub image: NotificationImage, + // Arrival-time popup reasoning stays stable while DND and renderer state change later + pub popup_decision: PopupDecisionRecord, +} + +impl NotificationView { + /// Return the exact committed identity represented by this UI snapshot + #[must_use] + pub const fn key(&self) -> NotificationKey { + NotificationKey { + id: self.id, + generation: self.generation, + } + } } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index bdadc973a..854b3903b 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -2,6 +2,7 @@ use std::future::Future; +use unixnotis_core::NotificationKey; use zbus::fdo::DBusProxy; use zbus::SignalContext; @@ -15,13 +16,32 @@ impl ControlServer { id: u32, action_key: &str, ) -> zbus::fdo::Result<()> { - self.invoke_validated_action_with_pre_emit(id, action_key, || std::future::ready(())) + let target = { + let store = self.state.store.lock().await; + store.active_action_target(id, action_key).ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification is not live or does not advertise this action".to_string(), + ) + })? + }; + self.invoke_validated_action_generation(target.key(), action_key) .await } - pub(super) async fn invoke_validated_action_with_pre_emit( + pub(super) async fn invoke_validated_action_generation( &self, - id: u32, + notification: NotificationKey, + action_key: &str, + ) -> zbus::fdo::Result<()> { + self.invoke_validated_action_generation_with_pre_emit(notification, action_key, || { + std::future::ready(()) + }) + .await + } + + pub(super) async fn invoke_validated_action_generation_with_pre_emit( + &self, + notification: NotificationKey, action_key: &str, pre_emit: F, ) -> zbus::fdo::Result<()> @@ -32,11 +52,13 @@ impl ControlServer { let target = { // Capture one concrete generation while validating the stored action identity let store = self.state.store.lock().await; - store.active_action_target(id, action_key).ok_or_else(|| { - zbus::fdo::Error::InvalidArgs( - "notification is not live or does not advertise this action".to_string(), - ) - })? + store + .active_action_target_generation(notification, action_key) + .ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification is not live or does not advertise this action".to_string(), + ) + })? }; let sender = target .sender_name @@ -62,7 +84,7 @@ impl ControlServer { .store .lock() .await - .is_active_notification_generation(id, &target); + .is_active_notification_generation(notification.id, &target); if !is_current { return Err(zbus::fdo::Error::InvalidArgs( "notification changed before its action could be invoked".to_string(), @@ -73,7 +95,7 @@ impl ControlServer { let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) .map_err(to_fdo_error)? .set_destination(bus_name.to_owned()); - NotificationServer::action_invoked(&context, id, action_key) + NotificationServer::action_invoked(&context, notification.id, action_key) .await .map_err(to_fdo_error) } diff --git a/crates/unixnotis-daemon/src/daemon/control/popup.rs b/crates/unixnotis-daemon/src/daemon/control/popup.rs index 3a46f0d78..7a9ac3933 100644 --- a/crates/unixnotis-daemon/src/daemon/control/popup.rs +++ b/crates/unixnotis-daemon/src/daemon/control/popup.rs @@ -1,5 +1,6 @@ //! Popup readiness authorization and owner-generation tracking +use unixnotis_core::{NotificationKey, PopupDeliveryStage}; use zbus::message::Header; use super::ControlServer; @@ -20,4 +21,25 @@ impl ControlServer { self.state.set_popups_ready(owner.as_str(), ready); Ok(()) } + + pub(super) async fn mark_popup_generation_rendered( + &self, + key: NotificationKey, + header: &Header<'_>, + ) -> zbus::fdo::Result<()> { + auth::authorize_popup_readiness_call(&self.state, header, "MarkPopupRendered").await?; + let recorded = self + .state + .store + .lock() + .await + .record_popup_delivery_stage(key, PopupDeliveryStage::Rendered); + if recorded { + Ok(()) + } else { + Err(zbus::fdo::Error::InvalidArgs( + "notification generation is no longer retained".to_string(), + )) + } + } } diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 32cd66e85..004437dfe 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -70,7 +70,7 @@ impl ControlServer { // Admission and content must describe the same committed generation self.authorize_control_call(header, "GetPopupCandidate") .await?; - let store = self.state.store.lock().await; + let mut store = self.state.store.lock().await; Ok(store.popup_candidate(id).into_iter().collect()) } diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index d47e564ff..6c8b93702 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -204,6 +204,20 @@ impl ControlServer { .map_err(to_fdo_error) } + pub(super) async fn dismiss_generation( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "DismissGeneration") + .await?; + self.state + .dismiss_generation(NotificationKey { id, generation }) + .await + .map_err(to_fdo_error) + } + pub(super) async fn invoke_action( &self, id: u32, @@ -214,6 +228,19 @@ impl ControlServer { self.invoke_validated_action(id, action_key).await } + pub(super) async fn invoke_action_generation( + &self, + id: u32, + generation: u64, + action_key: &str, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "InvokeActionGeneration") + .await?; + self.invoke_validated_action_generation(NotificationKey { id, generation }, action_key) + .await + } + pub(super) async fn reply_notification( &self, id: u32, @@ -283,6 +310,16 @@ impl ControlServer { .await } + async fn mark_popup_rendered( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.mark_popup_generation_rendered(NotificationKey { id, generation }, &header) + .await + } + #[zbus(signal)] pub(crate) async fn notification_added( ctx: &SignalContext<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index b0f3c1a96..d62589ccf 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -76,12 +76,12 @@ async fn action_signal_reaches_owner_but_not_unrelated_observer() { async fn validated_action_rejects_missing_and_stale_action_generations() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let id = { + let (id, notification) = { let mut store = state.store.lock().await; - store + let notification = store .insert(action_notification(&sender, "open"), 0) - .notification - .id + .notification; + (notification.id, notification.key()) }; let server = ControlServer::new(state.clone()); @@ -92,15 +92,47 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { let replacement_state = state.clone(); let replacement_sender = sender.clone(); server - .invoke_validated_action_with_pre_emit(id, "open", move || async move { - let replacement = action_notification(&replacement_sender, "different"); - let outcome = replacement_state.store.lock().await.insert(replacement, id); - assert!(outcome.replaced); - }) + .invoke_validated_action_generation_with_pre_emit( + notification, + "open", + move || async move { + let replacement = action_notification(&replacement_sender, "different"); + let outcome = replacement_state.store.lock().await.insert(replacement, id); + assert!(outcome.replaced); + }, + ) .await .expect_err("stale action generation must fail"); } +#[tokio::test] +async fn stale_action_does_not_target_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let (stale_key, replacement_key) = { + let mut store = state.store.lock().await; + let first = store + .insert(action_notification(&sender, "delete"), 0) + .notification; + let stale_key = first.key(); + let second = store + .insert(action_notification(&sender, "delete"), first.id) + .notification; + (stale_key, second.key()) + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(stale_key, "delete") + .await + .expect_err("a delayed action must not target a same-ID replacement"); + + let store = state.store.lock().await; + let replacement = store + .active_notification_view(replacement_key.id) + .expect("replacement should remain active"); + assert_eq!(replacement.key(), replacement_key); +} + #[tokio::test] async fn validated_action_rejects_a_conflicting_application_claim() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 7b072d5fa..de3e318fa 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -204,6 +204,78 @@ async fn invoke_action_rejects_unauthorized_sender_before_signal_emit() { .expect_err("unauthorized action should fail"); } +#[tokio::test] +async fn generation_dismiss_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let key = state + .store + .lock() + .await + .insert(notification("protected generation"), 0) + .notification + .key(); + let server = ControlServer::new(state.clone()); + let message = control_header_message("DismissGeneration"); + + server + .dismiss_generation(key.id, key.generation, message.header()) + .await + .expect_err("unauthorized generation dismiss should fail"); + + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(key.id) + .expect("unauthorized dismiss must preserve the notification") + .key(), + key + ); +} + +#[tokio::test] +async fn generation_action_rejects_unauthorized_sender_before_validation() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state); + let message = control_header_message("InvokeActionGeneration"); + + server + .invoke_action_generation(7, 11, "default", message.header()) + .await + .expect_err("unauthorized generation action should fail"); +} + +#[tokio::test] +async fn popup_render_acknowledgement_rejects_unauthorized_sender() { + let state = daemon_state_for_test(false).await; + let key = state + .store + .lock() + .await + .insert(notification("render acknowledgement"), 0) + .notification + .key(); + let server = ControlServer::new(state.clone()); + let message = control_header_message("MarkPopupRendered"); + + server + .mark_popup_generation_rendered(key, &message.header()) + .await + .expect_err("unauthorized render acknowledgement should fail"); + + assert_ne!( + state + .store + .lock() + .await + .notification_diagnostics(key.id, &unixnotis_core::UiHealth::default()) + .expect("notification diagnostics should remain available") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Rendered + ); +} + #[tokio::test] async fn timed_dnd_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index c03b0a806..086c774cc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -192,10 +192,17 @@ impl NotificationServer { replaces_id: u32, ) -> StoredNotification { // Store mutation and scheduler delivery share one serialized lock scope + let ui_health = self.state.ui_health(); let outcome = { let mut store = self.state.store.lock().await; let outcome = store.insert(notification, replaces_id); if !outcome.dropped { + // Commit-time renderer state is retained before it can change again + store.record_popup_commit_environment( + outcome.notification.key(), + outcome.popup_admission, + &ui_health, + ); // Resolve timeout after insertion so rule-mapped fields are already final let expiration = resolve_expiration(store.config(), &outcome.notification); store.set_expiration(&outcome.notification, expiration); @@ -271,6 +278,10 @@ impl NotificationServer { let id = outcome.notification.id; if let Err(error) = self.emit_notification_change(&outcome).await { warn!(?error, id, "notification committed but live fanout failed"); + self.state.store.lock().await.record_popup_delivery_stage( + outcome.notification.key(), + unixnotis_core::PopupDeliveryStage::FanoutFailed, + ); // Snapshot invalidation gives connected clients one best-effort recovery route let _ = self.state.publish_snapshot_invalidated().await; } diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index 8cc8026c2..324a7ea53 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use tracing::warn; -use unixnotis_core::{CloseReason, Notification}; +use unixnotis_core::{CloseReason, Notification, NotificationKey}; use super::DaemonState; @@ -64,6 +64,41 @@ impl DaemonState { Ok(()) } + pub async fn dismiss_generation(&self, key: NotificationKey) -> zbus::Result<()> { + let outcome = { + let mut store = self.store.lock().await; + let outcome = store.dismiss_generation(key); + if let Some(removed) = outcome.removed_active { + self.cancel_expiration(removed); + } + outcome + }; + + if !outcome.removed_any() { + return Err(zbus::Error::Failure( + "notification generation is no longer current".to_string(), + )); + } + + let removed_active = outcome.removed_active.is_some(); + let removed = outcome + .removed_active + .or(outcome.removed_history) + .expect("a removed generation must retain its exact key"); + if let Err(err) = self + .publish_notification_dismissed(removed, removed_active) + .await + { + warn!( + ?err, + id = key.id, + generation = key.generation, + "generation-safe dismiss committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } + pub async fn dismiss_replied_if_current( &self, id: u32, diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 202e107ce..0204a0f35 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -147,6 +147,65 @@ async fn generation_safe_dismiss_keeps_replacement_and_its_timer() { assert_eq!(active.summary, "replacement"); } +#[tokio::test] +async fn generation_safe_panel_dismiss_rejects_a_stale_same_id_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (stale_key, replacement_key) = { + let mut store = state.store.lock().await; + let original = store.insert(notification("original"), 0).notification; + let replacement = store + .insert(notification("replacement"), original.id) + .notification; + (original.key(), replacement.key()) + }; + + state + .dismiss_generation(stale_key) + .await + .expect_err("stale generation dismiss should fail"); + + assert!(receiver.try_recv().is_err()); + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(replacement_key.id) + .expect("replacement should remain active") + .key(), + replacement_key + ); +} + +#[tokio::test] +async fn generation_safe_panel_dismiss_removes_and_cancels_the_current_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let key = state + .store + .lock() + .await + .insert(notification("current"), 0) + .notification + .key(); + + state + .dismiss_generation(key) + .await + .expect("current generation dismiss should succeed"); + + assert_eq!(next_cancel_id(&mut receiver).await, key.id); + assert!(state + .store + .lock() + .await + .active_notification_view(key.id) + .is_none()); +} + #[tokio::test] async fn close_notification_removes_active_notification_and_cancels_timer() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index fa5629a1a..79369e05e 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use std::time::Instant; use indexmap::IndexMap; -use unixnotis_core::{Config, Notification, NotificationKey, PopupAdmissionView}; +use unixnotis_core::{ + Config, Notification, NotificationKey, PopupAdmissionView, PopupDecisionRecord, +}; use super::dnd::DndStateStore; use super::inhibitors::Inhibitor; @@ -21,6 +23,8 @@ pub struct NotificationStore { pub(super) active: IndexMap>, // Archived notifications with bounded retention pub(super) history: HistoryStore, + // Arrival-time popup decisions outlive active state while history retains the generation + pub(super) popup_decisions: HashMap, // Exact expiration identity per active notification generation pub(super) expirations: HashMap, // Effective DND switch after loading persisted state diff --git a/crates/unixnotis-daemon/src/store/notifications/history.rs b/crates/unixnotis-daemon/src/store/notifications/history.rs index 2e63fe7d5..a7356d969 100644 --- a/crates/unixnotis-daemon/src/store/notifications/history.rs +++ b/crates/unixnotis-daemon/src/store/notifications/history.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Weak}; -use unixnotis_core::{Notification, NotificationView}; +use unixnotis_core::{Notification, NotificationKey, NotificationView, PopupDecisionRecord}; struct HistoryEntry { notification: Arc, @@ -44,16 +44,29 @@ impl HistoryStore { self.order.clear(); } - pub(in crate::store) fn list_views(&self) -> Vec { + pub(in crate::store) fn list_views( + &self, + popup_decisions: &HashMap, + ) -> Vec { let mut views = Vec::with_capacity(self.entries.len()); for id in self.order.iter().rev() { if let Some(entry) = self.entries.get(id) { - views.push(entry.notification.to_list_view()); + let mut view = entry.notification.to_list_view(); + if let Some(decision) = popup_decisions.get(&entry.notification.key()) { + view.popup_decision.clone_from(decision); + } + views.push(view); } } views } + pub(in crate::store) fn contains_generation(&self, key: NotificationKey) -> bool { + self.entries + .get(&key.id) + .is_some_and(|entry| entry.notification.generation == key.generation) + } + pub(in crate::store) fn remove(&mut self, id: &u32) -> Option> { let removed = self.entries.remove(id).map(|entry| entry.notification); if removed.is_some() { @@ -63,6 +76,18 @@ impl HistoryStore { removed } + pub(in crate::store) fn remove_generation( + &mut self, + key: NotificationKey, + ) -> Option> { + // Numeric IDs can be reused, so history removal must compare the committed generation + let generation_matches = self + .entries + .get(&key.id) + .is_some_and(|entry| entry.notification.generation == key.generation); + generation_matches.then(|| self.remove(&key.id)).flatten() + } + pub(in crate::store) fn insert(&mut self, notification: Arc) { let id = notification.id; if self.entries.contains_key(&id) { diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index 6c2b3b85b..168e6c705 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -58,6 +58,8 @@ impl NotificationStore { self.active.shift_remove(&assigned_id); self.history.remove(&assigned_id); self.expirations.remove(&assigned_id); + self.popup_decisions + .retain(|key, _decision| key.id != assigned_id); let notification = Arc::new(notification); // Active map keeps insertion order so oldest eviction is deterministic @@ -65,8 +67,14 @@ impl NotificationStore { // Enforce active cap immediately so UI never sees oversized active sets let evicted = self.enforce_active_limit(); + let popup_admission = self.popup_admission(¬ification); + self.record_popup_commit_environment( + notification.key(), + popup_admission, + &unixnotis_core::UiHealth::default(), + ); InsertOutcome { - popup_admission: self.popup_admission(¬ification), + popup_admission, allow_sound: self.should_play_sound(¬ification), notification, replaced, diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index 13ca59e27..ff2c491fc 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -14,6 +14,7 @@ impl NotificationStore { // Closed rows and panel rows should follow the same archive rule self.push_history(notification, reason); } + self.prune_popup_decisions(); removed } @@ -29,10 +30,41 @@ impl NotificationStore { .remove(&id) .map(|notification| notification.key()); - DismissOutcome { + let outcome = DismissOutcome { removed_active: removed_active.map(|notification| notification.key()), removed_history, - } + }; + self.prune_popup_decisions(); + outcome + } + + pub fn dismiss_generation(&mut self, key: NotificationKey) -> DismissOutcome { + // Validate the generation before mutating either active or retained history state + let active_matches = self + .active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation); + let removed_active = if active_matches { + let removed = self.active.shift_remove(&key.id); + self.expirations.remove(&key.id); + removed.map(|notification| notification.key()) + } else { + None + }; + let removed_history = if removed_active.is_some() { + None + } else { + self.history + .remove_generation(key) + .map(|notification| notification.key()) + }; + + let outcome = DismissOutcome { + removed_active, + removed_history, + }; + self.prune_popup_decisions(); + outcome } pub fn dismiss_active_if_current(&mut self, id: u32, expected: &Arc) -> bool { @@ -71,10 +103,12 @@ impl NotificationStore { .remove_if_source(id, expected) .map(|notification| notification.key()) }; - DismissOutcome { + let outcome = DismissOutcome { removed_active, removed_history, - } + }; + self.prune_popup_decisions(); + outcome } pub fn drain_active_keys(&mut self) -> Vec { @@ -87,6 +121,7 @@ impl NotificationStore { .collect(); self.active.clear(); self.expirations.clear(); + self.prune_popup_decisions(); keys } @@ -124,6 +159,7 @@ impl NotificationStore { let removed = self.active.shift_remove(&ticket.id)?; self.expirations.remove(&ticket.id); self.push_history(removed.clone(), CloseReason::Expired); + self.prune_popup_decisions(); Some(removed) } } diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs index 5edf51dbb..faa26844b 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs @@ -66,3 +66,27 @@ fn clear_history_removes_archived_notifications() { assert_eq!(store.history_len(), 0); assert!(store.list_history().is_empty()); } + +#[test] +fn history_generation_checks_and_removal_require_the_exact_commit_key() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("archived"), 0).notification; + let current = notification.key(); + let stale = unixnotis_core::NotificationKey { + id: current.id, + generation: current.generation.saturating_add(1), + }; + store.close(current.id, CloseReason::Expired); + + assert!(store.history.contains_generation(current)); + assert!(!store.history.contains_generation(stale)); + assert!(store.history.remove_generation(stale).is_none()); + assert!(store.history.contains_generation(current)); + + let removed = store + .history + .remove_generation(current) + .expect("current generation should be removable"); + assert_eq!(removed.key(), current); + assert!(!store.history.contains_generation(current)); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs index 0b7414227..5203346f3 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -78,6 +78,27 @@ fn generation_safe_reply_dismissal_keeps_same_id_replacement() { assert!(store.active_notification_view(id).is_none()); } +#[test] +fn stale_panel_dismissal_keeps_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let original = store.insert(make_notification("original"), 0).notification; + let stale_key = original.key(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .notification; + + let outcome = store.dismiss_generation(stale_key); + + assert!(!outcome.removed_any()); + assert_eq!( + store + .active_notification_view(replacement.id) + .expect("replacement should remain active") + .key(), + replacement.key() + ); +} + #[test] fn replied_generation_is_removed_after_sender_archives_it() { let mut store = make_store_with_limits(12, 20); diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index e6bcd0b79..aed90a9d0 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -5,7 +5,8 @@ use indexmap::IndexMap; use tracing::{debug, warn}; use unixnotis_core::{ ApplicationActionPolicy, Config, ControlState, Notification, NotificationDiagnosticsView, - NotificationView, PopupAdmissionView, PopupCandidate, UiHealth, + NotificationKey, NotificationView, PopupAdmissionView, PopupCandidate, PopupDecisionRecord, + PopupDeliveryStage, UiHealth, }; use super::dnd::{DndStateStore, DND_STATE_VERSION}; @@ -72,6 +73,7 @@ impl NotificationStore { config, active: IndexMap::new(), history: HistoryStore::new(), + popup_decisions: HashMap::new(), expirations: HashMap::new(), dnd_state_store, next_inhibitor_id: 1, @@ -109,13 +111,13 @@ impl NotificationStore { self.active .values() .rev() - .map(|notification| notification.to_list_view()) + .map(|notification| self.list_view_with_popup_decision(notification)) .collect() } pub fn list_history(&self) -> Vec { // HistoryStore already returns newest first - self.history.list_views() + self.history.list_views(&self.popup_decisions) } pub fn list_popup_candidates(&self) -> Vec { @@ -123,8 +125,19 @@ impl NotificationStore { self.active .values() .rev() - .filter(|notification| !notification.suppress_popup) - .map(|notification| notification.to_list_view()) + .filter(|notification| { + !notification.suppress_popup + && self + .popup_decisions + .get(¬ification.key()) + .is_some_and(|decision| { + matches!( + decision.admission_at_commit, + PopupAdmissionView::Show | PopupAdmissionView::RendererUnavailable + ) + }) + }) + .map(|notification| self.list_view_with_popup_decision(notification)) .collect() } @@ -133,46 +146,117 @@ impl NotificationStore { // are consumed by trusted UIs that may need current image payloads self.active .get(&id) - .map(|notification| notification.to_view()) + .map(|notification| self.view_with_popup_decision(notification)) } - pub fn popup_candidate(&self, id: u32) -> Option { - // Payload and live gate policy are read from one immutable lock snapshot + pub fn popup_candidate(&mut self, id: u32) -> Option { + // Payload and its arrival-time policy are read from one store-lock snapshot let notification = self.active.get(&id)?; + let key = notification.key(); + let admission = self.popup_decisions.get(&key)?.admission_at_commit; + let view = self.view_with_popup_decision(notification); + if admission.should_show() { + self.record_popup_delivery_stage(key, PopupDeliveryStage::RendererFetched); + } Some(PopupCandidate { - notification: notification.to_view(), - admission: self.popup_admission(notification).to_view(), + notification: view, + admission, }) } pub fn notification_diagnostics( &self, id: u32, - ui_health: &UiHealth, + _ui_health: &UiHealth, ) -> Option { - let notification = self.active.get(&id)?; - let stored_admission = self.popup_admission(notification).to_view(); - let popup_admission = if stored_admission != PopupAdmissionView::Show { - stored_admission - } else if ui_health.popups_process_running && ui_health.popups_ready { - PopupAdmissionView::Show - } else { - PopupAdmissionView::RendererUnavailable - }; + let notification = self.active.get(&id).or_else(|| self.history.get(&id))?; + let decision = self.popup_decisions.get(¬ification.key())?; Some(NotificationDiagnosticsView { id, generation: notification.generation, stored: true, attribution: notification.attribution_diagnostics.clone(), - popup_admission, - renderer_process_running: ui_health.popups_process_running, - renderer_ready: ui_health.popups_ready, - configured_max_visible: u32::try_from(self.config.popups.max_visible) - .unwrap_or(u32::MAX), + popup_admission: decision.admission_at_commit, + renderer_process_running: decision.renderer_process_running_at_commit, + renderer_ready: decision.renderer_ready_at_commit, + configured_max_visible: decision.max_visible_at_commit, + decided_at_unix_ms: decision.decided_at_unix_ms, + delivery_stage: decision.delivery_stage, }) } + pub(crate) fn record_popup_commit_environment( + &mut self, + key: NotificationKey, + admission: super::PopupAdmission, + ui_health: &UiHealth, + ) { + let max_visible = u32::try_from(self.config.popups.max_visible).unwrap_or(u32::MAX); + let effective_admission = if !admission.should_show() { + admission.to_view() + } else if max_visible == 0 { + PopupAdmissionView::RendererDisabled + } else if ui_health.popups_process_running && ui_health.popups_ready { + PopupAdmissionView::Show + } else { + PopupAdmissionView::RendererUnavailable + }; + let delivery_stage = if effective_admission.should_show() { + PopupDeliveryStage::Admitted + } else { + PopupDeliveryStage::Suppressed + }; + self.popup_decisions.insert( + key, + PopupDecisionRecord { + admission_at_commit: effective_admission, + renderer_process_running_at_commit: ui_health.popups_process_running, + renderer_ready_at_commit: ui_health.popups_ready, + max_visible_at_commit: max_visible, + decided_at_unix_ms: chrono::Utc::now().timestamp_millis(), + delivery_stage, + }, + ); + } + + pub fn record_popup_delivery_stage( + &mut self, + key: NotificationKey, + stage: PopupDeliveryStage, + ) -> bool { + let Some(decision) = self.popup_decisions.get_mut(&key) else { + return false; + }; + decision.delivery_stage = stage; + true + } + + pub(super) fn prune_popup_decisions(&mut self) { + self.popup_decisions.retain(|key, _decision| { + self.active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation) + || self.history.contains_generation(*key) + }); + } + + fn view_with_popup_decision(&self, notification: &Notification) -> NotificationView { + let mut view = notification.to_view(); + if let Some(decision) = self.popup_decisions.get(¬ification.key()) { + view.popup_decision.clone_from(decision); + } + view + } + + fn list_view_with_popup_decision(&self, notification: &Notification) -> NotificationView { + let mut view = notification.to_list_view(); + if let Some(decision) = self.popup_decisions.get(¬ification.key()) { + view.popup_decision.clone_from(decision); + } + view + } + pub fn active_inline_reply_target( &self, id: u32, @@ -192,7 +276,22 @@ impl NotificationStore { } pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { - let notification = self.active.get(&id)?; + let generation = self.active.get(&id)?.generation; + self.active_action_target_generation( + unixnotis_core::NotificationKey { id, generation }, + action_key, + ) + } + + pub fn active_action_target_generation( + &self, + key: unixnotis_core::NotificationKey, + action_key: &str, + ) -> Option> { + let notification = self.active.get(&key.id)?; + if notification.generation != key.generation { + return None; + } // Weak or conflicting provenance must not gain an application-directed signal if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { return None; @@ -220,6 +319,7 @@ impl NotificationStore { pub fn clear_history(&mut self) { // Explicit history wipe used by CLI and control commands self.history.clear(); + self.prune_popup_decisions(); } } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 599c85374..4a0ed1e34 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -68,7 +68,7 @@ fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { } #[test] -fn notification_diagnostics_report_renderer_and_store_admission_separately() { +fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() { let mut store = make_store_with_limits(10, 10); let visible = store.insert(make_notification("visible"), 0).notification; let unavailable = store @@ -83,25 +83,27 @@ fn notification_diagnostics_report_renderer_and_store_admission_separately() { assert!(!unavailable.renderer_ready); store.set_dnd(true); + let dnd_suppressed = store + .insert(make_notification("DND suppressed"), 0) + .notification; + store.set_dnd(false); let ready = unixnotis_core::UiHealth { popups_process_running: true, popups_ready: true, ..unixnotis_core::UiHealth::default() }; let suppressed = store - .notification_diagnostics(visible.id, &ready) + .notification_diagnostics(dnd_suppressed.id, &ready) .expect("DND diagnostics"); assert_eq!(suppressed.popup_admission, PopupAdmissionView::Dnd); - assert!(suppressed.renderer_process_running); - assert!(suppressed.renderer_ready); + assert!(!suppressed.renderer_process_running); + assert!(!suppressed.renderer_ready); } #[test] fn notification_diagnostics_require_both_renderer_process_and_readiness() { let mut store = make_store_with_limits(10, 10); - let visible = store.insert(make_notification("visible"), 0).notification; - for (process_running, ready, expected) in [ (false, false, PopupAdmissionView::RendererUnavailable), (true, false, PopupAdmissionView::RendererUnavailable), @@ -113,8 +115,14 @@ fn notification_diagnostics_require_both_renderer_process_and_readiness() { popups_ready: ready, ..unixnotis_core::UiHealth::default() }; + let visible = store.insert(make_notification("visible"), 0).notification; + store.record_popup_commit_environment( + visible.key(), + crate::store::PopupAdmission::Show, + &health, + ); let diagnostics = store - .notification_diagnostics(visible.id, &health) + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) .expect("active notification diagnostics"); assert_eq!( @@ -124,6 +132,144 @@ fn notification_diagnostics_require_both_renderer_process_and_readiness() { } } +#[test] +fn disabled_popups_are_recorded_when_max_visible_is_zero() { + let mut config = Config::default(); + config.popups.max_visible = 0; + let mut store = NotificationStore::new(config); + let notification = store.insert(make_notification("disabled"), 0).notification; + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let diagnostics = store + .notification_diagnostics(notification.id, &ready) + .expect("disabled popup diagnostics"); + + assert_eq!( + diagnostics.popup_admission, + PopupAdmissionView::RendererDisabled + ); + assert_eq!(diagnostics.configured_max_visible, 0); +} + +#[test] +fn archived_notification_keeps_its_arrival_popup_explanation() { + let mut store = make_store_with_limits(10, 10); + store.set_dnd(true); + let notification = store + .insert(make_notification("archived DND"), 0) + .notification; + store.close(notification.id, CloseReason::Expired); + store.set_dnd(false); + + let diagnostics = store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("history diagnostics should remain available"); + + assert_eq!(diagnostics.generation, notification.generation); + assert_eq!(diagnostics.popup_admission, PopupAdmissionView::Dnd); +} + +#[test] +fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("admitted popup candidate"); + assert_eq!(candidate.admission, PopupAdmissionView::Show); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("fetched diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::RendererFetched + ); + + assert!(store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Rendered, + )); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("rendered diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Rendered + ); +} + +#[test] +fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering() { + let mut store = make_store_with_limits(10, 10); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + + let mut rule_suppressed = make_notification("persistent suppression"); + rule_suppressed.suppress_popup = true; + let rule_suppressed = store.insert(rule_suppressed, 0).notification; + store.record_popup_commit_environment( + rule_suppressed.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let arrival_suppressed = store + .insert(make_notification("arrival suppression"), 0) + .notification; + store.record_popup_commit_environment( + arrival_suppressed.key(), + crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), + &ready, + ); + + let admitted = store.insert(make_notification("admitted"), 0).notification; + store.record_popup_commit_environment( + admitted.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let candidates = store.list_popup_candidates(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].key(), admitted.key()); +} + +#[test] +fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("retained"), 0).notification; + + assert!(store.popup_decisions.contains_key(¬ification.key())); + store.close(notification.id, CloseReason::Expired); + assert!(store.popup_decisions.contains_key(¬ification.key())); + + store.clear_history(); + assert!(store.popup_decisions.is_empty()); +} + #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { let mut store = make_store_with_limits(12, 20); From 59688b2c26d0c28f51d3a91239557e8a0e3d59c1 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:23:59 -0500 Subject: [PATCH 135/275] refactor(presentation): derive identity from provenance Summary: derive identity from provenance. Scope: presentation. --- .../unixnotis-core/src/css/hooks/classes.rs | 6 + .../src/css/hooks/tests/hooks.rs | 28 ++- .../unixnotis-core/src/model/image/hints.rs | 9 +- .../src/model/image/tests/hints.rs | 13 +- .../unixnotis-ui/src/presentation/badges.rs | 34 +++- crates/unixnotis-ui/src/presentation/build.rs | 124 +++++++++--- crates/unixnotis-ui/src/presentation/mod.rs | 2 +- .../src/presentation/tests/badges.rs | 8 +- .../src/presentation/tests/presentation.rs | 179 +++++++++++++++++- .../src/presentation/tests/support.rs | 1 + crates/unixnotis-ui/src/presentation/types.rs | 8 +- 11 files changed, 359 insertions(+), 53 deletions(-) diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 7243dd207..97a63313c 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -70,8 +70,12 @@ pub mod panel_shell { pub const RELOAD_NOTICE: &str = "unixnotis-reload-notice"; pub const RELOAD_NOTICE_ERROR: &str = "unixnotis-reload-notice-error"; pub const RELOAD_NOTICE_WARNING: &str = "unixnotis-reload-notice-warning"; + pub const RELOAD_NOTICE_CONTENT: &str = "unixnotis-reload-notice-content"; pub const RELOAD_NOTICE_TEXT: &str = "unixnotis-reload-notice-text"; pub const RELOAD_NOTICE_CLOSE: &str = "unixnotis-reload-notice-close"; + pub const RELOAD_NOTICE_ACTIONS: &str = "unixnotis-reload-notice-actions"; + pub const RELOAD_NOTICE_ACTION: &str = "unixnotis-reload-notice-action"; + pub const RELOAD_NOTICE_ACTION_PRIMARY: &str = "unixnotis-reload-notice-action-primary"; pub const BODY_STACK: &str = "unixnotis-panel-body-stack"; pub const EDGE_TOP: &str = "unixnotis-panel-edge-top"; pub const EDGE_BOTTOM: &str = "unixnotis-panel-edge-bottom"; @@ -111,6 +115,8 @@ pub mod panel_card { pub const GROUP_COLLAPSED: &str = "unixnotis-panel-card-group-collapsed"; pub const GROUP_EXPANDED: &str = "unixnotis-panel-card-group-expanded"; pub const GROUPED: &str = "unixnotis-panel-card-grouped"; + pub const GROUP_FIRST: &str = "unixnotis-panel-card-group-first"; + pub const GROUP_LAST: &str = "unixnotis-panel-card-group-last"; pub const HAS_ACTIONS: &str = "unixnotis-panel-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-panel-card-has-body"; pub const HAS_SUMMARY: &str = "unixnotis-panel-card-has-summary"; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 132eb8a57..d5ce32c96 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -64,8 +64,12 @@ fn hook_names_stay_unique() { panel_shell::RELOAD_NOTICE, panel_shell::RELOAD_NOTICE_ERROR, panel_shell::RELOAD_NOTICE_WARNING, + panel_shell::RELOAD_NOTICE_CONTENT, panel_shell::RELOAD_NOTICE_TEXT, panel_shell::RELOAD_NOTICE_CLOSE, + panel_shell::RELOAD_NOTICE_ACTIONS, + panel_shell::RELOAD_NOTICE_ACTION, + panel_shell::RELOAD_NOTICE_ACTION_PRIMARY, panel_shell::BODY_STACK, panel_shell::EDGE_TOP, panel_shell::EDGE_BOTTOM, @@ -102,6 +106,8 @@ fn hook_names_stay_unique() { panel_card::GROUP_COLLAPSED, panel_card::GROUP_EXPANDED, panel_card::GROUPED, + panel_card::GROUP_FIRST, + panel_card::GROUP_LAST, panel_card::HAS_ACTIONS, panel_card::HAS_BODY, panel_card::HAS_SUMMARY, @@ -245,6 +251,14 @@ fn stock_panel_css_targets_real_group_card_hooks() { // Stock CSS targets explicit collapsed and expanded content states assert!(css.contains(&format!(".{}", panel_card::GROUP_COLLAPSED))); assert!(css.contains(&format!(".{}", panel_card::GROUP_EXPANDED))); + assert!(css.contains(&format!( + ".unixnotis-panel-card.{}", + panel_card::GROUP_COLLAPSED + ))); + assert!(css.contains(&format!( + ".unixnotis-panel-card.{}", + panel_card::GROUP_EXPANDED + ))); // These selectors belonged to an older nested-card idea and do not match the real tree assert!(!css.contains("unixnotis-group-cards")); @@ -252,6 +266,18 @@ fn stock_panel_css_targets_real_group_card_hooks() { assert!(!css.contains(".unixnotis-group-row-collapsed .unixnotis-panel-card")); } +#[test] +fn stock_group_count_stays_neutral_during_header_hover() { + let css = crate::theme::DEFAULT_PANEL_CSS; + + assert!(css.contains( + ".unixnotis-group-header:hover .unixnotis-group-count {\n background: alpha(#ffffff, 0.09);" + )); + assert!(!css.contains( + ".unixnotis-group-header:hover .unixnotis-group-count {\n background: alpha(@unixnotis-accent" + )); +} + #[test] fn stock_panel_css_avoids_decorative_stack_ghosts_and_negative_overlap() { let css = crate::theme::DEFAULT_PANEL_CSS; @@ -266,6 +292,6 @@ fn stock_panel_close_control_stays_quiet_until_hover_or_focus() { assert!(css.contains(".unixnotis-panel-close {\n")); assert!(css.contains("opacity: 0;")); - assert!(css.contains(".unixnotis-panel-card:hover .unixnotis-panel-close")); + assert!(css.contains(".unixnotis-panel-card-overlay:hover .unixnotis-panel-close")); assert!(css.contains(".unixnotis-panel-close:focus")); } diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index fee791b9e..3046be668 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -12,7 +12,7 @@ use super::{ impl NotificationImage { pub fn from_hints(app_name: &str, app_icon: &str, hints: &HashMap) -> Self { - // The notification spec prefers image-data over image-path and app_icon + // Content-image hints stay separate from the application identity icon let image_data = hints .get("image-data") .and_then(Self::parse_image_data) @@ -20,7 +20,7 @@ impl NotificationImage { .or_else(|| hints.get("icon_data").and_then(Self::parse_image_data)); let image_data = image_data.filter(Self::is_image_data_usable); - let mut image_path = hints + let image_path = hints .get("image-path") .and_then(owned_to_string) .or_else(|| hints.get("image_path").and_then(owned_to_string)) @@ -33,11 +33,6 @@ impl NotificationImage { .and_then(owned_to_string) .map(|entry| strip_desktop_suffix(&entry)); let app_icon_path = normalize_app_icon_path(app_icon); - if image_path.is_empty() { - if let Some(path) = app_icon_path.as_ref() { - image_path = path.clone(); - } - } let icon_name = bound_icon_name(&resolve_icon_name( app_name, app_icon, diff --git a/crates/unixnotis-core/src/model/image/tests/hints.rs b/crates/unixnotis-core/src/model/image/tests/hints.rs index 6dea6d90c..94c5b9bd7 100644 --- a/crates/unixnotis-core/src/model/image/tests/hints.rs +++ b/crates/unixnotis-core/src/model/image/tests/hints.rs @@ -22,7 +22,7 @@ fn from_hints_prefers_valid_image_data_over_image_path_and_icon() { } #[test] -fn from_hints_falls_back_from_invalid_image_data_to_app_icon_path() { +fn from_hints_never_promotes_an_app_icon_path_to_content_media() { let mut hints = HashMap::new(); hints.insert( "image-data".to_string(), @@ -32,10 +32,19 @@ fn from_hints_falls_back_from_invalid_image_data_to_app_icon_path() { let image = NotificationImage::from_hints("App", "/tmp/app-icon.png", &hints); assert!(!image.has_image_data); - assert_eq!(image.image_path, "/tmp/app-icon.png"); + assert!(image.image_path.is_empty()); assert!(image.icon_name.is_empty()); } +#[test] +fn from_hints_never_promotes_an_app_icon_name_to_content_media() { + let image = NotificationImage::from_hints("Signal", "signal-desktop", &HashMap::new()); + + assert!(!image.has_image_data); + assert!(image.image_path.is_empty()); + assert_eq!(image.icon_name, "signal-desktop"); +} + #[test] fn from_hints_uses_desktop_entry_before_app_name_for_icon_name() { let mut hints = HashMap::new(); diff --git a/crates/unixnotis-ui/src/presentation/badges.rs b/crates/unixnotis-ui/src/presentation/badges.rs index 401d8863d..97720f3fe 100644 --- a/crates/unixnotis-ui/src/presentation/badges.rs +++ b/crates/unixnotis-ui/src/presentation/badges.rs @@ -27,20 +27,36 @@ pub fn register_semantic_badges() -> Result<(), String> { /// Builds a daemon-controlled badge when authenticated application art is not allowed #[must_use] pub fn build_semantic_badge(badge: BadgePresentation, size: i32) -> Option { - register_semantic_badges().ok()?; - let file = match badge { + let image = gtk::Image::new(); + apply_semantic_badge(&image, badge, size).then_some(image) +} + +/// Applies one daemon-controlled symbolic icon to an existing reusable image widget +#[must_use] +pub fn apply_semantic_badge(image: >k::Image, badge: BadgePresentation, size: i32) -> bool { + if register_semantic_badges().is_err() { + return false; + } + let Some(display) = gtk::gdk::Display::default() else { + return false; + }; + let icon_theme = gtk::IconTheme::for_display(&display); + // Named symbolic icons use GTK's recoloring path instead of raw resource paintables + icon_theme.add_resource_path(RESOURCE_ROOT); + let icon_name = match badge { // Verified applications retain the authenticated desktop badge - BadgePresentation::AuthenticatedApplication => return None, - BadgePresentation::UnknownApplication => "unixnotis-app-unknown-symbolic.svg", - BadgePresentation::SuspiciousApplication => "unixnotis-shield-warning-symbolic.svg", - BadgePresentation::CommandLine => "unixnotis-terminal-symbolic.svg", - BadgePresentation::System => "unixnotis-system-symbolic.svg", + BadgePresentation::AuthenticatedApplication => return false, + BadgePresentation::UnknownApplication => "unixnotis-app-unknown-symbolic", + BadgePresentation::SuspiciousApplication => "unixnotis-shield-warning-symbolic", + BadgePresentation::CommandLine => "unixnotis-terminal-symbolic", + BadgePresentation::System => "unixnotis-system-symbolic", }; - let image = gtk::Image::from_resource(&format!("{RESOURCE_ROOT}/{file}")); let size = size.max(1); + image.set_paintable(None::<>k::gdk::Paintable>); + image.set_icon_name(Some(icon_name)); image.set_pixel_size(size); image.set_size_request(size, size); - Some(image) + true } #[cfg(test)] diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index e9ce96148..045adf7d6 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -3,7 +3,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unixnotis_core::{ - Action, ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationView, Urgency, + Action, ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationView, + PopupAdmissionView, Urgency, }; use super::text::{ @@ -24,6 +25,7 @@ pub struct NotificationPresentation { pub title: String, pub body: Option, pub timestamp: String, + pub popup_status: Option, pub media: MediaPresentation, pub actions: ActionPresentation, pub critical: bool, @@ -54,6 +56,7 @@ impl NotificationPresentation { body: has_visible_text(¬ification.body) .then(|| clamp_label_text(¬ification.body, BODY_LABEL_MAX_CHARS).into_owned()), timestamp: relative_time_label(notification.received_at_unix_seconds, now), + popup_status: popup_status(notification), media: MediaPresentation { thumbnail: thumbnail_kind(notification), }, @@ -63,13 +66,38 @@ impl NotificationPresentation { } } +fn popup_status(notification: &NotificationView) -> Option { + let decision = ¬ification.popup_decision; + if decision.decided_at_unix_ms <= 0 { + return None; + } + if decision.delivery_stage == unixnotis_core::PopupDeliveryStage::Rendered { + return (decision.admission_at_commit == PopupAdmissionView::RendererUnavailable) + .then(|| "Shown after popup renderer recovered".to_string()); + } + let status = match decision.delivery_stage { + unixnotis_core::PopupDeliveryStage::FanoutFailed => { + "Not shown — notification delivery failed" + } + _ => match decision.admission_at_commit { + PopupAdmissionView::Show => return None, + PopupAdmissionView::Rule => "Not shown — matched notification rule", + PopupAdmissionView::Dnd => "Not shown — Do Not Disturb was enabled", + PopupAdmissionView::Inhibitor => "Not shown — notifications were inhibited", + PopupAdmissionView::RendererUnavailable => "Not shown — popup renderer was unavailable", + PopupAdmissionView::RendererDisabled => "Not shown — popups are disabled", + }, + }; + Some(status.to_string()) +} + pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresentation { let level = trust_level(notification); let short_label = match level { - TrustLevel::Verified => None, + // Verified and command-line primary labels already communicate their source clearly + TrustLevel::Verified | TrustLevel::CommandLine => None, TrustLevel::Unverified => Some("Unverified".to_string()), TrustLevel::Suspicious => Some("Suspicious".to_string()), - TrustLevel::System => Some("Command-line tool".to_string()), }; let details_label = nonempty_text(¬ification.attribution.source_label); let has_reply_action = notification @@ -98,15 +126,23 @@ pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresen } const fn trust_level(notification: &NotificationView) -> TrustLevel { - if notification.attribution.has_warning() { - return TrustLevel::Suspicious; - } match notification.attribution.class { AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { - TrustLevel::Verified + if notification.attribution.has_warning() { + TrustLevel::Suspicious + } else { + TrustLevel::Verified + } } - AttributionClass::UserAssociated | AttributionClass::Unknown => TrustLevel::Unverified, - AttributionClass::TrustedRelay => TrustLevel::System, + AttributionClass::UserAssociated | AttributionClass::Unknown => { + if notification.attribution.has_warning() { + TrustLevel::Suspicious + } else { + TrustLevel::Unverified + } + } + // A verified relay remains a relay even when its caller-controlled label names an app + AttributionClass::TrustedRelay => TrustLevel::CommandLine, AttributionClass::Conflict => TrustLevel::Suspicious, } } @@ -115,16 +151,31 @@ fn identity_presentation( notification: &NotificationView, level: TrustLevel, ) -> IdentityPresentation { - let primary_label = - clamp_label_text(¬ification.attribution.display_name, APP_LABEL_MAX_CHARS).into_owned(); - let secondary_claim = (level == TrustLevel::Suspicious) - .then(|| claimed_identity(¬ification.attribution.source_label)) - .flatten(); + let claimed_label = + clamp_label_text(¬ification.attribution.display_name, APP_LABEL_MAX_CHARS); + let (primary_label, secondary_claim) = match notification.attribution.class { + AttributionClass::TrustedRelay => ( + "Command-line notification".to_string(), + visible_claim(&claimed_label).map(|claim| format!("App label: {claim}")), + ), + AttributionClass::Conflict => ( + "Unknown application".to_string(), + claimed_identity(¬ification.attribution.source_label) + .map(|claim| format!("Claims “{claim}”")), + ), + AttributionClass::Unknown => ( + "Unknown application".to_string(), + visible_claim(&claimed_label).map(|claim| format!("App label: {claim}")), + ), + AttributionClass::SystemAssociated + | AttributionClass::PortalAssociated + | AttributionClass::UserAssociated => (claimed_label.into_owned(), None), + }; let badge = match level { TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, TrustLevel::Unverified => BadgePresentation::UnknownApplication, TrustLevel::Suspicious => BadgePresentation::SuspiciousApplication, - TrustLevel::System => BadgePresentation::CommandLine, + TrustLevel::CommandLine => BadgePresentation::CommandLine, }; IdentityPresentation { primary_label, @@ -138,16 +189,24 @@ fn claimed_identity(source: &str) -> Option { .split(';') .next() .map(str::trim) - .filter(|value| value.starts_with("Claims to be "))?; - Some(claim.to_string()) + .and_then(|value| value.strip_prefix("Claims to be "))? + .trim(); + visible_claim(claim).map(ToString::to_string) +} + +fn visible_claim(claim: &str) -> Option<&str> { + let claim = claim.trim(); + (!claim.is_empty() && claim != "Unknown application").then_some(claim) } pub(super) fn notification_kind( notification: &NotificationView, trust_level: TrustLevel, ) -> NotificationKind { - if trust_level == TrustLevel::Suspicious { - return NotificationKind::Warning; + match trust_level { + TrustLevel::Suspicious => return NotificationKind::Warning, + TrustLevel::Unverified | TrustLevel::CommandLine => return NotificationKind::Utility, + TrustLevel::Verified => {} } let category_class = notification .category @@ -221,21 +280,32 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { .unwrap_or_default() .eq_ignore_ascii_case(category) }); - if category_is_media || !image_source_matches_authenticated_badge(notification) { - ThumbnailKind::Content - } else { - ThumbnailKind::None + let identity_is_verified = matches!( + notification.attribution.class, + AttributionClass::SystemAssociated | AttributionClass::PortalAssociated + ) && !notification.attribution.has_warning(); + if !identity_is_verified { + // Untrusted senders need an explicit media category before large imagery is shown + return if category_is_media { + ThumbnailKind::Content + } else { + ThumbnailKind::None + }; } + if notification.image.has_image_data + || category_is_media + || !image_path_matches_authenticated_badge(notification) + { + return ThumbnailKind::Content; + } + ThumbnailKind::None } -fn image_source_matches_authenticated_badge(notification: &NotificationView) -> bool { +fn image_path_matches_authenticated_badge(notification: &NotificationView) -> bool { let badge = notification.attribution.badge_icon.trim(); if badge.is_empty() { return false; } - if notification.image.icon_name.trim() == badge { - return true; - } let image_path = notification.image.image_path.trim(); if image_path.is_empty() { return false; diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs index e44db502b..2e0a0a9b6 100644 --- a/crates/unixnotis-ui/src/presentation/mod.rs +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -5,7 +5,7 @@ mod build; mod text; mod types; -pub use badges::{build_semantic_badge, register_semantic_badges}; +pub use badges::{apply_semantic_badge, build_semantic_badge, register_semantic_badges}; pub use build::NotificationPresentation; pub use text::{ clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, diff --git a/crates/unixnotis-ui/src/presentation/tests/badges.rs b/crates/unixnotis-ui/src/presentation/tests/badges.rs index 73893a2db..80001432f 100644 --- a/crates/unixnotis-ui/src/presentation/tests/badges.rs +++ b/crates/unixnotis-ui/src/presentation/tests/badges.rs @@ -10,7 +10,13 @@ fn uncertain_identity_badges_load_from_controlled_resources() { ] { let image = build_semantic_badge(badge, 20).expect("semantic badge should exist"); - assert!(image.paintable().is_some(), "bundled badge should load"); + let icon_name = image.icon_name().expect("named badge icon"); + assert!(icon_name.starts_with("unixnotis-")); + let display = gtk::gdk::Display::default().expect("GTK display"); + assert!( + gtk::IconTheme::for_display(&display).has_icon(&icon_name), + "badge should resolve through the named symbolic icon theme" + ); assert_eq!(image.pixel_size(), 20); } } diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 48e0c8a5f..fa1e99336 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -68,12 +68,189 @@ fn shared_model_downgrades_conflicts_and_denies_application_interaction() { ); assert_eq!( presentation.identity.secondary_claim.as_deref(), - Some("Claims to be Known application") + Some("Claims “Known application”") ); assert!(presentation.actions.primary.is_empty()); assert!(presentation.actions.overflow.is_empty()); } +#[test] +fn trusted_relay_claim_never_becomes_the_primary_application_identity() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + true, + "relay:notify-send:signal".to_string(), + ); + view.image.icon_name = "signal-desktop".to_string(); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.kind, NotificationKind::Utility); + assert_eq!(presentation.trust.level, TrustLevel::CommandLine); + assert!(presentation.trust.short_label.is_none()); + assert_eq!( + presentation.identity.primary_label, + "Command-line notification" + ); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("App label: Signal") + ); + assert_eq!(presentation.identity.badge, BadgePresentation::CommandLine); + assert_eq!(presentation.media.thumbnail, ThumbnailKind::None); +} + +#[test] +fn unknown_claim_stays_secondary_and_unverified() { + let mut view = notification(); + view.attribution = NotificationAttribution::unknown( + "Local helper", + "Source: /tmp/local-helper", + "unknown:local-helper".to_string(), + ); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unverified); + assert_eq!(presentation.identity.primary_label, "Unknown application"); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("App label: Local helper") + ); +} + +#[test] +fn untrusted_non_media_notification_cannot_render_content_art() { + let mut view = notification(); + view.attribution = NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + false, + "relay:notify-send:signal".to_string(), + ); + view.image.image_path = "/tmp/signal-logo.png".to_string(); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::None + ); + + view.category = "image.received".to_string(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn popup_status_uses_the_committed_reason_instead_of_current_state() { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: unixnotis_core::PopupAdmissionView::RendererDisabled, + renderer_process_running_at_commit: true, + renderer_ready_at_commit: true, + max_visible_at_commit: 0, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + Some("Not shown — popups are disabled") + ); +} + +#[test] +fn popup_status_distinguishes_renderer_recovery_and_delivery_failure() { + for (stage, admission, expected) in [ + ( + unixnotis_core::PopupDeliveryStage::Rendered, + unixnotis_core::PopupAdmissionView::RendererUnavailable, + Some("Shown after popup renderer recovered"), + ), + ( + unixnotis_core::PopupDeliveryStage::RendererFetched, + unixnotis_core::PopupAdmissionView::RendererUnavailable, + Some("Not shown — popup renderer was unavailable"), + ), + ( + unixnotis_core::PopupDeliveryStage::FanoutFailed, + unixnotis_core::PopupAdmissionView::Show, + Some("Not shown — notification delivery failed"), + ), + ( + unixnotis_core::PopupDeliveryStage::Rendered, + unixnotis_core::PopupAdmissionView::Show, + None, + ), + ] { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: admission, + decided_at_unix_ms: 1_000, + delivery_stage: stage, + ..unixnotis_core::PopupDecisionRecord::default() + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + expected, + "stage={stage:?}, admission={admission:?}" + ); + } +} + +#[test] +fn empty_and_generic_claims_never_create_secondary_identity_copy() { + for claim in ["", "Unknown application"] { + let mut view = notification(); + view.attribution = NotificationAttribution::trusted_relay( + claim, + "Sent via /usr/bin/notify-send", + false, + format!("relay:notify-send:{claim}"), + ); + + assert!( + NotificationPresentation::from_view_at(&view, 1_000) + .identity + .secondary_claim + .is_none(), + "claim={claim:?}" + ); + } +} + +#[test] +fn verified_media_category_or_pixel_data_can_override_duplicate_badge_suppression() { + for (has_image_data, category) in [(false, "image.received"), (true, "")] { + let mut view = notification(); + view.attribution.badge_icon = "same-icon".to_string(); + view.image.image_path = "same-icon".to_string(); + view.image.has_image_data = has_image_data; + view.category = category.to_string(); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "has_image_data={has_image_data}, category={category:?}" + ); + } +} + #[test] fn shared_model_keeps_user_association_unverified_and_noninteractive() { let mut view = notification(); diff --git a/crates/unixnotis-ui/src/presentation/tests/support.rs b/crates/unixnotis-ui/src/presentation/tests/support.rs index a3db512b6..b053651ff 100644 --- a/crates/unixnotis-ui/src/presentation/tests/support.rs +++ b/crates/unixnotis-ui/src/presentation/tests/support.rs @@ -27,5 +27,6 @@ pub(super) fn notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 1_000, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index 343d4f0e0..e36dbaeb7 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -19,9 +19,9 @@ impl NotificationKind { #[must_use] pub const fn action_limit(self) -> usize { + // Two visible actions preserve room for content; remaining actions use overflow match self { - Self::Communication => 3, - Self::Utility | Self::Warning => 1, + Self::Communication | Self::Utility | Self::Warning => 2, } } @@ -41,7 +41,7 @@ pub enum TrustLevel { Verified, Unverified, Suspicious, - System, + CommandLine, } impl TrustLevel { @@ -51,7 +51,7 @@ impl TrustLevel { Self::Verified => "verified", Self::Unverified => "unverified", Self::Suspicious => "suspicious", - Self::System => "system", + Self::CommandLine => "command-line", } } } From 12ce76d17ddd9081149c39bb6a7cc4edcac68d88 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:24:19 -0500 Subject: [PATCH 136/275] refactor(popups): use one provenance identity slot Summary: use one provenance identity slot. Scope: popups. --- crates/unixnotis-popups/src/dbus/commands.rs | 23 +++- .../src/dbus/runtime/tests/delivery.rs | 1 + .../src/dbus/tests/commands.rs | 17 ++- .../unixnotis-popups/src/dbus/tests/types.rs | 13 ++- crates/unixnotis-popups/src/dbus/types.rs | 21 +++- crates/unixnotis-popups/src/ui/entry/build.rs | 34 ++++-- .../src/ui/entry/builders/common.rs | 70 +++++------- .../src/ui/entry/builders/communication.rs | 33 +++--- .../src/ui/entry/builders/mod.rs | 7 +- .../src/ui/entry/builders/reply/tests/mod.rs | 1 + .../src/ui/entry/builders/tests/common.rs | 73 ++++++++++-- .../src/ui/entry/builders/utility.rs | 37 +++--- .../src/ui/entry/builders/warning.rs | 33 ++---- .../unixnotis-popups/src/ui/entry/commands.rs | 2 +- crates/unixnotis-popups/src/ui/entry/mod.rs | 1 + .../src/ui/entry/presentation/tests/kind.rs | 6 +- .../ui/entry/presentation/tests/support.rs | 1 + .../src/ui/entry/presentation/tests/trust.rs | 4 +- .../ui/entry/presentation/tests/view_model.rs | 21 ++-- .../src/ui/entry/tests/build.rs | 10 +- .../src/ui/entry/tests/commands.rs | 17 ++- .../src/ui/icons/tests/resolver/support.rs | 1 + .../src/ui/popups/mutation.rs | 5 +- .../src/ui/popups/tests/reconcile.rs | 1 + .../src/ui/state/tests/constructor.rs | 107 ++++++++++++++++++ .../src/ui/state/tests/mutation.rs | 82 +++++++++++++- 26 files changed, 457 insertions(+), 164 deletions(-) diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index 45e19618d..b0c19e7be 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -9,9 +9,20 @@ use super::types::UiCommand; pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> ZbusResult<()> { match command { - UiCommand::Dismiss(id) => timed_dbus_call(proxy.dismiss(id)).await, - UiCommand::InvokeAction { id, action_key } => { - timed_dbus_call(proxy.invoke_action(id, &action_key)).await + UiCommand::Dismiss(notification) => { + timed_dbus_call(proxy.dismiss_generation(notification.id, notification.generation)) + .await + } + UiCommand::InvokeAction { + notification, + action_key, + } => { + timed_dbus_call(proxy.invoke_action_generation( + notification.id, + notification.generation, + &action_key, + )) + .await } UiCommand::Reply { id, @@ -24,6 +35,10 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu let _ = outcome.send(reply_result); result } + UiCommand::Rendered(notification) => { + timed_dbus_call(proxy.mark_popup_rendered(notification.id, notification.generation)) + .await + } UiCommand::Shutdown(_) => Ok(()), } } @@ -37,7 +52,7 @@ pub fn drain_offline_commands( UiCommand::Reply { outcome, .. } => { let _ = outcome.send(Err("notification service is unavailable".to_string())); } - UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } => {} + UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } | UiCommand::Rendered(_) => {} } // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs index 775b7a3b3..113d1b38b 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -20,6 +20,7 @@ fn candidate(generation: u64, admission: PopupAdmissionView) -> PopupCandidate { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }, admission, } diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index 7838f61b5..3db9bad4b 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -1,4 +1,5 @@ use tokio::sync::mpsc; +use unixnotis_core::NotificationKey; use super::drain_offline_commands; use crate::dbus::UiCommand; @@ -6,10 +7,16 @@ use crate::dbus::UiCommand; #[test] fn drain_offline_commands_removes_all_queued_commands() { let (tx, mut rx) = mpsc::channel(4); - tx.try_send(UiCommand::Dismiss(10)) - .expect("dismiss command should queue"); + tx.try_send(UiCommand::Dismiss(NotificationKey { + id: 10, + generation: 12, + })) + .expect("dismiss command should queue"); tx.try_send(UiCommand::InvokeAction { - id: 11, + notification: NotificationKey { + id: 11, + generation: 13, + }, action_key: "default".to_string(), }) .expect("action command should queue"); @@ -48,7 +55,7 @@ fn drain_offline_commands_returns_shutdown_acknowledgement() { #[test] fn drain_offline_commands_reports_reply_delivery_failure() { let (tx, mut rx) = mpsc::channel(1); - let (outcome, result) = tokio::sync::oneshot::channel(); + let (outcome, mut result) = tokio::sync::oneshot::channel(); tx.try_send(UiCommand::Reply { id: 10, generation: 12, @@ -59,7 +66,7 @@ fn drain_offline_commands_reports_reply_delivery_failure() { assert!(drain_offline_commands(&mut rx).is_none()); assert_eq!( - result.blocking_recv().expect("reply result"), + result.try_recv().expect("reply result should be ready"), Err("notification service is unavailable".to_string()) ); } diff --git a/crates/unixnotis-popups/src/dbus/tests/types.rs b/crates/unixnotis-popups/src/dbus/tests/types.rs index 694cbe1b0..39ef3b1b3 100644 --- a/crates/unixnotis-popups/src/dbus/tests/types.rs +++ b/crates/unixnotis-popups/src/dbus/tests/types.rs @@ -1,8 +1,17 @@ use super::{UiCommand, UiEvent}; +use unixnotis_core::NotificationKey; #[test] -fn dismiss_command_preserves_notification_id() { - assert!(matches!(UiCommand::Dismiss(17), UiCommand::Dismiss(17))); +fn dismiss_command_preserves_notification_generation() { + let notification = NotificationKey { + id: 17, + generation: 23, + }; + + assert!(matches!( + UiCommand::Dismiss(notification), + UiCommand::Dismiss(key) if key == notification + )); } #[test] diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 85b8f38f7..12c1430f2 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -25,9 +25,9 @@ pub enum UiEvent { /// Commands sent from GTK handlers to the D-Bus runtime pub enum UiCommand { - Dismiss(u32), + Dismiss(NotificationKey), InvokeAction { - id: u32, + notification: NotificationKey, action_key: String, }, Reply { @@ -36,6 +36,7 @@ pub enum UiCommand { text: String, outcome: tokio::sync::oneshot::Sender>, }, + Rendered(NotificationKey), // A synchronous acknowledgement lets GTK wait for MarkPopupsNotReady before process exit Shutdown(std::sync::mpsc::SyncSender<()>), } @@ -43,10 +44,16 @@ pub enum UiCommand { impl std::fmt::Debug for UiCommand { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Dismiss(id) => formatter.debug_tuple("Dismiss").field(id).finish(), - Self::InvokeAction { id, action_key } => formatter + Self::Dismiss(notification) => formatter + .debug_tuple("Dismiss") + .field(notification) + .finish(), + Self::InvokeAction { + notification, + action_key, + } => formatter .debug_struct("InvokeAction") - .field("id", id) + .field("notification", notification) .field("action_key", action_key) .finish(), Self::Reply { id, generation, .. } => formatter @@ -56,6 +63,10 @@ impl std::fmt::Debug for UiCommand { // Reply text is private message content and must never enter debug logs .field("text", &"") .finish_non_exhaustive(), + Self::Rendered(notification) => formatter + .debug_tuple("Rendered") + .field(notification) + .finish(), Self::Shutdown(_) => formatter.write_str("Shutdown(..)"), } } diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index ca5365760..88b95bdda 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -59,23 +59,35 @@ impl UiState { let view = PopupEntryViewModel::for_notification(notification); let root = build_card_root(self, &view); let close = build_close_button(); - let rendered = build_popup_content(self, notification, &view, &close); + let rendered = build_popup_content(self, notification, &view); + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + content.set_hexpand(true); // Builder results feed stable state classes used by user themes set_class_state(&root, hooks::popup_card::HAS_ICON, rendered.has_icon); set_class_state(&root, hooks::popup_card::NO_ICON, !rendered.has_icon); set_class_state(&root, hooks::popup_card::HAS_IMAGE, rendered.has_image); - root.append(&rendered.widget); + content.append(&rendered.widget); if let Some(reply) = build_inline_reply(notification, &view, &self.command_tx) { - root.append(&reply); + content.append(&reply); } - if let Some(actions) = build_action_row(&self.command_tx, notification.id, &view) { - root.append(&actions); + if let Some(actions) = build_action_row(&self.command_tx, notification.key(), &view) { + content.append(&actions); } - connect_close_action(&close, notification.id, &self.command_tx); - connect_default_action(&root, notification.id, &view, &self.command_tx); + // The close control floats above content and never consumes metadata width + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&content)); + close.set_halign(gtk::Align::End); + close.set_valign(gtk::Align::Start); + close.set_margin_top(2); + close.set_margin_end(2); + overlay.add_overlay(&close); + root.append(&overlay); + + connect_close_action(&close, notification.key(), &self.command_tx); + connect_default_action(&root, notification.key(), &view, &self.command_tx); root } @@ -154,19 +166,19 @@ fn build_card_root(state: &UiState, view: &PopupEntryViewModel) -> gtk::Box { fn connect_close_action( close: >k::Button, - notification_id: u32, + notification: unixnotis_core::NotificationKey, command_tx: &tokio::sync::mpsc::Sender, ) { let command_tx = command_tx.clone(); close.connect_clicked(move |_| { // Dismissal remains independent from application-owned action policy - try_send_command(&command_tx, UiCommand::Dismiss(notification_id)); + try_send_command(&command_tx, UiCommand::Dismiss(notification)); }); } fn connect_default_action( root: >k::Box, - notification_id: u32, + notification: unixnotis_core::NotificationKey, view: &PopupEntryViewModel, command_tx: &tokio::sync::mpsc::Sender, ) { @@ -196,7 +208,7 @@ fn connect_default_action( try_send_command( &tx, UiCommand::InvokeAction { - id: notification_id, + notification, action_key: action_key.clone(), }, ); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 6d1baa794..f88b348ee 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -11,35 +11,37 @@ use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, Re use crate::dbus::UiCommand; use crate::ui::UiState; -pub(super) struct IdentityHeader { +pub(super) struct IdentityAvatar { pub(super) widget: gtk::Box, - pub(super) has_icon: bool, } -pub(super) fn build_identity_header( +pub(super) fn build_identity_avatar( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, - close: >k::Button, - app_icon_size: Option, -) -> IdentityHeader { + size: i32, +) -> Option { + let icon_size = (size - 14).max(18); + let icon = build_semantic_badge(view.badge, icon_size) + .or_else(|| state.build_app_icon_widget(notification, icon_size))?; + icon.set_valign(Align::Center); + icon.set_halign(Align::Center); + icon.add_css_class("unixnotis-popup-icon"); + + let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar.set_size_request(size, size); + avatar.set_halign(Align::Start); + avatar.set_valign(Align::Start); + avatar.add_css_class("unixnotis-identity-avatar"); + avatar.add_css_class(view.trust.level.css_class()); + avatar.append(&icon); + Some(IdentityAvatar { widget: avatar }) +} + +pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> gtk::Box { let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); header.add_css_class("unixnotis-popup-header-row"); - - let mut has_icon = false; - if let Some(size) = app_icon_size { - let icon = build_semantic_badge(view.badge, size) - .or_else(|| state.build_app_icon_widget(notification, size)); - if let Some(icon) = icon { - // Only daemon-associated badge inputs reach the quiet identity header - icon.set_valign(Align::Center); - icon.set_halign(Align::Start); - icon.add_css_class("unixnotis-popup-icon"); - icon.add_css_class("unixnotis-popup-app-icon"); - header.append(&icon); - has_icon = true; - } - } + header.set_margin_end(30); let app = gtk::Label::new(Some(&view.app_label)); app.set_xalign(0.0); @@ -63,12 +65,7 @@ pub(super) fn build_identity_header( time.set_single_line_mode(true); time.add_css_class("unixnotis-popup-time"); header.append(&time); - header.append(close); - - IdentityHeader { - widget: header, - has_icon, - } + header } pub(super) fn build_title_label(view: &PopupEntryViewModel) -> Option { @@ -129,7 +126,7 @@ pub(in crate::ui::entry) fn build_close_button() -> gtk::Button { pub(in crate::ui::entry) fn build_action_row( command_tx: &tokio::sync::mpsc::Sender, - notification_id: u32, + notification: unixnotis_core::NotificationKey, view: &PopupEntryViewModel, ) -> Option { if view.primary_actions.is_empty() && view.overflow_actions.is_empty() { @@ -139,22 +136,17 @@ pub(in crate::ui::entry) fn build_action_row( let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); actions.add_css_class("unixnotis-popup-actions"); for action in &view.primary_actions { - actions.append(&build_action_button( - command_tx, - notification_id, - action, - None, - )); + actions.append(&build_action_button(command_tx, notification, action, None)); } if !view.overflow_actions.is_empty() { - actions.append(&build_overflow_menu(command_tx, notification_id, view)); + actions.append(&build_overflow_menu(command_tx, notification, view)); } Some(actions) } fn build_action_button( command_tx: &tokio::sync::mpsc::Sender, - notification_id: u32, + notification: unixnotis_core::NotificationKey, action: &super::super::presentation::ActionViewModel, popover: Option<>k::Popover>, ) -> gtk::Button { @@ -171,7 +163,7 @@ fn build_action_button( try_send_command( &tx, UiCommand::InvokeAction { - id: notification_id, + notification, action_key: action_key.clone(), }, ); @@ -181,7 +173,7 @@ fn build_action_button( fn build_overflow_menu( command_tx: &tokio::sync::mpsc::Sender, - notification_id: u32, + notification: unixnotis_core::NotificationKey, view: &PopupEntryViewModel, ) -> gtk::MenuButton { let menu = gtk::MenuButton::new(); @@ -195,7 +187,7 @@ fn build_overflow_menu( for action in &view.overflow_actions { list.append(&build_action_button( command_tx, - notification_id, + notification, action, Some(&popover), )); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs index 4f9af1677..637f292b2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs @@ -3,31 +3,35 @@ use gtk::prelude::*; use unixnotis_core::NotificationView; -use super::common::{build_body_label, build_identity_header, build_reply_note, build_title_label}; +use super::common::{ + build_body_label, build_identity_avatar, build_identity_header, build_reply_note, + build_secondary_claim, build_title_label, +}; use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const COMMUNICATION_APP_ICON_SIZE: i32 = 20; +const COMMUNICATION_AVATAR_SIZE: i32 = 44; pub(super) fn build_communication_popup( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, - close: >k::Button, ) -> RenderedPopup { + let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); + main.add_css_class("unixnotis-popup-communication-content"); + let avatar = build_identity_avatar(state, notification, view, COMMUNICATION_AVATAR_SIZE); + if let Some(avatar) = avatar.as_ref() { + main.append(&avatar.widget); + } let content = gtk::Box::new(gtk::Orientation::Vertical, 3); - content.add_css_class("unixnotis-popup-communication-content"); + content.set_hexpand(true); // Communication cards read as app identity, sender, then message preview - let header = build_identity_header( - state, - notification, - view, - close, - Some(COMMUNICATION_APP_ICON_SIZE), - ); - content.append(&header.widget); + content.append(&build_identity_header(view)); + if let Some(claim) = build_secondary_claim(view) { + content.append(&claim); + } if let Some(title) = build_title_label(view) { content.append(&title); } @@ -38,10 +42,11 @@ pub(super) fn build_communication_popup( if let Some(note) = build_reply_note(view) { content.append(¬e); } + main.append(&content); RenderedPopup { - widget: content, - has_icon: header.has_icon, + widget: main, + has_icon: avatar.is_some(), has_image, } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 99214bef0..f6c78d6a6 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -26,15 +26,14 @@ pub(super) fn build_popup_content( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, - close: >k::Button, ) -> RenderedPopup { // Each layout owns its structure so future changes do not grow one conditional builder match view.kind { PopupKind::Communication => { - communication::build_communication_popup(state, notification, view, close) + communication::build_communication_popup(state, notification, view) } - PopupKind::Utility => utility::build_utility_popup(state, notification, view, close), - PopupKind::Warning => warning::build_warning_popup(state, notification, view, close), + PopupKind::Utility => utility::build_utility_popup(state, notification, view), + PopupKind::Warning => warning::build_warning_popup(state, notification, view), } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs index 794467c69..b2d81af7e 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs @@ -101,5 +101,6 @@ fn notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 1_000, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 1407ae31c..e54b4185d 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -1,6 +1,6 @@ use super::{ - build_action_row, build_body_label, build_close_button, build_header_spacer, build_reply_note, - build_title_label, build_urgency_badge, + build_action_row, build_body_label, build_close_button, build_header_spacer, + build_identity_avatar, build_reply_note, build_title_label, build_urgency_badge, }; use gtk::prelude::*; use unixnotis_core::{ @@ -10,6 +10,9 @@ use unixnotis_core::{ use crate::dbus::UiCommand; use crate::ui::entry::presentation::{PopupEntryViewModel, ReplyPresentation}; +use crate::ui::UiState; +use unixnotis_core::{Config, ThemePaths}; +use unixnotis_ui::css::CssManager; #[gtk::test] fn popup_critical_badge_uses_shared_hook_and_visibility() { @@ -71,7 +74,8 @@ fn close_button_and_header_spacer_keep_their_interaction_contracts() { fn action_row_dispatches_the_prepared_action_identity() { let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); let view = view_model_with_action(); - let row = build_action_row(&command_tx, 41, &view).expect("action row"); + let notification = notification(); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); let button = row .first_child() .and_downcast::() @@ -80,8 +84,12 @@ fn action_row_dispatches_the_prepared_action_identity() { button.emit_clicked(); match command_rx.try_recv().expect("queued action command") { - UiCommand::InvokeAction { id, action_key } => { - assert_eq!(id, 41); + UiCommand::InvokeAction { + notification, + action_key, + } => { + assert_eq!(notification.id, 41); + assert_eq!(notification.generation, 3); assert_eq!(action_key, "default"); } command => panic!("unexpected command: {command:?}"), @@ -101,9 +109,13 @@ fn extra_safe_action_builds_a_compact_overflow_menu() { key: "folder".to_string(), label: "Open folder".to_string(), }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, ]; let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); - let row = build_action_row(&command_tx, 41, &view).expect("action row"); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); let menu = row .last_child() .and_downcast::() @@ -116,7 +128,42 @@ fn extra_safe_action_builds_a_compact_overflow_menu() { #[gtk::test] fn empty_action_model_does_not_build_an_action_row() { let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); - assert!(build_action_row(&command_tx, 41, &view_model()).is_none()); + assert!(build_action_row(&command_tx, notification().key(), &view_model()).is_none()); +} + +#[gtk::test] +fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupAvatarSizing") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register avatar sizing application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-avatar-sizing"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.attribution = NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + false, + "relay:notify-send:signal".to_string(), + ); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36) + .expect("relay avatar should use a semantic badge"); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar should contain one image"); + + assert_eq!(avatar.widget.width_request(), 36); + assert_eq!(avatar.widget.height_request(), 36); + assert_eq!(icon.pixel_size(), 22); } fn view_model() -> PopupEntryViewModel { @@ -156,5 +203,17 @@ fn notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 1_000, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + } +} + +fn theme_paths(root: &std::path::Path) -> ThemePaths { + ThemePaths { + base_dir: root.to_path_buf(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs index 6b24cd414..1b751a393 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -1,44 +1,37 @@ //! Compact utility popup for device, transfer, clipboard, and generic events use gtk::prelude::*; -use gtk::Align; use unixnotis_core::NotificationView; -use unixnotis_ui::presentation::build_semantic_badge; -use super::common::{build_body_label, build_identity_header, build_title_label}; +use super::common::{ + build_body_label, build_identity_avatar, build_identity_header, build_secondary_claim, + build_title_label, +}; use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const UTILITY_ICON_SIZE: i32 = 24; +const UTILITY_AVATAR_SIZE: i32 = 36; pub(super) fn build_utility_popup( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, - close: >k::Button, ) -> RenderedPopup { - let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); + let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); main.add_css_class("unixnotis-popup-utility-content"); - let icon = build_semantic_badge(view.badge, UTILITY_ICON_SIZE) - .or_else(|| state.build_app_icon_widget(notification, UTILITY_ICON_SIZE)); - let has_icon = if let Some(icon) = icon { - // Utility symbols support scanning without becoming the card's dominant object - icon.set_halign(Align::Start); - icon.set_valign(Align::Start); - icon.add_css_class("unixnotis-popup-icon"); - icon.add_css_class("unixnotis-popup-utility-icon"); - main.append(&icon); - true - } else { - false - }; + let avatar = build_identity_avatar(state, notification, view, UTILITY_AVATAR_SIZE); + if let Some(avatar) = avatar.as_ref() { + main.append(&avatar.widget); + } let content = gtk::Box::new(gtk::Orientation::Vertical, 2); content.set_hexpand(true); - let header = build_identity_header(state, notification, view, close, None); - content.append(&header.widget); + content.append(&build_identity_header(view)); + if let Some(claim) = build_secondary_claim(view) { + content.append(&claim); + } if let Some(title) = build_title_label(view) { content.append(&title); } @@ -50,7 +43,7 @@ pub(super) fn build_utility_popup( RenderedPopup { widget: main, - has_icon, + has_icon: avatar.is_some(), has_image, } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs index 2e7807cab..5be5d091f 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs @@ -1,47 +1,34 @@ //! Restrained warning popup for conflicting application identity use gtk::prelude::*; -use gtk::Align; use unixnotis_core::NotificationView; -use unixnotis_ui::presentation::build_semantic_badge; use super::common::{ - build_body_label, build_identity_header, build_reply_note, build_secondary_claim, - build_title_label, + build_body_label, build_identity_avatar, build_identity_header, build_reply_note, + build_secondary_claim, build_title_label, }; use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const WARNING_ICON_SIZE: i32 = 20; +const WARNING_AVATAR_SIZE: i32 = 36; pub(super) fn build_warning_popup( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, - close: >k::Button, ) -> RenderedPopup { - let main = gtk::Box::new(gtk::Orientation::Horizontal, 10); + let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); main.add_css_class("unixnotis-popup-warning-content"); - let icon = build_semantic_badge(view.badge, WARNING_ICON_SIZE) - .or_else(|| state.build_app_icon_widget(notification, WARNING_ICON_SIZE)); - let has_icon = if let Some(icon) = icon { - // Conflict attribution supplies a daemon-owned generic badge instead of claimed branding - icon.set_halign(Align::Start); - icon.set_valign(Align::Start); - icon.add_css_class("unixnotis-popup-icon"); - icon.add_css_class("unixnotis-popup-warning-icon"); - main.append(&icon); - true - } else { - false - }; + let avatar = build_identity_avatar(state, notification, view, WARNING_AVATAR_SIZE); + if let Some(avatar) = avatar.as_ref() { + main.append(&avatar.widget); + } let content = gtk::Box::new(gtk::Orientation::Vertical, 3); content.set_hexpand(true); - let header = build_identity_header(state, notification, view, close, None); - content.append(&header.widget); + content.append(&build_identity_header(view)); if let Some(claim) = build_secondary_claim(view) { content.append(&claim); } @@ -59,7 +46,7 @@ pub(super) fn build_warning_popup( RenderedPopup { widget: main, - has_icon, + has_icon: avatar.is_some(), has_image, } } diff --git a/crates/unixnotis-popups/src/ui/entry/commands.rs b/crates/unixnotis-popups/src/ui/entry/commands.rs index bb117a90f..4f89f6de2 100644 --- a/crates/unixnotis-popups/src/ui/entry/commands.rs +++ b/crates/unixnotis-popups/src/ui/entry/commands.rs @@ -8,7 +8,7 @@ use tracing::debug; use crate::dbus::UiCommand; -pub(super) fn try_send_command(tx: &Sender, command: UiCommand) { +pub(in crate::ui) fn try_send_command(tx: &Sender, command: UiCommand) { // GTK click handlers must stay non-blocking even when the runtime queue is saturated match tx.try_send(command) { // Fast path for normal queue availability diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index 76744541e..3d92e2dfb 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -6,3 +6,4 @@ mod commands; mod presentation; pub(in crate::ui) use build::PopupEntry; +pub(in crate::ui) use commands::try_send_command; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs index a7bb70af7..dca540145 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs @@ -83,7 +83,7 @@ fn either_reply_contract_selects_the_communication_layout() { #[test] fn each_popup_kind_keeps_its_intended_action_budget() { - assert_eq!(PopupKind::Communication.action_limit(), 3); - assert_eq!(PopupKind::Utility.action_limit(), 1); - assert_eq!(PopupKind::Warning.action_limit(), 1); + assert_eq!(PopupKind::Communication.action_limit(), 2); + assert_eq!(PopupKind::Utility.action_limit(), 2); + assert_eq!(PopupKind::Warning.action_limit(), 2); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs index 113a0856f..76c5dbbeb 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs @@ -27,5 +27,6 @@ pub(super) fn notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 1_000, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index b697ae6c9..c30ec99ba 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -33,8 +33,8 @@ fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!(trust.level, TrustLevel::System); - assert_eq!(trust.short_label.as_deref(), Some("Command-line tool")); + assert_eq!(trust.level, TrustLevel::CommandLine); + assert!(trust.short_label.is_none()); assert_eq!( trust.details_label.as_deref(), Some("Sent via /usr/bin/notify-send") diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index a110b7ba5..573cc1e3a 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -49,15 +49,19 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { key: "folder".to_string(), label: "Open folder".to_string(), }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, ]; let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Utility); - assert_eq!(model.primary_actions.len(), 1); + assert_eq!(model.primary_actions.len(), 2); assert_eq!(model.primary_actions[0].key, "default"); assert_eq!(model.overflow_actions.len(), 1); - assert_eq!(model.overflow_actions[0].key, "folder"); + assert_eq!(model.overflow_actions[0].key, "archive"); } #[test] @@ -135,7 +139,7 @@ fn thumbnail_requires_real_image_data_or_a_nonempty_path() { } #[test] -fn either_badge_source_match_suppresses_duplicate_decoration() { +fn app_icon_name_never_suppresses_real_content_image_data() { let mut icon_match = notification(); icon_match.attribution.badge_icon = "example".to_string(); icon_match.image.has_image_data = true; @@ -147,11 +151,11 @@ fn either_badge_source_match_suppresses_duplicate_decoration() { }; assert_eq!( PopupEntryViewModel::for_notification_at(&icon_match, 1_000).thumbnail, - ThumbnailKind::None + ThumbnailKind::Content ); - let mut path_match = icon_match; - path_match.image.icon_name = "different".to_string(); + let mut path_match = notification(); + path_match.attribution.badge_icon = "example".to_string(); path_match.image.image_path = "example".to_string(); assert_eq!( PopupEntryViewModel::for_notification_at(&path_match, 1_000).thumbnail, @@ -222,10 +226,7 @@ fn conflicting_claim_uses_warning_layout_and_drops_actions() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Warning); - assert_eq!( - model.secondary_claim.as_deref(), - Some("Claims to be Signal") - ); + assert_eq!(model.secondary_claim.as_deref(), Some("Claims “Signal”")); assert!(model.primary_actions.is_empty()); assert!(model.overflow_actions.is_empty()); } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index 8b6d9cd3a..54a984dd9 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -25,12 +25,13 @@ fn default_card_action_is_allowed_for_plain_content_widgets() { fn close_button_dispatches_only_the_notification_dismissal() { let close = gtk::Button::new(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); - connect_close_action(&close, 31, &command_tx); + let notification = notification(); + connect_close_action(&close, notification.key(), &command_tx); close.emit_clicked(); match command_rx.try_recv().expect("queued dismiss command") { - UiCommand::Dismiss(id) => assert_eq!(id, 31), + UiCommand::Dismiss(key) => assert_eq!(key, notification.key()), command => panic!("unexpected command: {command:?}"), } } @@ -46,7 +47,7 @@ fn exact_default_action_adds_card_click_handling() { }); let model = PopupEntryViewModel::for_notification_at(&view, 1_000); - connect_default_action(&root, view.id, &model, &command_tx); + connect_default_action(&root, view.key(), &model, &command_tx); assert_eq!(root.observe_controllers().n_items(), 1); } @@ -62,7 +63,7 @@ fn nondefault_action_does_not_make_the_whole_card_clickable() { }); let model = PopupEntryViewModel::for_notification_at(&view, 1_000); - connect_default_action(&root, view.id, &model, &command_tx); + connect_default_action(&root, view.key(), &model, &command_tx); assert_eq!(root.observe_controllers().n_items(), 0); } @@ -91,5 +92,6 @@ fn notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 1_000, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs index d4beda4ea..fb08103c0 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs @@ -1,13 +1,21 @@ use super::try_send_command; use crate::dbus::UiCommand; +use unixnotis_core::NotificationKey; #[test] fn available_command_queue_receives_dismiss_without_delay() { let (tx, mut rx) = tokio::sync::mpsc::channel(1); - try_send_command(&tx, UiCommand::Dismiss(42)); + let notification = NotificationKey { + id: 42, + generation: 5, + }; + try_send_command(&tx, UiCommand::Dismiss(notification)); - assert!(matches!(rx.try_recv(), Ok(UiCommand::Dismiss(42)))); + assert!(matches!( + rx.try_recv(), + Ok(UiCommand::Dismiss(key)) if key == notification + )); } #[test] @@ -19,7 +27,10 @@ fn closed_command_queue_drops_action_without_panicking() { try_send_command( &tx, UiCommand::InvokeAction { - id: 7, + notification: NotificationKey { + id: 7, + generation: 9, + }, action_key: "open".to_string(), }, ); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index 8278fb724..850e17281 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -23,5 +23,6 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView icon_name: icon_name.to_string(), ..NotificationImage::default() }, + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index b0db8dffe..6f18b1f95 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -5,9 +5,10 @@ use tracing::debug; use unixnotis_core::{NotificationKey, NotificationView}; use unixnotis_ui::CutCorner; -use super::super::entry::PopupEntry; +use super::super::entry::{try_send_command, PopupEntry}; use super::super::window::refresh_popup_input_region; use super::super::UiState; +use crate::dbus::UiCommand; pub(super) struct ReconcilePlan { // Local rows missing from the daemon snapshot @@ -197,6 +198,7 @@ impl UiState { if let Some(entry) = self.popups.get_mut(&id) { entry.root = Some(new_root); } + try_send_command(&self.command_tx, UiCommand::Rendered(notification.key())); rebuilt_visible_row } @@ -213,6 +215,7 @@ impl UiState { // Swap in the fresh GTK nodes while keeping the cached payload untouched entry.revealer = built.revealer; entry.root = built.root; + try_send_command(&self.command_tx, UiCommand::Rendered(notification.key())); } pub(super) fn dematerialize_popup(&mut self, id: u32) { diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index e61bd5a39..a6bf38ed2 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -22,6 +22,7 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 4c2d10f14..2919d60dd 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -45,6 +45,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let entry = state.build_popup_entry(¬ification); @@ -93,6 +94,7 @@ fn default_popup_entry_uses_the_native_rounded_card_without_a_clipper() { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let entry = state.build_popup_entry(¬ification); @@ -164,6 +166,7 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let root = state.build_popup_root(¬ification); @@ -222,6 +225,7 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let root = state.build_popup_root(¬ification); @@ -273,6 +277,7 @@ fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let root = state.build_popup_root(¬ification); @@ -280,12 +285,87 @@ fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { assert!(root.has_css_class("warning")); assert!(root.has_css_class("suspicious")); assert!(visible_descendant_has_text(root.upcast_ref(), "Suspicious")); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "Claims “Signal”" + )); assert!(!visible_descendant_has_text( root.upcast_ref(), "application claim mismatch; source /tmp/fake" )); } +#[gtk::test] +fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupRelayProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup relay probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-relay-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let mut notification = NotificationView { + id: 5, + generation: 5, + app_name: "Signal".to_string(), + attribution: unixnotis_core::NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + true, + "relay:notify-send:signal".to_string(), + ), + summary: "John Doe".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + }; + notification.image.icon_name = "signal-desktop".to_string(); + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("command-line")); + assert!(root.has_css_class("utility")); + assert!(!root.has_css_class("suspicious")); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "Command-line notification" + )); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "App label: Signal" + )); + assert_eq!( + visible_descendant_class_count(root.upcast_ref(), "unixnotis-identity-avatar"), + 1 + ); + assert!(!visible_descendant_has_class( + root.upcast_ref(), + "unixnotis-popup-content-image" + )); + let close = descendant_with_class(root.upcast_ref(), "unixnotis-popup-close") + .expect("overlay close control"); + assert!(close + .parent() + .is_some_and(|parent| parent.is::())); +} + fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { let mut child = widget.first_child(); while let Some(current) = child { @@ -316,3 +396,30 @@ fn visible_descendant_has_text(widget: >k::Widget, expected: &str) -> bool { } false } + +fn visible_descendant_class_count(widget: >k::Widget, class_name: &str) -> usize { + let mut count = 0; + let mut child = widget.first_child(); + while let Some(current) = child { + if current.get_visible() && current.has_css_class(class_name) { + count += 1; + } + count += visible_descendant_class_count(¤t, class_name); + child = current.next_sibling(); + } + count +} + +fn descendant_with_class(widget: >k::Widget, class_name: &str) -> Option { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.has_css_class(class_name) { + return Some(current); + } + if let Some(found) = descendant_with_class(¤t, class_name) { + return Some(found); + } + child = current.next_sibling(); + } + None +} diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 6f61e7d2e..c3be57133 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -128,6 +128,53 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { assert!(state.build_app_icon_widget(&missing_content, 20).is_some()); } +#[gtk::test] +fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupMaterialization", 1); + let original = notification(21, 1, "original"); + + state.add_popup(original.clone()); + let original_entry = state + .popups + .get(&original.id) + .expect("visible popup should be stored"); + assert!(original_entry.is_materialized()); + assert_eq!(state.visible_popups, vec![original.id]); + let original_root = original_entry + .root + .clone() + .expect("visible popup should have a root"); + assert!(original_root.is_visible()); + assert_rendered_command(&mut command_rx, original.key()); + + let replacement = notification(21, 2, "replacement"); + state.update_popup(replacement.clone(), true); + let replacement_root = state + .popups + .get(&replacement.id) + .and_then(|entry| entry.root.clone()) + .expect("replacement popup should have a root"); + assert_ne!(original_root, replacement_root); + assert!(descendant_has_text( + replacement_root.upcast_ref(), + "replacement" + )); + assert_rendered_command(&mut command_rx, replacement.key()); +} + +fn assert_rendered_command( + command_rx: &mut tokio::sync::mpsc::Receiver, + expected: NotificationKey, +) { + match command_rx.try_recv().expect("render acknowledgement") { + crate::dbus::UiCommand::Rendered(notification) => { + assert_eq!(notification, expected); + } + command => panic!("unexpected command: {command:?}"), + } +} + fn descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { let mut child = widget.first_child(); while let Some(current) = child { @@ -139,7 +186,31 @@ fn descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { false } +fn descendant_has_text(widget: >k::Widget, expected: &str) -> bool { + if widget + .downcast_ref::() + .is_some_and(|label| label.text().as_str() == expected) + { + return true; + } + let mut child = widget.first_child(); + while let Some(current) = child { + if descendant_has_text(¤t, expected) { + return true; + } + child = current.next_sibling(); + } + false +} + fn popup_state(application_id: &str) -> UiState { + popup_state_with_commands(application_id, 0).0 +} + +fn popup_state_with_commands( + application_id: &str, + max_visible: usize, +) -> (UiState, tokio::sync::mpsc::Receiver) { let app = gtk::Application::builder() .application_id(application_id) .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -147,13 +218,15 @@ fn popup_state(application_id: &str) -> UiState { app.register(None::<>k::gio::Cancellable>) .expect("register popup mutation application"); let mut config = Config::default(); - // Queued-only rows keep the state test independent of compositor animation timing - config.popups.max_visible = 0; + config.popups.max_visible = max_visible; let root = std::env::temp_dir().join("unixnotis-popup-mutation"); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(4); let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); - UiState::new(&app, config, root.join("config.toml"), command_tx, css) + ( + UiState::new(&app, config, root.join("config.toml"), command_tx, css), + command_rx, + ) } fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { @@ -172,5 +245,6 @@ fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } From 5a40a1013d5e0b79648d104826040dd6a627c70d Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:25:25 -0500 Subject: [PATCH 137/275] refactor(panel): present complete group identity Summary: present complete group identity. Scope: panel. --- .../unixnotis-center/src/control/commands.rs | 19 ++- crates/unixnotis-center/src/control/model.rs | 16 +- .../src/control/tests/client.rs | 13 +- .../src/control/tests/commands.rs | 11 +- .../src/control/tests/events.rs | 1 + .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 1 + .../src/ui/notifications/model/item.rs | 11 ++ .../src/ui/notifications/model/tests/item.rs | 1 + .../src/ui/notifications/row/group.rs | 96 ++++++++--- .../notifications/row/notification/build.rs | 98 ++++++++--- .../notifications/row/notification/state.rs | 17 +- .../row/notification/tests/support.rs | 12 ++ .../row/notification/update/actions.rs | 91 +++++++--- .../row/notification/update/labels.rs | 2 + .../row/notification/update/metadata.rs | 16 +- .../row/notification/update/row.rs | 35 +++- .../row/notification/update/tests/actions.rs | 72 +++++++- .../row/notification/update/tests/state.rs | 156 +++++++++++++++++- .../row/notification/update/visual.rs | 4 +- .../src/ui/notifications/row/tests/group.rs | 72 +++++++- .../src/ui/notifications/store/blocks.rs | 9 +- .../src/ui/notifications/store/lifecycle.rs | 8 +- .../ui/notifications/store/tests/blocks.rs | 13 ++ .../ui/notifications/store/tests/lifecycle.rs | 17 ++ .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + 27 files changed, 675 insertions(+), 120 deletions(-) diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index f7f27c165..6847df498 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -20,9 +20,20 @@ pub async fn handle_command( ) -> ZbusResult<()> { match command { // Per-row actions still map straight to the daemon methods - UiCommand::Dismiss(id) => timed_dbus_call(proxy.dismiss(id)).await, - UiCommand::InvokeAction { id, action_key } => { - timed_dbus_call(proxy.invoke_action(id, &action_key)).await + UiCommand::Dismiss(notification) => { + timed_dbus_call(proxy.dismiss_generation(notification.id, notification.generation)) + .await + } + UiCommand::InvokeAction { + notification, + action_key, + } => { + timed_dbus_call(proxy.invoke_action_generation( + notification.id, + notification.generation, + &action_key, + )) + .await } UiCommand::Reply { id, @@ -138,7 +149,7 @@ pub async fn flush_offline_commands( } pub fn drop_stale_offline_commands(offline: &mut VecDeque) { - // Drop ID-based commands after reconnect to avoid acting on stale IDs + // Drop notification-key commands after reconnect because daemon generations are process-local // Commands that do not depend on old notification ids are kept let before = offline.len(); offline.retain(|command| { diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index 6ed0fb195..1aba8fe7d 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -42,9 +42,9 @@ pub enum UiEvent { /// Commands sent from GTK handlers to the D-Bus runtime. pub enum UiCommand { - Dismiss(u32), + Dismiss(NotificationKey), InvokeAction { - id: u32, + notification: NotificationKey, action_key: String, }, Reply { @@ -62,10 +62,16 @@ pub enum UiCommand { impl fmt::Debug for UiCommand { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Dismiss(id) => formatter.debug_tuple("Dismiss").field(id).finish(), - Self::InvokeAction { id, action_key } => formatter + Self::Dismiss(notification) => formatter + .debug_tuple("Dismiss") + .field(notification) + .finish(), + Self::InvokeAction { + notification, + action_key, + } => formatter .debug_struct("InvokeAction") - .field("id", id) + .field("notification", notification) .field("action_key", action_key) .finish(), Self::Reply { id, generation, .. } => formatter diff --git a/crates/unixnotis-center/src/control/tests/client.rs b/crates/unixnotis-center/src/control/tests/client.rs index dc9ddeec4..910d83204 100644 --- a/crates/unixnotis-center/src/control/tests/client.rs +++ b/crates/unixnotis-center/src/control/tests/client.rs @@ -1,18 +1,23 @@ use super::UI_COMMAND_QUEUE_CAPACITY; +use unixnotis_core::NotificationKey; #[test] fn command_queue_rejects_work_beyond_its_fixed_capacity() { let (sender, _receiver) = tokio::sync::mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); for id in 0..UI_COMMAND_QUEUE_CAPACITY { sender - .try_send(crate::control::UiCommand::Dismiss( - u32::try_from(id).expect("test command id fits u32"), - )) + .try_send(crate::control::UiCommand::Dismiss(NotificationKey { + id: u32::try_from(id).expect("test command id fits u32"), + generation: u64::try_from(id).expect("test generation fits u64"), + })) .expect("bounded queue accepts work below its limit"); } assert!(matches!( - sender.try_send(crate::control::UiCommand::Dismiss(u32::MAX)), + sender.try_send(crate::control::UiCommand::Dismiss(NotificationKey { + id: u32::MAX, + generation: u64::MAX, + })), Err(tokio::sync::mpsc::error::TrySendError::Full(_)) )); } diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index 18c713853..e4c6594e7 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -1,12 +1,19 @@ use super::*; +use unixnotis_core::NotificationKey; #[test] fn drop_stale_offline_commands_retains_safe_actions() { // Mix stale id-based actions with reconnect-safe commands let mut offline = VecDeque::new(); - offline.push_back(UiCommand::Dismiss(10)); + offline.push_back(UiCommand::Dismiss(NotificationKey { + id: 10, + generation: 12, + })); offline.push_back(UiCommand::InvokeAction { - id: 11, + notification: NotificationKey { + id: 11, + generation: 13, + }, action_key: "open".to_string(), }); offline.push_back(UiCommand::SetDnd(true)); diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index dd82d72f2..24f073173 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -18,6 +18,7 @@ fn notification(id: u32) -> NotificationView { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index 89630cb70..9ce75b170 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -88,6 +88,7 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { image_path: path.to_string_lossy().into_owned(), ..NotificationImage::default() }, + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; let resolution = resolver diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 6fbe3543a..75cc460b0 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -28,6 +28,7 @@ fn notification_view( is_transient: false, received_at_unix_seconds: 0, image, + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index ad2b977c6..595c661bd 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -63,6 +63,9 @@ pub struct RowData { pub group_key: Rc, pub count: u32, pub expanded: bool, + // Position flags let CSS form one continuous grouped surface + pub group_first: bool, + pub group_last: bool, // True when this notification is the visible card for a collapsed group pub stacked: bool, // Number of internal ghost cards shown under the visible notification card @@ -81,6 +84,8 @@ impl Default for RowData { group_key: Rc::from(""), count: 0, expanded: false, + group_first: false, + group_last: false, stacked: false, stack_depth: 0, is_active: false, @@ -104,6 +109,8 @@ impl RowData { group_key, count: count as u32, expanded, + group_first: false, + group_last: false, stacked: false, stack_depth: 0, is_active: false, @@ -128,6 +135,8 @@ impl RowData { group_key, count: 0, expanded, + group_first: false, + group_last: false, stacked, stack_depth, is_active, @@ -143,6 +152,8 @@ impl RowData { && Rc::ptr_eq(&self.group_key, &other.group_key) && self.count == other.count && self.expanded == other.expanded + && self.group_first == other.group_first + && self.group_last == other.group_last && self.stacked == other.stacked && self.stack_depth == other.stack_depth && self.is_active == other.is_active diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 0a895ddbf..d803922e4 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -22,6 +22,7 @@ fn notification(id: u32) -> Rc { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 1edc220da..42e3697db 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -10,16 +10,22 @@ use gtk::pango; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{css::hooks, util}; -use unixnotis_ui::presentation::{build_semantic_badge, NotificationPresentation, TrustLevel}; +use unixnotis_ui::presentation::{apply_semantic_badge, NotificationPresentation, TrustLevel}; use crate::control::UiEvent; use super::super::super::icons::IconResolver; use super::super::item::RowData; +const GROUP_AVATAR_SIZE: i32 = 26; +const GROUP_ICON_SIZE: i32 = 18; + pub(in crate::ui::notifications) struct GroupRowWidgets { + pub(super) avatar: gtk::Box, pub(super) icon: gtk::Image, pub(super) title: gtk::Label, + pub(super) secondary: gtk::Label, + pub(super) trust_chip: gtk::Label, pub(super) count: gtk::Label, pub(super) chevron: gtk::Image, pub(super) group_key: Rc>>, @@ -41,29 +47,52 @@ pub(in crate::ui::notifications) fn build_group_row( button.set_tooltip_text(Some("Toggle group")); let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar.set_halign(gtk::Align::Center); + avatar.set_valign(gtk::Align::Center); + avatar.set_size_request(GROUP_AVATAR_SIZE, GROUP_AVATAR_SIZE); + avatar.add_css_class("unixnotis-group-avatar"); let icon = gtk::Image::new(); - icon.set_pixel_size(18); + icon.set_pixel_size(GROUP_ICON_SIZE); icon.add_css_class(hooks::group_row::ICON); + avatar.append(&icon); + + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); + identity.set_hexpand(true); + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); let title = gtk::Label::new(None); title.set_xalign(0.0); - title.set_hexpand(true); title.set_ellipsize(pango::EllipsizeMode::End); + title.set_single_line_mode(true); title.add_css_class(hooks::group_row::TITLE); + let trust_chip = gtk::Label::new(None); + trust_chip.set_single_line_mode(true); + trust_chip.add_css_class("unixnotis-group-trust-chip"); + trust_chip.set_visible(false); + + let secondary = gtk::Label::new(None); + secondary.set_xalign(0.0); + secondary.set_ellipsize(pango::EllipsizeMode::End); + secondary.set_single_line_mode(true); + secondary.add_css_class("unixnotis-group-secondary"); + secondary.set_visible(false); + let count = gtk::Label::new(Some("0")); count.set_xalign(0.5); count.add_css_class(hooks::group_row::COUNT); - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - spacer.set_hexpand(true); - let chevron = gtk::Image::from_icon_name("pan-down-symbolic"); + chevron.set_pixel_size(14); chevron.add_css_class(hooks::group_row::CHEVRON); - header.append(&icon); - header.append(&title); - header.append(&spacer); + identity_top.append(&title); + identity_top.append(&trust_chip); + identity.append(&identity_top); + identity.append(&secondary); + header.append(&avatar); + header.append(&identity); header.append(&count); header.append(&chevron); button.set_child(Some(&header)); @@ -100,8 +129,11 @@ pub(in crate::ui::notifications) fn build_group_row( ( root, GroupRowWidgets { + avatar, icon, title, + secondary, + trust_chip, count, chevron, group_key, @@ -115,19 +147,30 @@ pub(in crate::ui::notifications) fn update_group_row( data: &RowData, icon_resolver: &IconResolver, ) { - let display_name = data + let presentation = data .notification .as_ref() - .map(|notification| { - NotificationPresentation::from_view(notification) - .identity - .primary_label - }) + .map(|notification| NotificationPresentation::from_view(notification)); + let display_name = presentation + .as_ref() + .map(|view| view.identity.primary_label.as_str()) .filter(|name| !name.is_empty()) - .unwrap_or_else(|| data.group_key.to_string()); + .unwrap_or(data.group_key.as_ref()); // Display application presentation while the daemon identity key drives grouping behavior // Fall back to the group key if no sample notification is available - set_label_text_if_changed(&group.title, &display_name); + set_label_text_if_changed(&group.title, display_name); + let secondary = presentation + .as_ref() + .and_then(|view| view.identity.secondary_claim.as_deref()) + .unwrap_or_default(); + set_label_text_if_changed(&group.secondary, secondary); + set_widget_visible_if_changed(&group.secondary, !secondary.is_empty()); + let trust_label = presentation + .as_ref() + .and_then(|view| view.trust.short_label.as_deref()) + .unwrap_or_default(); + set_label_text_if_changed(&group.trust_chip, trust_label); + set_widget_visible_if_changed(&group.trust_chip, !trust_label.is_empty()); let next_count = data.count.to_string(); set_label_text_if_changed(&group.count, &next_count); let chevron_name = if data.expanded { @@ -142,7 +185,10 @@ pub(in crate::ui::notifications) fn update_group_row( *group.group_key.borrow_mut() = data.group_key.clone(); if let Some(notification) = data.notification.as_ref() { - let presentation = NotificationPresentation::from_view(notification); + let Some(presentation) = presentation else { + return; + }; + set_widget_visible_if_changed(&group.avatar, true); if presentation.trust.details_label.is_none() { group.title.set_tooltip_text(None); } else if let Some(details) = presentation.trust.details_label.as_deref() { @@ -153,17 +199,25 @@ pub(in crate::ui::notifications) fn update_group_row( "unixnotis-attribution-warning", presentation.trust.level == TrustLevel::Suspicious, ); - if let Some(image) = build_semantic_badge(presentation.identity.badge, 18) { - group.icon.set_paintable(image.paintable().as_ref()); + for (level, class_name) in [ + (TrustLevel::Verified, "verified"), + (TrustLevel::Unverified, "unverified"), + (TrustLevel::Suspicious, "suspicious"), + (TrustLevel::CommandLine, "command-line"), + ] { + set_class_state(root, class_name, presentation.trust.level == level); + } + if apply_semantic_badge(&group.icon, presentation.identity.badge, GROUP_ICON_SIZE) { group.icon.set_visible(true); } else { let scale = root.scale_factor(); // Verified groups keep authenticated application art from the shared resolver - icon_resolver.apply_badge(&group.icon, notification.as_ref(), 18, scale); + icon_resolver.apply_badge(&group.icon, notification.as_ref(), GROUP_ICON_SIZE, scale); } set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); } else { + set_widget_visible_if_changed(&group.avatar, false); set_widget_visible_if_changed(&group.icon, false); set_class_state(root, hooks::group_row::NO_ICON, true); set_class_state(root, hooks::group_row::HAS_ICON, false); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index ed7540118..203debfe0 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -9,7 +9,7 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; -use unixnotis_core::css::hooks; +use unixnotis_core::{css::hooks, NotificationKey}; use unixnotis_ui::CutCorner; use crate::control::UiCommand; @@ -34,6 +34,8 @@ pub(in crate::ui::notifications) fn build_notification_row( let meta_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta_top.add_css_class(hooks::panel_card::META_TOP); meta_top.set_hexpand(true); + // Overlay dismiss control occupies the card's top-right corner + meta_top.set_margin_end(30); meta_top.set_visible(false); let meta_label = gtk::Label::new(None); @@ -52,13 +54,17 @@ pub(in crate::ui::notifications) fn build_notification_row( meta_top.append(&meta_spacer); meta_top.append(&time_badge); - // Header packs icon + app label + close button + // Header packs the identity shown only for standalone rows let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); header.add_css_class(hooks::panel_card::HEADER); let icon = gtk::Image::new(); - icon.set_pixel_size(22); + icon.set_pixel_size(20); icon.add_css_class("unixnotis-panel-icon"); + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); + identity.set_hexpand(true); + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let app_label = gtk::Label::new(None); app_label.set_xalign(0.0); // Ellipsis avoids row width spikes from long app names @@ -67,25 +73,38 @@ pub(in crate::ui::notifications) fn build_notification_row( app_label.set_max_width_chars(40); app_label.add_css_class("unixnotis-panel-app"); + let trust_chip = gtk::Label::new(None); + trust_chip.set_single_line_mode(true); + trust_chip.add_css_class("unixnotis-panel-trust-chip"); + trust_chip.set_visible(false); + + let secondary_claim = gtk::Label::new(None); + secondary_claim.set_xalign(0.0); + secondary_claim.set_single_line_mode(true); + secondary_claim.set_ellipsize(EllipsizeMode::End); + secondary_claim.add_css_class("unixnotis-panel-secondary-claim"); + secondary_claim.set_visible(false); + let urgency_badge = gtk::Label::new(Some("Critical")); // Reused rows toggle this widget instead of rebuilding the header tree urgency_badge.add_css_class(hooks::urgency::BADGE); urgency_badge.set_single_line_mode(true); urgency_badge.set_visible(false); - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // Spacer pushes close button to the far edge - spacer.set_hexpand(true); - let close_button = gtk::Button::from_icon_name("window-close-symbolic"); close_button.set_halign(gtk::Align::End); + close_button.set_valign(gtk::Align::Start); + close_button.set_margin_top(6); + close_button.set_margin_end(6); close_button.add_css_class("unixnotis-panel-close"); + identity_top.append(&app_label); + identity_top.append(&trust_chip); + identity_top.append(&urgency_badge); + identity.append(&identity_top); + identity.append(&secondary_claim); header.append(&icon); - header.append(&app_label); - header.append(&urgency_badge); - header.append(&spacer); - header.append(&close_button); + header.append(&identity); let body_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); body_row.set_hexpand(true); @@ -103,27 +122,36 @@ pub(in crate::ui::notifications) fn build_notification_row( // Summary is optional, so the update path decides later if the row should exist let summary_label = gtk::Label::new(None); summary_label.set_xalign(0.0); - // Summary can wrap but stays bounded to three lines + // One title line keeps short grouped rows compact summary_label.set_wrap(true); summary_label.set_wrap_mode(WrapMode::WordChar); summary_label.set_ellipsize(EllipsizeMode::End); - summary_label.set_lines(3); + summary_label.set_lines(1); summary_label.set_max_width_chars(88); summary_label.add_css_class("unixnotis-panel-summary"); // Body follows the same optional-row rule as summary text let body_label = gtk::Label::new(None); body_label.set_xalign(0.0); - // Body gets more lines than summary but still has upper bounds + // Three body lines provide context without dominating the panel body_label.set_wrap(true); body_label.set_wrap_mode(WrapMode::WordChar); body_label.set_ellipsize(EllipsizeMode::End); - body_label.set_lines(8); + body_label.set_lines(3); body_label.set_max_width_chars(112); body_label.add_css_class("unixnotis-panel-body"); + let popup_status = gtk::Label::new(None); + popup_status.set_xalign(0.0); + popup_status.set_wrap(true); + popup_status.set_wrap_mode(WrapMode::WordChar); + popup_status.set_lines(2); + popup_status.add_css_class("unixnotis-popup-status"); + popup_status.set_visible(false); + text_stack.append(&summary_label); text_stack.append(&body_label); + text_stack.append(&popup_status); body_row.append(&thumbnail); body_row.append(&text_stack); @@ -165,22 +193,35 @@ pub(in crate::ui::notifications) fn build_notification_row( // The wrapper clips the complete styled card while the inner box keeps all CSS hooks let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); + // Overlay controls never change the card's natural height + let card_overlay = gtk::Overlay::new(); + card_overlay.add_css_class("unixnotis-panel-card-overlay"); + card_overlay.set_child(Some(&card_plate)); + card_overlay.add_overlay(&close_button); + // One content card keeps grouped rows calm without decorative fake stack layers - root.append(&card_plate); + root.append(&card_overlay); - let notify_id = Rc::new(Cell::new(0)); - // Close click always targets the latest id assigned to this row + let notify_key = Rc::new(Cell::new(NotificationKey { + id: 0, + generation: 0, + })); + // Close click always targets the exact generation assigned to this recycled row let close_tx = command_tx; - let notify_id_clone = notify_id.clone(); + let notify_key_clone = notify_key.clone(); close_button.connect_clicked(move |_| { - let id = notify_id_clone.get(); - if id == 0 { + let notification = notify_key_clone.get(); + if notification.id == 0 { // Ignore clicks before first binding return; } - debug!(id, "dismiss clicked"); + debug!( + id = notification.id, + generation = notification.generation, + "dismiss clicked" + ); // Non-blocking enqueue avoids GTK stalls during D-Bus backpressure - try_send_command(&close_tx, UiCommand::Dismiss(id)); + try_send_command(&close_tx, UiCommand::Dismiss(notification)); }); // The reusable widget bundle is returned with the root so the list factory @@ -191,7 +232,10 @@ pub(in crate::ui::notifications) fn build_notification_row( card, card_plate, icon, + header, app_label, + secondary_claim, + trust_chip, urgency_badge, meta_top, meta_label, @@ -199,13 +243,17 @@ pub(in crate::ui::notifications) fn build_notification_row( thumbnail, summary_label, body_label, + popup_status, footer, footer_left, footer_right, actions_box, inline_reply, - notify_id, - action_cache_id: Cell::new(0), + notify_key, + action_cache_key: Cell::new(NotificationKey { + id: 0, + generation: 0, + }), action_cache: RefCell::new(Vec::new()), reply_cache: RefCell::new(( unixnotis_core::InlineReply::default(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 4614ff8d2..161896aaf 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -6,7 +6,7 @@ use std::borrow::Cow; use std::cell::{Cell, RefCell}; use std::rc::Rc; -use unixnotis_core::NotificationView; +use unixnotis_core::{NotificationKey, NotificationView}; use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation}; use super::reply::InlineReplyWidgets; @@ -18,8 +18,13 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card_plate: unixnotis_ui::CutCorner, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, + // Identity header collapses completely for rows owned by a group header + pub(super) header: gtk::Box, // App name text shown beside the icon pub(super) app_label: gtk::Label, + // Headerless singleton rows retain the caller label and trust state visibly + pub(super) secondary_claim: gtk::Label, + pub(super) trust_chip: gtk::Label, // Critical badge remains allocated so urgency changes only toggle visibility pub(super) urgency_badge: gtk::Label, // Optional metadata rows are present for themes but hidden unless config enables them @@ -34,6 +39,8 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) summary_label: gtk::Label, // Body text section that can span multiple lines pub(super) body_label: gtk::Label, + // Arrival-time popup explanation remains visible after runtime state changes + pub(super) popup_status: gtk::Label, pub(super) footer: gtk::Box, // Optional footer metadata hooks for theme-specific chips pub(super) footer_left: gtk::Label, @@ -42,10 +49,10 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) actions_box: gtk::Box, // Live-only reply form is kept outside the action button cache pub(super) inline_reply: InlineReplyWidgets, - // Current notification id bound to this reused row widget - pub(super) notify_id: Rc>, - // Recycled rows must rebuild action closures when the notification id changes - pub(super) action_cache_id: Cell, + // Exact notification identity bound to this reused row widget + pub(super) notify_key: Rc>, + // Recycled rows must rebuild action closures when the notification generation changes + pub(super) action_cache_key: Cell, // Last rendered action signature for cheap no-op detection pub(super) action_cache: RefCell>, // Reply metadata and live state are cached separately from ordinary actions diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index dc5007192..4cf035ea7 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -34,6 +34,7 @@ pub(super) fn sample_notification() -> NotificationView { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } @@ -43,6 +44,17 @@ pub(super) fn notification_row() -> (gtk::Box, NotificationRowWidgets) { build_notification_row(command_tx) } +pub(super) fn notification_row_with_receiver() -> ( + gtk::Box, + NotificationRowWidgets, + tokio::sync::mpsc::Receiver, +) { + support::init_gtk(); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(4); + let (root, row) = build_notification_row(command_tx); + (root, row, command_rx) +} + #[derive(Default)] pub(super) struct RowFlags { pub(super) is_active: bool, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 7e70708b8..19d3caa32 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -44,7 +44,7 @@ pub(super) fn update_actions( { let cached = row.action_cache.borrow(); let reply_cached = row.reply_cache.borrow(); - if row.action_cache_id.get() == notification.id + if row.action_cache_key.get() == notification.key() && cached.len() == safe_actions.len() && cached .iter() @@ -66,7 +66,7 @@ pub(super) fn update_actions( for action in &safe_actions { cached.push((action.key.clone(), action.label.clone())); } - row.action_cache_id.set(notification.id); + row.action_cache_key.set(notification.key()); *row.reply_cache.borrow_mut() = ( notification.inline_reply.clone(), notification.inline_reply_policy, @@ -103,31 +103,72 @@ pub(super) fn update_actions( row.actions_box.append(&button); } - for action in safe_actions { - // Bound action text before GTK measures the button - let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); - button.add_css_class("unixnotis-panel-action"); - button.add_css_class("unixnotis-notification-action"); - let action_key = action.key.clone(); - let tx = command_tx.clone(); - let id = notification.id; - let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); - button.connect_clicked(move |_| { - if !action_gate.try_start() { - return; - } - debug!(id, action = %action_key, "action invoked"); - // The closure keeps its own key copy so the button can outlive the loop frame - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); + for action in &presentation.actions.primary { + let button = build_action_button(command_tx, notification.key(), action); row.actions_box.append(&button); } + if !presentation.actions.overflow.is_empty() { + row.actions_box.append(&build_overflow_menu( + command_tx, + notification.key(), + &presentation.actions.overflow, + )); + } +} + +fn build_action_button( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + action: &unixnotis_ui::presentation::ActionView, +) -> gtk::Button { + // Bound action text before GTK measures the button + let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + let action_key = action.key.clone(); + let tx = command_tx.clone(); + let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); + button.connect_clicked(move |_| { + if !action_gate.try_start() { + return; + } + debug!( + id = notification.id, + generation = notification.generation, + action = %action_key, + "action invoked" + ); + // The closure keeps its own key copy so the button can outlive the loop frame + try_send_command( + &tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.clone(), + }, + ); + }); + button +} + +fn build_overflow_menu( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + actions: &[unixnotis_ui::presentation::ActionView], +) -> gtk::MenuButton { + let menu = gtk::MenuButton::new(); + menu.set_icon_name("view-more-symbolic"); + menu.set_tooltip_text(Some("More actions")); + menu.add_css_class("unixnotis-panel-action-overflow"); + + let popover = gtk::Popover::new(); + let list = gtk::Box::new(gtk::Orientation::Vertical, 4); + list.add_css_class("unixnotis-panel-action-overflow-list"); + for action in actions { + list.append(&build_action_button(command_tx, notification, action)); + } + popover.set_child(Some(&list)); + menu.set_popover(Some(&popover)); + menu } pub(super) fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs index 0a55e801c..45d116ed6 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs @@ -13,11 +13,13 @@ pub(super) fn update_notification_text( app_name: &str, summary: &str, body: &str, + popup_status: Option<&str>, ) { // App name always renders while optional rows collapse on empty text set_label_text_if_changed(&row.app_label, app_name); update_optional_label(&row.summary_label, summary, MAX_SUMMARY_LABEL_CHARS); update_optional_label(&row.body_label, body, MAX_BODY_LABEL_CHARS); + update_optional_label(&row.popup_status, popup_status.unwrap_or_default(), 160); } pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index 22d77ea1f..70877ff13 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -15,26 +15,30 @@ pub(super) fn update_metadata_labels( data: &RowData, notification: &NotificationView, ) { - // Metadata visibility controls both the compact header and footer lanes - set_widget_visible_if_changed(&row.meta_top, data.presentation.show_metadata); + let metadata = data.presentation.metadata.as_ref(); + let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); + // Relative time is core chronology; optional metadata controls only the extra labels + set_widget_visible_if_changed( + &row.meta_top, + data.presentation.show_metadata || !time_badge.is_empty(), + ); set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); if !data.presentation.show_metadata { - // Disabled lanes collapse fully so compact cards retain their shape + // Optional labels collapse while compact per-notification chronology remains set_label_visible_if_changed(&row.meta_label, false); - set_label_visible_if_changed(&row.time_badge, false); + set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); + set_label_text_if_changed(&row.time_badge, &time_badge); set_label_visible_if_changed(&row.footer_left, false); set_label_visible_if_changed(&row.footer_right, false); return; } // Urgency copy comes from one config block so themes can rename every lane together - let metadata = data.presentation.metadata.as_ref(); let meta = notification_meta_label(notification, metadata); set_label_visible_if_changed(&row.meta_label, !meta.is_empty()); set_label_text_if_changed(&row.meta_label, meta); // Missing or invalid timestamps hide the badge instead of showing stale text - let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); set_label_text_if_changed(&row.time_badge, &time_badge); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 3c9932b4d..0f8473b58 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -2,7 +2,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; -use unixnotis_ui::presentation::{build_semantic_badge, NotificationPresentation}; +use unixnotis_ui::presentation::{apply_semantic_badge, NotificationPresentation}; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -40,27 +40,51 @@ pub(in crate::ui::notifications) fn update_notification_row( &presentation.identity.primary_label, &presentation.title, presentation.body.as_deref().unwrap_or_default(), + presentation.popup_status.as_deref(), ); if presentation.trust.details_label.is_none() { row.app_label.set_tooltip_text(None); } else if let Some(details) = presentation.trust.details_label.as_deref() { row.app_label.set_tooltip_text(Some(details)); } + super::labels::set_label_text_if_changed( + &row.secondary_claim, + presentation + .identity + .secondary_claim + .as_deref() + .unwrap_or_default(), + ); + super::labels::set_label_visible_if_changed( + &row.secondary_claim, + show_identity && presentation.identity.secondary_claim.is_some(), + ); + super::labels::set_label_text_if_changed( + &row.trust_chip, + presentation + .trust + .short_label + .as_deref() + .unwrap_or_default(), + ); + super::labels::set_label_visible_if_changed( + &row.trust_chip, + show_identity && presentation.trust.short_label.is_some(), + ); update_metadata_labels(row, data, notification); - row.notify_id.set(notification.id); + row.notify_key.set(notification.key()); update_actions(row, command_tx, notification_snapshot, data.is_active); // Text and action changes must not restart an unchanged icon pipeline let next_sig = IconSignature::from(notification); let mut sig_guard = row.icon_sig.borrow_mut(); if show_identity && sig_guard.as_ref() != Some(&next_sig) { - if let Some(image) = build_semantic_badge(presentation.identity.badge, 22) { - row.icon.set_paintable(image.paintable().as_ref()); + if apply_semantic_badge(&row.icon, presentation.identity.badge, 20) { row.icon.set_visible(true); } else { let scale = row.card.scale_factor(); // Verified rows keep authenticated application art from the shared resolver - icon_resolver.apply_badge(&row.icon, notification, 22, scale); + icon_resolver.apply_badge(&row.icon, notification, 20, scale); } *sig_guard = Some(next_sig); } else if !show_identity { @@ -68,6 +92,7 @@ pub(in crate::ui::notifications) fn update_notification_row( } set_widget_visible_if_changed(&row.icon, show_identity); set_widget_visible_if_changed(&row.app_label, show_identity); + set_widget_visible_if_changed(&row.header, show_identity); if has_thumbnail { // Reapply visible thumbnails so config reloads cannot leave stale previews let scale = row.card.scale_factor(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 110682ab8..373e4eed3 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -186,8 +186,12 @@ fn update_notification_row_action_button_sends_command_once_per_click_window() { button.emit_clicked(); match command_rx.try_recv().expect("action command") { - UiCommand::InvokeAction { id, action_key } => { - assert_eq!(id, 1); + UiCommand::InvokeAction { + notification, + action_key, + } => { + assert_eq!(notification.id, 1); + assert_eq!(notification.generation, 1); assert_eq!(action_key, "open"); } command => panic!("expected action command, got {command:?}"), @@ -198,7 +202,7 @@ fn update_notification_row_action_button_sends_command_once_per_click_window() { } #[gtk::test] -fn recycled_action_button_targets_the_new_notification_id() { +fn recycled_action_button_targets_the_new_notification_generation() { let (_root, row) = notification_row(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); let mut first = sample_notification(); @@ -208,6 +212,7 @@ fn recycled_action_button_targets_the_new_notification_id() { }]; let mut second = first.clone(); second.id = 2; + second.generation = 7; update_notification_row( &row, @@ -244,7 +249,10 @@ fn recycled_action_button_targets_the_new_notification_id() { assert!(matches!( command_rx.try_recv(), - Ok(UiCommand::InvokeAction { id: 2, action_key }) if action_key == "open" + Ok(UiCommand::InvokeAction { notification, action_key }) + if notification.id == 2 + && notification.generation == 7 + && action_key == "open" )); } @@ -282,6 +290,62 @@ fn inactive_reply_action_stays_hidden_beside_a_regular_action() { assert_eq!(button.label().as_deref(), Some("Open")); } +#[gtk::test] +fn panel_keeps_two_primary_actions_and_moves_the_rest_into_more_menu() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.actions = ["Open", "Archive", "Mute"] + .into_iter() + .map(|label| Action { + key: label.to_ascii_lowercase(), + label: label.to_string(), + }) + .collect(); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 3); + assert!(row + .actions_box + .first_child() + .is_some_and(|child| child.is::())); + assert!(row + .actions_box + .last_child() + .is_some_and(|child| child.is::())); + let menu = row + .actions_box + .last_child() + .and_downcast::() + .expect("overflow menu"); + assert_eq!(menu.icon_name().as_deref(), Some("view-more-symbolic")); + assert_eq!(menu.tooltip_text().as_deref(), Some("More actions")); + assert!(menu.has_css_class("unixnotis-panel-action-overflow")); + let popover = menu.popover().expect("overflow popover"); + let list = popover + .child() + .and_downcast::() + .expect("overflow action list"); + assert!(list.has_css_class("unixnotis-panel-action-overflow-list")); + let overflow = list + .first_child() + .and_downcast::() + .expect("overflow action button"); + assert_eq!(overflow.label().as_deref(), Some("Mute")); +} + #[gtk::test] fn reply_action_label_prefers_hint_then_action_then_default() { let labels = [ diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 1194a0f96..7b1f43766 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -9,7 +9,7 @@ use crate::ui::icons::IconResolver; use super::super::super::state::IconSignature; use super::super::super::test_support::{ - notification_row, row_data, sample_notification, RowFlags, + notification_row, notification_row_with_receiver, row_data, sample_notification, RowFlags, }; use super::update_notification_row; @@ -27,12 +27,38 @@ fn icon_signature_changes_when_trust_presentation_changes() { ); } +#[gtk::test] +fn close_control_ignores_unbound_rows_and_keeps_the_bound_generation() { + let (root, row, mut command_rx) = notification_row_with_receiver(); + let close = descendant_with_class(root.upcast_ref(), "unixnotis-panel-close") + .and_downcast::() + .expect("panel close button"); + + close.emit_clicked(); + assert!( + command_rx.try_recv().is_err(), + "an unbound recycled row must not dismiss notification zero" + ); + + row.notify_key.set(unixnotis_core::NotificationKey { + id: 7, + generation: 11, + }); + close.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(crate::control::UiCommand::Dismiss(notification)) + if notification.id == 7 && notification.generation == 11 + )); +} + #[gtk::test] fn update_notification_row_applies_state_classes_and_text() { let (_root, row) = notification_row(); let mut notification = sample_notification(); notification.urgency = Urgency::Critical as u8; let notification = Rc::new(notification); + let expected_key = notification.key(); let data = row_data( notification, RowFlags { @@ -53,12 +79,13 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); assert!(!row.app_label.get_visible()); assert!(!row.icon.get_visible()); + assert!(!row.header.get_visible()); assert!(row.urgency_badge.get_visible()); assert_eq!(row.urgency_badge.text().as_str(), "Critical"); assert_eq!(row.app_label.text().as_str(), "demo"); assert_eq!(row.summary_label.text().as_str(), "summary"); assert_eq!(row.body_label.text().as_str(), "body"); - assert_eq!(row.notify_id.get(), 1); + assert_eq!(row.notify_key.get(), expected_key); assert!(row.icon_sig.borrow().is_none()); } @@ -88,7 +115,132 @@ fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); assert!(row.app_label.get_visible()); + assert!(row.header.get_visible()); assert_eq!(row.app_label.text().as_str(), "demo"); + assert!(row.icon_sig.borrow().is_some()); +} + +#[gtk::test] +fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + true, + "relay:notify-send:signal".to_string(), + ); + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.app_label.text().as_str(), "Command-line notification"); + assert_eq!(row.secondary_claim.text().as_str(), "App label: Signal"); + assert!(row.secondary_claim.get_visible()); + assert!(!row.trust_chip.get_visible()); + assert!(row.card.has_css_class("command-line")); + assert!(!row.card.has_css_class("suspicious")); +} + +#[gtk::test] +fn panel_text_limits_keep_compact_rows_content_driven() { + let (root, row) = notification_row(); + let close = descendant_with_class(root.upcast_ref(), "unixnotis-panel-close") + .expect("panel close button"); + + assert_eq!(row.summary_label.lines(), 1); + assert_eq!(row.body_label.lines(), 3); + assert!(close + .parent() + .is_some_and(|parent| parent.is::())); +} + +#[gtk::test] +fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + true, + "relay:notify-send:signal".to_string(), + ); + let data = row_data( + Rc::new(notification), + RowFlags { + stacked: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.app_label.get_visible()); + assert!(!row.secondary_claim.get_visible()); + assert!(!row.trust_chip.get_visible()); + assert!(!row.icon.get_visible()); +} + +#[gtk::test] +fn compact_metadata_keeps_only_a_valid_relative_timestamp_lane() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let current = row_data(notification.clone(), RowFlags::default()); + + update_notification_row(&row, ¤t, &IconResolver::new(), &command_tx); + + assert!(row.meta_top.get_visible()); + assert!(row.time_badge.get_visible()); + assert!(!row.meta_label.get_visible()); + assert!(!row.footer.get_visible()); + assert!(!row.footer_left.get_visible()); + assert!(!row.footer_right.get_visible()); + + let mut missing_time = row_data(notification, RowFlags::default()); + missing_time.presentation.received_at_ms = 0; + update_notification_row(&row, &missing_time, &IconResolver::new(), &command_tx); + + assert!(!row.meta_top.get_visible()); + assert!(!row.time_badge.get_visible()); +} + +fn descendant_with_class(widget: >k::Widget, class_name: &str) -> Option { + if widget.has_css_class(class_name) { + return Some(widget.clone()); + } + let mut child = widget.first_child(); + while let Some(current) = child { + if let Some(found) = descendant_with_class(¤t, class_name) { + return Some(found); + } + child = current.next_sibling(); + } + None +} + +#[gtk::test] +fn popup_suppression_reason_is_rendered_from_the_committed_decision() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: unixnotis_core::PopupAdmissionView::Dnd, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, + ..unixnotis_core::PopupDecisionRecord::default() + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!( + row.popup_status.text().as_str(), + "Not shown — Do Not Disturb was enabled" + ); + assert!(row.popup_status.get_visible()); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 47178511f..f14103de9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -26,7 +26,7 @@ pub(super) fn apply_visual_state( (TrustLevel::Verified, "verified"), (TrustLevel::Unverified, "unverified"), (TrustLevel::Suspicious, "suspicious"), - (TrustLevel::System, "system"), + (TrustLevel::CommandLine, "command-line"), ] { set_class_state(card, class_name, presentation.trust.level == level); } @@ -37,6 +37,8 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::panel_card::GROUPED, grouped); set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); + set_class_state(card, hooks::panel_card::GROUP_FIRST, data.group_first); + set_class_state(card, hooks::panel_card::GROUP_LAST, data.group_last); set_class_state( card, diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index b91961430..ac34b6339 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -15,10 +15,15 @@ fn notification(app_name: &str) -> Rc { id: 1, generation: 1, app_name: app_name.to_string(), - attribution: unixnotis_core::NotificationAttribution { - display_name: app_name.to_string(), - ..unixnotis_core::NotificationAttribution::default() - }, + attribution: unixnotis_core::NotificationAttribution::associated( + app_name, + "org.example.App", + "org.example.App", + "", + unixnotis_core::AttributionClass::SystemAssociated, + false, + "system:org.example.App".to_string(), + ), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), @@ -29,6 +34,7 @@ fn notification(app_name: &str) -> Rc { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), }) } @@ -39,6 +45,16 @@ fn header_button(root: >k::Box) -> gtk::Button { .expect("group child should be button") } +fn direct_child_count(container: >k::Box) -> usize { + let mut count = 0; + let mut child = container.first_child(); + while let Some(widget) = child { + count += 1; + child = widget.next_sibling(); + } + count +} + #[gtk::test] fn update_group_row_sets_title_count_and_expanded_state() { support::init_gtk(); @@ -49,6 +65,9 @@ fn update_group_row_sets_title_count_and_expanded_state() { update_group_row(&widgets, &root, &data, &IconResolver::new()); assert_eq!(widgets.title.text().as_str(), "Terminal"); + assert_eq!(widgets.avatar.width_request(), 26); + assert_eq!(widgets.avatar.height_request(), 26); + assert_eq!(widgets.icon.pixel_size(), 18); assert_eq!(widgets.count.text().as_str(), "3"); assert_eq!( widgets.chevron.icon_name().as_deref(), @@ -109,13 +128,52 @@ fn update_group_row_keeps_conflict_warning_out_of_the_title() { .title .tooltip_text() .is_some_and(|text| text.contains("Trusted Brand"))); - assert!( - widgets.icon.paintable().is_some(), - "conflicting identity should use a controlled warning badge" + assert_eq!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-shield-warning-symbolic") ); + assert_eq!(widgets.secondary.text().as_str(), "Claims “Trusted Brand”"); + assert_eq!(widgets.trust_chip.text().as_str(), "Suspicious"); + assert!(widgets.secondary.get_visible()); + assert!(widgets.trust_chip.get_visible()); assert!(root.has_css_class("unixnotis-attribution-warning")); } +#[gtk::test] +fn relay_group_header_keeps_claim_below_command_line_identity() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut relayed = notification("Signal").as_ref().clone(); + relayed.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + "Signal", + "Sent via /usr/bin/notify-send", + true, + "relay:notify-send:signal".to_string(), + ); + let data = RowData::group_header( + Rc::from("relay:notify-send:signal"), + 4, + false, + Rc::new(relayed), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + let header = header_button(&root) + .child() + .and_downcast::() + .expect("group header content"); + assert_eq!(direct_child_count(&header), 4); + assert_eq!(header.spacing(), 8); + assert_eq!(widgets.title.text().as_str(), "Command-line notification"); + assert_eq!(widgets.secondary.text().as_str(), "App label: Signal"); + assert!(widgets.secondary.get_visible()); + assert!(!widgets.trust_chip.get_visible()); + assert!(root.has_css_class("command-line")); + assert!(!root.has_css_class("unixnotis-attribution-warning")); +} + #[gtk::test] fn group_header_click_sends_toggle_event() { support::init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 1a83e5528..3846e9dab 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -59,7 +59,7 @@ impl NotificationList { metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, }; - entry.item.update(RowData::notification( + let mut row = RowData::notification( entry.app_key.clone(), entry.view.clone(), stacked, @@ -67,7 +67,12 @@ impl NotificationList { expanded, entry.is_active, presentation, - )); + ); + if ids.len() > 1 { + row.group_first = index == 0; + row.group_last = !expanded || index + 1 == ids.len(); + } + entry.item.update(row); items.push(entry.item.clone()); keys.push(RowKey::Notification { id: *id }); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 4daea452b..102d3b806 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -88,8 +88,12 @@ impl NotificationList { ) -> Rc { let id = notification.id; let app_key = self.intern_key(¬ification.attribution.group_key); + let received_at_ms = notification + .received_at_unix_seconds + .checked_mul(1_000) + .filter(|timestamp| *timestamp > 0) + .unwrap_or_else(now_millis); let view = Rc::new(notification); - let received_at_ms = now_millis(); let presentation = RowPresentation { received_at_ms, show_metadata: self.show_notification_metadata, @@ -134,7 +138,7 @@ impl NotificationList { } fn now_millis() -> i64 { - // Local receipt time avoids adding timestamp fields to the D-Bus model + // Local receipt time is a fallback for legacy or malformed timestamp values SystemTime::now() .duration_since(UNIX_EPOCH) .ok() diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index a05edc1b1..e285e70e0 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -78,6 +78,8 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert!(visible.stacked); assert_eq!(visible.stack_depth, 0); assert!(!visible.expanded); + assert!(visible.group_first); + assert!(visible.group_last); } #[gtk::test] @@ -93,6 +95,8 @@ fn build_group_block_keeps_single_collapsed_notification_unstacked() { let visible = items[0].data(); assert!(!visible.stacked); assert_eq!(visible.stack_depth, 0); + assert!(!visible.group_first); + assert!(!visible.group_last); } #[gtk::test] @@ -129,6 +133,15 @@ fn build_group_block_expands_group_to_all_notifications() { assert_eq!(data.stack_depth, 0); assert!(data.expanded); } + let first = items[1].data(); + let middle = items[2].data(); + let last = items[3].data(); + assert!(first.group_first); + assert!(!first.group_last); + assert!(!middle.group_first); + assert!(!middle.group_last); + assert!(!last.group_first); + assert!(last.group_last); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs index 7eb4326f2..dccd51623 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs @@ -251,6 +251,23 @@ fn insert_entry_records_recent_local_timestamp() { assert!(entry.received_at_ms <= after); } +#[gtk::test] +fn insert_entry_preserves_original_notification_timestamp_for_history_chronology() { + let mut list = support::make_list(); + let mut notification = support::notification(9, "Terminal"); + notification.received_at_unix_seconds = 1_700_000_123; + + list.insert_entry(notification, false); + + assert_eq!( + list.entries + .get(&9) + .expect("history entry should be stored") + .received_at_ms, + 1_700_000_123_000 + ); +} + #[test] fn now_millis_tracks_current_unix_time() { let system_ms = SystemTime::now() diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 67bdcb36f..81cdde272 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -26,6 +26,7 @@ fn make_view(is_transient: bool) -> NotificationView { is_transient, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } @@ -49,6 +50,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { is_transient, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index ba08d363e..f88bfe03d 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -74,5 +74,6 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { is_transient: false, received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), } } From 1570c212b04ea804bcc7d580f270c358e26fc992 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:25:30 -0500 Subject: [PATCH 138/275] style(ui): compact notification surfaces Summary: compact notification surfaces. Scope: ui. --- .../assets/internal-structure.css | 10 + crates/unixnotis-core/assets/panel.css | 190 ++++++++++++++++-- crates/unixnotis-core/assets/popup.css | 57 +++--- .../unixnotis-core/src/embedded/tests/css.rs | 6 +- 4 files changed, 216 insertions(+), 47 deletions(-) diff --git a/crates/unixnotis-core/assets/internal-structure.css b/crates/unixnotis-core/assets/internal-structure.css index 509919d61..670383ba6 100644 --- a/crates/unixnotis-core/assets/internal-structure.css +++ b/crates/unixnotis-core/assets/internal-structure.css @@ -7,6 +7,7 @@ background: alpha(@theme_bg_color, 0.92); } +.unixnotis-reload-notice-content, .unixnotis-reload-notice-text { min-width: 0; } @@ -17,6 +18,15 @@ padding: 0; } +.unixnotis-reload-notice-actions { + margin-top: 8px; +} + +.unixnotis-reload-notice-action { + min-height: 32px; + padding: 4px 10px; +} + /* GtkSearchEntry has no public icon child, so the native glyphs yield to owned controls */ .unixnotis-panel-search-owned-icons { -gtk-icon-source: none; diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index d0cf32418..aa46499cf 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -63,6 +63,38 @@ padding: 0; } +.unixnotis-reload-notice-actions { + margin-top: 8px; +} + +.unixnotis-reload-notice-action { + min-height: 32px; + padding: 4px 10px; + border-radius: 9px; + border: 1px solid alpha(#ffffff, 0.10); + background: alpha(#ffffff, 0.055); + color: alpha(#ffffff, 0.84); + box-shadow: none; +} + +.unixnotis-reload-notice-action:hover, +.unixnotis-reload-notice-action:focus-visible { + background: alpha(#ffffff, 0.10); + border-color: alpha(#ffffff, 0.18); +} + +.unixnotis-reload-notice-action-primary { + background: alpha(@unixnotis-accent, 0.20); + border-color: alpha(@unixnotis-accent, 0.38); + color: #d8fffb; +} + +.unixnotis-reload-notice-action-primary:hover, +.unixnotis-reload-notice-action-primary:focus-visible { + background: alpha(@unixnotis-accent, 0.28); + border-color: alpha(@unixnotis-accent, 0.52); +} + .unixnotis-panel-title-stack { min-width: 0; } @@ -333,7 +365,7 @@ entry selection { transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } -.unixnotis-panel-card:hover .unixnotis-panel-close, +.unixnotis-panel-card-overlay:hover .unixnotis-panel-close, .unixnotis-panel-close:focus, .unixnotis-panel-close:hover { opacity: 1; @@ -358,14 +390,15 @@ entry selection { */ .unixnotis-group { background: transparent; - margin-bottom: 8px; + margin-top: 12px; + margin-bottom: 0; } .unixnotis-group-header { - background: alpha(#ffffff, 0.025); + background: alpha(#ffffff, 0.035); color: @unixnotis-text; - border-radius: 999px; - padding: 6px 12px; + border-radius: 14px 14px 0 0; + padding: 7px 8px; border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.03); @@ -402,19 +435,59 @@ entry selection { .unixnotis-group-title { font-weight: 600; font-size: 12px; - letter-spacing: 0.2px; + letter-spacing: 0.1px; +} + +.unixnotis-group-avatar { + min-width: 26px; + min-height: 26px; + border-radius: 9px; + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.9); +} + +.unixnotis-group.command-line .unixnotis-group-avatar, +.unixnotis-group.unverified .unixnotis-group-avatar { + background: alpha(#fbbf24, 0.08); + color: alpha(#fde68a, 0.90); +} + +.unixnotis-group.suspicious .unixnotis-group-avatar { + background: alpha(#fb7185, 0.11); + color: #fecdd3; +} + +.unixnotis-group-secondary { + color: alpha(#ffffff, 0.54); + font-size: 11px; +} + +.unixnotis-group-trust-chip { + border-radius: 999px; + padding: 1px 6px; + font-size: 9px; + font-weight: 600; + background: alpha(#fbbf24, 0.09); + color: alpha(#fde68a, 0.86); + border: 1px solid alpha(#fbbf24, 0.18); +} + +.unixnotis-group.suspicious .unixnotis-group-trust-chip { + background: alpha(#fb7185, 0.12); + color: #fecdd3; + border-color: alpha(#fb7185, 0.30); } .unixnotis-group-count { - background: alpha(@unixnotis-accent, 0.12); - color: #bffaf5; + background: alpha(#ffffff, 0.06); + color: alpha(#ffffff, 0.72); border-radius: 999px; - padding: 2px 8px; + padding: 1px 6px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; - border: 1px solid alpha(@unixnotis-accent, 0.28); - min-width: 22px; + border: 1px solid alpha(#ffffff, 0.10); + min-width: 18px; box-shadow: none; } @@ -423,7 +496,7 @@ entry selection { } .unixnotis-group-row-collapsed .unixnotis-group-count { - border-color: alpha(@unixnotis-accent-2, 0.3); + border-color: alpha(#ffffff, 0.10); } .unixnotis-group-row-no-icon .unixnotis-group-title { @@ -447,7 +520,8 @@ entry selection { border-radius: 16px; padding: 10px 12px; padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); - margin-bottom: 8px; + margin-top: 12px; + margin-bottom: 0; box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); transition: border-color 0.15s ease-out; } @@ -456,12 +530,32 @@ entry selection { box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); } -.unixnotis-panel-card-group-collapsed { - margin-bottom: 8px; +.unixnotis-panel-card.unixnotis-panel-card-group-collapsed { + margin-top: 0; + margin-bottom: 12px; + border-radius: 0 0 14px 14px; + padding-top: 9px; + padding-bottom: 9px; + box-shadow: none; +} + +.unixnotis-panel-card.unixnotis-panel-card-group-expanded { + margin-top: 0; + margin-bottom: 0; + border-radius: 0; + padding-top: 9px; + padding-bottom: 9px; + border-top-color: alpha(#ffffff, 0.065); + box-shadow: none; +} + +.unixnotis-panel-card.unixnotis-panel-card-group-last { + border-radius: 0 0 14px 14px; + margin-bottom: 12px; } -.unixnotis-panel-card-group-expanded { - margin-bottom: var(--unixnotis-panel-card-gap); +.unixnotis-panel-card.unixnotis-panel-card-group-first { + border-top-color: alpha(#ffffff, 0.07); } .unixnotis-panel-card.active { @@ -572,6 +666,27 @@ entry selection { text-transform: uppercase; } +.unixnotis-panel-secondary-claim { + color: alpha(#ffffff, 0.54); + font-size: 11px; +} + +.unixnotis-panel-trust-chip { + border-radius: 999px; + padding: 1px 6px; + font-size: 9px; + font-weight: 600; + background: alpha(#fbbf24, 0.09); + color: alpha(#fde68a, 0.86); + border: 1px solid alpha(#fbbf24, 0.18); +} + +.unixnotis-panel-card.suspicious .unixnotis-panel-trust-chip { + background: alpha(#fb7185, 0.12); + color: #fecdd3; + border-color: alpha(#fb7185, 0.30); +} + .unixnotis-panel-summary { font-size: 13px; color: #ffffff; @@ -583,14 +698,47 @@ entry selection { font-size: 12px; } +.unixnotis-popup-status { + color: alpha(#ffffff, 0.52); + font-size: 11px; + margin-top: 2px; +} + .unixnotis-panel-icon { - margin-right: 8px; + min-width: 30px; + min-height: 30px; + margin-right: 2px; + border-radius: 10px; + padding: 5px; + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.90); +} + +.unixnotis-panel-card.command-line .unixnotis-panel-icon, +.unixnotis-panel-card.unverified .unixnotis-panel-icon { + background: alpha(#fbbf24, 0.08); + color: alpha(#fde68a, 0.90); +} + +.unixnotis-panel-card.suspicious .unixnotis-panel-icon { + background: alpha(#fb7185, 0.11); + color: #fecdd3; } .unixnotis-notification-actions { margin-top: 2px; } +.unixnotis-panel-action-overflow { + min-width: 32px; + padding-left: 7px; + padding-right: 7px; +} + +.unixnotis-panel-action-overflow-list { + padding: 6px; +} + .unixnotis-notification-action { background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.9), alpha(@unixnotis-surface, 0.95)); color: @unixnotis-text; @@ -627,9 +775,9 @@ entry selection { /* Interactive refinements follow the canonical base rules */ .unixnotis-group-header:hover .unixnotis-group-count { - background: alpha(@unixnotis-accent, 0.22); - color: #ffffff; - border-color: alpha(@unixnotis-accent, 0.45); + background: alpha(#ffffff, 0.09); + color: alpha(#ffffff, 0.82); + border-color: alpha(#ffffff, 0.14); } .unixnotis-panel-card:hover { diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 6af7a4505..a293207ba 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -15,7 +15,7 @@ min-height: var(--unixnotis-popup-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.70); - opacity: 0; + opacity: 0.62; transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; } @@ -94,7 +94,7 @@ } .unixnotis-popup-trust-chip.unverified, -.unixnotis-popup-trust-chip.system { +.unixnotis-popup-trust-chip.command-line { background: alpha(#fbbf24, 0.10); color: alpha(#fde68a, 0.84); border: 1px solid alpha(#fbbf24, 0.20); @@ -112,25 +112,33 @@ margin-top: 1px; } -.unixnotis-popup-app-icon { - min-width: 20px; - min-height: 20px; -} - .unixnotis-popup-icon { color: inherit; } -.unixnotis-popup-utility-icon { - min-width: 24px; - min-height: 24px; - margin-top: 1px; +.unixnotis-identity-avatar { + min-width: 36px; + min-height: 36px; + border-radius: 11px; + background: alpha(#ffffff, 0.07); + color: alpha(#ffffff, 0.92); } -.unixnotis-popup-warning-icon { - min-width: 20px; - min-height: 20px; - margin-top: 1px; +.unixnotis-identity-avatar.verified { + min-width: 44px; + min-height: 44px; + border-radius: 13px; +} + +.unixnotis-identity-avatar.command-line, +.unixnotis-identity-avatar.unverified { + background: alpha(#fbbf24, 0.08); + color: alpha(#fde68a, 0.90); +} + +.unixnotis-identity-avatar.suspicious { + background: alpha(#fb7185, 0.12); + color: #fecdd3; } .unixnotis-popup-body { @@ -147,8 +155,9 @@ } .unixnotis-popup-secondary-claim { - color: alpha(#fbbf24, 0.82); + color: alpha(#ffffff, 0.58); font-size: 12px; + font-weight: 400; margin-top: 1px; } @@ -160,14 +169,16 @@ } .unixnotis-popup-card.unverified { - border-color: alpha(#fbbf24, 0.24); + border-color: alpha(#ffffff, 0.10); +} + +.unixnotis-popup-card.command-line { + border-color: alpha(#ffffff, 0.12); } .unixnotis-popup-card.suspicious { - border-color: alpha(#fb7185, 0.48); - box-shadow: - 0 12px 32px -12px alpha(#000000, 0.58), - inset 2px 0 alpha(#fb7185, 0.58); + border-color: alpha(#fb7185, 0.42); + box-shadow: 0 12px 32px -12px alpha(#000000, 0.58); } .unixnotis-popup-actions { @@ -258,9 +269,7 @@ color: @unixnotis-critical-text; } -.unixnotis-popup-card.critical .unixnotis-popup-app-icon, -.unixnotis-popup-card.critical .unixnotis-popup-utility-icon, -.unixnotis-popup-card.critical .unixnotis-popup-warning-icon, +.unixnotis-popup-card.critical .unixnotis-identity-avatar, .unixnotis-popup-card.critical .unixnotis-popup-icon { color: @unixnotis-critical-icon; } diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index edad747a4..0d3b5bd85 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -29,6 +29,8 @@ fn motion_policy_disables_theme_motion_under_the_runtime_class() { #[test] fn internal_structure_css_contains_only_required_fallback_structure() { assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice")); + assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice-actions")); + assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice-action")); assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-panel-search-owned-icons")); assert!(!INTERNAL_STRUCTURE_CSS.contains("@define-color")); } @@ -151,7 +153,7 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { // Default popups must not restore the old raw provenance body row assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 20px")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 24px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 36px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 44px")); assert!(DEFAULT_POPUP_CSS.contains("min-width: 48px")); } From fdabd8abf58b3bc18dedb6b7d57e1e1ef3238fae Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:26:47 -0500 Subject: [PATCH 139/275] feat(theme): add explicit stock migration policy Summary: add explicit stock migration policy. Scope: theme. --- .../src/config/loading/io/mod.rs | 4 + .../src/config/loading/io/tests/mod.rs | 1 - .../config/loading/io/tests/theme_files.rs | 28 ++ .../config/loading/io/tests/theme_stock.rs | 197 -------- .../src/config/loading/io/theme_files.rs | 4 +- .../src/config/loading/io/theme_stock.rs | 213 -------- .../config/loading/io/theme_stock/files.rs | 141 ++++++ .../loading/io/theme_stock/migration.rs | 251 ++++++++++ .../src/config/loading/io/theme_stock/mod.rs | 18 + .../config/loading/io/theme_stock/model.rs | 105 ++++ .../config/loading/io/theme_stock/staging.rs | 76 +++ .../loading/io/theme_stock/tests/migration.rs | 464 ++++++++++++++++++ .../loading/io/theme_stock/tests/mod.rs | 21 + .../loading/io/theme_stock/tests/staging.rs | 84 ++++ crates/unixnotis-core/src/config/mod.rs | 5 +- 15 files changed, 1198 insertions(+), 414 deletions(-) delete mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs index 1a1088a72..dc588bae4 100644 --- a/crates/unixnotis-core/src/config/loading/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -12,6 +12,10 @@ mod write; pub use error::ConfigError; pub use load::MAX_CONFIG_BYTES; pub use paths::ThemePaths; +pub use theme_stock::{ + apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, + StockThemeApplyReport, StockThemeMigration, +}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index 15e50fb9c..740db0af7 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -7,5 +7,4 @@ mod script_migrations; mod scripts; mod support; mod theme_files; -mod theme_stock; mod write; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs index 072e54b2d..ad0ca2f6e 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs @@ -5,6 +5,7 @@ use std::fs; use crate::{Config, DEFAULT_BASE_CSS}; use super::super::theme_files::warn_legacy_rename_once; +use super::super::theme_stock::files::stock_preview_path; use super::support::test_root; #[test] @@ -65,6 +66,33 @@ fn ensure_theme_files_preserves_existing_base_css() { let _ = fs::remove_dir_all(root); } +#[test] +fn ensure_theme_files_stages_versioned_stock_without_replacing_user_css() { + let root = test_root("theme-stock-preview"); + fs::create_dir_all(&root).expect("theme root"); + let config = Config::default(); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, "/* user panel */").expect("custom panel css"); + + config + .ensure_theme_files(&paths) + .expect("theme previews should be staged"); + + assert_eq!( + fs::read_to_string(&paths.panel_css).expect("custom panel remains"), + "/* user panel */" + ); + let preview = stock_preview_path(&paths.panel_css).expect("versioned preview path"); + assert_eq!( + fs::read_to_string(preview).expect("versioned stock preview"), + crate::DEFAULT_PANEL_CSS + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn ensure_theme_files_keeps_legacy_style_when_backup_already_exists() { let root = test_root("theme-backup-exists"); diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs deleted file mode 100644 index 7c75f204f..000000000 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_stock.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Regression tests for exact-byte stock theme migration - -use std::fs; -use std::io; - -use super::super::theme_stock::{ - migrate_known_stock_file, migrate_stock_file_with_writer, replace_file_if_snapshot_matches, - stock_backup_path, MAX_STOCK_THEME_BYTES, -}; -use super::support::test_root; - -const OLD_STOCK: &[u8] = b"/* exact previous stock */\n.card { color: red; }\n"; -const CURRENT_STOCK: &[u8] = b"/* current flattened stock */\n.card { color: blue; }\n"; -const BACKUP_TAG: &str = "unixnotis-stock-test"; - -#[test] -fn exact_legacy_stock_file_is_backed_up_and_atomically_migrated() { - let root = test_root("exact-stock-migration"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - fs::write(&target, OLD_STOCK).expect("legacy stock"); - - let migrated = migrate(&target, OLD_STOCK).expect("stock migration"); - - assert!(migrated); - assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn one_byte_stock_modification_prevents_automatic_replacement() { - let root = test_root("modified-stock-preserved"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("widgets.css"); - let mut customized = OLD_STOCK.to_vec(); - customized.push(b' '); - fs::write(&target, &customized).expect("customized stock"); - - let migrated = migrate(&target, OLD_STOCK).expect("migration check"); - - assert!(!migrated); - assert_eq!(fs::read(&target).expect("customized stock"), customized); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - assert!(!backup.exists()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn current_stock_file_remains_unchanged() { - let root = test_root("current-stock-preserved"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("media.css"); - fs::write(&target, CURRENT_STOCK).expect("current stock"); - - let migrated = migrate(&target, OLD_STOCK).expect("migration check"); - - assert!(!migrated); - assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn interrupted_replacement_keeps_complete_legacy_file_and_backup() { - let root = test_root("interrupted-stock-migration"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - fs::write(&target, OLD_STOCK).expect("legacy stock"); - let digest = blake3::hash(OLD_STOCK).to_hex().to_string(); - - let result = migrate_stock_file_with_writer( - &target, - CURRENT_STOCK, - &digest, - BACKUP_TAG, - |_path, _contents, _snapshot| { - Err(io::Error::new( - io::ErrorKind::Interrupted, - "test interruption", - )) - }, - ); - - assert!(result.is_err()); - assert_eq!(fs::read(&target).expect("legacy stock"), OLD_STOCK); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn file_edited_after_backup_is_preserved_instead_of_migrated() { - let root = test_root("stock-migration-concurrent-edit"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - fs::write(&target, OLD_STOCK).expect("legacy stock"); - let digest = blake3::hash(OLD_STOCK).to_hex().to_string(); - let edited = b"/* user edit during migration */\n"; - - let migrated = migrate_stock_file_with_writer( - &target, - CURRENT_STOCK, - &digest, - BACKUP_TAG, - |path, contents, snapshot| { - // This hook models an editor winning the race after the backup completes - fs::write(path, edited)?; - replace_file_if_snapshot_matches(path, contents, snapshot) - }, - ) - .expect("concurrent migration check"); - - assert!(!migrated); - assert_eq!(fs::read(&target).expect("edited theme"), edited); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - assert_eq!(fs::read(backup).expect("stock backup"), OLD_STOCK); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn matching_existing_backup_allows_a_retried_migration() { - let root = test_root("stock-migration-retry"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - fs::write(&target, OLD_STOCK).expect("legacy stock"); - fs::write(&backup, OLD_STOCK).expect("matching stock backup"); - - let migrated = migrate(&target, OLD_STOCK).expect("retried stock migration"); - - assert!(migrated); - assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); - assert_eq!(fs::read(&backup).expect("stock backup"), OLD_STOCK); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn conflicting_existing_backup_uses_a_new_suffix_without_overwriting() { - let root = test_root("stock-migration-backup-conflict"); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - let backup = stock_backup_path(&target, BACKUP_TAG).expect("backup path"); - fs::write(&target, OLD_STOCK).expect("legacy stock"); - fs::write(&backup, b"custom backup").expect("conflicting stock backup"); - - let migrated = migrate(&target, OLD_STOCK).expect("collision-safe migration"); - let mut fallback_name = backup.as_os_str().to_os_string(); - fallback_name.push(".1"); - let fallback = std::path::PathBuf::from(fallback_name); - - assert!(migrated); - assert_eq!(fs::read(&target).expect("current stock"), CURRENT_STOCK); - assert_eq!( - fs::read(&backup).expect("conflicting stock backup"), - b"custom backup" - ); - assert_eq!( - fs::read(fallback).expect("fallback stock backup"), - OLD_STOCK - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn stock_migration_size_limit_accepts_exact_boundary_and_rejects_one_more_byte() { - let exact = vec![b'a'; usize::try_from(MAX_STOCK_THEME_BYTES).expect("test size")]; - let oversized = - vec![b'b'; usize::try_from(MAX_STOCK_THEME_BYTES.saturating_add(1)).expect("test size")]; - - for (name, contents, expected) in [ - ("exact-size-stock", exact, true), - ("oversized-stock", oversized, false), - ] { - let root = test_root(name); - fs::create_dir_all(&root).expect("theme root"); - let target = root.join("panel.css"); - fs::write(&target, &contents).expect("stock fixture"); - let digest = blake3::hash(&contents).to_hex().to_string(); - - let migrated = migrate_known_stock_file(&target, CURRENT_STOCK, &digest, BACKUP_TAG) - .expect("size boundary migration"); - - assert_eq!(migrated, expected, "{name}"); - if expected { - assert_eq!(fs::read(&target).expect("migrated stock"), CURRENT_STOCK); - } else { - assert_eq!(fs::read(&target).expect("preserved stock"), contents); - } - let _ = fs::remove_dir_all(root); - } -} - -fn migrate(target: &std::path::Path, legacy: &[u8]) -> Result { - let digest = blake3::hash(legacy).to_hex().to_string(); - migrate_known_stock_file(target, CURRENT_STOCK, &digest, BACKUP_TAG) -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs index 1d8974199..4b3c0f2ea 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_files.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_files.rs @@ -10,7 +10,7 @@ use crate::{ DEFAULT_WIDGETS_CSS, }; -use super::theme_stock::migrate_known_stock_themes; +use super::theme_stock::stage_current_stock_themes; use super::write::write_if_missing; use super::{ConfigError, ThemePaths}; @@ -39,7 +39,7 @@ impl Config { write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; - migrate_known_stock_themes(theme_paths)?; + stage_current_stock_themes(theme_paths)?; if legacy_contents.is_some() { let backup = legacy.with_extension("css.bak"); diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock.rs deleted file mode 100644 index 9ec8ce3a5..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Exact-byte migration for stock theme assets that shipped with older releases - -use std::io::{self, Read}; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use crate::filesystem::{ - open_regular_file, regular_file_contents_equal, write_file_atomic_preserving_mode, - write_file_if_missing, -}; -use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; - -use super::{ConfigError, ThemePaths}; - -pub(super) const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; -const MAX_BACKUP_COLLISION_RETRIES: u8 = 8; -const LEGACY_BACKUP_TAG: &str = "unixnotis-stock-9ca42584"; -const LEGACY_PANEL_DIGEST: &str = - "bd2342e4ff91dab10dbdece082d1c58e9352b3b8167e046697dd921b6de4ceb3"; -const LEGACY_WIDGETS_DIGEST: &str = - "72c0ab3c38557ea10adfee7e2b11a18b94317b9100579101c04beeb47092e5d2"; -const LEGACY_MEDIA_DIGEST: &str = - "f3618bdaf411d4b018cb9aa1688c9be0880a5bdc0016fdb5e35d8ec798ae6b36"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct FileSnapshot { - device: u64, - inode: u64, - size: u64, - modified: SystemTime, - digest: blake3::Hash, -} - -pub(super) fn migrate_known_stock_themes(paths: &ThemePaths) -> Result<(), ConfigError> { - // Each file migrates independently so one customized layer never changes another layer - migrate_known_stock_file( - &paths.panel_css, - DEFAULT_PANEL_CSS.as_bytes(), - LEGACY_PANEL_DIGEST, - LEGACY_BACKUP_TAG, - )?; - migrate_known_stock_file( - &paths.widgets_css, - DEFAULT_WIDGETS_CSS.as_bytes(), - LEGACY_WIDGETS_DIGEST, - LEGACY_BACKUP_TAG, - )?; - migrate_known_stock_file( - &paths.media_css, - DEFAULT_MEDIA_CSS.as_bytes(), - LEGACY_MEDIA_DIGEST, - LEGACY_BACKUP_TAG, - )?; - Ok(()) -} - -pub(super) fn migrate_known_stock_file( - path: &Path, - current_stock: &[u8], - legacy_digest: &str, - backup_tag: &str, -) -> Result { - migrate_stock_file_with_writer( - path, - current_stock, - legacy_digest, - backup_tag, - replace_file_if_snapshot_matches, - ) -} - -pub(super) fn migrate_stock_file_with_writer( - path: &Path, - current_stock: &[u8], - legacy_digest: &str, - backup_tag: &str, - replace_file: impl FnOnce(&Path, &[u8], &FileSnapshot) -> io::Result, -) -> Result { - // Unknown, unreadable, and oversized files remain user-owned and untouched - let Ok((original, existing)) = inspect_stock_file(path) else { - return Ok(false); - }; - if original.digest.to_hex().as_str() != legacy_digest { - return Ok(false); - } - - // The exact previous bytes are recoverable before the current stock file is published - let Some(_backup) = reserve_stock_backup(path, backup_tag, &existing)? else { - // A backup problem must retain the old theme without blocking daemon startup - return Ok(false); - }; - - // The replacement boundary rechecks the exact object and bytes that were backed up - replace_file(path, current_stock, &original).map_err(|error| migration_error(path, &error)) -} - -pub(super) fn replace_file_if_snapshot_matches( - path: &Path, - current_stock: &[u8], - original: &FileSnapshot, -) -> io::Result { - let (current, _contents) = inspect_stock_file(path)?; - if ¤t != original { - // A concurrent edit always wins over automatic stock migration - return Ok(false); - } - - // Atomic publication keeps either the complete old file or complete new file visible - write_file_atomic_preserving_mode(path, current_stock, 0o644)?; - Ok(true) -} - -fn inspect_stock_file(path: &Path) -> io::Result<(FileSnapshot, Vec)> { - let mut file = open_regular_file(path)?; - let before = file.metadata()?; - if before.len() > MAX_STOCK_THEME_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "stock theme exceeds migration size limit", - )); - } - - let capacity = usize::try_from(before.len()) - .map_err(|_error| io::Error::new(io::ErrorKind::InvalidData, "theme size is invalid"))?; - let mut contents = Vec::with_capacity(capacity); - file.by_ref() - .take(MAX_STOCK_THEME_BYTES.saturating_add(1)) - .read_to_end(&mut contents)?; - if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_STOCK_THEME_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "stock theme exceeds migration size limit", - )); - } - - let after = file.metadata()?; - let before_snapshot = snapshot_for_metadata(&before, &contents)?; - let after_snapshot = snapshot_for_metadata(&after, &contents)?; - if before_snapshot != after_snapshot { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "stock theme changed while it was being inspected", - )); - } - Ok((after_snapshot, contents)) -} - -fn snapshot_for_metadata( - metadata: &std::fs::Metadata, - contents: &[u8], -) -> io::Result { - Ok(FileSnapshot { - device: metadata.dev(), - inode: metadata.ino(), - size: metadata.len(), - modified: metadata.modified()?, - digest: blake3::hash(contents), - }) -} - -fn reserve_stock_backup( - path: &Path, - backup_tag: &str, - existing: &[u8], -) -> Result, ConfigError> { - let base = stock_backup_path(path, backup_tag)?; - for suffix in 0..=MAX_BACKUP_COLLISION_RETRIES { - let candidate = backup_candidate(&base, suffix); - match write_file_if_missing(&candidate, existing, 0o644) { - Ok(true) => return Ok(Some(candidate)), - Ok(false) => { - // Identical content is already a complete valid backup - if regular_file_contents_equal(&candidate, existing, MAX_STOCK_THEME_BYTES) - .unwrap_or(false) - { - return Ok(Some(candidate)); - } - } - Err(_) => { - // Another suffix may still be usable after a single path collision or race - } - } - } - Ok(None) -} - -fn backup_candidate(base: &Path, suffix: u8) -> PathBuf { - if suffix == 0 { - return base.to_path_buf(); - } - let mut name = base.as_os_str().to_os_string(); - name.push(format!(".{suffix}")); - PathBuf::from(name) -} - -pub(super) fn stock_backup_path(path: &Path, backup_tag: &str) -> Result { - let file_name = path.file_name().ok_or_else(|| { - ConfigError::ReadFailed(format!("theme path has no file name: {}", path.display())) - })?; - let mut backup_name = file_name.to_os_string(); - backup_name.push("."); - backup_name.push(backup_tag); - backup_name.push(".bak"); - Ok(path.with_file_name(backup_name)) -} - -fn migration_error(path: &Path, error: &io::Error) -> ConfigError { - ConfigError::ReadFailed(format!( - "failed to migrate exact stock theme {}: {error}", - path.display() - )) -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs new file mode 100644 index 000000000..22930c127 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs @@ -0,0 +1,141 @@ +//! Bounded, identity-stable file operations for stock theme migration + +use std::io::{self, Read}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use crate::filesystem::{open_regular_file, regular_file_contents_equal, write_file_if_missing}; + +use super::super::ConfigError; +use super::model::FileSnapshot; +use super::{MAX_STOCK_PATH_COLLISIONS, MAX_STOCK_THEME_BYTES}; + +const STOCK_PREVIEW_TAG: &str = "unixnotis-stock"; +const STOCK_KEEP_TAG: &str = "unixnotis-stock-kept"; + +pub(super) fn inspect_stock_file(path: &Path) -> io::Result<(FileSnapshot, Vec)> { + // One retained descriptor binds metadata and bytes to the same regular file + let mut file = open_regular_file(path)?; + let before = file.metadata()?; + if before.len() > MAX_STOCK_THEME_BYTES { + return Err(size_limit_error()); + } + + let capacity = usize::try_from(before.len()) + .map_err(|_error| io::Error::new(io::ErrorKind::InvalidData, "theme size is invalid"))?; + let mut contents = Vec::with_capacity(capacity); + file.by_ref() + .take(MAX_STOCK_THEME_BYTES.saturating_add(1)) + .read_to_end(&mut contents)?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_STOCK_THEME_BYTES { + return Err(size_limit_error()); + } + + // Metadata drift means an editor won the read race and the result cannot be authoritative + let after = file.metadata()?; + let before_snapshot = snapshot_for_metadata(&before, &contents)?; + let after_snapshot = snapshot_for_metadata(&after, &contents)?; + if before_snapshot != after_snapshot { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "stock theme changed while it was being inspected", + )); + } + Ok((after_snapshot, contents)) +} + +fn snapshot_for_metadata( + metadata: &std::fs::Metadata, + contents: &[u8], +) -> io::Result { + Ok(FileSnapshot { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified: metadata.modified()?, + digest: blake3::hash(contents), + }) +} + +pub(in crate::config::loading::io) fn stock_preview_path( + path: &Path, +) -> Result { + tagged_sibling_path( + path, + &format!("{STOCK_PREVIEW_TAG}-{}", env!("CARGO_PKG_VERSION")), + ) +} + +pub(super) fn stock_preview_candidates(path: &Path) -> Result, ConfigError> { + let base = stock_preview_path(path)?; + Ok((0..=MAX_STOCK_PATH_COLLISIONS) + .map(|suffix| collision_candidate(&base, suffix)) + .collect()) +} + +pub(super) fn stock_keep_marker_path(base_dir: &Path) -> PathBuf { + base_dir.join(format!(".{STOCK_KEEP_TAG}-{}", env!("CARGO_PKG_VERSION"))) +} + +pub(super) fn stock_backup_path(path: &Path) -> Result { + tagged_sibling_path( + path, + &format!("unixnotis-stock-before-{}", env!("CARGO_PKG_VERSION")), + ) + .map(|mut path| { + let mut name = path.as_os_str().to_os_string(); + name.push(".bak"); + path = PathBuf::from(name); + path + }) +} + +pub(super) fn reserve_stock_backup(path: &Path, existing: &[u8]) -> Result { + let base = stock_backup_path(path)?; + for suffix in 0..=MAX_STOCK_PATH_COLLISIONS { + let candidate = collision_candidate(&base, suffix); + match write_file_if_missing(&candidate, existing, 0o644) { + Ok(true) => return Ok(candidate), + Ok(false) => { + // A matching prior backup makes an interrupted Apply safe to retry + if regular_file_contents_equal(&candidate, existing, MAX_STOCK_THEME_BYTES) + .unwrap_or(false) + { + return Ok(candidate); + } + } + Err(_error) => { + // A linked or raced candidate cannot prevent trying the bounded suffix set + } + } + } + Err(ConfigError::ReadFailed( + "no collision-free stock theme backup path is available".to_string(), + )) +} + +pub(super) fn collision_candidate(base: &Path, suffix: u8) -> PathBuf { + if suffix == 0 { + return base.to_path_buf(); + } + let mut name = base.as_os_str().to_os_string(); + name.push(format!(".{suffix}")); + PathBuf::from(name) +} + +fn tagged_sibling_path(path: &Path, tag: &str) -> Result { + let file_name = path.file_name().ok_or_else(|| { + ConfigError::ReadFailed(format!("theme path has no file name: {}", path.display())) + })?; + let mut sibling_name = file_name.to_os_string(); + sibling_name.push("."); + sibling_name.push(tag); + Ok(path.with_file_name(sibling_name)) +} + +fn size_limit_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "stock theme exceeds migration size limit", + ) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs new file mode 100644 index 000000000..3f2cd721b --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs @@ -0,0 +1,251 @@ +//! Detection and explicitly approved stock theme migration + +use std::io; +use std::os::unix::ffi::OsStrExt; + +use crate::filesystem::{ + open_regular_file, write_file_atomic_preserving_mode, write_file_if_missing, +}; + +use super::super::{ConfigError, ThemePaths}; +use super::files::{inspect_stock_file, reserve_stock_backup, stock_keep_marker_path}; +use super::model::{ + FileSnapshot, StockThemeApplyReport, StockThemeCandidate, StockThemeLayer, StockThemeMigration, +}; +use super::staging::find_exact_stock_preview; + +const LEGACY_PANEL_DIGEST: &str = + "bd2342e4ff91dab10dbdece082d1c58e9352b3b8167e046697dd921b6de4ceb3"; +const LEGACY_WIDGETS_DIGEST: &str = + "72c0ab3c38557ea10adfee7e2b11a18b94317b9100579101c04beeb47092e5d2"; +const LEGACY_MEDIA_DIGEST: &str = + "f3618bdaf411d4b018cb9aa1688c9be0880a5bdc0016fdb5e35d8ec798ae6b36"; +const KEEP_MARKER_CONTENTS: &[u8] = b"UnixNotis stock theme kept for this release\n"; + +#[derive(Clone, Copy)] +pub(super) struct LegacyThemeSpec<'a> { + pub(super) layer: StockThemeLayer, + pub(super) digest: &'a str, +} + +const LEGACY_THEME_SPECS: [LegacyThemeSpec<'static>; 3] = [ + LegacyThemeSpec { + layer: StockThemeLayer::Panel, + digest: LEGACY_PANEL_DIGEST, + }, + LegacyThemeSpec { + layer: StockThemeLayer::Widgets, + digest: LEGACY_WIDGETS_DIGEST, + }, + LegacyThemeSpec { + layer: StockThemeLayer::Media, + digest: LEGACY_MEDIA_DIGEST, + }, +]; + +/// Detect exact theme files shipped by a previous `UnixNotis` release +/// +/// Customized, unreadable, linked, oversized, and current files remain outside the plan +/// +/// # Errors +/// +/// Returns an error when a persisted Keep Current marker has an unsafe file shape +pub fn detect_stock_theme_migration( + paths: &ThemePaths, +) -> Result, ConfigError> { + detect_stock_theme_migration_with_specs(paths, &LEGACY_THEME_SPECS) +} + +pub(super) fn detect_stock_theme_migration_with_specs( + paths: &ThemePaths, + specs: &[LegacyThemeSpec<'_>], +) -> Result, ConfigError> { + if keep_marker_exists(paths)? { + return Ok(None); + } + + let mut candidates = Vec::new(); + for spec in specs { + let path = spec.layer.path(paths); + // Inspection failures preserve user ownership and cannot create an eligible action + let Ok((snapshot, original_contents)) = inspect_stock_file(path) else { + continue; + }; + if snapshot.digest.to_hex().as_str() != spec.digest { + continue; + } + candidates.push(StockThemeCandidate { + layer: spec.layer, + path: path.to_path_buf(), + snapshot, + original_contents, + }); + } + + if candidates.is_empty() { + return Ok(None); + } + let fingerprint = migration_fingerprint(&candidates); + Ok(Some(StockThemeMigration { + candidates, + fingerprint, + })) +} + +impl StockThemeMigration { + /// Build panel CSS paths that point eligible layers at verified staged stock files + /// + /// # Errors + /// + /// Returns an error when configuration paths changed or no exact preview remains + pub fn preview_paths(&self, active: &ThemePaths) -> Result { + validate_plan_paths(active, self)?; + let mut preview = active.clone(); + for candidate in &self.candidates { + let path = find_exact_stock_preview( + candidate.layer.path(active), + candidate.layer.current_contents(), + )?; + candidate.layer.set_path(&mut preview, path); + } + Ok(preview) + } +} + +/// Apply one still-current migration plan after an explicit user action +/// +/// Every eligible file is backed up and revalidated before any replacement begins +/// +/// # Errors +/// +/// Returns an error without replacing a stale, edited, linked, or unbacked-up candidate +pub fn apply_stock_theme_migration( + paths: &ThemePaths, + migration: &StockThemeMigration, +) -> Result { + validate_plan_paths(paths, migration)?; + + // Validation before backup prevents obsolete UI actions from creating misleading backups + for candidate in &migration.candidates { + require_matching_snapshot(candidate)?; + } + for candidate in &migration.candidates { + let _backup = reserve_stock_backup(&candidate.path, &candidate.original_contents)?; + } + // A second whole-plan check ensures backup I/O did not hide an intervening edit + for candidate in &migration.candidates { + require_matching_snapshot(candidate)?; + } + + let mut updated_layers = 0; + for candidate in &migration.candidates { + if !replace_file_if_snapshot_matches( + &candidate.path, + candidate.layer.current_contents(), + &candidate.snapshot, + ) + .map_err(|error| migration_error(&error))? + { + return Err(stale_plan_error()); + } + updated_layers += 1; + } + + Ok(StockThemeApplyReport { updated_layers }) +} + +/// Persist the explicit choice to retain current files for this `UnixNotis` release +/// +/// # Errors +/// +/// Returns an error when the marker cannot be created as a regular file +pub fn keep_current_stock_theme(paths: &ThemePaths) -> Result<(), ConfigError> { + let marker = stock_keep_marker_path(&paths.base_dir); + match write_file_if_missing(&marker, KEEP_MARKER_CONTENTS, 0o644) { + Ok(true) => Ok(()), + Ok(false) => open_regular_file(&marker) + .map(|_file| ()) + .map_err(|error| marker_error(&error)), + Err(error) => Err(marker_error(&error)), + } +} + +pub(super) fn replace_file_if_snapshot_matches( + path: &std::path::Path, + current_stock: &[u8], + original: &FileSnapshot, +) -> io::Result { + let (current, _contents) = inspect_stock_file(path)?; + if ¤t != original { + // The editor always wins when the target changed after the approval plan was built + return Ok(false); + } + write_file_atomic_preserving_mode(path, current_stock, 0o644)?; + Ok(true) +} + +fn require_matching_snapshot(candidate: &StockThemeCandidate) -> Result<(), ConfigError> { + let (current, _contents) = + inspect_stock_file(&candidate.path).map_err(|error| migration_error(&error))?; + if current == candidate.snapshot { + Ok(()) + } else { + Err(stale_plan_error()) + } +} + +fn validate_plan_paths( + paths: &ThemePaths, + migration: &StockThemeMigration, +) -> Result<(), ConfigError> { + let unchanged = migration + .candidates + .iter() + .all(|candidate| candidate.layer.path(paths) == candidate.path); + if unchanged { + Ok(()) + } else { + Err(ConfigError::ReadFailed( + "theme configuration changed after the migration notice was shown".to_string(), + )) + } +} + +fn keep_marker_exists(paths: &ThemePaths) -> Result { + let marker = stock_keep_marker_path(&paths.base_dir); + match open_regular_file(&marker) { + Ok(_file) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(marker_error(&error)), + } +} + +fn migration_fingerprint(candidates: &[StockThemeCandidate]) -> String { + let mut hasher = blake3::Hasher::new(); + for candidate in candidates { + hasher.update(candidate.layer.label().as_bytes()); + hasher.update(candidate.path.as_os_str().as_bytes()); + hasher.update(&candidate.snapshot.device.to_le_bytes()); + hasher.update(&candidate.snapshot.inode.to_le_bytes()); + hasher.update(&candidate.snapshot.size.to_le_bytes()); + hasher.update(candidate.snapshot.digest.as_bytes()); + } + hasher.finalize().to_hex().to_string() +} + +fn stale_plan_error() -> ConfigError { + ConfigError::ReadFailed( + "theme files changed after the migration notice was shown; no stale file was replaced" + .to_string(), + ) +} + +fn migration_error(error: &io::Error) -> ConfigError { + ConfigError::ReadFailed(format!("failed to apply the approved stock theme: {error}")) +} + +fn marker_error(error: &io::Error) -> ConfigError { + ConfigError::ReadFailed(format!( + "failed to remember the Keep Current theme choice: {error}" + )) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs new file mode 100644 index 000000000..21c3f3346 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs @@ -0,0 +1,18 @@ +//! Explicit, non-destructive stock theme migration + +pub(in crate::config::loading::io) mod files; +mod migration; +mod model; +mod staging; + +pub use migration::{ + apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, +}; +pub use model::{StockThemeApplyReport, StockThemeMigration}; +pub(in crate::config::loading::io) use staging::stage_current_stock_themes; + +const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; +const MAX_STOCK_PATH_COLLISIONS: u8 = 8; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs new file mode 100644 index 000000000..3d3f6d4a2 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs @@ -0,0 +1,105 @@ +//! Stock theme migration domain types + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; + +use super::super::ThemePaths; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum StockThemeLayer { + Panel, + Widgets, + Media, +} + +impl StockThemeLayer { + pub(super) const fn label(self) -> &'static str { + match self { + Self::Panel => "panel", + Self::Widgets => "widgets", + Self::Media => "media", + } + } + + pub(super) fn path(self, paths: &ThemePaths) -> &Path { + match self { + Self::Panel => &paths.panel_css, + Self::Widgets => &paths.widgets_css, + Self::Media => &paths.media_css, + } + } + + pub(super) fn set_path(self, paths: &mut ThemePaths, path: PathBuf) { + match self { + Self::Panel => paths.panel_css = path, + Self::Widgets => paths.widgets_css = path, + Self::Media => paths.media_css = path, + } + } + + pub(super) const fn current_contents(self) -> &'static [u8] { + match self { + Self::Panel => DEFAULT_PANEL_CSS.as_bytes(), + Self::Widgets => DEFAULT_WIDGETS_CSS.as_bytes(), + Self::Media => DEFAULT_MEDIA_CSS.as_bytes(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct FileSnapshot { + pub(super) device: u64, + pub(super) inode: u64, + pub(super) size: u64, + pub(super) modified: SystemTime, + pub(super) digest: blake3::Hash, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct StockThemeCandidate { + pub(super) layer: StockThemeLayer, + pub(super) path: PathBuf, + pub(super) snapshot: FileSnapshot, + pub(super) original_contents: Vec, +} + +/// Exact known stock files that can be previewed or updated with explicit approval +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StockThemeMigration { + pub(super) candidates: Vec, + pub(super) fingerprint: String, +} + +impl StockThemeMigration { + /// Return how many independently editable theme layers are eligible + #[must_use] + pub const fn layer_count(&self) -> usize { + self.candidates.len() + } + + /// Return a compact normal-user description of the eligible layers + #[must_use] + pub fn layer_summary(&self) -> String { + let labels = self + .candidates + .iter() + .map(|candidate| candidate.layer.label()) + .collect::>(); + labels.join(", ") + } + + /// Return the stable identity used to reject stale UI actions + #[must_use] + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } +} + +/// Result of an approved stock theme update +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StockThemeApplyReport { + /// Number of active theme files replaced after revalidation + pub updated_layers: usize, +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs new file mode 100644 index 000000000..10a34ca2f --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs @@ -0,0 +1,76 @@ +//! Collision-safe versioned stock theme staging + +use std::io; +use std::path::{Path, PathBuf}; + +use crate::filesystem::{regular_file_contents_equal, write_file_if_missing}; +use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS}; + +use super::super::{ConfigError, ThemePaths}; +use super::files::stock_preview_candidates; +use super::MAX_STOCK_THEME_BYTES; + +pub(in crate::config::loading::io) fn stage_current_stock_themes( + paths: &ThemePaths, +) -> Result<(), ConfigError> { + // Startup only adds versioned siblings and never replaces an active or preview file + for (path, contents) in [ + (&paths.panel_css, DEFAULT_PANEL_CSS), + (&paths.popup_css, DEFAULT_POPUP_CSS), + (&paths.widgets_css, DEFAULT_WIDGETS_CSS), + (&paths.media_css, DEFAULT_MEDIA_CSS), + ] { + let _preview = stage_stock_preview(path, contents.as_bytes())?; + } + Ok(()) +} + +pub(super) fn stage_stock_preview(path: &Path, contents: &[u8]) -> Result { + let candidates = stock_preview_candidates(path)?; + let mut last_error = None; + for candidate in candidates { + match write_file_if_missing(&candidate, contents, 0o644) { + Ok(true) => return Ok(candidate), + Ok(false) => { + // Exact bytes make an existing staged file safe to advertise as stock + if regular_file_contents_equal(&candidate, contents, MAX_STOCK_THEME_BYTES) + .unwrap_or(false) + { + return Ok(candidate); + } + } + Err(error) => last_error = Some(error), + } + } + + let error = last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "every versioned stock preview path is occupied", + ) + }); + Err(staging_error(path, &error)) +} + +pub(super) fn find_exact_stock_preview( + path: &Path, + contents: &[u8], +) -> Result { + for candidate in stock_preview_candidates(path)? { + // Preview never loads a file merely because its name resembles a stock asset + if regular_file_contents_equal(&candidate, contents, MAX_STOCK_THEME_BYTES).unwrap_or(false) + { + return Ok(candidate); + } + } + Err(ConfigError::ReadFailed( + "verified stock theme preview is unavailable".to_string(), + )) +} + +fn staging_error(path: &Path, error: &io::Error) -> ConfigError { + ConfigError::ReadFailed(format!( + "failed to stage current stock theme {}: {error}", + path.display() + )) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs new file mode 100644 index 000000000..15bb997a9 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs @@ -0,0 +1,464 @@ +//! Explicit stock migration policy tests + +use std::fs; + +use crate::{Config, DEFAULT_PANEL_CSS}; + +use super::super::super::{ConfigError, ThemePaths}; +use super::super::files::{ + collision_candidate, inspect_stock_file, stock_backup_path, stock_keep_marker_path, +}; +use super::super::migration::{ + apply_stock_theme_migration, detect_stock_theme_migration, + detect_stock_theme_migration_with_specs, keep_current_stock_theme, + replace_file_if_snapshot_matches, LegacyThemeSpec, +}; +use super::super::model::{StockThemeLayer, StockThemeMigration}; +use super::super::staging::{stage_current_stock_themes, stage_stock_preview}; +use super::super::MAX_STOCK_THEME_BYTES; +use super::test_root; + +const LEGACY_STOCK: &[u8] = b"/* exact previous stock */\n.card { color: red; }\n"; + +fn detect_panel_migration(paths: &ThemePaths) -> Result, ConfigError> { + let digest = blake3::hash(LEGACY_STOCK).to_hex().to_string(); + detect_stock_theme_migration_with_specs( + paths, + &[LegacyThemeSpec { + layer: StockThemeLayer::Panel, + digest: &digest, + }], + ) +} + +#[test] +fn exact_legacy_stock_requires_an_explicit_apply_before_replacement() { + let root = test_root("explicit-apply"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy stock should be written"); + stage_current_stock_themes(&paths).expect("current stock should stage"); + + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + + assert_eq!( + fs::read(&paths.panel_css).expect("active stock should remain readable"), + LEGACY_STOCK, + "detection and startup staging must not replace the active file" + ); + assert_eq!(migration.layer_count(), 1, "one layer should be eligible"); + assert_eq!( + migration.fingerprint().len(), + 64, + "the plan fingerprint should retain a full BLAKE3 identity" + ); + assert_eq!( + migration.layer_summary(), + "panel", + "the notice should name the eligible layer" + ); + + let report = apply_stock_theme_migration(&paths, &migration) + .expect("explicitly approved migration should apply"); + + assert_eq!(report.updated_layers, 1, "one layer should be updated"); + assert_eq!( + fs::read(&paths.panel_css).expect("updated stock should be readable"), + DEFAULT_PANEL_CSS.as_bytes(), + "Apply should publish current stock bytes" + ); + assert_eq!( + fs::read(stock_backup_path(&paths.panel_css).expect("backup path should resolve")) + .expect("backup should be readable"), + LEGACY_STOCK, + "Apply should preserve the exact prior bytes" + ); + + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn preview_paths_change_only_the_exact_legacy_layers() { + let root = test_root("preview-paths"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + stage_current_stock_themes(&paths).expect("stock previews should stage"); + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + + let preview = migration + .preview_paths(&paths) + .expect("verified preview paths should resolve"); + + assert_ne!( + preview.panel_css, paths.panel_css, + "eligible panel CSS should point to the staged preview" + ); + assert_eq!( + preview.widgets_css, paths.widgets_css, + "unrelated widget CSS should retain its configured path" + ); + assert_eq!( + preview.media_css, paths.media_css, + "unrelated media CSS should retain its configured path" + ); + assert_eq!( + preview.popup_css, paths.popup_css, + "popup CSS should not be folded into a panel migration" + ); + + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn keep_current_persists_the_choice_without_changing_theme_bytes() { + let root = test_root("keep-current"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + assert!( + detect_panel_migration(&paths) + .expect("initial detection should succeed") + .is_some(), + "exact legacy stock should initially produce a notice" + ); + + keep_current_stock_theme(&paths).expect("Keep Current should persist"); + + assert!( + detect_panel_migration(&paths) + .expect("post-choice detection should succeed") + .is_none(), + "the current release should respect the persisted choice" + ); + assert_eq!( + fs::read(&paths.panel_css).expect("kept theme should be readable"), + LEGACY_STOCK, + "Keep Current must not alter active CSS" + ); + assert!( + stock_keep_marker_path(&paths.base_dir).is_file(), + "Keep Current should create a regular release marker" + ); + + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn custom_theme_is_not_offered_as_a_stock_migration() { + let root = test_root("custom-theme"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, b"/* customized */").expect("custom panel should be written"); + + let migration = detect_panel_migration(&paths).expect("custom theme inspection should succeed"); + + assert!( + migration.is_none(), + "non-stock bytes must remain outside the migration flow" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn stale_apply_does_not_replace_an_edit_made_after_the_notice() { + let root = test_root("stale-apply"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + let edited = b"/* user edit after notice */\n"; + fs::write(&paths.panel_css, edited).expect("user edit should be written"); + + let error = apply_stock_theme_migration(&paths, &migration) + .expect_err("stale approval must be rejected"); + + assert!( + error.to_string().contains("changed"), + "the failure should explain that the approval became stale" + ); + assert_eq!( + fs::read(&paths.panel_css).expect("edited theme should be readable"), + edited, + "the newer edit must win" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn final_snapshot_check_preserves_a_concurrent_edit() { + let root = test_root("final-snapshot"); + fs::create_dir_all(&root).expect("theme root should be created"); + let path = root.join("panel.css"); + fs::write(&path, LEGACY_STOCK).expect("legacy panel should be written"); + let (snapshot, _contents) = + inspect_stock_file(&path).expect("initial snapshot should be captured"); + let edited = b"/* editor won the race */\n"; + fs::write(&path, edited).expect("concurrent edit should be written"); + + let replaced = replace_file_if_snapshot_matches(&path, DEFAULT_PANEL_CSS.as_bytes(), &snapshot) + .expect("snapshot comparison should complete"); + + assert!(!replaced, "a changed file must not be replaced"); + assert_eq!( + fs::read(&path).expect("edited theme should be readable"), + edited, + "the concurrent edit must remain intact" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[cfg(unix)] +#[test] +fn linked_theme_is_never_eligible_for_replacement() { + let root = test_root("linked-theme"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + let protected = root.join("protected.css"); + fs::write(&protected, LEGACY_STOCK).expect("protected file should be written"); + std::os::unix::fs::symlink(&protected, &paths.panel_css) + .expect("active theme link should be created"); + + let migration = + detect_panel_migration(&paths).expect("linked theme inspection should remain non-fatal"); + + assert!(migration.is_none(), "linked CSS must never become eligible"); + assert_eq!( + fs::read(&protected).expect("protected CSS should be readable"), + LEGACY_STOCK, + "the link target must remain unchanged" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn every_candidate_is_revalidated_before_the_first_replacement() { + let root = test_root("whole-plan-revalidation"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + fs::write(&paths.widgets_css, LEGACY_STOCK).expect("legacy widgets should be written"); + let digest = blake3::hash(LEGACY_STOCK).to_hex().to_string(); + let migration = detect_stock_theme_migration_with_specs( + &paths, + &[ + LegacyThemeSpec { + layer: StockThemeLayer::Panel, + digest: &digest, + }, + LegacyThemeSpec { + layer: StockThemeLayer::Widgets, + digest: &digest, + }, + ], + ) + .expect("migration detection should succeed") + .expect("both exact layers should be eligible"); + assert_eq!( + migration.layer_count(), + 2, + "both exact layers should remain represented in the plan" + ); + fs::write(&paths.widgets_css, b"/* later widget edit */") + .expect("later widget edit should be written"); + + apply_stock_theme_migration(&paths, &migration) + .expect_err("one stale layer should reject the complete plan"); + + assert_eq!( + fs::read(&paths.panel_css).expect("panel should remain readable"), + LEGACY_STOCK, + "a later stale layer must stop earlier candidates from being replaced" + ); + assert_eq!( + fs::read(&paths.widgets_css).expect("widgets should remain readable"), + b"/* later widget edit */", + "the newer widget edit must remain intact" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn conflicting_backup_is_preserved_and_apply_uses_a_suffix() { + let root = test_root("backup-collision"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + let backup = stock_backup_path(&paths.panel_css).expect("backup path should resolve"); + fs::write(&backup, b"/* unrelated backup */").expect("collision should be written"); + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + + apply_stock_theme_migration(&paths, &migration) + .expect("Apply should use a collision-safe backup name"); + + assert_eq!( + fs::read(&backup).expect("collision should remain readable"), + b"/* unrelated backup */", + "Apply must never overwrite an existing backup" + ); + assert_eq!( + fs::read(collision_candidate(&backup, 1)).expect("suffix backup should be readable"), + LEGACY_STOCK, + "the exact prior bytes should use the next available suffix" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn modified_staged_file_cannot_be_loaded_as_a_stock_preview() { + let root = test_root("tampered-preview"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + let staged = stage_stock_preview(&paths.panel_css, DEFAULT_PANEL_CSS.as_bytes()) + .expect("panel preview should stage"); + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + fs::write(&staged, b"/* changed after staging */").expect("staged file should be changed"); + + migration + .preview_paths(&paths) + .expect_err("changed staged bytes must not be loaded as stock"); + + assert_eq!( + fs::read(&paths.panel_css).expect("active panel should remain readable"), + LEGACY_STOCK, + "a failed preview must not change the active theme" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[cfg(unix)] +#[test] +fn keep_current_rejects_a_marker_symlink_without_touching_its_target() { + let root = test_root("linked-keep-marker"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + let protected = root.join("protected-choice.txt"); + fs::write(&protected, b"protected").expect("protected marker target should be written"); + std::os::unix::fs::symlink(&protected, stock_keep_marker_path(&paths.base_dir)) + .expect("marker link should be created"); + + keep_current_stock_theme(&paths).expect_err("a linked marker must be rejected"); + + assert_eq!( + fs::read(&protected).expect("protected target should remain readable"), + b"protected", + "Keep Current must never follow a marker link" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn changed_configured_path_rejects_the_original_plan() { + let root = test_root("changed-config-path"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); + stage_current_stock_themes(&paths).expect("stock previews should stage"); + let migration = detect_panel_migration(&paths) + .expect("migration detection should succeed") + .expect("exact stock should be eligible"); + let mut changed_paths = paths.clone(); + changed_paths.panel_css = root.join("different-panel.css"); + + migration + .preview_paths(&changed_paths) + .expect_err("a plan must stay bound to its configured path"); + apply_stock_theme_migration(&changed_paths, &migration) + .expect_err("Apply must reject a changed configured path"); + + assert_eq!( + fs::read(&paths.panel_css).expect("original panel should remain readable"), + LEGACY_STOCK, + "path drift must not alter the originally approved file" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn stock_file_inspection_accepts_the_exact_limit_and_rejects_one_more_byte() { + let root = test_root("inspection-size-boundary"); + fs::create_dir_all(&root).expect("theme root should be created"); + let exact_size = usize::try_from(MAX_STOCK_THEME_BYTES).expect("test limit should fit usize"); + let exact = root.join("exact.css"); + let oversized = root.join("oversized.css"); + fs::write(&exact, vec![b'x'; exact_size]).expect("exact-sized CSS should be written"); + fs::write(&oversized, vec![b'x'; exact_size.saturating_add(1)]) + .expect("oversized CSS should be written"); + + let (snapshot, contents) = + inspect_stock_file(&exact).expect("the exact size limit should be accepted"); + let error = inspect_stock_file(&oversized).expect_err("one extra byte should be rejected"); + + assert_eq!( + snapshot.size, MAX_STOCK_THEME_BYTES, + "the exact boundary should preserve its full size" + ); + assert_eq!( + contents.len(), + exact_size, + "the exact boundary should preserve every byte" + ); + assert_eq!( + error.kind(), + std::io::ErrorKind::InvalidData, + "oversized stock input should fail as invalid data" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[cfg(unix)] +#[test] +fn production_detection_fails_closed_for_a_linked_keep_marker() { + let root = test_root("production-linked-marker"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + let protected = root.join("protected-marker.txt"); + fs::write(&protected, b"protected").expect("protected marker should be written"); + std::os::unix::fs::symlink(&protected, stock_keep_marker_path(&paths.base_dir)) + .expect("linked marker should be created"); + + detect_stock_theme_migration(&paths) + .expect_err("production detection must reject an unsafe marker shape"); + + assert_eq!( + fs::read(&protected).expect("protected marker should remain readable"), + b"protected", + "detection must not follow or modify the marker link" + ); + fs::remove_dir_all(root).expect("theme root should be removed"); +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs new file mode 100644 index 000000000..fa7f457e2 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs @@ -0,0 +1,21 @@ +//! Stock theme migration regression tests + +mod migration; +mod staging; + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn test_root(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + std::env::current_dir() + .expect("current directory should resolve") + .join("target") + .join(format!( + "unixnotis-theme-stock-{name}-{}-{unique}", + std::process::id() + )) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs new file mode 100644 index 000000000..ce8721fba --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs @@ -0,0 +1,84 @@ +//! Versioned preview staging tests + +use std::fs; + +use crate::{Config, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS}; + +use super::super::files::{collision_candidate, stock_preview_path}; +use super::super::staging::{find_exact_stock_preview, stage_current_stock_themes}; +use super::test_root; + +#[test] +fn staging_writes_every_stock_theme_under_a_versioned_sibling_name() { + let root = test_root("stage-all"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + + stage_current_stock_themes(&paths).expect("stock themes should stage"); + + for (path, expected) in [ + (&paths.panel_css, DEFAULT_PANEL_CSS), + (&paths.popup_css, DEFAULT_POPUP_CSS), + (&paths.widgets_css, DEFAULT_WIDGETS_CSS), + (&paths.media_css, DEFAULT_MEDIA_CSS), + ] { + let preview = stock_preview_path(path).expect("versioned stock path should resolve"); + assert_eq!( + fs::read_to_string(preview).expect("staged stock theme should be readable"), + expected, + "each preview should contain the current embedded stock layer" + ); + assert!( + !path.exists(), + "staging must not create or replace active CSS" + ); + } + + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn conflicting_preview_is_preserved_and_exact_stock_uses_a_suffix() { + let root = test_root("preview-collision"); + fs::create_dir_all(&root).expect("theme root should be created"); + let paths = Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + let primary = stock_preview_path(&paths.panel_css).expect("preview path should resolve"); + fs::write(&primary, "/* reviewed custom file */").expect("collision should be written"); + + stage_current_stock_themes(&paths).expect("stock themes should stage around collisions"); + + let fallback = collision_candidate(&primary, 1); + assert_eq!( + fs::read_to_string(&primary).expect("collision should remain readable"), + "/* reviewed custom file */", + "staging must preserve an occupied versioned path" + ); + assert_eq!( + fs::read_to_string(&fallback).expect("fallback preview should be readable"), + DEFAULT_PANEL_CSS, + "a collision-safe sibling should contain exact stock bytes" + ); + assert_eq!( + find_exact_stock_preview(&paths.panel_css, DEFAULT_PANEL_CSS.as_bytes()) + .expect("exact preview should be found"), + fallback, + "preview selection must ignore the caller-controlled collision" + ); + + fs::remove_dir_all(root).expect("theme root should be removed"); +} + +#[test] +fn preview_path_rejects_a_path_without_a_file_name() { + let error = stock_preview_path(std::path::Path::new("/")) + .expect_err("a directory root cannot identify a theme file"); + + assert!( + error.to_string().contains("no file name"), + "the error should explain why staging cannot continue" + ); +} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index fbb5be607..6920177c1 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -25,7 +25,10 @@ pub use icon_assets::{ ResolvedIconAsset, DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_BYTES, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; -pub use io::{ConfigError, ThemePaths, MAX_CONFIG_BYTES}; +pub use io::{ + apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, + ConfigError, StockThemeApplyReport, StockThemeMigration, ThemePaths, MAX_CONFIG_BYTES, +}; pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; pub use media::*; From 629ddd41dd2d9f041a249887c36867405143f691 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:28:22 -0500 Subject: [PATCH 140/275] feat(center): add explicit theme migration choices Summary: add explicit theme migration choices. Scope: center. --- crates/unixnotis-center/src/control/model.rs | 3 + .../src/control/tests/model.rs | 25 +- crates/unixnotis-center/src/ui/events.rs | 12 + .../src/ui/init/constructor.rs | 11 +- crates/unixnotis-center/src/ui/mod.rs | 1 + crates/unixnotis-center/src/ui/panel/mod.rs | 2 +- .../unixnotis-center/src/ui/panel/notice.rs | 46 +++- .../src/ui/panel/tests/notice.rs | 28 +- .../src/ui/reload/config/flow.rs | 3 + .../src/ui/reload/config/notice.rs | 16 +- .../src/ui/reload/config/tests/notice.rs | 40 +-- crates/unixnotis-center/src/ui/reload/mod.rs | 2 +- .../unixnotis-center/src/ui/reload/notices.rs | 11 +- .../src/ui/reload/tests/notices.rs | 36 +++ crates/unixnotis-center/src/ui/state.rs | 6 +- .../src/ui/theme_migration/actions.rs | 42 +++ .../src/ui/theme_migration/flow.rs | 194 ++++++++++++++ .../src/ui/theme_migration/mod.rs | 9 + .../src/ui/theme_migration/tests/actions.rs | 33 +++ .../fixtures/legacy-panel-9ca42584.css.gz | Bin 0 -> 4105 bytes .../src/ui/theme_migration/tests/flow.rs | 251 ++++++++++++++++++ .../src/ui/theme_migration/tests/mod.rs | 4 + 22 files changed, 733 insertions(+), 42 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/theme_migration/actions.rs create mode 100644 crates/unixnotis-center/src/ui/theme_migration/flow.rs create mode 100644 crates/unixnotis-center/src/ui/theme_migration/mod.rs create mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs create mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz create mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs create mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index 1aba8fe7d..ceb713f87 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -38,6 +38,9 @@ pub enum UiEvent { WidgetsCollapsed(bool), CssReload, ConfigReload, + ThemeMigrationPreview, + ThemeMigrationApply, + ThemeMigrationKeepCurrent, } /// Commands sent from GTK handlers to the D-Bus runtime. diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index 4d4a06c80..83e761b97 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -1,14 +1,35 @@ use super::{UiCommand, UiEvent}; +use unixnotis_core::NotificationKey; #[test] -fn dismiss_command_preserves_notification_id() { - assert!(matches!(UiCommand::Dismiss(29), UiCommand::Dismiss(29))); +fn dismiss_command_preserves_notification_generation() { + let notification = NotificationKey { + id: 29, + generation: 31, + }; + + assert!(matches!( + UiCommand::Dismiss(notification), + UiCommand::Dismiss(key) if key == notification + )); } #[test] fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); assert!(matches!(UiEvent::ConfigReload, UiEvent::ConfigReload)); + assert!(matches!( + UiEvent::ThemeMigrationPreview, + UiEvent::ThemeMigrationPreview + )); + assert!(matches!( + UiEvent::ThemeMigrationApply, + UiEvent::ThemeMigrationApply + )); + assert!(matches!( + UiEvent::ThemeMigrationKeepCurrent, + UiEvent::ThemeMigrationKeepCurrent + )); } #[test] diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 593086172..a333cf4b6 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -206,6 +206,18 @@ impl UiState { } } } + UiEvent::ThemeMigrationPreview => { + debug!("stock theme preview requested"); + self.preview_stock_theme_migration(); + } + UiEvent::ThemeMigrationApply => { + debug!("stock theme apply requested"); + self.apply_stock_theme_migration(); + } + UiEvent::ThemeMigrationKeepCurrent => { + debug!("stock theme retention requested"); + self.keep_current_stock_theme(); + } } } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index f93c1969b..5248635a5 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use unixnotis_core::{Config, PanelDebugLevel}; -use super::super::{hyprland, icons, panel, widgets, UiState, UiStateInit}; +use super::super::{hyprland, icons, panel, theme_migration, widgets, UiState, UiStateInit}; use super::builders::{ build_media_widget, build_notification_list, build_widget_sections, has_visible_widget_section, icon_resolver_for_widgets, @@ -54,6 +54,7 @@ impl UiState { init.command_tx.clone(), ); panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); + theme_migration::connect_notice_actions(&panel.reload_notice, init.event_tx.clone()); panel::header::search::connect_widget_collapse_toggle( &panel.header.actions.focus_toggle, &panel.sections.widget_revealer, @@ -81,7 +82,7 @@ impl UiState { } // Long-lived state owns every channel, guard, and optional widget built above - Self { + let mut state = Self { config: init.config, config_path: init.config_path, css: init.css, @@ -114,7 +115,11 @@ impl UiState { last_slow_refresh: None, // Reload notices preserve independent config and CSS failure identities reload_notices: super::super::reload::ReloadNoticeState::default(), + theme_migration: None, + theme_preview_active: false, _runtime: init.runtime, - } + }; + state.initialize_stock_theme_migration(); + state } } diff --git a/crates/unixnotis-center/src/ui/mod.rs b/crates/unixnotis-center/src/ui/mod.rs index 27ff2d753..900d4f891 100644 --- a/crates/unixnotis-center/src/ui/mod.rs +++ b/crates/unixnotis-center/src/ui/mod.rs @@ -14,6 +14,7 @@ mod motion; mod notifications; mod panel; mod state; +mod theme_migration; mod widget_builders; mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index e40f2db71..9b60d73c6 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -9,6 +9,6 @@ pub(in crate::ui) mod build; pub(in crate::ui) mod geometry; pub(in crate::ui) mod header; pub(in crate::ui) mod motion; -mod notice; +pub(in crate::ui) mod notice; mod state; pub(in crate::ui) mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index ec8229264..43cbbb072 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -9,14 +9,22 @@ pub(in crate::ui) struct ReloadNoticeWidgets { pub(in crate::ui) revealer: gtk::Revealer, pub(in crate::ui) shell: gtk::Box, pub(in crate::ui) label: gtk::Label, + pub(in crate::ui) close: gtk::Button, + pub(in crate::ui) actions: gtk::Box, + pub(in crate::ui) preview_button: gtk::Button, + pub(in crate::ui) apply_button: gtk::Button, + pub(in crate::ui) keep_button: gtk::Button, } -pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { - // Horizontal layout keeps the message and dismissal action on one row - let shell = gtk::Box::new(gtk::Orientation::Horizontal, 10); +pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { + // The outer column keeps migration choices below the compact status message + let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); shell.add_css_class(hooks::panel_shell::RELOAD_NOTICE); shell.set_hexpand(true); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 10); + content.add_css_class(hooks::panel_shell::RELOAD_NOTICE_CONTENT); + // Wrapping prevents long parser errors from changing panel width let label = gtk::Label::new(None); label.add_css_class(hooks::panel_shell::RELOAD_NOTICE_TEXT); @@ -31,8 +39,24 @@ pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { close.set_tooltip_text(Some("Dismiss reload notice")); close.set_valign(gtk::Align::Start); - shell.append(&label); - shell.append(&close); + content.append(&label); + content.append(&close); + shell.append(&content); + + // Theme migration remains an explicit three-way choice + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + actions.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTIONS); + actions.set_homogeneous(true); + actions.set_visible(false); + + let preview_button = notice_action("Preview", "Preview the staged stock panel theme"); + let apply_button = notice_action("Apply", "Back up and apply the staged stock theme"); + apply_button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY); + let keep_button = notice_action("Keep Current", "Keep the current theme for this release"); + actions.append(&preview_button); + actions.append(&apply_button); + actions.append(&keep_button); + shell.append(&actions); // A short vertical transition keeps the header position stable let revealer = gtk::Revealer::new(); @@ -48,9 +72,21 @@ pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { revealer, shell, label, + close, + actions, + preview_button, + apply_button, + keep_button, } } +fn notice_action(label: &str, tooltip: &str) -> gtk::Button { + let button = gtk::Button::with_label(label); + button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION); + button.set_tooltip_text(Some(tooltip)); + button +} + #[cfg(test)] #[path = "tests/notice.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/notice.rs b/crates/unixnotis-center/src/ui/panel/tests/notice.rs index 775c9faa4..3ad996080 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/notice.rs @@ -14,14 +14,28 @@ fn reload_notice_starts_hidden_and_dismiss_button_hides_it() { assert!(notice .label .has_css_class(hooks::panel_shell::RELOAD_NOTICE_TEXT)); + assert!(!notice.actions.get_visible()); + assert!(notice.close.get_visible()); notice.revealer.set_reveal_child(true); - let close = notice - .shell - .last_child() - .expect("notice close button") - .downcast::() - .expect("close button widget"); - close.emit_clicked(); + notice.close.emit_clicked(); assert!(!notice.revealer.reveals_child()); } + +#[gtk::test] +fn migration_actions_have_distinct_labels_and_primary_apply_style() { + let notice = build_reload_notice(); + + assert_eq!(notice.preview_button.label().as_deref(), Some("Preview")); + assert_eq!(notice.apply_button.label().as_deref(), Some("Apply")); + assert_eq!(notice.keep_button.label().as_deref(), Some("Keep Current")); + assert!(notice + .apply_button + .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); + assert!(!notice + .preview_button + .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); + assert!(!notice + .keep_button + .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/flow.rs b/crates/unixnotis-center/src/ui/reload/config/flow.rs index 6bf01f93e..0b08f76c9 100644 --- a/crates/unixnotis-center/src/ui/reload/config/flow.rs +++ b/crates/unixnotis-center/src/ui/reload/config/flow.rs @@ -42,6 +42,9 @@ impl UiState { // Any accepted config replaces a prior rejection before CSS reports its own result self.clear_reload_notice(ReloadNoticeKind::Config); self.apply_css_reload_notice(&css); + // Accepted paths end any in-memory preview and may expose a different exact stock plan + self.theme_preview_active = false; + self.refresh_stock_theme_migration_notice(); ConfigReloadOutcome::Applied { diagnostics: reload.diagnostics, css, diff --git a/crates/unixnotis-center/src/ui/reload/config/notice.rs b/crates/unixnotis-center/src/ui/reload/config/notice.rs index fa1191f63..1b3329617 100644 --- a/crates/unixnotis-center/src/ui/reload/config/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/notice.rs @@ -24,7 +24,7 @@ impl UiState { self.set_reload_notice(ReloadNoticeKind::Config, &message, true, &identity); } - pub(super) fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { + pub(in crate::ui) fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { // Intentional empty files are valid fallback requests and do not produce a notice let failures = report.read_failures().collect::>(); if failures.is_empty() { @@ -50,7 +50,7 @@ impl UiState { self.set_reload_notice(ReloadNoticeKind::Css, &message, false, &identity); } - fn set_reload_notice( + pub(in crate::ui) fn set_reload_notice( &mut self, kind: ReloadNoticeKind, message: &str, @@ -90,10 +90,20 @@ impl UiState { } else { hooks::panel_shell::RELOAD_NOTICE_WARNING }); + let shows_migration_actions = notice.fingerprint.kind == ReloadNoticeKind::ThemeMigration; + // Action notices cannot be dismissed without recording an explicit policy choice + self.panel + .reload_notice + .close + .set_visible(!shows_migration_actions); + self.panel + .reload_notice + .actions + .set_visible(shows_migration_actions); self.panel.reload_notice.revealer.set_reveal_child(true); } - pub(super) fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { + pub(in crate::ui) fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { self.reload_notices.clear(kind); self.render_reload_notice(); } diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs index 7f114cec5..1e5957932 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs @@ -39,15 +39,7 @@ fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { let _outcome = state.reload_config(); assert!(state.panel.reload_notice.revealer.reveals_child()); - let close = state - .panel - .reload_notice - .shell - .last_child() - .expect("reload notice close button") - .downcast::() - .expect("reload notice close widget"); - close.emit_clicked(); + state.panel.reload_notice.close.emit_clicked(); assert!(!state.panel.reload_notice.revealer.reveals_child()); let _same_outcome = state.reload_config(); @@ -65,15 +57,7 @@ fn changed_css_failure_reopens_after_the_previous_failure_was_dismissed() { assert!(first_report.read_failures().count() > 1); assert!(state.panel.reload_notice.revealer.reveals_child()); - let close = state - .panel - .reload_notice - .shell - .last_child() - .expect("reload notice close button") - .downcast::() - .expect("reload notice close widget"); - close.emit_clicked(); + state.panel.reload_notice.close.emit_clicked(); assert!(!state.panel.reload_notice.revealer.reveals_child()); let same_report = state.reload_css(); @@ -155,3 +139,23 @@ fn css_reload_notice_summarizes_multiple_unreadable_layers() { .shell .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); } + +#[gtk::test] +fn migration_notice_requires_an_explicit_action_instead_of_generic_dismissal() { + let mut state = state(); + state.set_reload_notice( + crate::ui::reload::ReloadNoticeKind::ThemeMigration, + "Stock theme update available", + false, + "migration-a", + ); + + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state.panel.reload_notice.actions.get_visible()); + assert!(!state.panel.reload_notice.close.get_visible()); + + state.capture_notice_dismissal(); + + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state.panel.reload_notice.actions.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/reload/mod.rs b/crates/unixnotis-center/src/ui/reload/mod.rs index 76baf8066..ca86474fa 100644 --- a/crates/unixnotis-center/src/ui/reload/mod.rs +++ b/crates/unixnotis-center/src/ui/reload/mod.rs @@ -5,4 +5,4 @@ mod notices; mod refresh; pub(in crate::ui) use config::{log_reload_rejection, ConfigReloadOutcome}; -pub(in crate::ui) use notices::ReloadNoticeState; +pub(in crate::ui) use notices::{ReloadNoticeKind, ReloadNoticeState}; diff --git a/crates/unixnotis-center/src/ui/reload/notices.rs b/crates/unixnotis-center/src/ui/reload/notices.rs index d2a8ae697..fa0fd3ea8 100644 --- a/crates/unixnotis-center/src/ui/reload/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/notices.rs @@ -1,9 +1,10 @@ //! Priority and dismissal state for configuration and CSS reload notices #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum ReloadNoticeKind { +pub(in crate::ui) enum ReloadNoticeKind { Config, Css, + ThemeMigration, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -23,6 +24,7 @@ pub(super) struct ReloadNotice { pub(in crate::ui) struct ReloadNoticeState { config: Option, css: Option, + theme_migration: Option, dismissed_config: Option, dismissed_css: Option, } @@ -38,6 +40,10 @@ impl ReloadNoticeState { // Duplicate watcher events retain dismissal for the same failure Self::replace_notice(&mut self.css, &mut self.dismissed_css, notice); } + ReloadNoticeKind::ThemeMigration => { + // Migration requires an explicit choice and has no generic dismissal lifecycle + self.theme_migration = Some(notice); + } } } @@ -52,6 +58,7 @@ impl ReloadNoticeState { self.css = None; self.dismissed_css = None; } + ReloadNoticeKind::ThemeMigration => self.theme_migration = None, } } @@ -79,6 +86,7 @@ impl ReloadNoticeState { // Each class remembers dismissal independently across priority changes ReloadNoticeKind::Config => self.dismissed_config = Some(notice.fingerprint), ReloadNoticeKind::Css => self.dismissed_css = Some(notice.fingerprint), + ReloadNoticeKind::ThemeMigration => {} } } @@ -93,6 +101,7 @@ impl ReloadNoticeState { .as_ref() .filter(|notice| self.dismissed_css.as_ref() != Some(¬ice.fingerprint)) }) + .or(self.theme_migration.as_ref()) } } diff --git a/crates/unixnotis-center/src/ui/reload/tests/notices.rs b/crates/unixnotis-center/src/ui/reload/tests/notices.rs index dde074d4f..3a53af827 100644 --- a/crates/unixnotis-center/src/ui/reload/tests/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/tests/notices.rs @@ -114,3 +114,39 @@ fn old_dismissal_does_not_hide_a_failure_after_an_intervening_fingerprint() { Some("config-a") ); } + +#[test] +fn migration_notice_waits_behind_failures_and_returns_after_recovery() { + let mut state = ReloadNoticeState::default(); + state.set(notice(ReloadNoticeKind::ThemeMigration, "migration-a")); + state.set(notice(ReloadNoticeKind::Css, "css-a")); + state.set(notice(ReloadNoticeKind::Config, "config-a")); + + assert_eq!( + state.visible().map(|notice| notice.fingerprint.kind), + Some(ReloadNoticeKind::Config) + ); + state.clear(ReloadNoticeKind::Config); + assert_eq!( + state.visible().map(|notice| notice.fingerprint.kind), + Some(ReloadNoticeKind::Css) + ); + state.clear(ReloadNoticeKind::Css); + assert_eq!( + state.visible().map(|notice| notice.fingerprint.kind), + Some(ReloadNoticeKind::ThemeMigration) + ); +} + +#[test] +fn generic_dismissal_does_not_discard_a_migration_choice() { + let mut state = ReloadNoticeState::default(); + state.set(notice(ReloadNoticeKind::ThemeMigration, "migration-a")); + + state.dismiss_visible(); + + assert_eq!( + state.visible().map(|notice| notice.fingerprint.kind), + Some(ReloadNoticeKind::ThemeMigration) + ); +} diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 929987737..d2e2435d3 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::time::Instant; use tokio::sync::mpsc; -use unixnotis_core::{Config, IconAssetResolver, Margins}; +use unixnotis_core::{Config, IconAssetResolver, Margins, StockThemeMigration}; use unixnotis_ui::css::CssManager; use crate::control::{UiCommand, UiEvent}; @@ -56,6 +56,10 @@ pub struct UiState { pub(super) last_slow_refresh: Option, // Separate config and CSS state preserves severity priority across watcher races pub(super) reload_notices: reload::ReloadNoticeState, + // Exact stock candidates remain bound to the filesystem snapshot shown in the notice + pub(super) theme_migration: Option, + // Preview changes only the in-memory CSS paths and never the configured active files + pub(super) theme_preview_active: bool, // Keeps the shared async runtime alive for D-Bus and media tasks pub(super) _runtime: Arc, } diff --git a/crates/unixnotis-center/src/ui/theme_migration/actions.rs b/crates/unixnotis-center/src/ui/theme_migration/actions.rs new file mode 100644 index 000000000..c1cf6dda9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/actions.rs @@ -0,0 +1,42 @@ +//! Theme migration notice action wiring + +use async_channel::TrySendError; +use gtk::prelude::*; + +use crate::control::UiEvent; +use crate::ui::panel::notice::ReloadNoticeWidgets; + +pub(in crate::ui) fn connect_notice_actions( + notice: &ReloadNoticeWidgets, + event_tx: async_channel::Sender, +) { + let preview_tx = event_tx.clone(); + notice.preview_button.connect_clicked(move |_| { + send_action(&preview_tx, UiEvent::ThemeMigrationPreview); + }); + + let apply_tx = event_tx.clone(); + notice.apply_button.connect_clicked(move |_| { + send_action(&apply_tx, UiEvent::ThemeMigrationApply); + }); + + notice.keep_button.connect_clicked(move |_| { + send_action(&event_tx, UiEvent::ThemeMigrationKeepCurrent); + }); +} + +fn send_action(event_tx: &async_channel::Sender, event: UiEvent) { + match event_tx.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + // Explicit user choices wait for queue capacity instead of disappearing under load + let event_tx = event_tx.clone(); + gtk::glib::MainContext::default().spawn_local(async move { + let _result = event_tx.send(event).await; + }); + } + Err(TrySendError::Closed(_event)) => { + // Shutdown already owns the UI when the receiver is gone + } + } +} diff --git a/crates/unixnotis-center/src/ui/theme_migration/flow.rs b/crates/unixnotis-center/src/ui/theme_migration/flow.rs new file mode 100644 index 000000000..929c549fc --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/flow.rs @@ -0,0 +1,194 @@ +//! Stock theme migration state transitions + +use tracing::{debug, warn}; +use unixnotis_core::{ + apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, Config, + ConfigError, StockThemeMigration, ThemePaths, +}; +use unixnotis_ui::css::CssReloadReport; + +use crate::ui::reload::ReloadNoticeKind; +use crate::ui::UiState; + +impl UiState { + pub(in crate::ui) fn initialize_stock_theme_migration(&mut self) { + self.refresh_stock_theme_migration_notice(); + } + + pub(in crate::ui) fn preview_stock_theme_migration(&mut self) { + let Some(migration) = self.theme_migration.clone() else { + return; + }; + let Ok(configured_paths) = self.configured_theme_paths() else { + self.show_theme_migration_failure( + &migration, + "Preview unavailable\nThe configured theme location could not be resolved", + ); + return; + }; + let preview_paths = match migration.preview_paths(&configured_paths) { + Ok(paths) => paths, + Err(error) => { + warn!(?error, "stock theme preview rejected"); + self.show_theme_migration_failure( + &migration, + "Preview unavailable\nVerified staged theme files could not be loaded", + ); + return; + } + }; + + // Only the center provider paths change during preview; active files remain untouched + self.css + .update_theme(preview_paths, self.config.theme.clone()); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + self.icon_resolver.clear_missing_cache(); + self.theme_preview_active = true; + self.apply_css_reload_notice(&report); + self.show_theme_migration_notice( + &migration, + &format!( + "Stock theme preview active\nReview the {} layer(s), then Apply or Keep Current", + migration.layer_summary() + ), + false, + ); + } + + pub(in crate::ui) fn apply_stock_theme_migration(&mut self) { + let Some(migration) = self.theme_migration.clone() else { + return; + }; + let configured_paths = match self.configured_theme_paths() { + Ok(paths) => paths, + Err(error) => { + warn!(?error, "stock theme Apply path resolution failed"); + self.show_theme_migration_failure( + &migration, + "Theme update was not applied\nThe configured theme location could not be resolved", + ); + return; + } + }; + + match apply_stock_theme_migration(&configured_paths, &migration) { + Ok(report) => { + debug!( + updated_layers = report.updated_layers, + "approved stock theme update applied" + ); + let css = self.restore_configured_theme(configured_paths); + self.theme_migration = None; + self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); + self.apply_css_reload_notice(&css); + } + Err(error) => { + warn!(?error, "approved stock theme update stopped"); + let css = self.restore_configured_theme(configured_paths); + self.apply_css_reload_notice(&css); + self.show_theme_migration_failure( + &migration, + "Theme update stopped safely\nA file changed or could not be backed up; current files remain active", + ); + } + } + } + + pub(in crate::ui) fn keep_current_stock_theme(&mut self) { + let Some(migration) = self.theme_migration.clone() else { + return; + }; + let configured_paths = match self.configured_theme_paths() { + Ok(paths) => paths, + Err(error) => { + warn!(?error, "Keep Current path resolution failed"); + self.show_theme_migration_failure( + &migration, + "Keep Current could not be saved\nThe configured theme location could not be resolved", + ); + return; + } + }; + + let outcome = keep_current_stock_theme(&configured_paths); + let css = self.restore_configured_theme(configured_paths); + self.apply_css_reload_notice(&css); + match outcome { + Ok(()) => { + self.theme_migration = None; + self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); + } + Err(error) => { + warn!(?error, "Keep Current choice could not be persisted"); + self.show_theme_migration_failure( + &migration, + "Keep Current could not be saved\nNo theme file was changed; try again after checking config permissions", + ); + } + } + } + + pub(in crate::ui) fn refresh_stock_theme_migration_notice(&mut self) { + let paths = match self.configured_theme_paths() { + Ok(paths) => paths, + Err(error) => { + warn!(?error, "stock theme migration path resolution failed"); + self.theme_migration = None; + self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); + return; + } + }; + match detect_stock_theme_migration(&paths) { + Ok(Some(migration)) => { + let message = format!( + "Stock theme update available\nPreview UnixNotis {} for the {} layer(s) before applying", + env!("CARGO_PKG_VERSION"), + migration.layer_summary() + ); + self.theme_migration = Some(migration.clone()); + self.show_theme_migration_notice(&migration, &message, false); + } + Ok(None) => { + self.theme_migration = None; + self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); + } + Err(error) => { + // An unsafe marker or unreadable root must never broaden migration eligibility + warn!(?error, "stock theme migration detection failed closed"); + self.theme_migration = None; + self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); + } + } + } + + fn configured_theme_paths(&self) -> Result { + let base = Config::config_dir_for_path(&self.config_path)?; + self.config.resolve_theme_paths_from(&base) + } + + fn restore_configured_theme(&mut self, paths: ThemePaths) -> CssReloadReport { + self.css.update_theme(paths, self.config.theme.clone()); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + self.icon_resolver.clear_missing_cache(); + self.theme_preview_active = false; + report + } + + fn show_theme_migration_failure(&mut self, migration: &StockThemeMigration, message: &str) { + self.show_theme_migration_notice(migration, message, true); + } + + fn show_theme_migration_notice( + &mut self, + migration: &StockThemeMigration, + message: &str, + error: bool, + ) { + self.set_reload_notice( + ReloadNoticeKind::ThemeMigration, + message, + error, + migration.fingerprint(), + ); + } +} diff --git a/crates/unixnotis-center/src/ui/theme_migration/mod.rs b/crates/unixnotis-center/src/ui/theme_migration/mod.rs new file mode 100644 index 000000000..175e4f9c8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/mod.rs @@ -0,0 +1,9 @@ +//! Explicit stock theme migration UI flow + +mod actions; +mod flow; + +pub(in crate::ui) use actions::connect_notice_actions; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs new file mode 100644 index 000000000..64aab0ff1 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs @@ -0,0 +1,33 @@ +//! Migration notice action wiring tests + +use gtk::prelude::*; + +use super::super::connect_notice_actions; +use crate::control::UiEvent; +use crate::ui::panel::notice::build_reload_notice; + +#[gtk::test] +fn migration_buttons_emit_three_distinct_policy_events() { + let notice = build_reload_notice(); + let (event_tx, event_rx) = async_channel::bounded(3); + connect_notice_actions(¬ice, event_tx); + + notice.preview_button.emit_clicked(); + notice.apply_button.emit_clicked(); + notice.keep_button.emit_clicked(); + + assert!(matches!( + event_rx.try_recv().expect("Preview should emit an event"), + UiEvent::ThemeMigrationPreview + )); + assert!(matches!( + event_rx.try_recv().expect("Apply should emit an event"), + UiEvent::ThemeMigrationApply + )); + assert!(matches!( + event_rx + .try_recv() + .expect("Keep Current should emit an event"), + UiEvent::ThemeMigrationKeepCurrent + )); +} diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz b/crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..f0c2fac22eca0de255f767fe5b1238f322554773 GIT binary patch literal 4105 zcmV+k5ccmMiwFP!000021LYiPkEF)+`~Hg3t}HE1Gv;o(M~V`~NfbrdSaJN>s)p)r zv@{@~XQplCzvtBf6i~&{Gm6)a6WP;H$E$Z=#W!E0AF|}-Q(h+(DsV+755k~};!*N2g8dgF8!N1Q0rMRkLgSWZwW4ZpQ7YhSv^wyduVimoV6^KvhbtI11E-yHHXCZ$P1xmJ7R3j7Wp zKjHGDX%2VcZ`lR~j$^>V9@zn3pnag8Dqb7iUNE(IK~BL&Nj_h2L=Retmi1kQt~%rZ90H0Zc>Jm$8rG7DnHhI zbdpu1hB@?q^aMC9&iLbpBl~BAtOv`3Q~u}OyAgAnXM*A2oX2qqJY;e%+cNS0%yBv8r~|xg4gDbcerIU7V>Agz5$sIo*ny$u z6q}OK@hC;@aE78*!!4ts1E!iMy~|0jp}a{oQ={R|gsK*hKt}2Qq>`jL2sHfximv0A zJ%x10DfFZbyJ(TeboGzN(~nFuFw}WUc4-!B>AvqTagdhSImRU9CMnCD^Sjta=%p5M z!C{N(O1^KXcm3dF`{=MSZaA~sGcK7f*YD>XLhnG=@%KV8`2>hEE zACfewZwMZgaPCWyk{9$0E>VFCQdW%%)9F*L?6`KcG}=*3P8?Bht6~HNn7$=3bp%Na z+h%o{r-*VMe#(=$0^ceo+z$gUj`J9O3r=H(OZpdEvI9?zRAE#>%kGh*<1!AG$(_-k zwX5l;uH?`j_1M;j(;`E-*oPZ4RX|OGwa9~L(18mBML3N<&JX(?0=s&Ltqu*HZX4Tg zYKW-{0j_fvm~UpdO*Y{*=9`;DS?KKL26^etj0YZMGs=e(@Z$M-Pe zy}?2uTmUF#@)KYJr!;VTRza{0XCSCRBBFBx!1w%Dou5u{R0;)X0yEFj+an?`FfoQB ziz!J~Tp6S#c_KK47l5w(c?^R)C+IY6GwDtY3?U5=_Z@-%`f|gAz0p%Xbg^u>LN*4A zuADz{AgVrzFqE<|*>lu5rMKcd^bKrp>~ipD6Du+19eV+KYv^!mC`*<#y~qB{Ow{Xj zt;rUe^8^Ap_~^ThN@eCx#T`N$d2bbWuz{9#U>C{GYdC+BxAxJQM4yGmT7Y(M0`2s# zCo+R~cO}}l3*ny>fz931JHFQ$=Lw-{bX{=|i3SITF&^=kz0 zF|H~xxbr3W^f-eNJT%RZG(ii09Ikau!-IYnIEK@+MSqWkovUQKy+`VF)?EHWCd)AN zH^;KvaN+@1M9utJuFFv}741&u61g1oxJb=G&X|Q71dZH)CJJl4icC9X| zZ}Ln&z_X*tR=(I6Ma;8(O^B=m1}P7F&f84bq^fM3w=pBsCC*^?5^$!9p>*eLW3Cau z3>RF&BkeP~?D!#FF=3Wyv>z4N3K3vPnj+uyq_+R=9>fkDx(Ran);*NtYHqW37K^S- zFk;C;@aJq$Jj^h*vb3ES`v{luI++*G*5iYFM^mZM25{S2uCKUedBY(#01xQ*;6r|P%*9|02%olTC#>3Bg^ARQ-qJbqRVuCNQzl|szwnfslz$3 zb2o5Zp8bQx{$W{ac_xE;%C7nHXzsgB4ezJ_TfA$>JM#FpZZ$E%HU_GBl##NgG1x@V zFGH`cXEoiM!tAE9u&!gI!`2S{Uj16-0<@#N0RyuakBT}? zC`A$NY*!bDvKVa$V$ZA9&T5K=+L?VFB-O7)1W9{_{>n-)yfj{+kGLpMk)&y53|oVw zT8)0cK}Urm8OoNCn=tN5EK(3BOE_KrB6yoZjyAR(LB&Rl8ADD=2HN}wohQ0))J~y{ zzt^T`8N7=!vn)$70m!%l(|avB+JnQ*rEh~*Hv&xx9*EGM8#NMdCdTM0Ozi+Ul+cUE z)Zr(nt*Q41<9ffNtM@xPN&KJJ`<++Iqg1UQRSEBF4YN z=4pzHs-FZO(QApH*kt(idu2IKicfG|FBPqg*;wGtri$7{Z(3stxYhY2x1`dEm1q_< zlpU#j?Y2p#Gh_2!Hl4Ycy+<9Pa_cP3?AnuWo%zm6p52g4M32{kmO z4j7e54J?$dJ>s=IbuQ2am8e%jYxLHKBKEnd_eqda}&0xh`AZ`}%g`k)e z^;viY*Q)6o=6ogelE~QILCTIj9cvjWyBSMJ`D>Ig^Ng#uq5DB&spN)l*@7%=eKbW| zu@(jh7Zo7z0f#)ksa&<`kddCMx?Wh9?6^oGUd$9cc~tr5OHtoUrfS5ENOm8Q=4il^ zXALqy5mFNF;(}YtYwnNDeMs6x^BLG0ona98HH@PPU6n4zby(}_UvLdUekGiJ*dsr( zzZu}8o0V*p1w{>KRk<(}VaOgg z3Xb3vIsLg?G`98@jnA`4tdWG}`XGt|6kUsglo4wHQ1+e`#iH9g+74h@D&z^;TRp90 zuIV9CE!6c-_Iy@FFt^30p%|9aY~a>fd4m$5RSntIA|8;jkAS6<>>#-C;9%;U+JuJy_?;J~g>BhBzl2L)_+j_P z@$9YZu{T?z`~HBo4|jJsgd`l7zK3aOrU#DrlBBmidjB`-I=$bZ_y53InHPlq`6;h+ z^gY-;_))=sfF@;f)O9i@V?M zy9DO;ZHRU7;=`aG4^Kn)V2P*G7Q(Wy!y)%AV{z=(%<*8)869prU)CM61tj53Wt+F^ zSNwn1NWYxQk-Gyozhv*=;?B6e?po3p!%gnJnWC4~7jTv`#_G zFOx{f)nBdf$B5Mrc8mpwe!a`*I7(}_!sYX(^4#~?JbUZ-ETI6v*r5}#r~TXI zzY+DTNUhFAPt+rrO}}o}+}+TVa$R1iPf!Pb`&fM9sv~G%6E_sG?yuSI?q1M}!5>??+Q=v+;OgBkUgBm%HjaPx)gBwN457sCf zy0>?g;uQ5|$)6b)=#~`MK17GuC$?0W{;njKarpiR1R?(#&P! zDodT{irbD2s=MWDSoU;Yw~M^M1L&x(Kcv^o<|$j*Mi5jW;!7NFbz*vOw@--(#f?ny zXhmS5gnBw?@SS)U}&XfMr$5;65r_kF-IFnoj6B^8pfp4R^YwVL2M Ha#{cYOV9si literal 0 HcmV?d00001 diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs new file mode 100644 index 000000000..daad11475 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs @@ -0,0 +1,251 @@ +//! End-to-end migration notice state tests + +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +use gtk::prelude::*; +use unixnotis_core::{detect_stock_theme_migration, Config, ThemePaths, DEFAULT_PANEL_CSS}; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::ui::{UiState, UiStateInit}; + +static LEGACY_PANEL_CSS: OnceLock> = OnceLock::new(); +static APP_ID: AtomicUsize = AtomicUsize::new(0); + +fn legacy_panel_css() -> &'static [u8] { + LEGACY_PANEL_CSS + .get_or_init(|| { + let compressed = include_bytes!("fixtures/legacy-panel-9ca42584.css.gz"); + let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice()); + let mut css = Vec::new(); + decoder + .read_to_end(&mut css) + .expect("historical panel fixture should decompress"); + css + }) + .as_slice() +} + +struct MigrationFixture { + state: UiState, + paths: ThemePaths, + root: PathBuf, +} + +impl Drop for MigrationFixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).expect("migration test directory should be removed"); + } +} + +fn migration_fixture(name: &str) -> MigrationFixture { + let serial = APP_ID.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.theme.migration.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + // Optional processes stay outside the migration fixture + config.panel.respect_work_area = false; + config.media.enabled = false; + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let root = std::env::current_dir() + .expect("current directory should resolve") + .join("target") + .join(format!( + "unixnotis-theme-migration-{name}-{}-{serial}", + std::process::id() + )); + fs::create_dir_all(&root).expect("migration test directory should be created"); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + fs::write(&paths.panel_css, legacy_panel_css()).expect("legacy panel CSS should be written"); + config + .ensure_theme_files(&paths) + .expect("active and staged theme files should be prepared"); + + let css = CssManager::new_panel(paths.clone(), config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + let state = UiState::new(UiStateInit { + app, + config, + config_path: root.join("config.toml"), + command_tx, + css, + event_tx, + media_handle: None, + runtime, + }); + + MigrationFixture { state, paths, root } +} + +#[gtk::test] +fn startup_offers_actions_for_an_exact_historical_stock_theme() { + let fixture = migration_fixture("startup"); + + assert!( + fixture.state.theme_migration.is_some(), + "the exact historical panel should produce a migration plan" + ); + assert!( + fixture.state.panel.reload_notice.revealer.reveals_child(), + "the migration notice should be visible at startup" + ); + assert!( + fixture.state.panel.reload_notice.actions.get_visible(), + "Preview, Apply, and Keep Current should be visible" + ); + assert!( + !fixture.state.panel.reload_notice.close.get_visible(), + "the generic close action must not bypass the explicit choice" + ); + assert!( + fixture + .state + .panel + .reload_notice + .label + .text() + .contains("panel"), + "the notice should identify the eligible layer" + ); +} + +#[gtk::test] +fn preview_event_uses_verified_staged_css_without_changing_the_active_file() { + let mut fixture = migration_fixture("preview"); + + fixture.state.handle_event(UiEvent::ThemeMigrationPreview); + + assert!( + fixture.state.theme_preview_active, + "Preview should mark the in-memory CSS state active" + ); + assert_ne!( + fixture.state.css.theme_paths().panel_css, + fixture.paths.panel_css, + "Preview should point the provider at a versioned stock sibling" + ); + assert_eq!( + fs::read(&fixture.paths.panel_css).expect("active panel CSS should remain readable"), + legacy_panel_css(), + "Preview must not replace the user-editable active file" + ); + assert!( + fixture + .state + .panel + .reload_notice + .label + .text() + .contains("preview active"), + "the notice should explain the temporary preview state" + ); +} + +#[gtk::test] +fn apply_replaces_exact_stock_only_after_click_and_clears_the_notice() { + let mut fixture = migration_fixture("apply"); + + fixture.state.apply_stock_theme_migration(); + + assert!( + fixture.state.theme_migration.is_none(), + "a successful Apply should consume the plan" + ); + assert!(!fixture.state.theme_preview_active); + assert_eq!( + fixture.state.css.theme_paths().panel_css, + fixture.paths.panel_css, + "Apply should restore the configured active path" + ); + assert_eq!( + fs::read(&fixture.paths.panel_css).expect("applied panel CSS should be readable"), + DEFAULT_PANEL_CSS.as_bytes(), + "Apply should publish current stock bytes" + ); + assert!( + !fixture.state.panel.reload_notice.revealer.reveals_child(), + "the migration notice should close after a successful Apply" + ); + assert!( + fs::read_dir(&fixture.root) + .expect("theme directory should remain readable") + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().ends_with(".bak")), + "Apply should retain a recoverable backup" + ); +} + +#[gtk::test] +fn keep_current_restores_a_preview_and_persists_the_choice() { + let mut fixture = migration_fixture("keep"); + fixture.state.preview_stock_theme_migration(); + + fixture.state.keep_current_stock_theme(); + + assert!(fixture.state.theme_migration.is_none()); + assert!(!fixture.state.theme_preview_active); + assert_eq!( + fixture.state.css.theme_paths().panel_css, + fixture.paths.panel_css, + "Keep Current should restore the configured active path" + ); + assert_eq!( + fs::read(&fixture.paths.panel_css).expect("kept panel CSS should be readable"), + legacy_panel_css(), + "Keep Current must preserve the historical bytes" + ); + assert!( + detect_stock_theme_migration(&fixture.paths) + .expect("persisted choice should remain readable") + .is_none(), + "the version-scoped choice should suppress the same notice on restart" + ); +} + +#[gtk::test] +fn stale_apply_reports_failure_and_preserves_the_newer_edit() { + let mut fixture = migration_fixture("stale-apply"); + let edited = b"/* edited after the notice */\n"; + fs::write(&fixture.paths.panel_css, edited).expect("newer edit should be written"); + + fixture.state.apply_stock_theme_migration(); + + assert!( + fixture.state.theme_migration.is_some(), + "a failed Apply should retain an explicit recovery choice" + ); + assert!(fixture.state.panel.reload_notice.actions.get_visible()); + assert!( + fixture + .state + .panel + .reload_notice + .label + .text() + .contains("stopped safely"), + "the panel should explain that no stale approval was used" + ); + assert_eq!( + fs::read(&fixture.paths.panel_css).expect("edited panel CSS should remain readable"), + edited, + "the newer edit must remain active" + ); +} diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs new file mode 100644 index 000000000..bffde5556 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs @@ -0,0 +1,4 @@ +//! Theme migration UI regression tests + +mod actions; +mod flow; From 042fc3d3d728f2861a72772378b9cca4f7dc3905 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 28 Jul 2026 19:30:45 -0500 Subject: [PATCH 141/275] test(center): include reconnect jitter in deadlines Summary: include reconnect jitter in deadlines. Scope: center. --- crates/unixnotis-center/src/control/tests/reconnect.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/unixnotis-center/src/control/tests/reconnect.rs b/crates/unixnotis-center/src/control/tests/reconnect.rs index 159289688..f6da32d28 100644 --- a/crates/unixnotis-center/src/control/tests/reconnect.rs +++ b/crates/unixnotis-center/src/control/tests/reconnect.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures_util::StreamExt; +use unixnotis_core::reconnect::BACKOFF_JITTER_MS; use unixnotis_core::CONTROL_BUS_NAME; use zbus::fdo::DBusProxy; use zbus::names::BusName; @@ -182,8 +183,9 @@ fn transient_initial_owner_probe_retries_without_an_owner_change_signal() { let (event_tx, event_rx) = async_channel::bounded(4); let (_command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); let mut offline_commands = std::collections::VecDeque::new(); + // The deadline covers the production jitter ceiling plus scheduler headroom let outcome = tokio::time::timeout( - Duration::from_millis(100), + Duration::from_millis(BACKOFF_JITTER_MS + 100), wait_for_control_owner_with_probe( { let attempts = attempts.clone(); From 354aa06ffa0f8bbb2144a27aab8f0d1228315c11 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:09:10 -0500 Subject: [PATCH 142/275] feat(theme): replace legacy migration with versioned modes Summary: replace legacy migration with versioned modes. Scope: theme. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/noticenterctl/src/app/local.rs | 4 +- crates/noticenterctl/src/app/runner.rs | 1 + crates/noticenterctl/src/cli/args.rs | 9 + crates/noticenterctl/src/cli/command.rs | 13 +- crates/noticenterctl/src/cli/mod.rs | 2 +- crates/noticenterctl/src/main.rs | 1 + crates/noticenterctl/src/theme/export.rs | 81 ++++++ crates/noticenterctl/src/theme/mod.rs | 16 ++ crates/unixnotis-center/src/main.rs | 4 +- crates/unixnotis-center/src/ui/events.rs | 18 +- .../src/ui/init/constructor.rs | 8 +- crates/unixnotis-center/src/ui/mod.rs | 2 +- .../unixnotis-center/src/ui/panel/notice.rs | 27 +- .../src/ui/reload/config/flow.rs | 5 +- .../src/ui/reload/config/notice.rs | 6 +- .../unixnotis-center/src/ui/reload/notices.rs | 16 +- crates/unixnotis-center/src/ui/state.rs | 6 +- .../src/ui/theme_compatibility/actions.rs | 37 +++ .../src/ui/theme_compatibility/flow.rs | 57 ++++ .../src/ui/theme_compatibility/mod.rs | 9 + .../src/ui/theme_migration/flow.rs | 194 -------------- crates/unixnotis-core/Cargo.toml | 1 + .../src/config/appearance/theme.rs | 21 ++ .../src/config/loading/io/mod.rs | 11 +- .../src/config/loading/io/theme_contract.rs | 102 +++++++ .../src/config/loading/io/theme_files.rs | 83 ------ .../src/config/loading/io/theme_mode.rs | 66 +++++ .../config/loading/io/theme_stock/files.rs | 141 ---------- .../loading/io/theme_stock/migration.rs | 251 ------------------ .../src/config/loading/io/theme_stock/mod.rs | 18 -- .../config/loading/io/theme_stock/model.rs | 105 -------- .../config/loading/io/theme_stock/staging.rs | 76 ------ .../src/config/loading/io/write.rs | 13 - crates/unixnotis-core/src/config/mod.rs | 4 +- .../src/actions/config/provision.rs | 75 +----- crates/unixnotis-ui/src/css/loader/mod.rs | 2 +- crates/unixnotis-ui/src/css/loader/model.rs | 9 + .../unixnotis-ui/src/css/loader/provider.rs | 22 ++ crates/unixnotis-ui/src/css/manager/report.rs | 2 + .../src/css/manager/stack/model.rs | 20 +- .../src/css/manager/stack/reload.rs | 41 ++- 43 files changed, 563 insertions(+), 1018 deletions(-) create mode 100644 crates/noticenterctl/src/theme/export.rs create mode 100644 crates/noticenterctl/src/theme/mod.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/actions.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/flow.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/mod.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/flow.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_contract.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_files.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/theme_mode.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/write.rs diff --git a/Cargo.lock b/Cargo.lock index 1b3877892..627bb8ada 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3621,6 +3621,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "toml 0.8.23", + "toml_edit 0.22.27", "tracing", "tracing-subscriber", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index b88556523..095678656 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ serde_ignored = "0.1" sha2 = "0.10" tar = "0.4" toml = "0.8" +toml_edit = "0.22.27" thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time", "process", "io-util" ] } tracing = "0.1" diff --git a/crates/noticenterctl/src/app/local.rs b/crates/noticenterctl/src/app/local.rs index 4145b2058..667351431 100644 --- a/crates/noticenterctl/src/app/local.rs +++ b/crates/noticenterctl/src/app/local.rs @@ -4,19 +4,21 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use crate::cli::{Command, PresetCommand}; +use crate::cli::{Command, PresetCommand, ThemeCommand}; pub(super) fn handle_local_command( command: Command, mut run_css: impl FnMut(Option) -> Result<()>, mut run_preset: impl FnMut(PresetCommand) -> Result<()>, mut sync_session: impl FnMut(crate::cli::DoctorServiceManagerArg) -> Result<()>, + mut run_theme: impl FnMut(ThemeCommand) -> Result<()>, ) -> Result<()> { // Local commands remain available while the session bus or daemon is unavailable match command { Command::CssCheck { config } => run_css(config), Command::Preset { command } => run_preset(command).context("preset command failed"), Command::SyncSessionEnvironment { service_manager } => sync_session(service_manager), + Command::Theme { command } => run_theme(command).context("theme command failed"), // The caller routes daemon-backed commands before reaching this helper _ => Ok(()), } diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index e2057aac6..4da8c5466 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -23,6 +23,7 @@ pub fn run() -> Result<()> { crate::css_check::run, crate::preset::run_preset, crate::session_environment::sync, + crate::theme::run, )?; return Ok(()); } diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index 9ed04788a..895b9e017 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -105,3 +105,12 @@ pub enum PresetCommand { input: String, }, } + +#[derive(Subcommand, Debug)] +pub enum ThemeCommand { + // Export editable copies of the embedded stock theme into a new directory + ExportStock { + #[arg(long, value_name = "DIRECTORY")] + output: Option, + }, +} diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index d3b741cf7..95c76a001 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap::Subcommand; -use super::args::{DndState, DoctorServiceManagerArg, PresetCommand}; +use super::args::{DndState, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; use super::{DebugLevelArg, InhibitScopeArg}; use super::{DndClockTime, DndDuration}; @@ -90,6 +90,11 @@ pub enum Command { #[command(subcommand)] command: PresetCommand, }, + // Export editable stock theme files without changing the active theme mode + Theme { + #[command(subcommand)] + command: ThemeCommand, + }, } impl Command { @@ -117,6 +122,7 @@ impl Command { Self::CssCheck { .. } | Self::Doctor { .. } | Self::Preset { .. } + | Self::Theme { .. } | Self::SyncSessionEnvironment { .. } ) } @@ -125,7 +131,10 @@ impl Command { // Doctor uses local inputs but still needs asynchronous D-Bus and process timeouts matches!( self, - Self::CssCheck { .. } | Self::Preset { .. } | Self::SyncSessionEnvironment { .. } + Self::CssCheck { .. } + | Self::Preset { .. } + | Self::Theme { .. } + | Self::SyncSessionEnvironment { .. } ) } } diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs index 4cbebdbfb..0aa906212 100644 --- a/crates/noticenterctl/src/cli/mod.rs +++ b/crates/noticenterctl/src/cli/mod.rs @@ -4,7 +4,7 @@ mod args; mod command; mod dnd; -pub use args::{Args, DndState, DoctorServiceManagerArg, PresetCommand}; +pub use args::{Args, DndState, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; pub use args::{DebugLevelArg, InhibitScopeArg}; pub use command::Command; pub use dnd::{DndClockTime, DndDuration}; diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index 189dc3942..23ba7c9d8 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -26,6 +26,7 @@ mod output; mod preset; mod session_environment; mod system_tools; +mod theme; use std::process::ExitCode; diff --git a/crates/noticenterctl/src/theme/export.rs b/crates/noticenterctl/src/theme/export.rs new file mode 100644 index 000000000..79111198f --- /dev/null +++ b/crates/noticenterctl/src/theme/export.rs @@ -0,0 +1,81 @@ +//! Safe export of editable embedded stock theme files + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, write_file_if_missing, CreateDirectoryOutcome, +}; +use unixnotis_core::{ + Config, ThemeManifest, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, + DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, THEME_API_VERSION, +}; + +const DEFAULT_EXPORT_DIRECTORY: &str = "stock-theme-v2"; + +pub(super) fn run(output: Option) -> Result<()> { + let destination = match output { + Some(output) => output, + None => default_export_directory()?, + }; + export_stock_theme(&destination)?; + crate::output::write_stdout(&format!( + "Exported editable stock theme to {}\nThe active theme mode was not changed.\n", + destination.display() + )) +} + +fn default_export_directory() -> Result { + let config_path = Config::active_config_path().context("resolve active config path")?; + default_export_directory_for_config(&config_path) +} + +pub(super) fn default_export_directory_for_config(config_path: &Path) -> Result { + let parent = config_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| anyhow!("active config path has no parent directory"))?; + Ok(parent.join(DEFAULT_EXPORT_DIRECTORY)) +} + +pub(super) fn export_stock_theme(destination: &Path) -> Result<()> { + match create_directory_all(destination, 0o700).with_context(|| { + format!( + "create stock theme export directory {}", + destination.display() + ) + })? { + CreateDirectoryOutcome::TargetCreated => {} + CreateDirectoryOutcome::TargetAlreadyExisted => { + return Err(anyhow!( + "stock theme export directory already exists: {}", + destination.display() + )); + } + } + + let manifest = toml::to_string_pretty(&ThemeManifest { + api_version: THEME_API_VERSION, + name: "UnixNotis stock export".to_string(), + }) + .context("serialize stock theme manifest")?; + for (name, contents) in [ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", DEFAULT_POPUP_CSS), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ("theme.toml", manifest.as_str()), + ] { + let path = destination.join(name); + let created = write_file_if_missing(&path, contents.as_bytes(), 0o600) + .with_context(|| format!("write exported stock theme file {name}"))?; + if !created { + return Err(anyhow!( + "stock theme export was interrupted by an existing file: {}", + path.display() + )); + } + } + Ok(()) +} diff --git a/crates/noticenterctl/src/theme/mod.rs b/crates/noticenterctl/src/theme/mod.rs new file mode 100644 index 000000000..0b0046c99 --- /dev/null +++ b/crates/noticenterctl/src/theme/mod.rs @@ -0,0 +1,16 @@ +//! Local theme management commands + +mod export; + +use anyhow::Result; + +use crate::cli::ThemeCommand; + +pub fn run(command: ThemeCommand) -> Result<()> { + match command { + ThemeCommand::ExportStock { output } => export::run(output), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/main.rs b/crates/unixnotis-center/src/main.rs index 567a542f9..77c8ad129 100644 --- a/crates/unixnotis-center/src/main.rs +++ b/crates/unixnotis-center/src/main.rs @@ -70,9 +70,7 @@ fn main() -> Result<()> { let theme_paths = config .resolve_theme_paths_from(&theme_base) .context("resolve theme paths")?; - config - .ensure_theme_files(&theme_paths) - .context("ensure theme files")?; + // Theme discovery is read-only; missing files intentionally select embedded stock CSS // Built-in defaults can run without the installer, so helper scripts are owned here too Config::ensure_default_scripts_in(&theme_base).context("ensure default scripts")?; diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index a333cf4b6..3d3c1c182 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -110,7 +110,7 @@ impl UiState { debug!(app = %key, "group toggled"); self.log_debug(PanelDebugLevel::Verbose, || format!("group toggled: {key}")); self.list.toggle_group(&key); - // Toggling can change stacked visibility; counts reflect total entries + // Toggling can change grouped visibility; counts reflect total entries self.refresh_counts(); } UiEvent::MediaUpdated(infos) => { @@ -206,17 +206,13 @@ impl UiState { } } } - UiEvent::ThemeMigrationPreview => { - debug!("stock theme preview requested"); - self.preview_stock_theme_migration(); + UiEvent::UseStockTheme => { + debug!("embedded stock theme requested"); + self.use_stock_theme(); } - UiEvent::ThemeMigrationApply => { - debug!("stock theme apply requested"); - self.apply_stock_theme_migration(); - } - UiEvent::ThemeMigrationKeepCurrent => { - debug!("stock theme retention requested"); - self.keep_current_stock_theme(); + UiEvent::OpenThemeFolder => { + debug!("theme folder requested"); + self.open_theme_folder(); } } } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 5248635a5..ad2b35879 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use unixnotis_core::{Config, PanelDebugLevel}; -use super::super::{hyprland, icons, panel, theme_migration, widgets, UiState, UiStateInit}; +use super::super::{hyprland, icons, panel, theme_compatibility, widgets, UiState, UiStateInit}; use super::builders::{ build_media_widget, build_notification_list, build_widget_sections, has_visible_widget_section, icon_resolver_for_widgets, @@ -54,7 +54,7 @@ impl UiState { init.command_tx.clone(), ); panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); - theme_migration::connect_notice_actions(&panel.reload_notice, init.event_tx.clone()); + theme_compatibility::connect_notice_actions(&panel.reload_notice, init.event_tx.clone()); panel::header::search::connect_widget_collapse_toggle( &panel.header.actions.focus_toggle, &panel.sections.widget_revealer, @@ -115,11 +115,9 @@ impl UiState { last_slow_refresh: None, // Reload notices preserve independent config and CSS failure identities reload_notices: super::super::reload::ReloadNoticeState::default(), - theme_migration: None, - theme_preview_active: false, _runtime: init.runtime, }; - state.initialize_stock_theme_migration(); + state.initialize_theme_compatibility(); state } } diff --git a/crates/unixnotis-center/src/ui/mod.rs b/crates/unixnotis-center/src/ui/mod.rs index 900d4f891..7b091c457 100644 --- a/crates/unixnotis-center/src/ui/mod.rs +++ b/crates/unixnotis-center/src/ui/mod.rs @@ -14,7 +14,7 @@ mod motion; mod notifications; mod panel; mod state; -mod theme_migration; +mod theme_compatibility; mod widget_builders; mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index 43cbbb072..4b22b84ed 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -11,13 +11,12 @@ pub(in crate::ui) struct ReloadNoticeWidgets { pub(in crate::ui) label: gtk::Label, pub(in crate::ui) close: gtk::Button, pub(in crate::ui) actions: gtk::Box, - pub(in crate::ui) preview_button: gtk::Button, - pub(in crate::ui) apply_button: gtk::Button, - pub(in crate::ui) keep_button: gtk::Button, + pub(in crate::ui) use_stock_button: gtk::Button, + pub(in crate::ui) open_theme_folder_button: gtk::Button, } pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { - // The outer column keeps migration choices below the compact status message + // The outer column keeps compatibility choices below the compact status message let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); shell.add_css_class(hooks::panel_shell::RELOAD_NOTICE); shell.set_hexpand(true); @@ -43,19 +42,18 @@ pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { content.append(&close); shell.append(&content); - // Theme migration remains an explicit three-way choice + // Theme compatibility offers a safe fallback and access to the untouched files let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); actions.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTIONS); actions.set_homogeneous(true); actions.set_visible(false); - let preview_button = notice_action("Preview", "Preview the staged stock panel theme"); - let apply_button = notice_action("Apply", "Back up and apply the staged stock theme"); - apply_button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY); - let keep_button = notice_action("Keep Current", "Keep the current theme for this release"); - actions.append(&preview_button); - actions.append(&apply_button); - actions.append(&keep_button); + let use_stock_button = notice_action("Use stock theme", "Use bundled UnixNotis styling"); + use_stock_button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY); + let open_theme_folder_button = + notice_action("Open theme folder", "Open the configured theme folder"); + actions.append(&use_stock_button); + actions.append(&open_theme_folder_button); shell.append(&actions); // A short vertical transition keeps the header position stable @@ -74,9 +72,8 @@ pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { label, close, actions, - preview_button, - apply_button, - keep_button, + use_stock_button, + open_theme_folder_button, } } diff --git a/crates/unixnotis-center/src/ui/reload/config/flow.rs b/crates/unixnotis-center/src/ui/reload/config/flow.rs index 0b08f76c9..eaf485280 100644 --- a/crates/unixnotis-center/src/ui/reload/config/flow.rs +++ b/crates/unixnotis-center/src/ui/reload/config/flow.rs @@ -42,9 +42,7 @@ impl UiState { // Any accepted config replaces a prior rejection before CSS reports its own result self.clear_reload_notice(ReloadNoticeKind::Config); self.apply_css_reload_notice(&css); - // Accepted paths end any in-memory preview and may expose a different exact stock plan - self.theme_preview_active = false; - self.refresh_stock_theme_migration_notice(); + self.refresh_theme_compatibility_notice(); ConfigReloadOutcome::Applied { diagnostics: reload.diagnostics, css, @@ -86,6 +84,7 @@ impl UiState { self.capture_notice_dismissal(); let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); self.apply_css_reload_notice(&report); + self.refresh_theme_compatibility_notice(); report } } diff --git a/crates/unixnotis-center/src/ui/reload/config/notice.rs b/crates/unixnotis-center/src/ui/reload/config/notice.rs index 1b3329617..b93dae29b 100644 --- a/crates/unixnotis-center/src/ui/reload/config/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/notice.rs @@ -90,16 +90,16 @@ impl UiState { } else { hooks::panel_shell::RELOAD_NOTICE_WARNING }); - let shows_migration_actions = notice.fingerprint.kind == ReloadNoticeKind::ThemeMigration; + let shows_theme_actions = notice.fingerprint.kind == ReloadNoticeKind::ThemeCompatibility; // Action notices cannot be dismissed without recording an explicit policy choice self.panel .reload_notice .close - .set_visible(!shows_migration_actions); + .set_visible(!shows_theme_actions); self.panel .reload_notice .actions - .set_visible(shows_migration_actions); + .set_visible(shows_theme_actions); self.panel.reload_notice.revealer.set_reveal_child(true); } diff --git a/crates/unixnotis-center/src/ui/reload/notices.rs b/crates/unixnotis-center/src/ui/reload/notices.rs index fa0fd3ea8..ac3eb2ced 100644 --- a/crates/unixnotis-center/src/ui/reload/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/notices.rs @@ -4,7 +4,7 @@ pub(in crate::ui) enum ReloadNoticeKind { Config, Css, - ThemeMigration, + ThemeCompatibility, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -24,7 +24,7 @@ pub(super) struct ReloadNotice { pub(in crate::ui) struct ReloadNoticeState { config: Option, css: Option, - theme_migration: Option, + theme_compatibility: Option, dismissed_config: Option, dismissed_css: Option, } @@ -40,9 +40,9 @@ impl ReloadNoticeState { // Duplicate watcher events retain dismissal for the same failure Self::replace_notice(&mut self.css, &mut self.dismissed_css, notice); } - ReloadNoticeKind::ThemeMigration => { - // Migration requires an explicit choice and has no generic dismissal lifecycle - self.theme_migration = Some(notice); + ReloadNoticeKind::ThemeCompatibility => { + // Compatibility requires an explicit stock selection or a corrected manifest + self.theme_compatibility = Some(notice); } } } @@ -58,7 +58,7 @@ impl ReloadNoticeState { self.css = None; self.dismissed_css = None; } - ReloadNoticeKind::ThemeMigration => self.theme_migration = None, + ReloadNoticeKind::ThemeCompatibility => self.theme_compatibility = None, } } @@ -86,7 +86,7 @@ impl ReloadNoticeState { // Each class remembers dismissal independently across priority changes ReloadNoticeKind::Config => self.dismissed_config = Some(notice.fingerprint), ReloadNoticeKind::Css => self.dismissed_css = Some(notice.fingerprint), - ReloadNoticeKind::ThemeMigration => {} + ReloadNoticeKind::ThemeCompatibility => {} } } @@ -101,7 +101,7 @@ impl ReloadNoticeState { .as_ref() .filter(|notice| self.dismissed_css.as_ref() != Some(¬ice.fingerprint)) }) - .or(self.theme_migration.as_ref()) + .or(self.theme_compatibility.as_ref()) } } diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index d2e2435d3..929987737 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::time::Instant; use tokio::sync::mpsc; -use unixnotis_core::{Config, IconAssetResolver, Margins, StockThemeMigration}; +use unixnotis_core::{Config, IconAssetResolver, Margins}; use unixnotis_ui::css::CssManager; use crate::control::{UiCommand, UiEvent}; @@ -56,10 +56,6 @@ pub struct UiState { pub(super) last_slow_refresh: Option, // Separate config and CSS state preserves severity priority across watcher races pub(super) reload_notices: reload::ReloadNoticeState, - // Exact stock candidates remain bound to the filesystem snapshot shown in the notice - pub(super) theme_migration: Option, - // Preview changes only the in-memory CSS paths and never the configured active files - pub(super) theme_preview_active: bool, // Keeps the shared async runtime alive for D-Bus and media tasks pub(super) _runtime: Arc, } diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs b/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs new file mode 100644 index 000000000..981abbe25 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs @@ -0,0 +1,37 @@ +//! Theme compatibility notice action wiring + +use async_channel::TrySendError; +use gtk::prelude::*; + +use crate::control::UiEvent; +use crate::ui::panel::notice::ReloadNoticeWidgets; + +pub(in crate::ui) fn connect_notice_actions( + notice: &ReloadNoticeWidgets, + event_tx: async_channel::Sender, +) { + let stock_tx = event_tx.clone(); + notice.use_stock_button.connect_clicked(move |_| { + send_action(&stock_tx, UiEvent::UseStockTheme); + }); + + notice.open_theme_folder_button.connect_clicked(move |_| { + send_action(&event_tx, UiEvent::OpenThemeFolder); + }); +} + +fn send_action(event_tx: &async_channel::Sender, event: UiEvent) { + match event_tx.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + // Explicit choices wait for queue capacity instead of disappearing under load + let event_tx = event_tx.clone(); + gtk::glib::MainContext::default().spawn_local(async move { + let _result = event_tx.send(event).await; + }); + } + Err(TrySendError::Closed(_event)) => { + // Shutdown already owns the UI when the receiver is gone + } + } +} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs b/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs new file mode 100644 index 000000000..93a7c33b9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs @@ -0,0 +1,57 @@ +//! Non-mutating custom theme compatibility flow + +use gio::prelude::FileExt; +use tracing::warn; +use unixnotis_core::{persist_theme_mode, ThemeMode}; + +use crate::ui::reload::ReloadNoticeKind; +use crate::ui::UiState; + +impl UiState { + pub(in crate::ui) fn initialize_theme_compatibility(&mut self) { + self.refresh_theme_compatibility_notice(); + } + + pub(in crate::ui) fn refresh_theme_compatibility_notice(&mut self) { + let state = self.css.theme_contract(); + if state.is_incompatible() { + self.set_reload_notice( + ReloadNoticeKind::ThemeCompatibility, + "Theme is incompatible with this UnixNotis version.\nYour files were not changed; embedded stock styling is active.", + false, + &format!("{state:?}"), + ); + } else { + self.clear_reload_notice(ReloadNoticeKind::ThemeCompatibility); + } + } + + pub(in crate::ui) fn use_stock_theme(&mut self) { + if let Err(error) = persist_theme_mode(&self.config_path, ThemeMode::Stock) { + warn!( + kind = error.kind(), + "failed to persist the embedded stock theme selection" + ); + self.set_reload_notice( + ReloadNoticeKind::ThemeCompatibility, + "Could not save the stock theme selection.\nYour custom files were not changed.", + true, + error.kind(), + ); + return; + } + + // Reloading from disk makes the saved choice and active providers advance together + let _outcome = self.reload_config(); + } + + pub(in crate::ui) fn open_theme_folder(&self) { + let folder = gio::File::for_path(&self.css.theme_paths().base_dir); + let uri = folder.uri(); + if let Err(error) = + gio::AppInfo::launch_default_for_uri(uri.as_str(), None::<&gio::AppLaunchContext>) + { + warn!(?error, "failed to open the configured theme folder"); + } + } +} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs b/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs new file mode 100644 index 000000000..914d5edfd --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs @@ -0,0 +1,9 @@ +//! Versioned custom theme compatibility UI + +mod actions; +mod flow; + +pub(in crate::ui) use actions::connect_notice_actions; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/theme_migration/flow.rs b/crates/unixnotis-center/src/ui/theme_migration/flow.rs deleted file mode 100644 index 929c549fc..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/flow.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Stock theme migration state transitions - -use tracing::{debug, warn}; -use unixnotis_core::{ - apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, Config, - ConfigError, StockThemeMigration, ThemePaths, -}; -use unixnotis_ui::css::CssReloadReport; - -use crate::ui::reload::ReloadNoticeKind; -use crate::ui::UiState; - -impl UiState { - pub(in crate::ui) fn initialize_stock_theme_migration(&mut self) { - self.refresh_stock_theme_migration_notice(); - } - - pub(in crate::ui) fn preview_stock_theme_migration(&mut self) { - let Some(migration) = self.theme_migration.clone() else { - return; - }; - let Ok(configured_paths) = self.configured_theme_paths() else { - self.show_theme_migration_failure( - &migration, - "Preview unavailable\nThe configured theme location could not be resolved", - ); - return; - }; - let preview_paths = match migration.preview_paths(&configured_paths) { - Ok(paths) => paths, - Err(error) => { - warn!(?error, "stock theme preview rejected"); - self.show_theme_migration_failure( - &migration, - "Preview unavailable\nVerified staged theme files could not be loaded", - ); - return; - } - }; - - // Only the center provider paths change during preview; active files remain untouched - self.css - .update_theme(preview_paths, self.config.theme.clone()); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - self.icon_resolver.clear_missing_cache(); - self.theme_preview_active = true; - self.apply_css_reload_notice(&report); - self.show_theme_migration_notice( - &migration, - &format!( - "Stock theme preview active\nReview the {} layer(s), then Apply or Keep Current", - migration.layer_summary() - ), - false, - ); - } - - pub(in crate::ui) fn apply_stock_theme_migration(&mut self) { - let Some(migration) = self.theme_migration.clone() else { - return; - }; - let configured_paths = match self.configured_theme_paths() { - Ok(paths) => paths, - Err(error) => { - warn!(?error, "stock theme Apply path resolution failed"); - self.show_theme_migration_failure( - &migration, - "Theme update was not applied\nThe configured theme location could not be resolved", - ); - return; - } - }; - - match apply_stock_theme_migration(&configured_paths, &migration) { - Ok(report) => { - debug!( - updated_layers = report.updated_layers, - "approved stock theme update applied" - ); - let css = self.restore_configured_theme(configured_paths); - self.theme_migration = None; - self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); - self.apply_css_reload_notice(&css); - } - Err(error) => { - warn!(?error, "approved stock theme update stopped"); - let css = self.restore_configured_theme(configured_paths); - self.apply_css_reload_notice(&css); - self.show_theme_migration_failure( - &migration, - "Theme update stopped safely\nA file changed or could not be backed up; current files remain active", - ); - } - } - } - - pub(in crate::ui) fn keep_current_stock_theme(&mut self) { - let Some(migration) = self.theme_migration.clone() else { - return; - }; - let configured_paths = match self.configured_theme_paths() { - Ok(paths) => paths, - Err(error) => { - warn!(?error, "Keep Current path resolution failed"); - self.show_theme_migration_failure( - &migration, - "Keep Current could not be saved\nThe configured theme location could not be resolved", - ); - return; - } - }; - - let outcome = keep_current_stock_theme(&configured_paths); - let css = self.restore_configured_theme(configured_paths); - self.apply_css_reload_notice(&css); - match outcome { - Ok(()) => { - self.theme_migration = None; - self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); - } - Err(error) => { - warn!(?error, "Keep Current choice could not be persisted"); - self.show_theme_migration_failure( - &migration, - "Keep Current could not be saved\nNo theme file was changed; try again after checking config permissions", - ); - } - } - } - - pub(in crate::ui) fn refresh_stock_theme_migration_notice(&mut self) { - let paths = match self.configured_theme_paths() { - Ok(paths) => paths, - Err(error) => { - warn!(?error, "stock theme migration path resolution failed"); - self.theme_migration = None; - self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); - return; - } - }; - match detect_stock_theme_migration(&paths) { - Ok(Some(migration)) => { - let message = format!( - "Stock theme update available\nPreview UnixNotis {} for the {} layer(s) before applying", - env!("CARGO_PKG_VERSION"), - migration.layer_summary() - ); - self.theme_migration = Some(migration.clone()); - self.show_theme_migration_notice(&migration, &message, false); - } - Ok(None) => { - self.theme_migration = None; - self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); - } - Err(error) => { - // An unsafe marker or unreadable root must never broaden migration eligibility - warn!(?error, "stock theme migration detection failed closed"); - self.theme_migration = None; - self.clear_reload_notice(ReloadNoticeKind::ThemeMigration); - } - } - } - - fn configured_theme_paths(&self) -> Result { - let base = Config::config_dir_for_path(&self.config_path)?; - self.config.resolve_theme_paths_from(&base) - } - - fn restore_configured_theme(&mut self, paths: ThemePaths) -> CssReloadReport { - self.css.update_theme(paths, self.config.theme.clone()); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - self.icon_resolver.clear_missing_cache(); - self.theme_preview_active = false; - report - } - - fn show_theme_migration_failure(&mut self, migration: &StockThemeMigration, message: &str) { - self.show_theme_migration_notice(migration, message, true); - } - - fn show_theme_migration_notice( - &mut self, - migration: &StockThemeMigration, - message: &str, - error: bool, - ) { - self.set_reload_notice( - ReloadNoticeKind::ThemeMigration, - message, - error, - migration.fingerprint(), - ); - } -} diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index adcd086b0..7cb56aec2 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -15,6 +15,7 @@ serde_repr.workspace = true serde_ignored.workspace = true shell-words.workspace = true toml.workspace = true +toml_edit.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/unixnotis-core/src/config/appearance/theme.rs b/crates/unixnotis-core/src/config/appearance/theme.rs index 08bd8c47a..b5fc6502b 100644 --- a/crates/unixnotis-core/src/config/appearance/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/theme.rs @@ -4,9 +4,29 @@ use serde::{Deserialize, Serialize}; use super::corners::CutCorners; +#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ThemeMode { + #[default] + Stock, + Custom, +} + +impl ThemeMode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Stock => "stock", + Self::Custom => "custom", + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ThemeConfig { + /// Embedded stock or explicitly enabled versioned custom CSS + pub mode: ThemeMode, #[serde(alias = "style_css")] pub base_css: String, pub popup_css: String, @@ -39,6 +59,7 @@ mod tests; impl Default for ThemeConfig { fn default() -> Self { Self { + mode: ThemeMode::Stock, base_css: "base.css".to_string(), popup_css: "popup.css".to_string(), panel_css: "panel.css".to_string(), diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs index dc588bae4..1b757038e 100644 --- a/crates/unixnotis-core/src/config/loading/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -5,17 +5,16 @@ mod load; mod paths; mod script_migrations; mod scripts; -mod theme_files; -mod theme_stock; -mod write; +mod theme_contract; +mod theme_mode; pub use error::ConfigError; pub use load::MAX_CONFIG_BYTES; pub use paths::ThemePaths; -pub use theme_stock::{ - apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, - StockThemeApplyReport, StockThemeMigration, +pub use theme_contract::{ + ThemeContractState, ThemeIncompatibility, ThemeManifest, THEME_API_VERSION, }; +pub use theme_mode::{persist_theme_mode, ThemeModeWriteError}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs new file mode 100644 index 000000000..2f5efc05d --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs @@ -0,0 +1,102 @@ +//! Read-only custom theme compatibility contract + +use std::io::ErrorKind; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::filesystem::read_regular_file_bounded; + +use super::ThemePaths; + +/// Theme contract understood by this release +pub const THEME_API_VERSION: u32 = 2; + +const THEME_MANIFEST_FILE: &str = "theme.toml"; +const MAX_THEME_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_THEME_NAME_CHARS: usize = 128; + +/// Manifest required before user-controlled CSS can be loaded +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeManifest { + pub api_version: u32, + pub name: String, +} + +/// Reason an existing custom theme could not be enabled safely +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ThemeIncompatibility { + MissingManifest, + UnreadableManifest, + InvalidManifest, + UnsupportedVersion { found: u32 }, + InvalidName, +} + +/// Active source selected without changing user files +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ThemeContractState { + EmbeddedStock, + Compatible(ThemeManifest), + Incompatible(ThemeIncompatibility), +} + +impl ThemeContractState { + /// Return whether configured CSS may be loaded + #[must_use] + pub const fn custom_theme_allowed(&self) -> bool { + matches!(self, Self::Compatible(_)) + } + + /// Return whether the panel should explain the stock fallback + #[must_use] + pub const fn is_incompatible(&self) -> bool { + matches!(self, Self::Incompatible(_)) + } +} + +impl ThemePaths { + /// Return the manifest anchored beside the active configuration + #[must_use] + pub fn manifest_path(&self) -> PathBuf { + self.base_dir.join(THEME_MANIFEST_FILE) + } + + /// Select custom or embedded CSS without creating or changing files + #[must_use] + pub fn inspect_theme_contract(&self) -> ThemeContractState { + let manifest_path = self.manifest_path(); + let contents = match read_regular_file_bounded(&manifest_path, MAX_THEME_MANIFEST_BYTES) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => { + return ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest); + } + Err(_error) => { + return ThemeContractState::Incompatible(ThemeIncompatibility::UnreadableManifest); + } + }; + let Ok(contents) = std::str::from_utf8(&contents) else { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidManifest); + }; + let Ok(mut manifest) = toml::from_str::(contents) else { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidManifest); + }; + if manifest.api_version != THEME_API_VERSION { + return ThemeContractState::Incompatible(ThemeIncompatibility::UnsupportedVersion { + found: manifest.api_version, + }); + } + + // A bounded printable name keeps diagnostics useful without becoming another payload + manifest.name = manifest.name.trim().to_string(); + if manifest.name.is_empty() + || manifest.name.chars().count() > MAX_THEME_NAME_CHARS + || manifest.name.chars().any(char::is_control) + { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName); + } + + ThemeContractState::Compatible(manifest) + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/theme_files.rs deleted file mode 100644 index 4b3c0f2ea..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_files.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Provisioning and migration for configured theme files - -use std::sync::atomic::{AtomicBool, Ordering}; - -use tracing::warn; - -use crate::filesystem::{read_regular_file_bounded, rename_regular_file_no_replace}; -use crate::{ - Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, - DEFAULT_WIDGETS_CSS, -}; - -use super::theme_stock::stage_current_stock_themes; -use super::write::write_if_missing; -use super::{ConfigError, ThemePaths}; - -static LEGACY_RENAME_WARNED: AtomicBool = AtomicBool::new(false); -const MAX_LEGACY_THEME_BYTES: u64 = 16 * 1024 * 1024; - -impl Config { - /// Ensure all theme files exist in the config directory - /// - /// # Errors - /// - /// Returns an error when a missing theme file cannot be created safely - pub fn ensure_theme_files(&self, theme_paths: &ThemePaths) -> Result<(), ConfigError> { - // Use the same base directory used for resolving theme paths - let config_dir = &theme_paths.base_dir; - - let legacy = config_dir.join("style.css"); - let base_exists = theme_paths.base_css.exists(); - let legacy_contents = (!base_exists).then(|| read_legacy_theme(&legacy)).flatten(); - - write_if_missing( - &theme_paths.base_css, - legacy_contents.as_deref().unwrap_or(DEFAULT_BASE_CSS), - )?; - write_if_missing(&theme_paths.panel_css, DEFAULT_PANEL_CSS)?; - write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; - write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; - write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; - stage_current_stock_themes(theme_paths)?; - - if legacy_contents.is_some() { - let backup = legacy.with_extension("css.bak"); - if let Err(err) = rename_regular_file_no_replace(&legacy, &backup) { - // Base CSS is already safe, so a failed backup move remains non-fatal - warn_legacy_rename_once(&legacy, &backup, &err); - } - } - - Ok(()) - } -} - -fn read_legacy_theme(path: &std::path::Path) -> Option { - // Legacy migration accepts only bounded UTF-8 from one stable regular-file descriptor - let bytes = read_regular_file_bounded(path, MAX_LEGACY_THEME_BYTES).ok()?; - String::from_utf8(bytes) - .ok() - .filter(|contents| !contents.trim().is_empty()) -} - -pub(super) fn warn_legacy_rename_once( - source: &std::path::Path, - backup: &std::path::Path, - err: &std::io::Error, -) -> bool { - if LEGACY_RENAME_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - ?err, - legacy = %source.display(), - backup = %backup.display(), - "failed to rename legacy style.css" - ); - true - } else { - false - } -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_mode.rs b/crates/unixnotis-core/src/config/loading/io/theme_mode.rs new file mode 100644 index 000000000..6e86ba80c --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_mode.rs @@ -0,0 +1,66 @@ +//! Atomic persistence for the explicit theme source + +use std::path::Path; + +use thiserror::Error; +use toml_edit::{value, DocumentMut, Item, Table}; + +use crate::filesystem::{read_regular_file_bounded, write_file_atomic_preserving_mode}; +use crate::{Config, ThemeMode}; + +use super::MAX_CONFIG_BYTES; + +#[derive(Debug, Error)] +pub enum ThemeModeWriteError { + #[error("read config: {0}")] + Read(std::io::Error), + #[error("config is not valid UTF-8")] + Encoding, + #[error("config is invalid: {0}")] + InvalidConfig(String), + #[error("config document is invalid: {0}")] + InvalidDocument(toml_edit::TomlError), + #[error("theme section is not a table")] + InvalidThemeSection, + #[error("write config: {0}")] + Write(std::io::Error), +} + +impl ThemeModeWriteError { + #[must_use] + pub const fn kind(&self) -> &'static str { + match self { + Self::Read(_) => "read", + Self::Encoding => "encoding", + Self::InvalidConfig(_) => "invalid-config", + Self::InvalidDocument(_) => "invalid-document", + Self::InvalidThemeSection => "invalid-theme-section", + Self::Write(_) => "write", + } + } +} + +/// Persist the theme source while retaining unrelated TOML formatting +/// +/// # Errors +/// +/// Returns an error when the existing config is unsafe, invalid, or cannot be replaced atomically +pub fn persist_theme_mode(path: &Path, mode: ThemeMode) -> Result<(), ThemeModeWriteError> { + let bytes = + read_regular_file_bounded(path, MAX_CONFIG_BYTES).map_err(ThemeModeWriteError::Read)?; + let contents = std::str::from_utf8(&bytes).map_err(|_error| ThemeModeWriteError::Encoding)?; + Config::parse_with_report(contents) + .map_err(|error| ThemeModeWriteError::InvalidConfig(error.to_string()))?; + let mut document = contents + .parse::() + .map_err(ThemeModeWriteError::InvalidDocument)?; + if !document.as_table().contains_key("theme") { + document["theme"] = Item::Table(Table::new()); + } + let Some(theme) = document["theme"].as_table_mut() else { + return Err(ThemeModeWriteError::InvalidThemeSection); + }; + theme["mode"] = value(mode.as_str()); + write_file_atomic_preserving_mode(path, document.to_string().as_bytes(), 0o600) + .map_err(ThemeModeWriteError::Write) +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs deleted file mode 100644 index 22930c127..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/files.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Bounded, identity-stable file operations for stock theme migration - -use std::io::{self, Read}; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; - -use crate::filesystem::{open_regular_file, regular_file_contents_equal, write_file_if_missing}; - -use super::super::ConfigError; -use super::model::FileSnapshot; -use super::{MAX_STOCK_PATH_COLLISIONS, MAX_STOCK_THEME_BYTES}; - -const STOCK_PREVIEW_TAG: &str = "unixnotis-stock"; -const STOCK_KEEP_TAG: &str = "unixnotis-stock-kept"; - -pub(super) fn inspect_stock_file(path: &Path) -> io::Result<(FileSnapshot, Vec)> { - // One retained descriptor binds metadata and bytes to the same regular file - let mut file = open_regular_file(path)?; - let before = file.metadata()?; - if before.len() > MAX_STOCK_THEME_BYTES { - return Err(size_limit_error()); - } - - let capacity = usize::try_from(before.len()) - .map_err(|_error| io::Error::new(io::ErrorKind::InvalidData, "theme size is invalid"))?; - let mut contents = Vec::with_capacity(capacity); - file.by_ref() - .take(MAX_STOCK_THEME_BYTES.saturating_add(1)) - .read_to_end(&mut contents)?; - if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_STOCK_THEME_BYTES { - return Err(size_limit_error()); - } - - // Metadata drift means an editor won the read race and the result cannot be authoritative - let after = file.metadata()?; - let before_snapshot = snapshot_for_metadata(&before, &contents)?; - let after_snapshot = snapshot_for_metadata(&after, &contents)?; - if before_snapshot != after_snapshot { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "stock theme changed while it was being inspected", - )); - } - Ok((after_snapshot, contents)) -} - -fn snapshot_for_metadata( - metadata: &std::fs::Metadata, - contents: &[u8], -) -> io::Result { - Ok(FileSnapshot { - device: metadata.dev(), - inode: metadata.ino(), - size: metadata.len(), - modified: metadata.modified()?, - digest: blake3::hash(contents), - }) -} - -pub(in crate::config::loading::io) fn stock_preview_path( - path: &Path, -) -> Result { - tagged_sibling_path( - path, - &format!("{STOCK_PREVIEW_TAG}-{}", env!("CARGO_PKG_VERSION")), - ) -} - -pub(super) fn stock_preview_candidates(path: &Path) -> Result, ConfigError> { - let base = stock_preview_path(path)?; - Ok((0..=MAX_STOCK_PATH_COLLISIONS) - .map(|suffix| collision_candidate(&base, suffix)) - .collect()) -} - -pub(super) fn stock_keep_marker_path(base_dir: &Path) -> PathBuf { - base_dir.join(format!(".{STOCK_KEEP_TAG}-{}", env!("CARGO_PKG_VERSION"))) -} - -pub(super) fn stock_backup_path(path: &Path) -> Result { - tagged_sibling_path( - path, - &format!("unixnotis-stock-before-{}", env!("CARGO_PKG_VERSION")), - ) - .map(|mut path| { - let mut name = path.as_os_str().to_os_string(); - name.push(".bak"); - path = PathBuf::from(name); - path - }) -} - -pub(super) fn reserve_stock_backup(path: &Path, existing: &[u8]) -> Result { - let base = stock_backup_path(path)?; - for suffix in 0..=MAX_STOCK_PATH_COLLISIONS { - let candidate = collision_candidate(&base, suffix); - match write_file_if_missing(&candidate, existing, 0o644) { - Ok(true) => return Ok(candidate), - Ok(false) => { - // A matching prior backup makes an interrupted Apply safe to retry - if regular_file_contents_equal(&candidate, existing, MAX_STOCK_THEME_BYTES) - .unwrap_or(false) - { - return Ok(candidate); - } - } - Err(_error) => { - // A linked or raced candidate cannot prevent trying the bounded suffix set - } - } - } - Err(ConfigError::ReadFailed( - "no collision-free stock theme backup path is available".to_string(), - )) -} - -pub(super) fn collision_candidate(base: &Path, suffix: u8) -> PathBuf { - if suffix == 0 { - return base.to_path_buf(); - } - let mut name = base.as_os_str().to_os_string(); - name.push(format!(".{suffix}")); - PathBuf::from(name) -} - -fn tagged_sibling_path(path: &Path, tag: &str) -> Result { - let file_name = path.file_name().ok_or_else(|| { - ConfigError::ReadFailed(format!("theme path has no file name: {}", path.display())) - })?; - let mut sibling_name = file_name.to_os_string(); - sibling_name.push("."); - sibling_name.push(tag); - Ok(path.with_file_name(sibling_name)) -} - -fn size_limit_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - "stock theme exceeds migration size limit", - ) -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs deleted file mode 100644 index 3f2cd721b..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/migration.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! Detection and explicitly approved stock theme migration - -use std::io; -use std::os::unix::ffi::OsStrExt; - -use crate::filesystem::{ - open_regular_file, write_file_atomic_preserving_mode, write_file_if_missing, -}; - -use super::super::{ConfigError, ThemePaths}; -use super::files::{inspect_stock_file, reserve_stock_backup, stock_keep_marker_path}; -use super::model::{ - FileSnapshot, StockThemeApplyReport, StockThemeCandidate, StockThemeLayer, StockThemeMigration, -}; -use super::staging::find_exact_stock_preview; - -const LEGACY_PANEL_DIGEST: &str = - "bd2342e4ff91dab10dbdece082d1c58e9352b3b8167e046697dd921b6de4ceb3"; -const LEGACY_WIDGETS_DIGEST: &str = - "72c0ab3c38557ea10adfee7e2b11a18b94317b9100579101c04beeb47092e5d2"; -const LEGACY_MEDIA_DIGEST: &str = - "f3618bdaf411d4b018cb9aa1688c9be0880a5bdc0016fdb5e35d8ec798ae6b36"; -const KEEP_MARKER_CONTENTS: &[u8] = b"UnixNotis stock theme kept for this release\n"; - -#[derive(Clone, Copy)] -pub(super) struct LegacyThemeSpec<'a> { - pub(super) layer: StockThemeLayer, - pub(super) digest: &'a str, -} - -const LEGACY_THEME_SPECS: [LegacyThemeSpec<'static>; 3] = [ - LegacyThemeSpec { - layer: StockThemeLayer::Panel, - digest: LEGACY_PANEL_DIGEST, - }, - LegacyThemeSpec { - layer: StockThemeLayer::Widgets, - digest: LEGACY_WIDGETS_DIGEST, - }, - LegacyThemeSpec { - layer: StockThemeLayer::Media, - digest: LEGACY_MEDIA_DIGEST, - }, -]; - -/// Detect exact theme files shipped by a previous `UnixNotis` release -/// -/// Customized, unreadable, linked, oversized, and current files remain outside the plan -/// -/// # Errors -/// -/// Returns an error when a persisted Keep Current marker has an unsafe file shape -pub fn detect_stock_theme_migration( - paths: &ThemePaths, -) -> Result, ConfigError> { - detect_stock_theme_migration_with_specs(paths, &LEGACY_THEME_SPECS) -} - -pub(super) fn detect_stock_theme_migration_with_specs( - paths: &ThemePaths, - specs: &[LegacyThemeSpec<'_>], -) -> Result, ConfigError> { - if keep_marker_exists(paths)? { - return Ok(None); - } - - let mut candidates = Vec::new(); - for spec in specs { - let path = spec.layer.path(paths); - // Inspection failures preserve user ownership and cannot create an eligible action - let Ok((snapshot, original_contents)) = inspect_stock_file(path) else { - continue; - }; - if snapshot.digest.to_hex().as_str() != spec.digest { - continue; - } - candidates.push(StockThemeCandidate { - layer: spec.layer, - path: path.to_path_buf(), - snapshot, - original_contents, - }); - } - - if candidates.is_empty() { - return Ok(None); - } - let fingerprint = migration_fingerprint(&candidates); - Ok(Some(StockThemeMigration { - candidates, - fingerprint, - })) -} - -impl StockThemeMigration { - /// Build panel CSS paths that point eligible layers at verified staged stock files - /// - /// # Errors - /// - /// Returns an error when configuration paths changed or no exact preview remains - pub fn preview_paths(&self, active: &ThemePaths) -> Result { - validate_plan_paths(active, self)?; - let mut preview = active.clone(); - for candidate in &self.candidates { - let path = find_exact_stock_preview( - candidate.layer.path(active), - candidate.layer.current_contents(), - )?; - candidate.layer.set_path(&mut preview, path); - } - Ok(preview) - } -} - -/// Apply one still-current migration plan after an explicit user action -/// -/// Every eligible file is backed up and revalidated before any replacement begins -/// -/// # Errors -/// -/// Returns an error without replacing a stale, edited, linked, or unbacked-up candidate -pub fn apply_stock_theme_migration( - paths: &ThemePaths, - migration: &StockThemeMigration, -) -> Result { - validate_plan_paths(paths, migration)?; - - // Validation before backup prevents obsolete UI actions from creating misleading backups - for candidate in &migration.candidates { - require_matching_snapshot(candidate)?; - } - for candidate in &migration.candidates { - let _backup = reserve_stock_backup(&candidate.path, &candidate.original_contents)?; - } - // A second whole-plan check ensures backup I/O did not hide an intervening edit - for candidate in &migration.candidates { - require_matching_snapshot(candidate)?; - } - - let mut updated_layers = 0; - for candidate in &migration.candidates { - if !replace_file_if_snapshot_matches( - &candidate.path, - candidate.layer.current_contents(), - &candidate.snapshot, - ) - .map_err(|error| migration_error(&error))? - { - return Err(stale_plan_error()); - } - updated_layers += 1; - } - - Ok(StockThemeApplyReport { updated_layers }) -} - -/// Persist the explicit choice to retain current files for this `UnixNotis` release -/// -/// # Errors -/// -/// Returns an error when the marker cannot be created as a regular file -pub fn keep_current_stock_theme(paths: &ThemePaths) -> Result<(), ConfigError> { - let marker = stock_keep_marker_path(&paths.base_dir); - match write_file_if_missing(&marker, KEEP_MARKER_CONTENTS, 0o644) { - Ok(true) => Ok(()), - Ok(false) => open_regular_file(&marker) - .map(|_file| ()) - .map_err(|error| marker_error(&error)), - Err(error) => Err(marker_error(&error)), - } -} - -pub(super) fn replace_file_if_snapshot_matches( - path: &std::path::Path, - current_stock: &[u8], - original: &FileSnapshot, -) -> io::Result { - let (current, _contents) = inspect_stock_file(path)?; - if ¤t != original { - // The editor always wins when the target changed after the approval plan was built - return Ok(false); - } - write_file_atomic_preserving_mode(path, current_stock, 0o644)?; - Ok(true) -} - -fn require_matching_snapshot(candidate: &StockThemeCandidate) -> Result<(), ConfigError> { - let (current, _contents) = - inspect_stock_file(&candidate.path).map_err(|error| migration_error(&error))?; - if current == candidate.snapshot { - Ok(()) - } else { - Err(stale_plan_error()) - } -} - -fn validate_plan_paths( - paths: &ThemePaths, - migration: &StockThemeMigration, -) -> Result<(), ConfigError> { - let unchanged = migration - .candidates - .iter() - .all(|candidate| candidate.layer.path(paths) == candidate.path); - if unchanged { - Ok(()) - } else { - Err(ConfigError::ReadFailed( - "theme configuration changed after the migration notice was shown".to_string(), - )) - } -} - -fn keep_marker_exists(paths: &ThemePaths) -> Result { - let marker = stock_keep_marker_path(&paths.base_dir); - match open_regular_file(&marker) { - Ok(_file) => Ok(true), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(marker_error(&error)), - } -} - -fn migration_fingerprint(candidates: &[StockThemeCandidate]) -> String { - let mut hasher = blake3::Hasher::new(); - for candidate in candidates { - hasher.update(candidate.layer.label().as_bytes()); - hasher.update(candidate.path.as_os_str().as_bytes()); - hasher.update(&candidate.snapshot.device.to_le_bytes()); - hasher.update(&candidate.snapshot.inode.to_le_bytes()); - hasher.update(&candidate.snapshot.size.to_le_bytes()); - hasher.update(candidate.snapshot.digest.as_bytes()); - } - hasher.finalize().to_hex().to_string() -} - -fn stale_plan_error() -> ConfigError { - ConfigError::ReadFailed( - "theme files changed after the migration notice was shown; no stale file was replaced" - .to_string(), - ) -} - -fn migration_error(error: &io::Error) -> ConfigError { - ConfigError::ReadFailed(format!("failed to apply the approved stock theme: {error}")) -} - -fn marker_error(error: &io::Error) -> ConfigError { - ConfigError::ReadFailed(format!( - "failed to remember the Keep Current theme choice: {error}" - )) -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs deleted file mode 100644 index 21c3f3346..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Explicit, non-destructive stock theme migration - -pub(in crate::config::loading::io) mod files; -mod migration; -mod model; -mod staging; - -pub use migration::{ - apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, -}; -pub use model::{StockThemeApplyReport, StockThemeMigration}; -pub(in crate::config::loading::io) use staging::stage_current_stock_themes; - -const MAX_STOCK_THEME_BYTES: u64 = 1_048_576; -const MAX_STOCK_PATH_COLLISIONS: u8 = 8; - -#[cfg(test)] -mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs deleted file mode 100644 index 3d3f6d4a2..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/model.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Stock theme migration domain types - -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_WIDGETS_CSS}; - -use super::super::ThemePaths; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum StockThemeLayer { - Panel, - Widgets, - Media, -} - -impl StockThemeLayer { - pub(super) const fn label(self) -> &'static str { - match self { - Self::Panel => "panel", - Self::Widgets => "widgets", - Self::Media => "media", - } - } - - pub(super) fn path(self, paths: &ThemePaths) -> &Path { - match self { - Self::Panel => &paths.panel_css, - Self::Widgets => &paths.widgets_css, - Self::Media => &paths.media_css, - } - } - - pub(super) fn set_path(self, paths: &mut ThemePaths, path: PathBuf) { - match self { - Self::Panel => paths.panel_css = path, - Self::Widgets => paths.widgets_css = path, - Self::Media => paths.media_css = path, - } - } - - pub(super) const fn current_contents(self) -> &'static [u8] { - match self { - Self::Panel => DEFAULT_PANEL_CSS.as_bytes(), - Self::Widgets => DEFAULT_WIDGETS_CSS.as_bytes(), - Self::Media => DEFAULT_MEDIA_CSS.as_bytes(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(super) struct FileSnapshot { - pub(super) device: u64, - pub(super) inode: u64, - pub(super) size: u64, - pub(super) modified: SystemTime, - pub(super) digest: blake3::Hash, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(super) struct StockThemeCandidate { - pub(super) layer: StockThemeLayer, - pub(super) path: PathBuf, - pub(super) snapshot: FileSnapshot, - pub(super) original_contents: Vec, -} - -/// Exact known stock files that can be previewed or updated with explicit approval -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StockThemeMigration { - pub(super) candidates: Vec, - pub(super) fingerprint: String, -} - -impl StockThemeMigration { - /// Return how many independently editable theme layers are eligible - #[must_use] - pub const fn layer_count(&self) -> usize { - self.candidates.len() - } - - /// Return a compact normal-user description of the eligible layers - #[must_use] - pub fn layer_summary(&self) -> String { - let labels = self - .candidates - .iter() - .map(|candidate| candidate.layer.label()) - .collect::>(); - labels.join(", ") - } - - /// Return the stable identity used to reject stale UI actions - #[must_use] - pub fn fingerprint(&self) -> &str { - &self.fingerprint - } -} - -/// Result of an approved stock theme update -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct StockThemeApplyReport { - /// Number of active theme files replaced after revalidation - pub updated_layers: usize, -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs deleted file mode 100644 index 10a34ca2f..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/staging.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Collision-safe versioned stock theme staging - -use std::io; -use std::path::{Path, PathBuf}; - -use crate::filesystem::{regular_file_contents_equal, write_file_if_missing}; -use crate::{DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS}; - -use super::super::{ConfigError, ThemePaths}; -use super::files::stock_preview_candidates; -use super::MAX_STOCK_THEME_BYTES; - -pub(in crate::config::loading::io) fn stage_current_stock_themes( - paths: &ThemePaths, -) -> Result<(), ConfigError> { - // Startup only adds versioned siblings and never replaces an active or preview file - for (path, contents) in [ - (&paths.panel_css, DEFAULT_PANEL_CSS), - (&paths.popup_css, DEFAULT_POPUP_CSS), - (&paths.widgets_css, DEFAULT_WIDGETS_CSS), - (&paths.media_css, DEFAULT_MEDIA_CSS), - ] { - let _preview = stage_stock_preview(path, contents.as_bytes())?; - } - Ok(()) -} - -pub(super) fn stage_stock_preview(path: &Path, contents: &[u8]) -> Result { - let candidates = stock_preview_candidates(path)?; - let mut last_error = None; - for candidate in candidates { - match write_file_if_missing(&candidate, contents, 0o644) { - Ok(true) => return Ok(candidate), - Ok(false) => { - // Exact bytes make an existing staged file safe to advertise as stock - if regular_file_contents_equal(&candidate, contents, MAX_STOCK_THEME_BYTES) - .unwrap_or(false) - { - return Ok(candidate); - } - } - Err(error) => last_error = Some(error), - } - } - - let error = last_error.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "every versioned stock preview path is occupied", - ) - }); - Err(staging_error(path, &error)) -} - -pub(super) fn find_exact_stock_preview( - path: &Path, - contents: &[u8], -) -> Result { - for candidate in stock_preview_candidates(path)? { - // Preview never loads a file merely because its name resembles a stock asset - if regular_file_contents_equal(&candidate, contents, MAX_STOCK_THEME_BYTES).unwrap_or(false) - { - return Ok(candidate); - } - } - Err(ConfigError::ReadFailed( - "verified stock theme preview is unavailable".to_string(), - )) -} - -fn staging_error(path: &Path, error: &io::Error) -> ConfigError { - ConfigError::ReadFailed(format!( - "failed to stage current stock theme {}: {error}", - path.display() - )) -} diff --git a/crates/unixnotis-core/src/config/loading/io/write.rs b/crates/unixnotis-core/src/config/loading/io/write.rs deleted file mode 100644 index 40392c333..000000000 --- a/crates/unixnotis-core/src/config/loading/io/write.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Safe filesystem writes shared by configuration provisioning paths - -use std::path::Path; - -use crate::filesystem::write_file_if_missing; - -use super::ConfigError; - -pub(super) fn write_if_missing(path: &Path, contents: &str) -> Result<(), ConfigError> { - write_file_if_missing(path, contents.as_bytes(), 0o644) - .map(|_created| ()) - .map_err(|err| ConfigError::ReadFailed(err.to_string())) -} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 6920177c1..9a19263bf 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -26,8 +26,8 @@ pub use icon_assets::{ DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; pub use io::{ - apply_stock_theme_migration, detect_stock_theme_migration, keep_current_stock_theme, - ConfigError, StockThemeApplyReport, StockThemeMigration, ThemePaths, MAX_CONFIG_BYTES, + persist_theme_mode, ConfigError, ThemeContractState, ThemeIncompatibility, ThemeManifest, + ThemeModeWriteError, ThemePaths, MAX_CONFIG_BYTES, THEME_API_VERSION, }; pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index 1ca4880bc..13da956a9 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -41,38 +41,8 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { ensure_installer_config(ctx, &config_dir)?; ensure_default_scripts(ctx, &config_dir)?; - let theme_paths = config - .resolve_theme_paths() - .map_err(|err| anyhow!(err.to_string()))?; - let theme_entries = [ - ("base.css", &theme_paths.base_css), - ("panel.css", &theme_paths.panel_css), - ("popup.css", &theme_paths.popup_css), - ("widgets.css", &theme_paths.widgets_css), - ("media.css", &theme_paths.media_css), - ]; - - let pre_existing = theme_entries - .iter() - .map(|(_, path)| path.exists()) - .collect::>(); - - config - .ensure_theme_files(&theme_paths) - .map_err(|err| anyhow!(err.to_string()))?; - - for ((name, path), existed) in theme_entries.iter().zip(pre_existing.iter()) { - let status = if *existed { "present" } else { "created" }; - log_line( - ctx, - format!( - "Theme file {}: {} ({})", - name, - status, - format_with_home(path) - ), - ); - } + // New installations use embedded stock CSS until a versioned custom theme is installed + log_line(ctx, "Theme source: embedded stock".to_string()); Ok(()) } @@ -136,43 +106,22 @@ pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { "media.css", backup_dir.as_deref(), )?; + backup_existing_file( + ctx, + &theme_paths.manifest_path(), + "theme.toml", + backup_dir.as_deref(), + )?; backup_default_scripts(ctx, &config_dir, backup_dir.as_deref())?; - write_file_atomic( - &theme_paths.base_css, - unixnotis_core::DEFAULT_BASE_CSS.as_bytes(), - 0o644, - ) - .with_context(|| "failed to write base.css")?; - write_file_atomic( - &theme_paths.panel_css, - unixnotis_core::DEFAULT_PANEL_CSS.as_bytes(), - 0o644, - ) - .with_context(|| "failed to write panel.css")?; - write_file_atomic( - &theme_paths.popup_css, - unixnotis_core::DEFAULT_POPUP_CSS.as_bytes(), - 0o644, - ) - .with_context(|| "failed to write popup.css")?; - write_file_atomic( - &theme_paths.widgets_css, - unixnotis_core::DEFAULT_WIDGETS_CSS.as_bytes(), - 0o644, - ) - .with_context(|| "failed to write widgets.css")?; - write_file_atomic( - &theme_paths.media_css, - unixnotis_core::DEFAULT_MEDIA_CSS.as_bytes(), - 0o644, - ) - .with_context(|| "failed to write media.css")?; write_default_scripts(&config_dir)?; log_line( ctx, - format!("Reset theme files in {}", format_with_home(&config_dir)), + format!( + "Theme source reset to embedded stock; custom files preserved in {}", + format_with_home(&config_dir) + ), ); Ok(()) } diff --git a/crates/unixnotis-ui/src/css/loader/mod.rs b/crates/unixnotis-ui/src/css/loader/mod.rs index 6b502d849..785f8f1f2 100644 --- a/crates/unixnotis-ui/src/css/loader/mod.rs +++ b/crates/unixnotis-ui/src/css/loader/mod.rs @@ -7,7 +7,7 @@ mod tokens; mod urls; pub(super) use model::{CssFileLoadResult, CssFileLoadSource}; -pub(super) use provider::load_provider_with_overrides; +pub(super) use provider::{load_embedded_provider_with_overrides, load_provider_with_overrides}; #[cfg(test)] #[path = "tests/provider.rs"] diff --git a/crates/unixnotis-ui/src/css/loader/model.rs b/crates/unixnotis-ui/src/css/loader/model.rs index 3b26536cd..a213d3d08 100644 --- a/crates/unixnotis-ui/src/css/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/model.rs @@ -3,6 +3,8 @@ /// Source used for the CSS bytes passed to GTK #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(in crate::css) enum CssFileLoadSource { + /// Embedded stock CSS was selected by the versioned theme contract + EmbeddedStock, /// Non-empty custom CSS was read from disk Custom, /// An intentionally empty file used embedded defaults @@ -19,6 +21,13 @@ pub(in crate::css) struct CssFileLoadResult { } impl CssFileLoadResult { + pub(in crate::css) const fn embedded_stock() -> Self { + Self { + source: CssFileLoadSource::EmbeddedStock, + error: None, + } + } + pub(in crate::css) const fn custom() -> Self { Self { source: CssFileLoadSource::Custom, diff --git a/crates/unixnotis-ui/src/css/loader/provider.rs b/crates/unixnotis-ui/src/css/loader/provider.rs index b6711d1c6..f23f9803f 100644 --- a/crates/unixnotis-ui/src/css/loader/provider.rs +++ b/crates/unixnotis-ui/src/css/loader/provider.rs @@ -10,6 +10,28 @@ use super::model::CssFileLoadResult; use super::tokens::ensure_base_tokens; use super::urls::rebase_relative_css_asset_urls; +/// Load the embedded layer without consulting a configured stylesheet +pub fn load_embedded_provider_with_overrides( + load_css_data: impl Fn(&str), + path: &Path, + fallback: &str, + overrides: &str, + inject_base_tokens: bool, +) -> CssFileLoadResult { + let fallback = if inject_base_tokens { + ensure_base_tokens(fallback, path) + } else { + fallback.to_string() + }; + let merged = if overrides.trim().is_empty() { + fallback + } else { + format!("{fallback}\n{overrides}") + }; + load_css_data(&rebase_relative_css_asset_urls(&merged, path)); + CssFileLoadResult::embedded_stock() +} + /// Load CSS into a provider, applying overrides and falling back to defaults pub fn load_provider_with_overrides( load_css_data: impl Fn(&str), diff --git a/crates/unixnotis-ui/src/css/manager/report.rs b/crates/unixnotis-ui/src/css/manager/report.rs index 86bf053a3..81d0814cf 100644 --- a/crates/unixnotis-ui/src/css/manager/report.rs +++ b/crates/unixnotis-ui/src/css/manager/report.rs @@ -7,6 +7,8 @@ use super::layers::CssProviderLayer; /// Source used for one active CSS layer #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CssLayerSource { + /// Embedded stock selected before any custom stylesheet was read + EmbeddedStock, /// Non-empty configured file Custom, /// Embedded defaults selected by an intentionally empty file diff --git a/crates/unixnotis-ui/src/css/manager/stack/model.rs b/crates/unixnotis-ui/src/css/manager/stack/model.rs index 826527ffa..493158986 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/model.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/model.rs @@ -1,7 +1,7 @@ //! CSS stack state and surface-specific construction use gtk::CssProvider; -use unixnotis_core::{ThemeConfig, ThemePaths}; +use unixnotis_core::{ThemeConfig, ThemeContractState, ThemeMode, ThemePaths}; use super::super::provider::CssProviderBackend; @@ -41,6 +41,12 @@ impl CssManager { pub const fn theme_paths(&self) -> &ThemePaths { &self.inner.theme_paths } + + /// Return the source contract selected for the next reload + #[must_use] + pub fn theme_contract(&self) -> ThemeContractState { + self.inner.theme_contract() + } } #[derive(Clone)] @@ -93,3 +99,15 @@ impl CssManagerInner { } } } + +impl

CssManagerInner

+where + P: CssProviderBackend, +{ + pub(super) fn theme_contract(&self) -> ThemeContractState { + match self.theme_config.mode { + ThemeMode::Stock => ThemeContractState::EmbeddedStock, + ThemeMode::Custom => self.theme_paths.inspect_theme_contract(), + } + } +} diff --git a/crates/unixnotis-ui/src/css/manager/stack/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/reload.rs index f68dd18bc..344ddc296 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/reload.rs @@ -6,7 +6,8 @@ use unixnotis_core::{ }; use super::super::super::loader::{ - load_provider_with_overrides, CssFileLoadResult, CssFileLoadSource, + load_embedded_provider_with_overrides, load_provider_with_overrides, CssFileLoadResult, + CssFileLoadSource, }; use super::super::super::overrides::{ build_base_overrides, build_panel_overrides, build_popup_overrides, build_widgets_overrides, @@ -36,13 +37,15 @@ where { pub(super) fn reload(&self, fallback: &str) -> CssReloadReport { let mut loaded = Vec::new(); + let custom_theme_allowed = self.theme_contract().custom_theme_allowed(); // Structural fallbacks always load below every user-controlled layer self.internal_structure .load_css_data(INTERNAL_STRUCTURE_CSS); // Base variables load before every surface-specific provider let base_overrides = build_base_overrides(&self.theme_config); - let result = load_provider_with_overrides( + let result = load_provider( + custom_theme_allowed, |data| self.base.load_css_data(data), &self.theme_paths.base_css, fallback, @@ -58,7 +61,8 @@ where // Optional providers distinguish panel and popup process layouts if let Some(panel) = self.panel.as_ref() { let panel_overrides = build_panel_overrides(&self.theme_config); - let result = load_provider_with_overrides( + let result = load_provider( + custom_theme_allowed, |data| panel.load_css_data(data), &self.theme_paths.panel_css, DEFAULT_PANEL_CSS, @@ -75,7 +79,8 @@ where // Widget overrides remain isolated from panel structural rules if let Some(widgets) = self.widgets.as_ref() { let widgets_overrides = build_widgets_overrides(&self.theme_config); - let result = load_provider_with_overrides( + let result = load_provider( + custom_theme_allowed, |data| widgets.load_css_data(data), &self.theme_paths.widgets_css, DEFAULT_WIDGETS_CSS, @@ -91,7 +96,8 @@ where // Media has no generated override layer and remains fully theme controlled if let Some(media) = self.media.as_ref() { - let result = load_provider_with_overrides( + let result = load_provider( + custom_theme_allowed, |data| media.load_css_data(data), &self.theme_paths.media_css, DEFAULT_MEDIA_CSS, @@ -108,7 +114,8 @@ where // Popup geometry tokens apply only when the popup provider exists if let Some(popup) = self.popup.as_ref() { let popup_overrides = build_popup_overrides(&self.theme_config); - let result = load_provider_with_overrides( + let result = load_provider( + custom_theme_allowed, |data| popup.load_css_data(data), &self.theme_paths.popup_css, DEFAULT_POPUP_CSS, @@ -138,6 +145,27 @@ where } } +fn load_provider( + custom_theme_allowed: bool, + load_css_data: impl Fn(&str), + path: &std::path::Path, + fallback: &str, + overrides: &str, + inject_base_tokens: bool, +) -> CssFileLoadResult { + if custom_theme_allowed { + load_provider_with_overrides(load_css_data, path, fallback, overrides, inject_base_tokens) + } else { + load_embedded_provider_with_overrides( + load_css_data, + path, + fallback, + overrides, + inject_base_tokens, + ) + } +} + fn layer_reload( layer: CssProviderLayer, path: std::path::PathBuf, @@ -145,6 +173,7 @@ fn layer_reload( ) -> CssLayerReload { // Loader sources map into the stable public report vocabulary let source = match result.source { + CssFileLoadSource::EmbeddedStock => CssLayerSource::EmbeddedStock, CssFileLoadSource::Custom => CssLayerSource::Custom, CssFileLoadSource::EmptyFallback => CssLayerSource::EmptyFallback, CssFileLoadSource::ReadFailureFallback => CssLayerSource::ReadFailureFallback, From 4adbcf40424eeefae8c476cf657a1f65b0ac7059 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:09:10 -0500 Subject: [PATCH 143/275] test(theme): cover versioned theme modes and stock fallback Summary: cover versioned theme modes and stock fallback. Scope: theme. --- crates/noticenterctl/src/app/tests/local.rs | 2 + crates/noticenterctl/src/app/tests/runner.rs | 2 + crates/noticenterctl/src/cli/tests/command.rs | 30 +- crates/noticenterctl/src/cli/tests/help.rs | 5 + .../noticenterctl/src/theme/tests/export.rs | 97 ++++ crates/noticenterctl/src/theme/tests/mod.rs | 3 + .../src/ui/panel/tests/notice.rs | 20 +- .../src/ui/reload/config/tests/notice.rs | 17 +- .../src/ui/reload/config/tests/support.rs | 22 +- .../src/ui/reload/tests/notices.rs | 18 +- .../ui/theme_compatibility/tests/actions.rs | 24 + .../src/ui/theme_compatibility/tests/flow.rs | 137 ++++++ .../src/ui/theme_compatibility/tests/mod.rs | 2 + .../src/ui/theme_migration/tests/actions.rs | 33 -- .../fixtures/legacy-panel-9ca42584.css.gz | Bin 4105 -> 0 bytes .../src/ui/theme_migration/tests/flow.rs | 251 ---------- .../src/ui/theme_migration/tests/mod.rs | 4 - .../src/config/loading/io/tests/mod.rs | 4 +- .../config/loading/io/tests/theme_contract.rs | 169 +++++++ .../config/loading/io/tests/theme_files.rs | 271 ---------- .../src/config/loading/io/tests/theme_mode.rs | 85 ++++ .../src/config/loading/io/tests/write.rs | 36 -- .../loading/io/theme_stock/tests/migration.rs | 464 ------------------ .../loading/io/theme_stock/tests/mod.rs | 21 - .../loading/io/theme_stock/tests/staging.rs | 84 ---- .../src/actions/config/tests/provision.rs | 22 +- .../src/css/manager/stack/tests/reload.rs | 72 ++- .../src/css/manager/tests/report.rs | 6 + 28 files changed, 702 insertions(+), 1199 deletions(-) create mode 100644 crates/noticenterctl/src/theme/tests/export.rs create mode 100644 crates/noticenterctl/src/theme/tests/mod.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs create mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs create mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/tests/write.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs diff --git a/crates/noticenterctl/src/app/tests/local.rs b/crates/noticenterctl/src/app/tests/local.rs index e31b83a60..47f51d70a 100644 --- a/crates/noticenterctl/src/app/tests/local.rs +++ b/crates/noticenterctl/src/app/tests/local.rs @@ -19,6 +19,7 @@ fn daemon_command_is_not_dispatched_to_local_handlers() { Ok(()) }, |_| Ok(()), + |_| Ok(()), ) .expect("ignore daemon command in local dispatcher"); @@ -36,6 +37,7 @@ fn local_handler_error_is_returned_to_the_caller() { |_| anyhow::bail!("CSS check failed"), |_| -> Result<()> { Ok(()) }, |_| -> Result<()> { Ok(()) }, + |_| -> Result<()> { Ok(()) }, ); let error = result.expect_err("local command failure should be returned"); diff --git a/crates/noticenterctl/src/app/tests/runner.rs b/crates/noticenterctl/src/app/tests/runner.rs index 5d0c01294..952ba46df 100644 --- a/crates/noticenterctl/src/app/tests/runner.rs +++ b/crates/noticenterctl/src/app/tests/runner.rs @@ -19,6 +19,7 @@ fn handle_local_command_runs_css_check_branch() { }, |_| -> Result<()> { panic!("preset runner should not be called for css check") }, |_| -> Result<()> { panic!("session runner should not be called for css check") }, + |_| -> Result<()> { panic!("theme runner should not be called for css check") }, ) .expect("css check should dispatch"); @@ -45,6 +46,7 @@ fn handle_local_command_runs_preset_branch_with_command_payload() { Ok(()) }, |_| -> Result<()> { panic!("session runner should not be called for preset command") }, + |_| -> Result<()> { panic!("theme runner should not be called for preset command") }, ) .expect("preset should dispatch"); diff --git a/crates/noticenterctl/src/cli/tests/command.rs b/crates/noticenterctl/src/cli/tests/command.rs index b84cd2b00..a6c8c6dce 100644 --- a/crates/noticenterctl/src/cli/tests/command.rs +++ b/crates/noticenterctl/src/cli/tests/command.rs @@ -1,6 +1,6 @@ use clap::Parser; -use super::super::{Args, Command, DoctorServiceManagerArg, PresetCommand}; +use super::super::{Args, Command, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; #[test] fn local_only_classification_distinguishes_local_and_control_commands() { @@ -18,6 +18,10 @@ fn local_only_classification_distinguishes_local_and_control_commands() { } } .is_local_only()); + assert!(Command::Theme { + command: ThemeCommand::ExportStock { output: None } + } + .is_local_only()); assert!(!Command::ClearActive.is_local_only()); } @@ -39,6 +43,10 @@ fn synchronous_classification_builds_a_runtime_only_when_needed() { } } .is_synchronous()); + assert!(Command::Theme { + command: ThemeCommand::ExportStock { output: None } + } + .is_synchronous()); assert!(!Command::Doctor { json: false, @@ -49,3 +57,23 @@ fn synchronous_classification_builds_a_runtime_only_when_needed() { .is_synchronous()); assert!(!Command::ClearActive.is_synchronous()); } + +#[test] +fn theme_export_stock_is_local_and_accepts_an_optional_directory() { + let args = Args::try_parse_from([ + "noticenterctl", + "theme", + "export-stock", + "--output", + "editable-theme", + ]) + .expect("theme export arguments should parse"); + + let Command::Theme { + command: ThemeCommand::ExportStock { output }, + } = args.command + else { + panic!("theme export command should be selected"); + }; + assert_eq!(output, Some("editable-theme".into())); +} diff --git a/crates/noticenterctl/src/cli/tests/help.rs b/crates/noticenterctl/src/cli/tests/help.rs index 79e5c0889..2fdce64e2 100644 --- a/crates/noticenterctl/src/cli/tests/help.rs +++ b/crates/noticenterctl/src/cli/tests/help.rs @@ -10,6 +10,7 @@ fn root_help_lists_the_supported_command_groups() { assert!(help.contains("css-check")); assert!(help.contains("doctor")); assert!(help.contains("preset")); + assert!(help.contains("theme")); } #[test] @@ -27,6 +28,10 @@ fn command_help_lists_output_debug_and_preset_controls() { vec!["noticenterctl", "preset", "--help"], vec!["export", "import", "inspect"], ), + ( + vec!["noticenterctl", "theme", "--help"], + vec!["export-stock"], + ), ] { let error = Args::try_parse_from(arguments).expect_err("help should stop parsing"); let help = error.to_string(); diff --git a/crates/noticenterctl/src/theme/tests/export.rs b/crates/noticenterctl/src/theme/tests/export.rs new file mode 100644 index 000000000..73a6793be --- /dev/null +++ b/crates/noticenterctl/src/theme/tests/export.rs @@ -0,0 +1,97 @@ +//! Stock theme export behavior + +use std::fs; +use std::os::unix::fs::symlink; + +use unixnotis_core::{ThemeManifest, DEFAULT_BASE_CSS, THEME_API_VERSION}; + +use super::super::export::{default_export_directory_for_config, export_stock_theme}; + +fn test_root(name: &str) -> std::path::PathBuf { + let serial = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("unixnotis-{name}-{}-{serial}", std::process::id())) +} + +#[test] +fn stock_export_creates_complete_versioned_editable_copies() { + let root = test_root("theme-export"); + fs::create_dir_all(&root).expect("test root should be created"); + let destination = root.join("stock"); + + export_stock_theme(&destination).expect("stock theme should be exported"); + + assert_eq!( + fs::read_to_string(destination.join("base.css")).expect("base CSS should be readable"), + DEFAULT_BASE_CSS + ); + for name in [ + "panel.css", + "popup.css", + "widgets.css", + "media.css", + "theme.toml", + ] { + assert!( + destination.join(name).is_file(), + "stock export should include {name}" + ); + } + let manifest = fs::read_to_string(destination.join("theme.toml")) + .expect("theme manifest should be readable"); + let manifest = + toml::from_str::(&manifest).expect("theme manifest should be valid"); + assert_eq!(manifest.api_version, THEME_API_VERSION); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_refuses_an_existing_destination_without_changing_it() { + let root = test_root("theme-export-collision"); + let destination = root.join("stock"); + fs::create_dir_all(&destination).expect("existing destination should be created"); + let sentinel = destination.join("personal.css"); + fs::write(&sentinel, "/* keep */").expect("sentinel should be written"); + + export_stock_theme(&destination).expect_err("existing export directory must be rejected"); + + assert_eq!( + fs::read_to_string(&sentinel).expect("sentinel should remain readable"), + "/* keep */" + ); + assert!( + !destination.join("base.css").exists(), + "rejected export must not create theme files" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_rejects_a_symlinked_destination_parent() { + let root = test_root("theme-export-symlink"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("outside directory should be created"); + symlink(&outside, &linked).expect("linked parent should be created"); + + export_stock_theme(&linked.join("stock")) + .expect_err("stock export must not traverse a symbolic link"); + + assert!( + !outside.join("stock").exists(), + "symlink rejection must not create files outside the selected tree" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn default_stock_export_directory_is_sibling_of_active_config() { + let config = std::path::Path::new("profile/unixnotis/config.toml"); + + assert_eq!( + default_export_directory_for_config(config).expect("default export path should resolve"), + std::path::Path::new("profile/unixnotis/stock-theme-v2") + ); +} diff --git a/crates/noticenterctl/src/theme/tests/mod.rs b/crates/noticenterctl/src/theme/tests/mod.rs new file mode 100644 index 000000000..db02c8b1b --- /dev/null +++ b/crates/noticenterctl/src/theme/tests/mod.rs @@ -0,0 +1,3 @@ +//! Theme command tests + +mod export; diff --git a/crates/unixnotis-center/src/ui/panel/tests/notice.rs b/crates/unixnotis-center/src/ui/panel/tests/notice.rs index 3ad996080..e543a92d7 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/notice.rs @@ -23,19 +23,21 @@ fn reload_notice_starts_hidden_and_dismiss_button_hides_it() { } #[gtk::test] -fn migration_actions_have_distinct_labels_and_primary_apply_style() { +fn compatibility_actions_have_distinct_labels_and_primary_stock_style() { let notice = build_reload_notice(); - assert_eq!(notice.preview_button.label().as_deref(), Some("Preview")); - assert_eq!(notice.apply_button.label().as_deref(), Some("Apply")); - assert_eq!(notice.keep_button.label().as_deref(), Some("Keep Current")); + assert_eq!( + notice.use_stock_button.label().as_deref(), + Some("Use stock theme") + ); + assert_eq!( + notice.open_theme_folder_button.label().as_deref(), + Some("Open theme folder") + ); assert!(notice - .apply_button + .use_stock_button .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); assert!(!notice - .preview_button - .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); - assert!(!notice - .keep_button + .open_theme_folder_button .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); } diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs index 1e5957932..bcde96fa0 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs @@ -3,7 +3,9 @@ use std::fs; use gtk::prelude::*; use super::super::outcome::ConfigReloadOutcome; -use super::support::{state, write_config}; +use super::support::{ + enable_missing_panel_layer_fixture, state, write_compatible_theme_manifest, write_config, +}; #[gtk::test] fn accepted_reload_clears_rejected_config_notice() { @@ -25,6 +27,7 @@ fn accepted_reload_clears_rejected_config_notice() { ] { fs::write(path, "/* intentionally valid */").expect("theme css"); } + write_compatible_theme_manifest(&state); let outcome = state.reload_config(); @@ -53,6 +56,7 @@ fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { #[gtk::test] fn changed_css_failure_reopens_after_the_previous_failure_was_dismissed() { let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); let first_report = state.reload_css(); assert!(first_report.read_failures().count() > 1); assert!(state.panel.reload_notice.revealer.reveals_child()); @@ -90,6 +94,7 @@ fn successful_css_only_reload_does_not_clear_config_rejection_notice() { ] { fs::write(path, "/* valid reload css */").expect("theme css"); } + write_compatible_theme_manifest(&state); fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); let rejection = state.panel.reload_notice.label.text(); @@ -104,6 +109,7 @@ fn successful_css_only_reload_does_not_clear_config_rejection_notice() { #[gtk::test] fn css_failure_cannot_replace_an_active_config_rejection() { let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); let rejection = state.panel.reload_notice.label.text(); @@ -123,6 +129,7 @@ fn css_failure_cannot_replace_an_active_config_rejection() { #[gtk::test] fn css_reload_notice_summarizes_multiple_unreadable_layers() { let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); let report = state.reload_css(); assert!(report.read_failures().count() > 1); @@ -141,13 +148,13 @@ fn css_reload_notice_summarizes_multiple_unreadable_layers() { } #[gtk::test] -fn migration_notice_requires_an_explicit_action_instead_of_generic_dismissal() { +fn theme_compatibility_notice_requires_an_explicit_action() { let mut state = state(); state.set_reload_notice( - crate::ui::reload::ReloadNoticeKind::ThemeMigration, - "Stock theme update available", + crate::ui::reload::ReloadNoticeKind::ThemeCompatibility, + "Theme is incompatible", false, - "migration-a", + "compatibility-a", ); assert!(state.panel.reload_notice.revealer.reveals_child()); diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs index be706f2d6..f756ee9e5 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use gtk::prelude::*; -use unixnotis_core::Config; +use unixnotis_core::{Config, ThemeMode, THEME_API_VERSION}; use unixnotis_ui::css::CssManager; use crate::control::{UiCommand, UiEvent}; @@ -64,3 +64,23 @@ pub(super) fn write_config(path: &Path, config: &Config) { let text = toml::to_string(config).expect("test config should serialize"); fs::write(path, text).expect("test config should be written"); } + +pub(super) fn write_compatible_theme_manifest(state: &UiState) { + let manifest = state.css.theme_paths().manifest_path(); + fs::write( + manifest, + format!("api_version = {THEME_API_VERSION}\nname = \"Test theme\"\n"), + ) + .expect("compatible theme manifest should be written"); +} + +pub(super) fn enable_missing_panel_layer_fixture(state: &mut UiState) { + state.config.theme.mode = ThemeMode::Custom; + state + .css + .update_theme(state.css.theme_paths().clone(), state.config.theme.clone()); + // A popup-only custom layer makes the contract active while panel layers stay absent + fs::write(&state.css.theme_paths().popup_css, "/* popup only */") + .expect("popup theme fixture should be written"); + write_compatible_theme_manifest(state); +} diff --git a/crates/unixnotis-center/src/ui/reload/tests/notices.rs b/crates/unixnotis-center/src/ui/reload/tests/notices.rs index 3a53af827..acb653d7f 100644 --- a/crates/unixnotis-center/src/ui/reload/tests/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/tests/notices.rs @@ -116,9 +116,12 @@ fn old_dismissal_does_not_hide_a_failure_after_an_intervening_fingerprint() { } #[test] -fn migration_notice_waits_behind_failures_and_returns_after_recovery() { +fn theme_compatibility_notice_waits_behind_failures_and_returns_after_recovery() { let mut state = ReloadNoticeState::default(); - state.set(notice(ReloadNoticeKind::ThemeMigration, "migration-a")); + state.set(notice( + ReloadNoticeKind::ThemeCompatibility, + "compatibility-a", + )); state.set(notice(ReloadNoticeKind::Css, "css-a")); state.set(notice(ReloadNoticeKind::Config, "config-a")); @@ -134,19 +137,22 @@ fn migration_notice_waits_behind_failures_and_returns_after_recovery() { state.clear(ReloadNoticeKind::Css); assert_eq!( state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::ThemeMigration) + Some(ReloadNoticeKind::ThemeCompatibility) ); } #[test] -fn generic_dismissal_does_not_discard_a_migration_choice() { +fn generic_dismissal_does_not_discard_a_theme_compatibility_choice() { let mut state = ReloadNoticeState::default(); - state.set(notice(ReloadNoticeKind::ThemeMigration, "migration-a")); + state.set(notice( + ReloadNoticeKind::ThemeCompatibility, + "compatibility-a", + )); state.dismiss_visible(); assert_eq!( state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::ThemeMigration) + Some(ReloadNoticeKind::ThemeCompatibility) ); } diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs new file mode 100644 index 000000000..2d0df828a --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs @@ -0,0 +1,24 @@ +use gtk::prelude::*; + +use super::super::connect_notice_actions; +use crate::control::UiEvent; +use crate::ui::panel::notice::build_reload_notice; + +#[gtk::test] +fn compatibility_buttons_emit_stock_and_folder_events() { + let notice = build_reload_notice(); + let (event_tx, event_rx) = async_channel::bounded(2); + connect_notice_actions(¬ice, event_tx); + + notice.use_stock_button.emit_clicked(); + notice.open_theme_folder_button.emit_clicked(); + + assert!(matches!( + event_rx.try_recv().expect("stock action should emit"), + UiEvent::UseStockTheme + )); + assert!(matches!( + event_rx.try_recv().expect("folder action should emit"), + UiEvent::OpenThemeFolder + )); +} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs new file mode 100644 index 000000000..6a89ffcfa --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs @@ -0,0 +1,137 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use gtk::prelude::*; +use unixnotis_core::{Config, ThemeContractState, ThemeMode}; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::ui::{UiState, UiStateInit}; + +static NEXT_APP: AtomicUsize = AtomicUsize::new(0); + +struct ThemeFixture { + state: UiState, + custom_css: PathBuf, + original: String, + root: PathBuf, +} + +impl Drop for ThemeFixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).expect("theme test directory should be removable"); + } +} + +fn incompatible_theme_fixture(name: &str) -> ThemeFixture { + let serial = NEXT_APP.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.theme.compatibility.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + config.theme.mode = ThemeMode::Custom; + config.panel.respect_work_area = false; + config.media.enabled = false; + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let root = std::env::temp_dir().join(format!( + "unixnotis-theme-compatibility-{name}-{}-{serial}", + std::process::id() + )); + fs::create_dir_all(&root).expect("theme test directory should be created"); + let paths = config + .resolve_theme_paths_from(&root) + .expect("theme paths should resolve"); + let original = "/* incompatible custom theme must be preserved */".to_string(); + fs::write(&paths.panel_css, &original).expect("custom theme should be writable"); + let config_path = root.join("config.toml"); + fs::write( + &config_path, + toml::to_string_pretty(&config).expect("theme config should serialize"), + ) + .expect("theme config should be writable"); + + let css = CssManager::new_panel(paths.clone(), config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + let state = UiState::new(UiStateInit { + app, + config, + config_path, + command_tx, + css, + event_tx, + media_handle: None, + runtime, + }); + + ThemeFixture { + state, + custom_css: paths.panel_css, + original, + root, + } +} + +#[gtk::test] +fn incompatible_theme_shows_non_mutating_stock_fallback_notice() { + let fixture = incompatible_theme_fixture("notice"); + + assert!(fixture.state.panel.reload_notice.revealer.reveals_child()); + assert!(fixture.state.panel.reload_notice.actions.get_visible()); + assert!(!fixture.state.panel.reload_notice.close.get_visible()); + assert!(fixture + .state + .panel + .reload_notice + .label + .text() + .contains("incompatible")); + assert_eq!( + fs::read_to_string(&fixture.custom_css).expect("custom theme should remain readable"), + fixture.original + ); +} + +#[gtk::test] +fn stock_action_disables_custom_reads_without_changing_custom_file() { + let mut fixture = incompatible_theme_fixture("stock"); + + fixture.state.handle_event(UiEvent::UseStockTheme); + + assert_eq!( + fixture.state.css.theme_contract(), + ThemeContractState::EmbeddedStock + ); + assert!(!fixture.state.panel.reload_notice.revealer.reveals_child()); + assert_eq!( + fs::read_to_string(&fixture.custom_css).expect("custom theme should remain readable"), + fixture.original + ); + let persisted = Config::load_from_path(&fixture.state.config_path) + .expect("stock theme selection should leave a valid config"); + assert_eq!( + persisted.theme.mode, + ThemeMode::Stock, + "stock theme selection must survive a process restart" + ); + let paths = persisted + .resolve_theme_paths_from(&fixture.root) + .expect("persisted theme paths should resolve"); + assert_eq!( + CssManager::new_panel(paths, persisted.theme).theme_contract(), + ThemeContractState::EmbeddedStock, + "a new CSS manager must retain the persisted stock selection" + ); +} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs new file mode 100644 index 000000000..858b6b547 --- /dev/null +++ b/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs @@ -0,0 +1,2 @@ +mod actions; +mod flow; diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs deleted file mode 100644 index 64aab0ff1..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/tests/actions.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Migration notice action wiring tests - -use gtk::prelude::*; - -use super::super::connect_notice_actions; -use crate::control::UiEvent; -use crate::ui::panel::notice::build_reload_notice; - -#[gtk::test] -fn migration_buttons_emit_three_distinct_policy_events() { - let notice = build_reload_notice(); - let (event_tx, event_rx) = async_channel::bounded(3); - connect_notice_actions(¬ice, event_tx); - - notice.preview_button.emit_clicked(); - notice.apply_button.emit_clicked(); - notice.keep_button.emit_clicked(); - - assert!(matches!( - event_rx.try_recv().expect("Preview should emit an event"), - UiEvent::ThemeMigrationPreview - )); - assert!(matches!( - event_rx.try_recv().expect("Apply should emit an event"), - UiEvent::ThemeMigrationApply - )); - assert!(matches!( - event_rx - .try_recv() - .expect("Keep Current should emit an event"), - UiEvent::ThemeMigrationKeepCurrent - )); -} diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz b/crates/unixnotis-center/src/ui/theme_migration/tests/fixtures/legacy-panel-9ca42584.css.gz deleted file mode 100644 index f0c2fac22eca0de255f767fe5b1238f322554773..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4105 zcmV+k5ccmMiwFP!000021LYiPkEF)+`~Hg3t}HE1Gv;o(M~V`~NfbrdSaJN>s)p)r zv@{@~XQplCzvtBf6i~&{Gm6)a6WP;H$E$Z=#W!E0AF|}-Q(h+(DsV+755k~};!*N2g8dgF8!N1Q0rMRkLgSWZwW4ZpQ7YhSv^wyduVimoV6^KvhbtI11E-yHHXCZ$P1xmJ7R3j7Wp zKjHGDX%2VcZ`lR~j$^>V9@zn3pnag8Dqb7iUNE(IK~BL&Nj_h2L=Retmi1kQt~%rZ90H0Zc>Jm$8rG7DnHhI zbdpu1hB@?q^aMC9&iLbpBl~BAtOv`3Q~u}OyAgAnXM*A2oX2qqJY;e%+cNS0%yBv8r~|xg4gDbcerIU7V>Agz5$sIo*ny$u z6q}OK@hC;@aE78*!!4ts1E!iMy~|0jp}a{oQ={R|gsK*hKt}2Qq>`jL2sHfximv0A zJ%x10DfFZbyJ(TeboGzN(~nFuFw}WUc4-!B>AvqTagdhSImRU9CMnCD^Sjta=%p5M z!C{N(O1^KXcm3dF`{=MSZaA~sGcK7f*YD>XLhnG=@%KV8`2>hEE zACfewZwMZgaPCWyk{9$0E>VFCQdW%%)9F*L?6`KcG}=*3P8?Bht6~HNn7$=3bp%Na z+h%o{r-*VMe#(=$0^ceo+z$gUj`J9O3r=H(OZpdEvI9?zRAE#>%kGh*<1!AG$(_-k zwX5l;uH?`j_1M;j(;`E-*oPZ4RX|OGwa9~L(18mBML3N<&JX(?0=s&Ltqu*HZX4Tg zYKW-{0j_fvm~UpdO*Y{*=9`;DS?KKL26^etj0YZMGs=e(@Z$M-Pe zy}?2uTmUF#@)KYJr!;VTRza{0XCSCRBBFBx!1w%Dou5u{R0;)X0yEFj+an?`FfoQB ziz!J~Tp6S#c_KK47l5w(c?^R)C+IY6GwDtY3?U5=_Z@-%`f|gAz0p%Xbg^u>LN*4A zuADz{AgVrzFqE<|*>lu5rMKcd^bKrp>~ipD6Du+19eV+KYv^!mC`*<#y~qB{Ow{Xj zt;rUe^8^Ap_~^ThN@eCx#T`N$d2bbWuz{9#U>C{GYdC+BxAxJQM4yGmT7Y(M0`2s# zCo+R~cO}}l3*ny>fz931JHFQ$=Lw-{bX{=|i3SITF&^=kz0 zF|H~xxbr3W^f-eNJT%RZG(ii09Ikau!-IYnIEK@+MSqWkovUQKy+`VF)?EHWCd)AN zH^;KvaN+@1M9utJuFFv}741&u61g1oxJb=G&X|Q71dZH)CJJl4icC9X| zZ}Ln&z_X*tR=(I6Ma;8(O^B=m1}P7F&f84bq^fM3w=pBsCC*^?5^$!9p>*eLW3Cau z3>RF&BkeP~?D!#FF=3Wyv>z4N3K3vPnj+uyq_+R=9>fkDx(Ran);*NtYHqW37K^S- zFk;C;@aJq$Jj^h*vb3ES`v{luI++*G*5iYFM^mZM25{S2uCKUedBY(#01xQ*;6r|P%*9|02%olTC#>3Bg^ARQ-qJbqRVuCNQzl|szwnfslz$3 zb2o5Zp8bQx{$W{ac_xE;%C7nHXzsgB4ezJ_TfA$>JM#FpZZ$E%HU_GBl##NgG1x@V zFGH`cXEoiM!tAE9u&!gI!`2S{Uj16-0<@#N0RyuakBT}? zC`A$NY*!bDvKVa$V$ZA9&T5K=+L?VFB-O7)1W9{_{>n-)yfj{+kGLpMk)&y53|oVw zT8)0cK}Urm8OoNCn=tN5EK(3BOE_KrB6yoZjyAR(LB&Rl8ADD=2HN}wohQ0))J~y{ zzt^T`8N7=!vn)$70m!%l(|avB+JnQ*rEh~*Hv&xx9*EGM8#NMdCdTM0Ozi+Ul+cUE z)Zr(nt*Q41<9ffNtM@xPN&KJJ`<++Iqg1UQRSEBF4YN z=4pzHs-FZO(QApH*kt(idu2IKicfG|FBPqg*;wGtri$7{Z(3stxYhY2x1`dEm1q_< zlpU#j?Y2p#Gh_2!Hl4Ycy+<9Pa_cP3?AnuWo%zm6p52g4M32{kmO z4j7e54J?$dJ>s=IbuQ2am8e%jYxLHKBKEnd_eqda}&0xh`AZ`}%g`k)e z^;viY*Q)6o=6ogelE~QILCTIj9cvjWyBSMJ`D>Ig^Ng#uq5DB&spN)l*@7%=eKbW| zu@(jh7Zo7z0f#)ksa&<`kddCMx?Wh9?6^oGUd$9cc~tr5OHtoUrfS5ENOm8Q=4il^ zXALqy5mFNF;(}YtYwnNDeMs6x^BLG0ona98HH@PPU6n4zby(}_UvLdUekGiJ*dsr( zzZu}8o0V*p1w{>KRk<(}VaOgg z3Xb3vIsLg?G`98@jnA`4tdWG}`XGt|6kUsglo4wHQ1+e`#iH9g+74h@D&z^;TRp90 zuIV9CE!6c-_Iy@FFt^30p%|9aY~a>fd4m$5RSntIA|8;jkAS6<>>#-C;9%;U+JuJy_?;J~g>BhBzl2L)_+j_P z@$9YZu{T?z`~HBo4|jJsgd`l7zK3aOrU#DrlBBmidjB`-I=$bZ_y53InHPlq`6;h+ z^gY-;_))=sfF@;f)O9i@V?M zy9DO;ZHRU7;=`aG4^Kn)V2P*G7Q(Wy!y)%AV{z=(%<*8)869prU)CM61tj53Wt+F^ zSNwn1NWYxQk-Gyozhv*=;?B6e?po3p!%gnJnWC4~7jTv`#_G zFOx{f)nBdf$B5Mrc8mpwe!a`*I7(}_!sYX(^4#~?JbUZ-ETI6v*r5}#r~TXI zzY+DTNUhFAPt+rrO}}o}+}+TVa$R1iPf!Pb`&fM9sv~G%6E_sG?yuSI?q1M}!5>??+Q=v+;OgBkUgBm%HjaPx)gBwN457sCf zy0>?g;uQ5|$)6b)=#~`MK17GuC$?0W{;njKarpiR1R?(#&P! zDodT{irbD2s=MWDSoU;Yw~M^M1L&x(Kcv^o<|$j*Mi5jW;!7NFbz*vOw@--(#f?ny zXhmS5gnBw?@SS)U}&XfMr$5;65r_kF-IFnoj6B^8pfp4R^YwVL2M Ha#{cYOV9si diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs deleted file mode 100644 index daad11475..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/tests/flow.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! End-to-end migration notice state tests - -use std::fs; -use std::io::Read; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; - -use gtk::prelude::*; -use unixnotis_core::{detect_stock_theme_migration, Config, ThemePaths, DEFAULT_PANEL_CSS}; -use unixnotis_ui::css::CssManager; - -use crate::control::{UiCommand, UiEvent}; -use crate::ui::{UiState, UiStateInit}; - -static LEGACY_PANEL_CSS: OnceLock> = OnceLock::new(); -static APP_ID: AtomicUsize = AtomicUsize::new(0); - -fn legacy_panel_css() -> &'static [u8] { - LEGACY_PANEL_CSS - .get_or_init(|| { - let compressed = include_bytes!("fixtures/legacy-panel-9ca42584.css.gz"); - let mut decoder = flate2::read::GzDecoder::new(compressed.as_slice()); - let mut css = Vec::new(); - decoder - .read_to_end(&mut css) - .expect("historical panel fixture should decompress"); - css - }) - .as_slice() -} - -struct MigrationFixture { - state: UiState, - paths: ThemePaths, - root: PathBuf, -} - -impl Drop for MigrationFixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.root).expect("migration test directory should be removed"); - } -} - -fn migration_fixture(name: &str) -> MigrationFixture { - let serial = APP_ID.fetch_add(1, Ordering::Relaxed); - let app = gtk::Application::builder() - .application_id(format!("dev.unixnotis.theme.migration.test{serial}")) - .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) - .build(); - app.register(None::<>k::gio::Cancellable>) - .expect("test application should register"); - - let mut config = Config::default(); - // Optional processes stay outside the migration fixture - config.panel.respect_work_area = false; - config.media.enabled = false; - config.widgets.volume.enabled = false; - config.widgets.brightness.enabled = false; - config.widgets.toggles.clear(); - config.widgets.stats.clear(); - config.widgets.cards.clear(); - - let root = std::env::current_dir() - .expect("current directory should resolve") - .join("target") - .join(format!( - "unixnotis-theme-migration-{name}-{}-{serial}", - std::process::id() - )); - fs::create_dir_all(&root).expect("migration test directory should be created"); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, legacy_panel_css()).expect("legacy panel CSS should be written"); - config - .ensure_theme_files(&paths) - .expect("active and staged theme files should be prepared"); - - let css = CssManager::new_panel(paths.clone(), config.theme.clone()); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); - let (event_tx, _event_rx) = async_channel::bounded::(8); - let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); - let state = UiState::new(UiStateInit { - app, - config, - config_path: root.join("config.toml"), - command_tx, - css, - event_tx, - media_handle: None, - runtime, - }); - - MigrationFixture { state, paths, root } -} - -#[gtk::test] -fn startup_offers_actions_for_an_exact_historical_stock_theme() { - let fixture = migration_fixture("startup"); - - assert!( - fixture.state.theme_migration.is_some(), - "the exact historical panel should produce a migration plan" - ); - assert!( - fixture.state.panel.reload_notice.revealer.reveals_child(), - "the migration notice should be visible at startup" - ); - assert!( - fixture.state.panel.reload_notice.actions.get_visible(), - "Preview, Apply, and Keep Current should be visible" - ); - assert!( - !fixture.state.panel.reload_notice.close.get_visible(), - "the generic close action must not bypass the explicit choice" - ); - assert!( - fixture - .state - .panel - .reload_notice - .label - .text() - .contains("panel"), - "the notice should identify the eligible layer" - ); -} - -#[gtk::test] -fn preview_event_uses_verified_staged_css_without_changing_the_active_file() { - let mut fixture = migration_fixture("preview"); - - fixture.state.handle_event(UiEvent::ThemeMigrationPreview); - - assert!( - fixture.state.theme_preview_active, - "Preview should mark the in-memory CSS state active" - ); - assert_ne!( - fixture.state.css.theme_paths().panel_css, - fixture.paths.panel_css, - "Preview should point the provider at a versioned stock sibling" - ); - assert_eq!( - fs::read(&fixture.paths.panel_css).expect("active panel CSS should remain readable"), - legacy_panel_css(), - "Preview must not replace the user-editable active file" - ); - assert!( - fixture - .state - .panel - .reload_notice - .label - .text() - .contains("preview active"), - "the notice should explain the temporary preview state" - ); -} - -#[gtk::test] -fn apply_replaces_exact_stock_only_after_click_and_clears_the_notice() { - let mut fixture = migration_fixture("apply"); - - fixture.state.apply_stock_theme_migration(); - - assert!( - fixture.state.theme_migration.is_none(), - "a successful Apply should consume the plan" - ); - assert!(!fixture.state.theme_preview_active); - assert_eq!( - fixture.state.css.theme_paths().panel_css, - fixture.paths.panel_css, - "Apply should restore the configured active path" - ); - assert_eq!( - fs::read(&fixture.paths.panel_css).expect("applied panel CSS should be readable"), - DEFAULT_PANEL_CSS.as_bytes(), - "Apply should publish current stock bytes" - ); - assert!( - !fixture.state.panel.reload_notice.revealer.reveals_child(), - "the migration notice should close after a successful Apply" - ); - assert!( - fs::read_dir(&fixture.root) - .expect("theme directory should remain readable") - .filter_map(Result::ok) - .any(|entry| entry.file_name().to_string_lossy().ends_with(".bak")), - "Apply should retain a recoverable backup" - ); -} - -#[gtk::test] -fn keep_current_restores_a_preview_and_persists_the_choice() { - let mut fixture = migration_fixture("keep"); - fixture.state.preview_stock_theme_migration(); - - fixture.state.keep_current_stock_theme(); - - assert!(fixture.state.theme_migration.is_none()); - assert!(!fixture.state.theme_preview_active); - assert_eq!( - fixture.state.css.theme_paths().panel_css, - fixture.paths.panel_css, - "Keep Current should restore the configured active path" - ); - assert_eq!( - fs::read(&fixture.paths.panel_css).expect("kept panel CSS should be readable"), - legacy_panel_css(), - "Keep Current must preserve the historical bytes" - ); - assert!( - detect_stock_theme_migration(&fixture.paths) - .expect("persisted choice should remain readable") - .is_none(), - "the version-scoped choice should suppress the same notice on restart" - ); -} - -#[gtk::test] -fn stale_apply_reports_failure_and_preserves_the_newer_edit() { - let mut fixture = migration_fixture("stale-apply"); - let edited = b"/* edited after the notice */\n"; - fs::write(&fixture.paths.panel_css, edited).expect("newer edit should be written"); - - fixture.state.apply_stock_theme_migration(); - - assert!( - fixture.state.theme_migration.is_some(), - "a failed Apply should retain an explicit recovery choice" - ); - assert!(fixture.state.panel.reload_notice.actions.get_visible()); - assert!( - fixture - .state - .panel - .reload_notice - .label - .text() - .contains("stopped safely"), - "the panel should explain that no stale approval was used" - ); - assert_eq!( - fs::read(&fixture.paths.panel_css).expect("edited panel CSS should remain readable"), - edited, - "the newer edit must remain active" - ); -} diff --git a/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs b/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs deleted file mode 100644 index bffde5556..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/tests/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Theme migration UI regression tests - -mod actions; -mod flow; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index 740db0af7..a462c3256 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -6,5 +6,5 @@ mod paths; mod script_migrations; mod scripts; mod support; -mod theme_files; -mod write; +mod theme_contract; +mod theme_mode; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs new file mode 100644 index 000000000..3d6466502 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs @@ -0,0 +1,169 @@ +use std::fs; + +use crate::{ThemeContractState, ThemeIncompatibility, ThemeManifest, THEME_API_VERSION}; + +use super::support::test_root; + +fn theme_root(name: &str) -> std::path::PathBuf { + let root = test_root(name); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("temporary theme root should be creatable"); + root +} + +fn theme_paths(root: &std::path::Path) -> crate::ThemePaths { + crate::Config::default() + .resolve_theme_paths_from(root) + .expect("theme paths should resolve") +} + +#[test] +fn custom_mode_without_a_manifest_is_incompatible_without_creating_files() { + let root = theme_root("theme-contract-stock"); + let paths = theme_paths(&root); + + let state = paths.inspect_theme_contract(); + + assert_eq!( + state, + ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest) + ); + assert!(!paths.manifest_path().exists()); + assert!(!paths.base_css.exists()); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn matching_manifest_enables_existing_custom_theme() { + let root = theme_root("theme-contract-compatible"); + let paths = theme_paths(&root); + fs::write(&paths.base_css, "/* custom */").expect("custom CSS should be writable"); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"Night Glass\"\n"), + ) + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Compatible(ThemeManifest { + api_version: THEME_API_VERSION, + name: "Night Glass".to_string(), + }) + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn existing_theme_without_manifest_is_incompatible_and_unchanged() { + let root = theme_root("theme-contract-missing"); + let paths = theme_paths(&root); + let original = "/* preserve this exact theme */"; + fs::write(&paths.panel_css, original).expect("custom CSS should be writable"); + + let state = paths.inspect_theme_contract(); + + assert_eq!( + state, + ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest) + ); + assert_eq!( + fs::read_to_string(&paths.panel_css).expect("custom CSS should remain readable"), + original + ); + assert!(!paths.manifest_path().exists()); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn unsupported_manifest_version_falls_back_without_rewriting_theme() { + let root = theme_root("theme-contract-version"); + let paths = theme_paths(&root); + let original = "/* older theme */"; + fs::write(&paths.base_css, original).expect("custom CSS should be writable"); + fs::write(paths.manifest_path(), "api_version = 1\nname = \"Old\"\n") + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::UnsupportedVersion { found: 1 }) + ); + assert_eq!( + fs::read_to_string(&paths.base_css).expect("custom CSS should remain readable"), + original + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn blank_or_control_character_theme_names_are_incompatible() { + let root = theme_root("theme-contract-invalid-names"); + let paths = theme_paths(&root); + for name in [" ", "Bad\\tName"] { + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{name}\"\n"), + ) + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName) + ); + } + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn theme_name_length_accepts_the_limit_and_rejects_the_next_character() { + let root = theme_root("theme-contract-name-limit"); + let paths = theme_paths(&root); + let maximum_name = "a".repeat(128); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{maximum_name}\"\n"), + ) + .expect("theme manifest should be writable"); + assert!(matches!( + paths.inspect_theme_contract(), + ThemeContractState::Compatible(_) + )); + + let oversized_name = "a".repeat(129); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{oversized_name}\"\n"), + ) + .expect("theme manifest should be writable"); + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName) + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[cfg(unix)] +#[test] +fn linked_manifest_is_rejected_without_following_its_target() { + use std::os::unix::fs::symlink; + + let root = theme_root("theme-contract-linked"); + let outside = theme_root("theme-contract-linked-outside"); + let paths = theme_paths(&root); + fs::write(&paths.base_css, "/* custom */").expect("custom CSS should be writable"); + let target = outside.join("theme.toml"); + fs::write(&target, "api_version = 2\nname = \"Linked\"\n") + .expect("outside manifest should be writable"); + symlink(&target, paths.manifest_path()).expect("manifest symlink should be creatable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::UnreadableManifest) + ); + assert_eq!( + fs::read_to_string(target).expect("outside manifest should remain readable"), + "api_version = 2\nname = \"Linked\"\n" + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); + fs::remove_dir_all(outside).expect("temporary outside root should be removable"); +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs deleted file mode 100644 index ad0ca2f6e..000000000 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_files.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Tests for provisioning configured theme files - -use std::fs; - -use crate::{Config, DEFAULT_BASE_CSS}; - -use super::super::theme_files::warn_legacy_rename_once; -use super::super::theme_stock::files::stock_preview_path; -use super::support::test_root; - -#[test] -fn ensure_theme_files_writes_missing_files_and_renames_legacy_style() { - let root = test_root("theme-files"); - // Legacy style.css should be migrated only when base.css does not exist yet - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - fs::write(root.join("style.css"), "/* custom legacy */").expect("legacy css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - // The legacy stylesheet becomes the new base stylesheet and leaves a backup marker - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - "/* custom legacy */" - ); - assert!(paths.panel_css.exists()); - assert!(paths.popup_css.exists()); - assert!(paths.widgets_css.exists()); - assert!(paths.media_css.exists()); - assert!(root.join("style.css.bak").exists()); - assert!(!root.join("style.css").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_preserves_existing_base_css() { - let root = test_root("theme-preserve"); - // Existing base.css is user-owned and must win over legacy migration - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.base_css, "/* keep */").expect("existing base css"); - fs::write(root.join("style.css"), "/* legacy ignored */").expect("legacy css"); - - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - "/* keep */" - ); - assert!(root.join("style.css").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_stages_versioned_stock_without_replacing_user_css() { - let root = test_root("theme-stock-preview"); - fs::create_dir_all(&root).expect("theme root"); - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, "/* user panel */").expect("custom panel css"); - - config - .ensure_theme_files(&paths) - .expect("theme previews should be staged"); - - assert_eq!( - fs::read_to_string(&paths.panel_css).expect("custom panel remains"), - "/* user panel */" - ); - let preview = stock_preview_path(&paths.panel_css).expect("versioned preview path"); - assert_eq!( - fs::read_to_string(preview).expect("versioned stock preview"), - crate::DEFAULT_PANEL_CSS - ); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_keeps_legacy_style_when_backup_already_exists() { - let root = test_root("theme-backup-exists"); - // A pre-existing backup means migration already happened or was handled by the user - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - fs::write(root.join("style.css"), "/* keep legacy */").expect("legacy css"); - fs::write(root.join("style.css.bak"), "/* keep backup */").expect("backup css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - // Both legacy and backup files should remain untouched in this conservative path - assert_eq!( - fs::read_to_string(root.join("style.css")).expect("legacy css"), - "/* keep legacy */" - ); - assert_eq!( - fs::read_to_string(root.join("style.css.bak")).expect("backup css"), - "/* keep backup */" - ); - - let _ = fs::remove_dir_all(root); -} - -#[test] -#[cfg(unix)] -fn ensure_theme_files_ignores_a_legacy_symlink_and_keeps_its_target() { - let root = test_root("theme-legacy-link"); - let protected = root.join("protected.css"); - let legacy = root.join("style.css"); - fs::create_dir_all(&root).expect("theme root"); - fs::write(&protected, "/* protected */").expect("protected css"); - std::os::unix::fs::symlink(&protected, &legacy).expect("legacy link"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should use defaults"); - - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - DEFAULT_BASE_CSS - ); - assert_eq!( - fs::read_to_string(&protected).expect("protected css"), - "/* protected */" - ); - assert!(fs::symlink_metadata(&legacy) - .expect("legacy link remains") - .file_type() - .is_symlink()); - assert!(!root.join("style.css.bak").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -#[cfg(unix)] -fn ensure_theme_files_preserves_a_dangling_backup_link_and_legacy_source() { - let root = test_root("theme-dangling-backup-link"); - let legacy = root.join("style.css"); - let backup = root.join("style.css.bak"); - fs::create_dir_all(&root).expect("theme root"); - fs::write(&legacy, "/* legacy */").expect("legacy css"); - std::os::unix::fs::symlink("missing.css", &backup).expect("dangling backup link"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - "/* legacy */" - ); - assert_eq!( - fs::read_to_string(&legacy).expect("legacy css"), - "/* legacy */" - ); - assert_eq!( - fs::read_link(&backup).expect("backup link remains"), - std::path::Path::new("missing.css") - ); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_ignores_an_oversized_legacy_theme() { - const OVERSIZED_LEGACY_BYTES: usize = 16 * 1024 * 1024 + 1; - - let root = test_root("theme-oversized-legacy"); - let legacy = root.join("style.css"); - fs::create_dir_all(&root).expect("theme root"); - fs::write(&legacy, vec![b'x'; OVERSIZED_LEGACY_BYTES]).expect("oversized legacy css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should use defaults"); - - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - DEFAULT_BASE_CSS - ); - assert_eq!( - fs::metadata(&legacy).expect("legacy css remains").len(), - OVERSIZED_LEGACY_BYTES as u64 - ); - assert!(!root.join("style.css.bak").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_accepts_a_legacy_theme_at_the_exact_size_limit() { - const MAX_LEGACY_BYTES: usize = 16 * 1024 * 1024; - - let root = test_root("theme-exact-limit-legacy"); - let legacy = root.join("style.css"); - fs::create_dir_all(&root).expect("theme root"); - fs::write(&legacy, vec![b'x'; MAX_LEGACY_BYTES]).expect("limit-sized legacy css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("limit-sized theme should migrate"); - - assert_eq!( - fs::metadata(&paths.base_css).expect("base css").len(), - MAX_LEGACY_BYTES as u64 - ); - assert_eq!( - fs::metadata(root.join("style.css.bak")) - .expect("legacy backup") - .len(), - MAX_LEGACY_BYTES as u64 - ); - assert!(!legacy.exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn legacy_rename_warning_is_emitted_only_once_per_process() { - let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test failure"); - - assert!(warn_legacy_rename_once( - std::path::Path::new("style.css"), - std::path::Path::new("style.css.bak"), - &error, - )); - assert!(!warn_legacy_rename_once( - std::path::Path::new("style.css"), - std::path::Path::new("style.css.bak"), - &error, - )); -} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs new file mode 100644 index 000000000..75d9b6d58 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs @@ -0,0 +1,85 @@ +//! Explicit theme-source persistence tests + +use std::fs; +use std::os::unix::fs::symlink; + +use crate::{persist_theme_mode, Config, ThemeMode}; + +use super::support::test_root; + +#[test] +fn persist_theme_mode_updates_only_the_existing_theme_mode() { + let root = test_root("theme-mode-update"); + fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("config.toml"); + let original = "# retained heading\n[theme]\n# retained mode comment\nmode = \"custom\"\nbase_css = \"personal.css\"\n\n[panel]\nwidth = 418\n"; + fs::write(&path, original).expect("test config should be written"); + + persist_theme_mode(&path, ThemeMode::Stock).expect("theme mode should be persisted"); + + let contents = fs::read_to_string(&path).expect("updated config should be readable"); + assert!(contents.contains("# retained heading")); + assert!(contents.contains("# retained mode comment")); + assert!(contents.contains("base_css = \"personal.css\"")); + assert!(contents.contains("width = 418")); + assert_eq!( + Config::load_from_path(&path) + .expect("updated config should remain valid") + .theme + .mode, + ThemeMode::Stock + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn persist_theme_mode_creates_a_theme_table_when_it_is_absent() { + let root = test_root("theme-mode-create"); + fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("config.toml"); + fs::write(&path, "[panel]\nwidth = 419\n").expect("test config should be written"); + + persist_theme_mode(&path, ThemeMode::Custom).expect("theme table should be created"); + + let config = Config::load_from_path(&path).expect("updated config should remain valid"); + assert_eq!(config.theme.mode, ThemeMode::Custom); + assert_eq!(config.panel.width, 419); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn persist_theme_mode_rejects_invalid_config_without_replacing_it() { + let root = test_root("theme-mode-invalid"); + fs::create_dir_all(&root).expect("test directory should be created"); + let path = root.join("config.toml"); + let invalid = b"[theme\nmode = \"custom\"\n"; + fs::write(&path, invalid).expect("invalid test config should be written"); + + persist_theme_mode(&path, ThemeMode::Stock).expect_err("invalid config must not be replaced"); + + assert_eq!( + fs::read(&path).expect("invalid config should remain readable"), + invalid + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} + +#[test] +fn persist_theme_mode_rejects_a_symlink_without_touching_its_target() { + let root = test_root("theme-mode-symlink"); + fs::create_dir_all(&root).expect("test directory should be created"); + let outside = root.join("outside.toml"); + let path = root.join("config.toml"); + let original = b"[theme]\nmode = \"custom\"\n"; + fs::write(&outside, original).expect("outside config should be written"); + symlink(&outside, &path).expect("config symlink should be created"); + + persist_theme_mode(&path, ThemeMode::Stock) + .expect_err("theme mode persistence must reject symbolic links"); + + assert_eq!( + fs::read(&outside).expect("outside config should remain readable"), + original + ); + fs::remove_dir_all(root).expect("test directory should be removable"); +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/write.rs b/crates/unixnotis-core/src/config/loading/io/tests/write.rs deleted file mode 100644 index 8ee0c0e47..000000000 --- a/crates/unixnotis-core/src/config/loading/io/tests/write.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Tests for safe configuration file writes - -use std::fs; - -use super::support::test_root; - -#[test] -fn write_if_missing_preserves_existing_contents() { - let root = test_root("write-if-missing"); - // Existing files should be treated as user-owned content - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("root"); - let path = root.join("file.txt"); - fs::write(&path, "keep").expect("existing file"); - - super::super::write::write_if_missing(&path, "replace").expect("write should succeed"); - - assert_eq!(fs::read_to_string(&path).expect("file contents"), "keep"); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn write_if_missing_creates_new_file() { - let root = test_root("write-if-missing-create"); - // Missing files are safe for bootstrap helpers to create - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("root"); - let path = root.join("file.txt"); - - super::super::write::write_if_missing(&path, "created").expect("write should succeed"); - - assert_eq!(fs::read_to_string(&path).expect("file contents"), "created"); - - let _ = fs::remove_dir_all(root); -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs deleted file mode 100644 index 15bb997a9..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/migration.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Explicit stock migration policy tests - -use std::fs; - -use crate::{Config, DEFAULT_PANEL_CSS}; - -use super::super::super::{ConfigError, ThemePaths}; -use super::super::files::{ - collision_candidate, inspect_stock_file, stock_backup_path, stock_keep_marker_path, -}; -use super::super::migration::{ - apply_stock_theme_migration, detect_stock_theme_migration, - detect_stock_theme_migration_with_specs, keep_current_stock_theme, - replace_file_if_snapshot_matches, LegacyThemeSpec, -}; -use super::super::model::{StockThemeLayer, StockThemeMigration}; -use super::super::staging::{stage_current_stock_themes, stage_stock_preview}; -use super::super::MAX_STOCK_THEME_BYTES; -use super::test_root; - -const LEGACY_STOCK: &[u8] = b"/* exact previous stock */\n.card { color: red; }\n"; - -fn detect_panel_migration(paths: &ThemePaths) -> Result, ConfigError> { - let digest = blake3::hash(LEGACY_STOCK).to_hex().to_string(); - detect_stock_theme_migration_with_specs( - paths, - &[LegacyThemeSpec { - layer: StockThemeLayer::Panel, - digest: &digest, - }], - ) -} - -#[test] -fn exact_legacy_stock_requires_an_explicit_apply_before_replacement() { - let root = test_root("explicit-apply"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy stock should be written"); - stage_current_stock_themes(&paths).expect("current stock should stage"); - - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - - assert_eq!( - fs::read(&paths.panel_css).expect("active stock should remain readable"), - LEGACY_STOCK, - "detection and startup staging must not replace the active file" - ); - assert_eq!(migration.layer_count(), 1, "one layer should be eligible"); - assert_eq!( - migration.fingerprint().len(), - 64, - "the plan fingerprint should retain a full BLAKE3 identity" - ); - assert_eq!( - migration.layer_summary(), - "panel", - "the notice should name the eligible layer" - ); - - let report = apply_stock_theme_migration(&paths, &migration) - .expect("explicitly approved migration should apply"); - - assert_eq!(report.updated_layers, 1, "one layer should be updated"); - assert_eq!( - fs::read(&paths.panel_css).expect("updated stock should be readable"), - DEFAULT_PANEL_CSS.as_bytes(), - "Apply should publish current stock bytes" - ); - assert_eq!( - fs::read(stock_backup_path(&paths.panel_css).expect("backup path should resolve")) - .expect("backup should be readable"), - LEGACY_STOCK, - "Apply should preserve the exact prior bytes" - ); - - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn preview_paths_change_only_the_exact_legacy_layers() { - let root = test_root("preview-paths"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - stage_current_stock_themes(&paths).expect("stock previews should stage"); - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - - let preview = migration - .preview_paths(&paths) - .expect("verified preview paths should resolve"); - - assert_ne!( - preview.panel_css, paths.panel_css, - "eligible panel CSS should point to the staged preview" - ); - assert_eq!( - preview.widgets_css, paths.widgets_css, - "unrelated widget CSS should retain its configured path" - ); - assert_eq!( - preview.media_css, paths.media_css, - "unrelated media CSS should retain its configured path" - ); - assert_eq!( - preview.popup_css, paths.popup_css, - "popup CSS should not be folded into a panel migration" - ); - - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn keep_current_persists_the_choice_without_changing_theme_bytes() { - let root = test_root("keep-current"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - assert!( - detect_panel_migration(&paths) - .expect("initial detection should succeed") - .is_some(), - "exact legacy stock should initially produce a notice" - ); - - keep_current_stock_theme(&paths).expect("Keep Current should persist"); - - assert!( - detect_panel_migration(&paths) - .expect("post-choice detection should succeed") - .is_none(), - "the current release should respect the persisted choice" - ); - assert_eq!( - fs::read(&paths.panel_css).expect("kept theme should be readable"), - LEGACY_STOCK, - "Keep Current must not alter active CSS" - ); - assert!( - stock_keep_marker_path(&paths.base_dir).is_file(), - "Keep Current should create a regular release marker" - ); - - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn custom_theme_is_not_offered_as_a_stock_migration() { - let root = test_root("custom-theme"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, b"/* customized */").expect("custom panel should be written"); - - let migration = detect_panel_migration(&paths).expect("custom theme inspection should succeed"); - - assert!( - migration.is_none(), - "non-stock bytes must remain outside the migration flow" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn stale_apply_does_not_replace_an_edit_made_after_the_notice() { - let root = test_root("stale-apply"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - let edited = b"/* user edit after notice */\n"; - fs::write(&paths.panel_css, edited).expect("user edit should be written"); - - let error = apply_stock_theme_migration(&paths, &migration) - .expect_err("stale approval must be rejected"); - - assert!( - error.to_string().contains("changed"), - "the failure should explain that the approval became stale" - ); - assert_eq!( - fs::read(&paths.panel_css).expect("edited theme should be readable"), - edited, - "the newer edit must win" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn final_snapshot_check_preserves_a_concurrent_edit() { - let root = test_root("final-snapshot"); - fs::create_dir_all(&root).expect("theme root should be created"); - let path = root.join("panel.css"); - fs::write(&path, LEGACY_STOCK).expect("legacy panel should be written"); - let (snapshot, _contents) = - inspect_stock_file(&path).expect("initial snapshot should be captured"); - let edited = b"/* editor won the race */\n"; - fs::write(&path, edited).expect("concurrent edit should be written"); - - let replaced = replace_file_if_snapshot_matches(&path, DEFAULT_PANEL_CSS.as_bytes(), &snapshot) - .expect("snapshot comparison should complete"); - - assert!(!replaced, "a changed file must not be replaced"); - assert_eq!( - fs::read(&path).expect("edited theme should be readable"), - edited, - "the concurrent edit must remain intact" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[cfg(unix)] -#[test] -fn linked_theme_is_never_eligible_for_replacement() { - let root = test_root("linked-theme"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - let protected = root.join("protected.css"); - fs::write(&protected, LEGACY_STOCK).expect("protected file should be written"); - std::os::unix::fs::symlink(&protected, &paths.panel_css) - .expect("active theme link should be created"); - - let migration = - detect_panel_migration(&paths).expect("linked theme inspection should remain non-fatal"); - - assert!(migration.is_none(), "linked CSS must never become eligible"); - assert_eq!( - fs::read(&protected).expect("protected CSS should be readable"), - LEGACY_STOCK, - "the link target must remain unchanged" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn every_candidate_is_revalidated_before_the_first_replacement() { - let root = test_root("whole-plan-revalidation"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - fs::write(&paths.widgets_css, LEGACY_STOCK).expect("legacy widgets should be written"); - let digest = blake3::hash(LEGACY_STOCK).to_hex().to_string(); - let migration = detect_stock_theme_migration_with_specs( - &paths, - &[ - LegacyThemeSpec { - layer: StockThemeLayer::Panel, - digest: &digest, - }, - LegacyThemeSpec { - layer: StockThemeLayer::Widgets, - digest: &digest, - }, - ], - ) - .expect("migration detection should succeed") - .expect("both exact layers should be eligible"); - assert_eq!( - migration.layer_count(), - 2, - "both exact layers should remain represented in the plan" - ); - fs::write(&paths.widgets_css, b"/* later widget edit */") - .expect("later widget edit should be written"); - - apply_stock_theme_migration(&paths, &migration) - .expect_err("one stale layer should reject the complete plan"); - - assert_eq!( - fs::read(&paths.panel_css).expect("panel should remain readable"), - LEGACY_STOCK, - "a later stale layer must stop earlier candidates from being replaced" - ); - assert_eq!( - fs::read(&paths.widgets_css).expect("widgets should remain readable"), - b"/* later widget edit */", - "the newer widget edit must remain intact" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn conflicting_backup_is_preserved_and_apply_uses_a_suffix() { - let root = test_root("backup-collision"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - let backup = stock_backup_path(&paths.panel_css).expect("backup path should resolve"); - fs::write(&backup, b"/* unrelated backup */").expect("collision should be written"); - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - - apply_stock_theme_migration(&paths, &migration) - .expect("Apply should use a collision-safe backup name"); - - assert_eq!( - fs::read(&backup).expect("collision should remain readable"), - b"/* unrelated backup */", - "Apply must never overwrite an existing backup" - ); - assert_eq!( - fs::read(collision_candidate(&backup, 1)).expect("suffix backup should be readable"), - LEGACY_STOCK, - "the exact prior bytes should use the next available suffix" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn modified_staged_file_cannot_be_loaded_as_a_stock_preview() { - let root = test_root("tampered-preview"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - let staged = stage_stock_preview(&paths.panel_css, DEFAULT_PANEL_CSS.as_bytes()) - .expect("panel preview should stage"); - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - fs::write(&staged, b"/* changed after staging */").expect("staged file should be changed"); - - migration - .preview_paths(&paths) - .expect_err("changed staged bytes must not be loaded as stock"); - - assert_eq!( - fs::read(&paths.panel_css).expect("active panel should remain readable"), - LEGACY_STOCK, - "a failed preview must not change the active theme" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[cfg(unix)] -#[test] -fn keep_current_rejects_a_marker_symlink_without_touching_its_target() { - let root = test_root("linked-keep-marker"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - let protected = root.join("protected-choice.txt"); - fs::write(&protected, b"protected").expect("protected marker target should be written"); - std::os::unix::fs::symlink(&protected, stock_keep_marker_path(&paths.base_dir)) - .expect("marker link should be created"); - - keep_current_stock_theme(&paths).expect_err("a linked marker must be rejected"); - - assert_eq!( - fs::read(&protected).expect("protected target should remain readable"), - b"protected", - "Keep Current must never follow a marker link" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn changed_configured_path_rejects_the_original_plan() { - let root = test_root("changed-config-path"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.panel_css, LEGACY_STOCK).expect("legacy panel should be written"); - stage_current_stock_themes(&paths).expect("stock previews should stage"); - let migration = detect_panel_migration(&paths) - .expect("migration detection should succeed") - .expect("exact stock should be eligible"); - let mut changed_paths = paths.clone(); - changed_paths.panel_css = root.join("different-panel.css"); - - migration - .preview_paths(&changed_paths) - .expect_err("a plan must stay bound to its configured path"); - apply_stock_theme_migration(&changed_paths, &migration) - .expect_err("Apply must reject a changed configured path"); - - assert_eq!( - fs::read(&paths.panel_css).expect("original panel should remain readable"), - LEGACY_STOCK, - "path drift must not alter the originally approved file" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn stock_file_inspection_accepts_the_exact_limit_and_rejects_one_more_byte() { - let root = test_root("inspection-size-boundary"); - fs::create_dir_all(&root).expect("theme root should be created"); - let exact_size = usize::try_from(MAX_STOCK_THEME_BYTES).expect("test limit should fit usize"); - let exact = root.join("exact.css"); - let oversized = root.join("oversized.css"); - fs::write(&exact, vec![b'x'; exact_size]).expect("exact-sized CSS should be written"); - fs::write(&oversized, vec![b'x'; exact_size.saturating_add(1)]) - .expect("oversized CSS should be written"); - - let (snapshot, contents) = - inspect_stock_file(&exact).expect("the exact size limit should be accepted"); - let error = inspect_stock_file(&oversized).expect_err("one extra byte should be rejected"); - - assert_eq!( - snapshot.size, MAX_STOCK_THEME_BYTES, - "the exact boundary should preserve its full size" - ); - assert_eq!( - contents.len(), - exact_size, - "the exact boundary should preserve every byte" - ); - assert_eq!( - error.kind(), - std::io::ErrorKind::InvalidData, - "oversized stock input should fail as invalid data" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[cfg(unix)] -#[test] -fn production_detection_fails_closed_for_a_linked_keep_marker() { - let root = test_root("production-linked-marker"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - let protected = root.join("protected-marker.txt"); - fs::write(&protected, b"protected").expect("protected marker should be written"); - std::os::unix::fs::symlink(&protected, stock_keep_marker_path(&paths.base_dir)) - .expect("linked marker should be created"); - - detect_stock_theme_migration(&paths) - .expect_err("production detection must reject an unsafe marker shape"); - - assert_eq!( - fs::read(&protected).expect("protected marker should remain readable"), - b"protected", - "detection must not follow or modify the marker link" - ); - fs::remove_dir_all(root).expect("theme root should be removed"); -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs deleted file mode 100644 index fa7f457e2..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Stock theme migration regression tests - -mod migration; -mod staging; - -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -fn test_root(name: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should follow the Unix epoch") - .as_nanos(); - std::env::current_dir() - .expect("current directory should resolve") - .join("target") - .join(format!( - "unixnotis-theme-stock-{name}-{}-{unique}", - std::process::id() - )) -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs b/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs deleted file mode 100644 index ce8721fba..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_stock/tests/staging.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Versioned preview staging tests - -use std::fs; - -use crate::{Config, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS}; - -use super::super::files::{collision_candidate, stock_preview_path}; -use super::super::staging::{find_exact_stock_preview, stage_current_stock_themes}; -use super::test_root; - -#[test] -fn staging_writes_every_stock_theme_under_a_versioned_sibling_name() { - let root = test_root("stage-all"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - - stage_current_stock_themes(&paths).expect("stock themes should stage"); - - for (path, expected) in [ - (&paths.panel_css, DEFAULT_PANEL_CSS), - (&paths.popup_css, DEFAULT_POPUP_CSS), - (&paths.widgets_css, DEFAULT_WIDGETS_CSS), - (&paths.media_css, DEFAULT_MEDIA_CSS), - ] { - let preview = stock_preview_path(path).expect("versioned stock path should resolve"); - assert_eq!( - fs::read_to_string(preview).expect("staged stock theme should be readable"), - expected, - "each preview should contain the current embedded stock layer" - ); - assert!( - !path.exists(), - "staging must not create or replace active CSS" - ); - } - - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn conflicting_preview_is_preserved_and_exact_stock_uses_a_suffix() { - let root = test_root("preview-collision"); - fs::create_dir_all(&root).expect("theme root should be created"); - let paths = Config::default() - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - let primary = stock_preview_path(&paths.panel_css).expect("preview path should resolve"); - fs::write(&primary, "/* reviewed custom file */").expect("collision should be written"); - - stage_current_stock_themes(&paths).expect("stock themes should stage around collisions"); - - let fallback = collision_candidate(&primary, 1); - assert_eq!( - fs::read_to_string(&primary).expect("collision should remain readable"), - "/* reviewed custom file */", - "staging must preserve an occupied versioned path" - ); - assert_eq!( - fs::read_to_string(&fallback).expect("fallback preview should be readable"), - DEFAULT_PANEL_CSS, - "a collision-safe sibling should contain exact stock bytes" - ); - assert_eq!( - find_exact_stock_preview(&paths.panel_css, DEFAULT_PANEL_CSS.as_bytes()) - .expect("exact preview should be found"), - fallback, - "preview selection must ignore the caller-controlled collision" - ); - - fs::remove_dir_all(root).expect("theme root should be removed"); -} - -#[test] -fn preview_path_rejects_a_path_without_a_file_name() { - let error = stock_preview_path(std::path::Path::new("/")) - .expect_err("a directory root cannot identify a theme file"); - - assert!( - error.to_string().contains("no file name"), - "the error should explain why staging cannot continue" - ); -} diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index 4e263bf83..644a42848 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -11,7 +11,7 @@ use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; use crate::test_support::env::{test_env_lock, EnvGuard}; -use unixnotis_core::Config; +use unixnotis_core::{Config, ThemeMode}; use super::super::provision::{ensure_config, reset_config}; @@ -37,7 +37,7 @@ fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> Action } #[test] -fn ensure_config_creates_every_default_and_preserves_the_live_config() { +fn ensure_config_uses_embedded_theme_and_preserves_the_live_config() { let _lock = test_env_lock(); let root = crate::test_support::fs::unique_temp_path("ensure-config"); let xdg_root = root.join("xdg"); @@ -63,8 +63,12 @@ fn ensure_config_creates_every_default_and_preserves_the_live_config() { "popup.css", "widgets.css", "media.css", + "theme.toml", ] { - assert!(config_dir.join(name).is_file(), "missing theme file {name}"); + assert!( + !config_dir.join(name).exists(), + "new installs should not create custom theme file {name}" + ); } for script in unixnotis_core::DEFAULT_SCRIPTS { assert!(config_dir.join(script.relative_path).is_file()); @@ -80,7 +84,7 @@ fn ensure_config_creates_every_default_and_preserves_the_live_config() { } #[test] -fn reset_config_backs_up_custom_files_and_restores_every_default() { +fn reset_config_backs_up_custom_files_and_selects_embedded_stock() { let _lock = test_env_lock(); let root = crate::test_support::fs::unique_temp_path("reset-config"); let xdg_root = root.join("xdg"); @@ -103,11 +107,17 @@ fn reset_config_backs_up_custom_files_and_restores_every_default() { reset_config(&mut context).expect("config reset should succeed"); let config_text = fs::read_to_string(&config_path).expect("read reset config"); - toml::from_str::(&config_text).expect("reset config should parse"); + let reset = toml::from_str::(&config_text).expect("reset config should parse"); assert_ne!(config_text, "custom = true\n"); + assert_eq!(reset.theme.mode, ThemeMode::Stock); assert_eq!( fs::read_to_string(config_dir.join("base.css")).expect("read reset theme"), - unixnotis_core::DEFAULT_BASE_CSS + "/* custom */\n", + "reset must not convert embedded stock into a custom theme snapshot" + ); + assert!( + !config_dir.join("theme.toml").exists(), + "ordinary reset must not materialize a stock theme manifest" ); assert_eq!( fs::read_to_string(&script_path).expect("read reset script"), diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs index 197012968..02524662a 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; use gtk::gdk; -use unixnotis_core::{ThemeConfig, ThemePaths}; +use unixnotis_core::{ThemeConfig, ThemeMode, ThemePaths, THEME_API_VERSION}; use super::super::model::{CssManager, CssManagerInner}; use crate::css::manager::layers::CssProviderLayer; @@ -69,6 +69,11 @@ fn write_theme(paths: &ThemePaths, marker: &str) { .expect("widgets css"); fs::write(&paths.media_css, format!(".media {{ color: {marker}; }}")).expect("media css"); fs::write(&paths.popup_css, format!(".popup {{ color: {marker}; }}")).expect("popup css"); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"Test theme\"\n"), + ) + .expect("theme manifest"); } #[expect( @@ -79,9 +84,13 @@ fn panel_manager( paths: ThemePaths, loaded: Rc>>, ) -> CssManagerInner { + let theme_config = ThemeConfig { + mode: ThemeMode::Custom, + ..ThemeConfig::default() + }; CssManagerInner { theme_paths: paths, - theme_config: ThemeConfig::default(), + theme_config, internal_structure: RecordingProvider::new("internal", Rc::clone(&loaded)), base: RecordingProvider::new("base", Rc::clone(&loaded)), panel: Some(RecordingProvider::new("panel", Rc::clone(&loaded))), @@ -92,6 +101,51 @@ fn panel_manager( } } +#[test] +fn stock_mode_ignores_compatible_custom_theme_files() { + let root = unique_theme_root("stock-mode"); + let paths = theme_paths(&root); + let loaded = Rc::new(RefCell::new(Vec::new())); + write_theme(&paths, "magenta"); + let mut manager = panel_manager(paths, Rc::clone(&loaded)); + manager.theme_config.mode = ThemeMode::Stock; + + let report = manager.reload(".fallback { color: red; }"); + + assert!(report + .layers + .iter() + .all(|layer| layer.source == CssLayerSource::EmbeddedStock)); + assert!(loaded + .borrow() + .iter() + .all(|(_label, css)| !css.contains("magenta"))); + fs::remove_dir_all(root).expect("remove stock mode test root"); +} + +#[test] +fn incompatible_theme_uses_embedded_stock_without_reading_custom_css() { + let root = unique_theme_root("incompatible-theme"); + let paths = theme_paths(&root); + let loaded = Rc::new(RefCell::new(Vec::new())); + write_theme(&paths, "magenta"); + fs::write(paths.manifest_path(), "api_version = 1\nname = \"Old\"\n").expect("old manifest"); + let manager = panel_manager(paths, Rc::clone(&loaded)); + + let report = manager.reload(".fallback { color: red; }"); + + assert!(report + .layers + .iter() + .all(|layer| layer.source == CssLayerSource::EmbeddedStock)); + assert!(loaded + .borrow() + .iter() + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) + .all(|(_, css)| !css.contains("magenta"))); + fs::remove_dir_all(root).expect("remove incompatible theme test root"); +} + #[test] fn panel_reload_loads_base_panel_widgets_and_media_layers() { let root = unique_theme_root("reload-panel"); @@ -148,7 +202,11 @@ fn update_theme_changes_the_paths_used_by_the_next_reload() { write_theme(&new_paths, "blue"); let mut manager = panel_manager(old_paths, Rc::clone(&loaded)); - manager.update_theme(new_paths, ThemeConfig::default()); + let theme = ThemeConfig { + mode: ThemeMode::Custom, + ..ThemeConfig::default() + }; + manager.update_theme(new_paths, theme); let report = manager.reload(".fallback { color: red; }"); assert_eq!(report.layers.len(), 4); @@ -174,9 +232,13 @@ fn public_manager_reload_and_theme_update_report_the_applied_stack() { let new_paths = theme_paths(&new_root); write_theme(&old_paths, "red"); write_theme(&new_paths, "blue"); - let mut manager = CssManager::new_panel(old_paths, ThemeConfig::default()); + let theme = ThemeConfig { + mode: ThemeMode::Custom, + ..ThemeConfig::default() + }; + let mut manager = CssManager::new_panel(old_paths, theme.clone()); - manager.update_theme(new_paths.clone(), ThemeConfig::default()); + manager.update_theme(new_paths.clone(), theme); let report = manager.reload(".fallback { color: red; }"); assert_eq!(report.layers.len(), 4); diff --git a/crates/unixnotis-ui/src/css/manager/tests/report.rs b/crates/unixnotis-ui/src/css/manager/tests/report.rs index ea0b538b7..6933044e6 100644 --- a/crates/unixnotis-ui/src/css/manager/tests/report.rs +++ b/crates/unixnotis-ui/src/css/manager/tests/report.rs @@ -6,6 +6,12 @@ use super::*; fn read_failures_excludes_custom_and_intentional_empty_fallbacks() { let report = CssReloadReport { layers: vec![ + CssLayerReload { + layer: CssProviderLayer::Popup, + path: PathBuf::from("popup.css"), + source: CssLayerSource::EmbeddedStock, + error: None, + }, CssLayerReload { layer: CssProviderLayer::Base, path: PathBuf::from("base.css"), From 4d8693f824b28b9cff93ded78c9e019332e84cb9 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:10:25 -0500 Subject: [PATCH 144/275] feat(attribution): prove app ownership with package provenance Summary: prove app ownership with package provenance. Scope: attribution. --- Cargo.lock | 1 + .../src/ui/theme_migration/actions.rs | 42 -- .../src/ui/theme_migration/mod.rs | 9 - crates/unixnotis-daemon/Cargo.toml | 1 + .../identity/desktop_index/index.rs | 262 ++++++++++++- .../identity/desktop_index/mod.rs | 2 + .../identity/desktop_index/model.rs | 24 ++ .../identity/desktop_index/provenance.rs | 365 ++++++++++++++++++ .../identity/desktop_index/record.rs | 75 +++- .../identity/desktop_index/scan.rs | 2 + .../desktop_index/tests/provenance.rs | 138 +++++++ .../desktop_index/tests/verification.rs | 227 ++++++++++- .../identity/desktop_index/verification.rs | 129 +++++-- 13 files changed, 1149 insertions(+), 128 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/actions.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_migration/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs diff --git a/Cargo.lock b/Cargo.lock index 627bb8ada..f1cd4358f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3650,6 +3650,7 @@ dependencies = [ "unicode-security", "unixnotis-core", "url", + "wait-timeout", "zbus", ] diff --git a/crates/unixnotis-center/src/ui/theme_migration/actions.rs b/crates/unixnotis-center/src/ui/theme_migration/actions.rs deleted file mode 100644 index c1cf6dda9..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/actions.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Theme migration notice action wiring - -use async_channel::TrySendError; -use gtk::prelude::*; - -use crate::control::UiEvent; -use crate::ui::panel::notice::ReloadNoticeWidgets; - -pub(in crate::ui) fn connect_notice_actions( - notice: &ReloadNoticeWidgets, - event_tx: async_channel::Sender, -) { - let preview_tx = event_tx.clone(); - notice.preview_button.connect_clicked(move |_| { - send_action(&preview_tx, UiEvent::ThemeMigrationPreview); - }); - - let apply_tx = event_tx.clone(); - notice.apply_button.connect_clicked(move |_| { - send_action(&apply_tx, UiEvent::ThemeMigrationApply); - }); - - notice.keep_button.connect_clicked(move |_| { - send_action(&event_tx, UiEvent::ThemeMigrationKeepCurrent); - }); -} - -fn send_action(event_tx: &async_channel::Sender, event: UiEvent) { - match event_tx.try_send(event) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - // Explicit user choices wait for queue capacity instead of disappearing under load - let event_tx = event_tx.clone(); - gtk::glib::MainContext::default().spawn_local(async move { - let _result = event_tx.send(event).await; - }); - } - Err(TrySendError::Closed(_event)) => { - // Shutdown already owns the UI when the receiver is gone - } - } -} diff --git a/crates/unixnotis-center/src/ui/theme_migration/mod.rs b/crates/unixnotis-center/src/ui/theme_migration/mod.rs deleted file mode 100644 index 175e4f9c8..000000000 --- a/crates/unixnotis-center/src/ui/theme_migration/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Explicit stock theme migration UI flow - -mod actions; -mod flow; - -pub(in crate::ui) use actions::connect_notice_actions; - -#[cfg(test)] -mod tests; diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index 3aa517444..8545aab7b 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -24,3 +24,4 @@ notify.workspace = true rustix.workspace = true shell-words.workspace = true url.workspace = true +wait-timeout.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index 406294910..f9bbcb646 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -1,10 +1,13 @@ //! Desktop record lookup tables and trusted relay matching -use std::path::Path; +use std::path::{Path, PathBuf}; use super::super::executable::{executable_evidence_for_path, FileIdentity}; -use super::model::{DesktopIdentityIndex, DesktopRecord, ExecutableIdentity}; -use super::names::{normalize_brand_name, normalize_desktop_id}; +use super::model::{ + DesktopApplicationFamily, DesktopIdentityIndex, DesktopRecord, ExecutableIdentity, + LaunchArgument, +}; +use super::names::{normalize_brand_name, normalize_desktop_id, normalize_name}; impl DesktopIdentityIndex { pub(in crate::daemon::notifications::identity) fn records_for_id( @@ -33,22 +36,152 @@ impl DesktopIdentityIndex { .collect() } - pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( + pub(in crate::daemon::notifications::identity) fn records_for_claim( &self, claim: &str, + ) -> Vec<&DesktopRecord> { + let normalized = normalize_name(claim); + let mut indices = self + .by_name + .get(&normalized) + .into_iter() + .flatten() + .copied() + .collect::>(); + + // Protected confusable names still resolve to concrete system candidates + let protected = normalize_brand_name(claim); + if !protected.is_empty() { + indices.extend( + self.records + .iter() + .enumerate() + .filter_map(|(index, record)| { + (record.system_origin + && [&record.display_name, &record.id] + .iter() + .any(|name| normalize_brand_name(name) == protected)) + .then_some(index) + }), + ); + } + indices.sort_unstable(); + indices.dedup(); + indices + .into_iter() + .filter_map(|index| self.records.get(index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn family_for_record( + &self, + record: &DesktopRecord, + ) -> Option<&DesktopApplicationFamily> { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + let family_index = *self.family_by_record.get(record_index)?.as_ref()?; + self.families.get(family_index) + } + + pub(in crate::daemon::notifications::identity) fn family_index_for_record( + &self, + record: &DesktopRecord, + ) -> Option { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + self.family_by_record.get(record_index).copied().flatten() + } + + pub(in crate::daemon::notifications::identity) fn canonical_id_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record str { + self.family_for_record(record) + .map_or(record.id.as_str(), |family| family.canonical_id.as_str()) + } + + pub(in crate::daemon::notifications::identity) fn canonical_record_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record DesktopRecord { + let Some(family) = self.family_for_record(record) else { + return record; + }; + family + .records + .iter() + .filter_map(|index| self.records.get(*index)) + .find(|candidate| { + normalize_desktop_id(&candidate.id) == normalize_desktop_id(&family.canonical_id) + }) + .unwrap_or(record) + } + + pub(in crate::daemon::notifications::identity) fn records_share_family( + &self, + left: &DesktopRecord, + right: &DesktopRecord, ) -> bool { - // Confusable spellings share one protected-brand skeleton - let claim = normalize_brand_name(claim); - !claim.is_empty() && self.system_brand_names.contains(&claim) + match ( + self.family_index_for_record(left), + self.family_index_for_record(right), + ) { + (Some(left), Some(right)) => left == right, + _ => std::ptr::eq(left, right), + } } - pub(in crate::daemon::notifications::identity) fn has_system_record_for_id( + pub(in crate::daemon::notifications::identity) fn record_matches_claim( &self, - id: &str, + record: &DesktopRecord, + claim: &str, ) -> bool { - self.records_for_id(id) - .iter() - .any(|record| record.system_origin) + let normalized = normalize_name(claim); + record.claim_matches(claim) + || self + .family_for_record(record) + .is_some_and(|family| family.names.contains(&normalized)) + || self + .records_for_claim(claim) + .iter() + .any(|candidate| std::ptr::eq(*candidate, record)) + } + + pub(in crate::daemon::notifications::identity) fn records_form_one_application_family( + &self, + identity: FileIdentity, + system_origin: bool, + ) -> bool { + let families = self + .records_for_executable(identity) + .into_iter() + .filter(|record| record.system_origin == system_origin) + .filter_map(|record| self.family_index_for_record(record)) + .collect::>(); + families.len() == 1 + } + + pub(in crate::daemon::notifications::identity) async fn install_provenance_for_path_async( + &self, + path: PathBuf, + ) -> super::provenance::InstallProvenance { + let ownership = std::sync::Arc::clone(&self.package_ownership); + tokio::task::spawn_blocking(move || ownership.resolve_one(&path)) + .await + .unwrap_or_default() + } + + pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( + &self, + claim: &str, + ) -> bool { + // Confusable spellings share one protected-brand skeleton + let claim = normalize_brand_name(claim); + !claim.is_empty() && self.system_brand_names.contains(&claim) } pub(in crate::daemon::notifications::identity) fn trusted_relay_path( @@ -142,6 +275,12 @@ impl DesktopIdentityIndex { .entry(normalize_desktop_id(&record.id)) .or_default() .push(record_index); + for name in &record.names { + self.by_name + .entry(name.clone()) + .or_default() + .push(record_index); + } // Only records with a reproducible launch contract become executable evidence if record.association_eligible { if let Some(identity) = record.executable_identity { @@ -152,9 +291,108 @@ impl DesktopIdentityIndex { } } self.records.push(record); + self.index_application_family(record_index); + } + + pub(super) fn rebuild_application_families(&mut self) { + self.families.clear(); + self.family_by_record.clear(); + for record_index in 0..self.records.len() { + self.index_application_family(record_index); + } + } + + fn index_application_family(&mut self, record_index: usize) { + let Some(record) = self.records.get(record_index) else { + self.family_by_record.push(None); + return; + }; + let Some(executable_identity) = record.executable_identity else { + self.family_by_record.push(None); + return; + }; + let protected_payloads = protected_payload_signature(record); + let family_index = self.families.iter().position(|family| { + family.executable_identity.same_file(executable_identity) + && family.system_origin == record.system_origin + && family.system_association == record.system_association + && family + .install_provenance + .same_application_source(&record.executable_provenance) + && family.protected_payloads == protected_payloads + && family_names_are_compatible(family, record) + }); + + if let Some(family_index) = family_index { + let family = &mut self.families[family_index]; + family.records.push(record_index); + family.names.extend(record.names.iter().cloned()); + if canonical_id_precedes(&record.id, &family.canonical_id) { + family.canonical_id.clone_from(&record.id); + } + self.family_by_record.push(Some(family_index)); + return; + } + + let family_index = self.families.len(); + self.families.push(DesktopApplicationFamily { + canonical_id: record.id.clone(), + executable_identity, + records: vec![record_index], + names: record.names.clone(), + system_origin: record.system_origin, + system_association: record.system_association, + install_provenance: record.executable_provenance.clone(), + protected_payloads, + }); + self.family_by_record.push(Some(family_index)); } } +fn protected_payload_signature(record: &DesktopRecord) -> Vec<(usize, u64, u64)> { + record + .launch_spec + .iter() + .flat_map(|spec| spec.arguments.iter().enumerate()) + .filter_map(|(position, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + let (_path, identity) = literal.file.as_ref()?; + (!literal.value.starts_with(b"-")).then_some(( + position, + identity.device, + identity.inode, + )) + }) + .collect() +} + +fn family_names_are_compatible(family: &DesktopApplicationFamily, record: &DesktopRecord) -> bool { + if family.names.iter().any(|name| record.names.contains(name)) { + return true; + } + let family_id = normalize_desktop_id(&family.canonical_id); + let record_id = normalize_desktop_id(&record.id); + id_is_alias_of(&family_id, &record_id) +} + +fn id_is_alias_of(left: &str, right: &str) -> bool { + left == right + || left + .strip_prefix(right) + .is_some_and(|suffix| suffix.starts_with('.')) + || right + .strip_prefix(left) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn canonical_id_precedes(candidate: &str, current: &str) -> bool { + let candidate = normalize_desktop_id(candidate); + let current = normalize_desktop_id(current); + (candidate.len(), candidate.as_str()) < (current.len(), current.as_str()) +} + fn trusted_system_executable_path(path: &Path) -> bool { const ROOTS: [&str; 8] = [ "/bin", diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index a249f6aff..1b6a56f4e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -5,6 +5,7 @@ mod launch; pub(in crate::daemon::notifications::identity) mod model; mod names; mod program; +pub(in crate::daemon::notifications::identity) mod provenance; mod record; mod refresh; mod scan; @@ -15,6 +16,7 @@ pub use model::DesktopIdentityIndex; pub(super) use model::DesktopRecord; pub(super) use model::{LaunchFailure, LaunchVerification, VerifiedLaunch}; pub(super) use names::{normalize_desktop_id, normalize_name}; +pub(super) use provenance::InstallProvenance; pub use refresh::spawn_desktop_index_refresh; pub use scan::DesktopIndexSnapshot; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index 2b8ba4bf7..a5f36cc4d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -2,9 +2,11 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::Arc; use super::super::executable::FileIdentity; use super::names::normalize_name; +use super::provenance::{InstallProvenance, PackageOwnershipCache}; #[derive(Debug, Clone)] pub(in crate::daemon::notifications::identity) struct LaunchSpec { @@ -61,6 +63,7 @@ pub(in crate::daemon::notifications::identity) enum VerifiedLaunch { /// Stable reason for a launch decision that cannot authenticate the claim #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications::identity) enum LaunchFailure { + MissingSenderEvidence, MissingCommandLine, UnstructuredCommandLine, UnsupportedWrapper, @@ -70,6 +73,7 @@ pub(in crate::daemon::notifications::identity) enum LaunchFailure { ProtectedPayloadMismatch, RequiredArgumentMismatch, DesktopClaimMismatch, + NoDesktopCandidate, } /// Three-way launch result keeps missing evidence distinct from contradiction @@ -85,9 +89,12 @@ pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) id: String, pub(in crate::daemon::notifications::identity) display_name: String, pub(in crate::daemon::notifications::identity) badge_icon: String, + pub(in crate::daemon::notifications::identity) desktop_path: Option, pub(in crate::daemon::notifications::identity) executable_path: Option, pub(in crate::daemon::notifications::identity) executable_identity: Option, pub(in crate::daemon::notifications::identity) desktop_identity: Option, + pub(in crate::daemon::notifications::identity) desktop_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) executable_provenance: InstallProvenance, pub(in crate::daemon::notifications::identity) system_origin: bool, pub(in crate::daemon::notifications::identity) system_association: bool, pub(in crate::daemon::notifications::identity) association_eligible: bool, @@ -103,14 +110,31 @@ impl DesktopRecord { } } +/// Canonical identity shared by equivalent desktop-entry aliases +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct DesktopApplicationFamily { + pub(in crate::daemon::notifications::identity) canonical_id: String, + pub(in crate::daemon::notifications::identity) executable_identity: FileIdentity, + pub(in crate::daemon::notifications::identity) records: Vec, + pub(in crate::daemon::notifications::identity) names: HashSet, + pub(in crate::daemon::notifications::identity) system_origin: bool, + pub(in crate::daemon::notifications::identity) system_association: bool, + pub(in crate::daemon::notifications::identity) install_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) protected_payloads: Vec<(usize, u64, u64)>, +} + #[derive(Debug, Default)] pub struct DesktopIdentityIndex { pub(super) records: Vec, + pub(super) families: Vec, + pub(super) family_by_record: Vec>, pub(super) by_id: HashMap>, pub(super) by_identity: HashMap<(u64, u64), Vec>, + pub(super) by_name: HashMap>, pub(super) system_brand_names: HashSet, pub(in crate::daemon::notifications::identity) trusted_relays: Vec, pub(in crate::daemon::notifications::identity) trusted_portals: Vec, + pub(super) package_ownership: Arc, } #[derive(Debug, Clone)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs new file mode 100644 index 000000000..4952e6ea0 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs @@ -0,0 +1,365 @@ +//! Immutable installation ownership used by desktop attribution + +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use super::super::executable::executable_evidence_for_path; +use wait_timeout::ChildExt; + +const MAX_OWNERSHIP_PATHS: usize = 16_384; +const MAX_COMMAND_ARGUMENT_BYTES: usize = 192 * 1024; +const MAX_COMMAND_PATHS: usize = 4_096; +const MAX_OWNERSHIP_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PACKAGE_ID_BYTES: usize = 256; +const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); + +/// System database that established package ownership +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub(in crate::daemon::notifications) enum PackageProvider { + Pacman, + Dpkg, + Rpm, +} + +#[derive(Debug, Clone)] +struct PackageProviderCommand { + provider: PackageProvider, + executable: PathBuf, +} + +/// Installation source shared by protected desktop and executable files +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq)] +pub(in crate::daemon::notifications) enum InstallProvenance { + Package { + provider: PackageProvider, + package_id: String, + }, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "bundle ownership is part of the closed provenance model before a backend is available" + ) + )] + ImmutableBundle { bundle_id: String }, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "portal ownership is retained as a separate authority domain" + ) + )] + Portal { app_id: String }, + #[default] + Unknown, +} + +impl InstallProvenance { + pub(in crate::daemon::notifications::identity) fn same_application_source( + &self, + other: &Self, + ) -> bool { + match (self, other) { + ( + Self::Package { + provider: left_provider, + package_id: left_id, + }, + Self::Package { + provider: right_provider, + package_id: right_id, + }, + ) => left_provider == right_provider && left_id == right_id, + ( + Self::ImmutableBundle { bundle_id: left }, + Self::ImmutableBundle { bundle_id: right }, + ) + | (Self::Portal { app_id: left }, Self::Portal { app_id: right }) => left == right, + _ => false, + } + } + + pub(in crate::daemon::notifications::identity) const fn is_known(&self) -> bool { + !matches!(self, Self::Unknown) + } +} + +#[derive(Debug, Default)] +pub(super) struct PackageOwnershipCache { + provider: OnceLock>, + entries: Mutex>, +} + +impl PackageOwnershipCache { + pub(super) fn resolve_many( + &self, + paths: impl IntoIterator, + ) -> HashMap { + // Dedupe before taking the cache lock so repeated desktop aliases stay cheap + let paths = paths + .into_iter() + .take(MAX_OWNERSHIP_PATHS) + .collect::>(); + let missing = self.entries.lock().map_or_else( + |_| paths.iter().cloned().collect::>(), + |entries| { + paths + .iter() + .filter(|path| !entries.contains_key(*path)) + .cloned() + .collect::>() + }, + ); + + if !missing.is_empty() { + let resolved = self + .provider + .get_or_init(detect_package_provider) + .as_ref() + .map_or_else(HashMap::new, |provider| { + query_package_ownership(provider, &missing) + }); + if let Ok(mut entries) = self.entries.lock() { + for path in missing { + entries.insert( + path.clone(), + resolved + .get(&path) + .cloned() + .unwrap_or(InstallProvenance::Unknown), + ); + } + } + } + + self.entries.lock().map_or_else( + |_| HashMap::new(), + |entries| { + paths + .into_iter() + .map(|path| { + let provenance = entries + .get(&path) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + (path, provenance) + }) + .collect() + }, + ) + } + + pub(super) fn resolve_one(&self, path: &Path) -> InstallProvenance { + self.resolve_many([path.to_path_buf()]) + .remove(path) + .unwrap_or(InstallProvenance::Unknown) + } +} + +fn detect_package_provider() -> Option { + [ + ("pacman", PackageProvider::Pacman), + ("dpkg-query", PackageProvider::Dpkg), + ("rpm", PackageProvider::Rpm), + ] + .into_iter() + .find_map(|(program, provider)| { + let executable = unixnotis_core::util::trusted_system_program_path(program)?; + let evidence = executable_evidence_for_path(&executable)?; + // Provider output affects attribution, so user-writable commands are never accepted + (evidence.identity.is_system_managed() && evidence.identity.is_executable_regular()) + .then_some(PackageProviderCommand { + provider, + executable: evidence.canonical_path, + }) + }) +} + +fn query_package_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + match provider.provider { + PackageProvider::Pacman => query_in_chunks(provider, paths, &["-Qo"], parse_pacman_output), + PackageProvider::Dpkg => query_in_chunks(provider, paths, &["--search"], parse_dpkg_output), + PackageProvider::Rpm => { + // RPM does not retain the queried path in batch output + // Single-path lookups remain safe while bulk indexing fails closed + if paths.len() == 1 { + query_rpm_owner(provider, &paths[0]) + .map(|owner| HashMap::from([(paths[0].clone(), owner)])) + .unwrap_or_default() + } else { + HashMap::new() + } + } + } +} + +fn query_in_chunks( + provider: &PackageProviderCommand, + paths: &[PathBuf], + arguments: &[&str], + parser: OwnershipOutputParser, +) -> HashMap { + let mut resolved = HashMap::new(); + let mut start = 0; + while start < paths.len() { + let mut bytes = 0_usize; + let mut end = start; + while end < paths.len() && end.saturating_sub(start) < MAX_COMMAND_PATHS { + let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); + if end > start && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { + break; + } + bytes = bytes.saturating_add(next); + end = end.saturating_add(1); + } + let chunk = &paths[start..end]; + let mut command = Command::new(&provider.executable); + command + .args(arguments) + .args(chunk) + .env_clear() + .env("LC_ALL", "C"); + if let Some(output) = run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { + resolved.extend(parser(&output.stdout, chunk, provider.provider)); + } + start = end; + } + resolved +} + +type OwnershipOutputParser = + fn(&[u8], &[PathBuf], PackageProvider) -> HashMap; + +fn parse_pacman_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let marker = b" is owned by "; + let position = line + .windows(marker.len()) + .position(|window| window == marker)?; + let path = expected.get(&line[..position])?; + let package = line.get(position.saturating_add(marker.len())..)?; + let package = package.split(|byte| *byte == b' ').next()?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +fn parse_dpkg_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let position = line.windows(2).rposition(|window| window == b": ")?; + let package = line.get(..position)?.split(|byte| *byte == b',').next()?; + let path = expected.get(line.get(position.saturating_add(2)..)?)?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +fn query_rpm_owner(provider: &PackageProviderCommand, path: &Path) -> Option { + let mut command = Command::new(&provider.executable); + command + .args(["-qf", "--queryformat", "%{NAME}\n"]) + .arg(path) + .env_clear() + .env("LC_ALL", "C"); + let output = run_package_query(&mut command, MAX_PACKAGE_ID_BYTES.saturating_add(1))?; + if !output.status.success() || output.stdout.len() > MAX_PACKAGE_ID_BYTES.saturating_add(1) { + return None; + } + package_provenance( + provider.provider, + output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout), + ) +} + +fn package_provenance(provider: PackageProvider, package: &[u8]) -> Option { + if package.is_empty() + || package.len() > MAX_PACKAGE_ID_BYTES + || package + .iter() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return None; + } + Some(InstallProvenance::Package { + provider, + package_id: std::str::from_utf8(package).ok()?.to_string(), + }) +} + +#[derive(Debug)] +struct PackageQueryOutput { + status: ExitStatus, + stdout: Vec, +} + +fn run_package_query(command: &mut Command, output_limit: usize) -> Option { + run_package_query_with_timeout(command, output_limit, PACKAGE_QUERY_TIMEOUT) +} + +fn run_package_query_with_timeout( + command: &mut Command, + output_limit: usize, + timeout: Duration, +) -> Option { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let limit = u64::try_from(output_limit) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut output = Vec::new(); + stdout.take(limit).read_to_end(&mut output).ok()?; + Some(output) + }); + + let status = if let Some(status) = child.wait_timeout(timeout).ok()? { + status + } else { + let _kill_result = child.kill(); + let _wait_result = child.wait(); + let _reader_result = reader.join(); + return None; + }; + let stdout = reader.join().ok()??; + if stdout.len() > output_limit { + return None; + } + Some(PackageQueryOutput { status, stdout }) +} + +#[cfg(test)] +#[path = "tests/provenance.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index c21730e68..8434b8da3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -5,10 +5,11 @@ use std::path::Path; use gio::prelude::AppInfoExt; -use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::executable::executable_evidence_for_path; use super::launch::build_launch_spec; use super::model::{DesktopIdentityIndex, DesktopRecord}; use super::names::{normalize_desktop_id, normalize_name}; +use super::provenance::InstallProvenance; impl DesktopIdentityIndex { pub(super) fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { @@ -39,25 +40,23 @@ impl DesktopIdentityIndex { // Every association needs a complete Exec contract instead of a runtime-name exception let association_eligible = launch_spec.is_some(); // System association requires protected metadata and a reproducible launch specification - let system_association = association_eligible - && system_origin - && desktop_identity.is_some_and(FileIdentity::is_system_managed) - && executable_identity.is_some_and(FileIdentity::is_system_managed) - && launch_spec - .as_ref() - .is_some_and(|spec| spec.literal_files_are_system_managed); + // Package ownership is attached in one bounded batch after scanning finishes + let system_association = false; let badge_icon = desktop .string("Icon") .map_or_else(|| id.clone(), |value| value.to_string()); - let names = association_aliases(&desktop, &id, &display_name, executable_path.as_deref()); + let names = association_aliases(&desktop, &id, &display_name); self.index_record(DesktopRecord { id, display_name, badge_icon, + desktop_path: Some(path.to_path_buf()), executable_path, executable_identity, desktop_identity, + desktop_provenance: InstallProvenance::Unknown, + executable_provenance: InstallProvenance::Unknown, system_origin, system_association, association_eligible, @@ -66,15 +65,63 @@ impl DesktopIdentityIndex { names, }); } + + pub(super) fn finalize_install_provenance(&mut self) { + let paths = self + .records + .iter() + .filter(|record| record.system_origin) + .flat_map(|record| { + record + .desktop_path + .iter() + .chain(record.executable_path.iter()) + .cloned() + }) + .collect::>(); + let ownership = self.package_ownership.resolve_many(paths); + + for record in &mut self.records { + if !record.system_origin { + continue; + } + record.desktop_provenance = record + .desktop_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + record.executable_provenance = record + .executable_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + record.system_association = record.association_eligible + && record + .desktop_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .executable_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .launch_spec + .as_ref() + .is_some_and(|spec| spec.literal_files_are_system_managed) + && record + .desktop_provenance + .same_application_source(&record.executable_provenance); + } + self.rebuild_application_families(); + } } fn association_aliases( desktop: &gio::DesktopAppInfo, id: &str, display_name: &str, - executable_path: Option<&Path>, ) -> HashSet { - // These aliases are considered only after executable identity already agrees + // Desktop metadata supplies claim aliases while executable naming stays separate let mut names = HashSet::from([ normalize_name(display_name), normalize_name(desktop.name().as_str()), @@ -86,12 +133,6 @@ fn association_aliases( if let Some(wm_class) = desktop.startup_wm_class() { names.insert(normalize_name(wm_class.as_str())); } - if let Some(executable) = executable_path - .and_then(Path::file_name) - .and_then(|name| name.to_str()) - { - names.insert(normalize_name(executable)); - } names.retain(|name| !name.is_empty()); names } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs index ce3f8de8e..7dd3f7c21 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs @@ -98,6 +98,8 @@ impl DesktopIdentityIndex { ); } } + // One ownership batch ties protected desktop and executable files to one install source + index.finalize_install_provenance(); // Relay trust is tied to the installed file identity instead of its basename index.index_trusted_relay(Path::new("/usr/bin/notify-send")); index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs new file mode 100644 index 000000000..989c0ef8e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs @@ -0,0 +1,138 @@ +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; + +use super::{ + package_provenance, parse_dpkg_output, parse_pacman_output, run_package_query, + run_package_query_with_timeout, InstallProvenance, PackageProvider, +}; + +#[test] +fn matching_package_sources_establish_one_installation_owner() { + let desktop = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }; + let executable = desktop.clone(); + + assert!(desktop.same_application_source(&executable)); + assert!( + !desktop.same_application_source(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "shared-runtime".to_string(), + }) + ); + assert!(!desktop.same_application_source(&InstallProvenance::Unknown)); +} + +#[test] +fn bundle_and_portal_provenance_require_exact_domain_identity() { + let bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.App".to_string(), + }; + let same_bundle = bundle.clone(); + let other_bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.Other".to_string(), + }; + let portal = InstallProvenance::Portal { + app_id: "org.example.App".to_string(), + }; + let same_portal = portal.clone(); + let other_portal = InstallProvenance::Portal { + app_id: "org.example.Other".to_string(), + }; + + assert!(bundle.same_application_source(&same_bundle)); + assert!(!bundle.same_application_source(&other_bundle)); + assert!(!bundle.same_application_source(&portal)); + assert!(portal.same_application_source(&same_portal)); + assert!(!portal.same_application_source(&other_portal)); +} + +#[test] +fn pacman_output_is_mapped_to_the_exact_queried_path() { + let desktop = PathBuf::from("/usr/share/applications/example.desktop"); + let executable = PathBuf::from("/usr/bin/example"); + let output = b"/usr/bin/example is owned by example-app 2.0-1\n\ +/usr/share/applications/example.desktop is owned by example-app 2.0-1\n"; + + let ownership = parse_pacman_output( + output, + &[desktop.clone(), executable.clone()], + PackageProvider::Pacman, + ); + + for path in [desktop, executable] { + assert_eq!( + ownership.get(&path), + Some(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }), + "the exact queried file should retain its package owner" + ); + } +} + +#[test] +fn dpkg_output_keeps_architecture_qualified_package_identity() { + let executable = PathBuf::from("/usr/bin/example"); + let ownership = parse_dpkg_output( + b"example-app:amd64: /usr/bin/example\n", + std::slice::from_ref(&executable), + PackageProvider::Dpkg, + ); + + assert_eq!( + ownership.get(&executable), + Some(&InstallProvenance::Package { + provider: PackageProvider::Dpkg, + package_id: "example-app:amd64".to_string(), + }) + ); +} + +#[test] +fn malformed_package_identity_is_rejected() { + assert!(package_provenance(PackageProvider::Pacman, b"").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad package").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad\npackage").is_none()); +} + +#[test] +fn package_query_deadline_stops_a_stalled_provider() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 2"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(20)); + + assert!(output.is_none()); + assert!( + started.elapsed() < Duration::from_secs(1), + "the package provider deadline should stop a stalled process promptly" + ); +} + +#[test] +fn package_query_rejects_output_beyond_the_declared_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 12345"]); + + assert!( + run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_none(), + "oversized provider output must fail closed" + ); +} + +#[test] +fn package_query_accepts_successful_output_at_the_exact_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 1234"]); + + let output = run_package_query(&mut command, 4) + .expect("successful provider output at the exact limit should be retained"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"1234"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs index f26dfb693..9bbda2e7f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -3,17 +3,26 @@ use std::path::Path; use super::{ classify_launch_authority, executable_contract_is_dedicated, field_value_matches, - is_dynamic_or_option, is_protected_payload, literal_file_identities_are_current, - literal_file_matches, match_ordered_dedicated_contract, match_ordered_exec_contract, - verify_dedicated, verify_protected_payload, verify_record_launch, MAX_PROCESS_ARGUMENTS, + is_protected_payload, literal_file_identities_are_current, literal_file_matches, + match_ordered_dedicated_contract, match_ordered_exec_contract, verify_dedicated, + verify_protected_payload, verify_record_launch, MAX_PROCESS_ARGUMENTS, }; use crate::daemon::notifications::identity::desktop_index::model::{ DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, VerifiedLaunch, }; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::InstallProvenance; use crate::daemon::notifications::identity::executable::executable_evidence_for_path; use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; +fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { + match argument { + LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, + LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), + } +} + #[test] fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { let dynamic = LaunchArgument::FieldCode(FieldCode::Files); @@ -119,6 +128,42 @@ fn trusted_payload_cannot_be_used_as_a_decoy_argument() { ); } +#[test] +fn variable_width_field_before_protected_payload_does_not_create_false_conflict() { + let payload = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("protected payload"); + let spec = LaunchSpec { + executable: payload.identity, + arguments: vec![ + LaunchArgument::FieldCode(FieldCode::Files), + LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some((Path::new("/usr/bin/true").to_path_buf(), payload.identity)), + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let sender = structured_command(&[ + "/usr/bin/true", + "/tmp/one.txt", + "/tmp/two.txt", + "/usr/bin/true", + "--unexpected", + ]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a matched protected payload must not become contradictory because a later option differs" + ); +} + #[test] fn ordered_contract_preserves_repeated_literals_and_field_positions() { let spec = LaunchSpec { @@ -228,6 +273,34 @@ fn dedicated_contract_does_not_accept_reordered_fixed_options() { ); } +#[test] +fn empty_dedicated_contract_accepts_application_owned_cli_arguments() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--title", + "Native application", + "--passive-popup", + "Message body", + "30", + ]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); +} + #[test] fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { for field_code in [FieldCode::Files, FieldCode::Urls] { @@ -244,9 +317,12 @@ fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { id: "org.example.Runtime".to_string(), display_name: "Runtime application".to_string(), badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), executable_path: Some("/usr/bin/true".into()), executable_identity: Some(executable.identity), desktop_identity: None, + desktop_provenance: test_package("runtime-desktop"), + executable_provenance: test_package("runtime"), system_origin: true, system_association: true, association_eligible: true, @@ -270,6 +346,127 @@ fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { } } +#[test] +fn dedicated_system_application_accepts_dynamic_url_field() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.True".to_string(), + display_name: "True".to_string(), + badge_icon: "true".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), + executable_path: Some("/usr/bin/true".into()), + executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("true"), + executable_provenance: test_package("true"), + system_origin: true, + system_association: true, + association_eligible: true, + dbus_activatable: false, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("dedicated application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "normal URL arguments must not erase dedicated executable authority" + ); +} + +#[test] +fn dynamic_runtime_requires_matching_immutable_installation_provenance() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "True".to_string(), + badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), + executable_path: Some("/usr/bin/true".into()), + executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("runtime-frontend"), + executable_provenance: test_package("shared-runtime"), + system_origin: true, + system_association: true, + association_eligible: true, + dbus_activatable: false, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("runtime application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a package-owned shared runtime must not inherit desktop application authority" + ); +} + +#[test] +fn same_package_dynamic_file_runtime_is_not_dedicated() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Runtime", &spec); + record.desktop_provenance = test_package("runtime"); + record.executable_provenance = test_package("runtime"); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("runtime application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "package ownership cannot prove whether a file field is a document or active program" + ); +} + #[test] fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { for (wrapper_count, environment_count, expected) in [ @@ -305,12 +502,15 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() literal_files_are_system_managed: true, }; let record = DesktopRecord { - id: "org.example.Boundary".to_string(), + id: "org.example.True".to_string(), display_name: "Boundary".to_string(), badge_icon: "boundary".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), executable_path: Some("/usr/bin/true".into()), executable_identity: Some(executable.identity), desktop_identity: None, + desktop_provenance: test_package("true"), + executable_provenance: test_package("true"), system_origin: true, system_association: true, association_eligible: true, @@ -321,7 +521,7 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() let mut index = DesktopIdentityIndex::default(); index.index_record(record); let indexed = index - .records_for_id("org.example.Boundary") + .records_for_id("org.example.True") .into_iter() .next() .expect("indexed boundary record"); @@ -340,11 +540,12 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() } #[test] -fn dedicated_authority_rejects_each_open_ended_positional_contract() { +fn dedicated_authority_accepts_url_fields_but_rejects_ambiguous_file_fields_and_payloads() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); for (arguments, expected) in [ (Vec::new(), true), + (vec![LaunchArgument::FieldCode(FieldCode::Url)], true), (vec![LaunchArgument::FieldCode(FieldCode::File)], false), ( vec![LaunchArgument::Literal(LiteralArgument { @@ -362,9 +563,9 @@ fn dedicated_authority_rejects_each_open_ended_positional_contract() { literal_files_are_system_managed: true, }; let mut index = DesktopIdentityIndex::default(); - index.index_record(record_for_spec("org.example.DedicatedBoundary", &spec)); + index.index_record(record_for_spec("org.example.True", &spec)); let record = index - .records_for_id("org.example.DedicatedBoundary") + .records_for_id("org.example.True") .into_iter() .next() .expect("indexed dedicated boundary record"); @@ -494,9 +695,12 @@ fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { id: id.to_string(), display_name: "Contract application".to_string(), badge_icon: "contract".to_string(), + desktop_path: Some(format!("/usr/share/applications/{id}.desktop").into()), executable_path: Some("/usr/bin/true".into()), executable_identity: Some(spec.executable), desktop_identity: None, + desktop_provenance: test_package(id), + executable_provenance: test_package(id), system_origin: true, system_association: true, association_eligible: true, @@ -506,6 +710,13 @@ fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { } } +fn test_package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} + fn structured_command(arguments: &[&str]) -> CommandLineEvidence { CommandLineEvidence { argv: arguments diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs index bb9dab3f1..fa8115aa2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs @@ -9,7 +9,6 @@ use super::model::{ DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, }; -use super::names::normalize_desktop_id; const MAX_PROCESS_ARGUMENTS: usize = 256; @@ -53,16 +52,15 @@ fn classify_launch_authority( return LaunchAuthority::ProtectedPayload; } - // A caller-selected file or URL can change what a shared runtime executes - // Uniqueness in the desktop index cannot turn that open-ended selector into app identity - if !spec.arguments.is_empty() && spec.arguments.iter().all(is_dynamic_or_option) { - return LaunchAuthority::DynamicOnly; - } - if executable_contract_is_dedicated(record, index, spec) { return LaunchAuthority::DedicatedExecutable; } + // Dynamic documents are safe only after the executable establishes the application + if spec.arguments.iter().any(is_dynamic_document_field) { + return LaunchAuthority::DynamicOnly; + } + LaunchAuthority::Ambiguous } @@ -71,27 +69,18 @@ fn executable_contract_is_dedicated( index: &DesktopIdentityIndex, spec: &LaunchSpec, ) -> bool { - let distinct_ids = index - .records_for_executable(spec.executable) - .into_iter() - .filter(|candidate| !record.system_origin || candidate.system_origin) - .map(|candidate| normalize_desktop_id(&candidate.id)) - .collect::>(); - - // A dedicated executable contract has no unresolved positional selector - // Fixed positional values on a shared runtime could select code just like a file field - distinct_ids.len() == 1 - && !spec - .arguments - .iter() - .any(|argument| matches!(argument, LaunchArgument::FieldCode(_))) - && !spec.arguments.iter().any(|argument| { - matches!( - argument, - LaunchArgument::Literal(literal) - if !literal.value.starts_with(b"-") && literal.file.is_none() - ) - }) + record.system_origin + && record.system_association + && spec.executable.is_system_managed() + && spec.executable.is_executable_regular() + && record + .desktop_provenance + .same_application_source(&record.executable_provenance) + && index.records_form_one_application_family(spec.executable, record.system_origin) + // A file in argv[1] can be either a document or an interpreter's active program + // Static desktop metadata cannot distinguish those roles without a protected payload + && !spec.arguments.iter().any(is_dynamic_file_field) + && !spec.arguments.iter().any(is_unprotected_fixed_payload) } fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { @@ -105,7 +94,7 @@ fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> La CommandLineQuality::Structured => { let actual = command_line.argv.get(1..).unwrap_or_default(); if actual.len() <= MAX_PROCESS_ARGUMENTS - && match_ordered_dedicated_contract(spec, actual) + && (spec.arguments.is_empty() || match_ordered_dedicated_contract(spec, actual)) { LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) } else { @@ -199,8 +188,14 @@ fn match_dedicated_arguments( visited, )) } - // Dynamic selectors prevent dedicated classification before matching begins - LaunchArgument::FieldCode(_) => false, + LaunchArgument::FieldCode(code) => match_dedicated_field( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), }; if matches_expected { return true; @@ -219,6 +214,41 @@ fn match_dedicated_arguments( ) } +fn match_dedicated_field( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_dedicated_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + fn match_ordered_exec_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { let mut visited = HashSet::new(); match_arguments(&spec.arguments, actual, 0, 0, &mut visited) @@ -344,10 +374,17 @@ fn protected_payload_position_mismatch(spec: &LaunchSpec, actual: &[Vec]) -> }; is_protected_payload(argument).then_some((index, literal)) }) - .any(|(index, literal)| { - !actual - .get(index) - .is_some_and(|value| literal_file_matches(literal, value)) + .any(|(template_index, literal)| { + !(0..actual.len()).any(|actual_index| { + let mut visited = HashSet::new(); + match_arguments( + &spec.arguments[..template_index], + &actual[..actual_index], + 0, + 0, + &mut visited, + ) && literal_file_matches(literal, &actual[actual_index]) + }) }) } @@ -387,11 +424,23 @@ fn is_protected_payload(argument: &LaunchArgument) -> bool { ) } -fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { - match argument { - LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, - LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), - } +const fn is_dynamic_document_field(argument: &LaunchArgument) -> bool { + matches!(argument, LaunchArgument::FieldCode(_)) +} + +const fn is_dynamic_file_field(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::FieldCode(FieldCode::File | FieldCode::Files) + ) +} + +fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(literal) + if !literal.value.starts_with(b"-") && literal.file.is_none() + ) } #[cfg(test)] From d022773837c74797b872ab4a5a01f5f2e5010463 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:10:48 -0500 Subject: [PATCH 145/275] fix(attribution): require concrete evidence for conflicts Summary: require concrete evidence for conflicts. Scope: attribution. --- .../unixnotis-core/src/model/attribution.rs | 251 ++++++--- crates/unixnotis-core/src/model/mod.rs | 3 +- .../daemon/notifications/identity/policy.rs | 20 +- .../daemon/notifications/identity/resolver.rs | 478 ++++-------------- .../identity/resolver/candidates.rs | 289 +++++++++++ .../identity/resolver/diagnostics.rs | 27 +- .../identity/resolver/evidence.rs | 168 ++++++ .../identity/resolver/resolution.rs | 229 +++++++++ .../daemon/notifications/identity/sender.rs | 160 +++++- 9 files changed, 1146 insertions(+), 479 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index 4e05ff251..aa998529a 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -1,4 +1,4 @@ -//! Notification application association and interaction policy +//! Structured notification attribution and interaction policy use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -7,28 +7,51 @@ use zbus::zvariant::Type; use crate::util; const MAX_ATTRIBUTION_TEXT_BYTES: usize = 256; +const MAX_GROUP_KEY_BYTES: usize = 512; -/// Evidence class used to present an application without claiming universal authentication -// Representation-aware Serde keeps the D-Bus body aligned with its one-byte signature +/// Daemon-owned result of application identity evaluation +// Representation-aware Serde keeps each wire value at one byte #[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] -pub enum AttributionClass { - SystemAssociated = 0, - PortalAssociated = 1, - UserAssociated = 2, - TrustedRelay = 3, +pub enum AttributionStatus { + Verified = 0, + Recognized = 1, #[default] - Unknown = 4, - Conflict = 5, + Unresolved = 2, + Conflict = 3, + Relay = 4, +} + +/// Stable reason for one attribution result +// Numeric ranges keep positive, uncertain, and contradictory evidence easy to inspect +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum AttributionReason { + ExactSystemExecutable = 0, + VerifiedPortalAppId = 1, + ExactUserExecutable = 2, + VerifiedProtectedPayload = 3, + TrustedRelayExecutable = 4, + + #[default] + MissingSenderEvidence = 10, + MissingCommandLine = 11, + AmbiguousDesktopRecords = 12, + DynamicLaunchContract = 13, + UnsupportedWrapper = 14, + NoDesktopCandidate = 15, + + ExecutableMismatch = 20, + ProtectedPayloadMismatch = 21, + ApplicationClaimMismatch = 22, } /// Independent policy for credential-like inline text controls -// Ordinary enum Serde writes a wider variant index that strict brokers reject +// Value one stays unused until confirmation is enforced by the daemon #[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum InlineReplyPolicy { Allow = 0, - // Value 1 stays unused until confirmation is enforced by the daemon #[default] Deny = 2, } @@ -41,20 +64,22 @@ pub enum ApplicationActionPolicy { Deny, } -/// Application presentation derived by the daemon from sender and desktop metadata +/// Application identity selected from sender and desktop evidence #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationAttribution { - // Primary titles stay short and never contain diagnostics + // The primary label is always selected by the daemon pub display_name: String, - // Empty values represent unavailable optional D-Bus fields + // The protocol app_name stays visible without becoming identity evidence + pub claimed_name: String, + // Empty strings represent optional wire fields that were not resolved pub desktop_id: String, pub badge_icon: String, - // Secondary source or warning text belongs in a tooltip or separate status element - pub source_label: String, - pub class: AttributionClass, - // Risk presentation stays separate from association and interaction policy - pub warning: bool, - // Opaque daemon-built identity key prevents claimed names from merging trusted groups + // Status and reason carry state without parsing diagnostic text + pub status: AttributionStatus, + pub reason: AttributionReason, + // Human-readable detail is display-only and never interpreted by clients + pub diagnostic_detail: String, + // The daemon owns grouping so copied labels cannot join trusted groups pub group_key: String, } @@ -62,105 +87,163 @@ impl Default for NotificationAttribution { fn default() -> Self { Self { display_name: "Unknown application".to_string(), + claimed_name: String::new(), desktop_id: String::new(), - badge_icon: "dialog-warning-symbolic".to_string(), - source_label: String::new(), - class: AttributionClass::Unknown, - warning: false, + badge_icon: "application-x-executable-symbolic".to_string(), + status: AttributionStatus::Unresolved, + reason: AttributionReason::MissingSenderEvidence, + diagnostic_detail: String::new(), group_key: "unknown".to_string(), } } } impl NotificationAttribution { + /// Build a strongly bound application identity #[must_use] - pub fn associated( + pub fn verified( display_name: &str, + claimed_name: &str, desktop_id: &str, badge_icon: &str, - source_label: &str, - class: AttributionClass, - warning: bool, + reason: AttributionReason, + diagnostic_detail: &str, group_key: String, ) -> Self { - Self { - display_name: display_name_or_unknown(display_name), - desktop_id: bounded_text(desktop_id), - badge_icon: bounded_text(badge_icon), - source_label: bounded_text(source_label), - class, - warning, + Self::resolved( + display_name, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Verified, + reason, + diagnostic_detail, group_key, - } + ) } + /// Build a known but non-authoritative application identity #[must_use] - pub fn unknown(display_name: &str, source_label: &str, group_key: String) -> Self { - Self::associated( + pub fn recognized( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self::resolved( display_name, - "", - "dialog-question-symbolic", - source_label, - AttributionClass::Unknown, - false, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Recognized, + reason, + diagnostic_detail, group_key, ) } + /// Build an attribution without a reliable desktop association #[must_use] - pub fn conflict(claimed_name: &str, source_label: &str, group_key: String) -> Self { - let claim = display_name_or_unknown(claimed_name); - Self::associated( + pub fn unresolved( + claimed_name: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self::resolved( "Unknown application", + claimed_name, "", - "dialog-warning-symbolic", - &format!("Claims to be {claim}; {source_label}"), - AttributionClass::Conflict, - true, + "application-x-executable-symbolic", + AttributionStatus::Unresolved, + reason, + diagnostic_detail, group_key, ) } + /// Build an attribution backed by a concrete contradictory candidate #[must_use] - pub fn trusted_relay( - display_name: &str, - source_label: &str, - warning: bool, + pub fn conflict( + claimed_name: &str, + desktop_id: &str, + reason: AttributionReason, + diagnostic_detail: &str, group_key: String, ) -> Self { - Self::associated( - display_name, - "", - "dialog-information-symbolic", - source_label, - AttributionClass::TrustedRelay, - warning, + debug_assert!( + matches!( + reason, + AttributionReason::ExecutableMismatch + | AttributionReason::ProtectedPayloadMismatch + | AttributionReason::ApplicationClaimMismatch + ), + "conflict attribution requires a concrete contradiction reason" + ); + Self::resolved( + "Unknown application", + claimed_name, + desktop_id, + "dialog-warning-symbolic", + AttributionStatus::Conflict, + reason, + diagnostic_detail, group_key, ) } + /// Build a known relay identity without authenticating its app label #[must_use] - pub const fn has_warning(&self) -> bool { - self.warning + pub fn relay(claimed_name: &str, diagnostic_detail: &str, group_key: String) -> Self { + Self::resolved( + "Command-line notification", + claimed_name, + "", + "utilities-terminal-symbolic", + AttributionStatus::Relay, + AttributionReason::TrustedRelayExecutable, + diagnostic_detail, + group_key, + ) } - /// Policy for application-owned actions derived from daemon attribution evidence - #[must_use] - pub const fn application_action_policy(&self) -> ApplicationActionPolicy { - // A warning means current evidence conflicts even when a weak association was found - if self.warning { - return ApplicationActionPolicy::Deny; + #[expect( + clippy::needless_pass_by_value, + clippy::too_many_arguments, + reason = "the wire fields stay explicit at construction" + )] + fn resolved( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + status: AttributionStatus, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self { + display_name: display_name_or_unknown(display_name), + claimed_name: bounded_text(claimed_name), + desktop_id: bounded_text(desktop_id), + badge_icon: bounded_text(badge_icon), + status, + reason, + diagnostic_detail: bounded_text(diagnostic_detail), + group_key: bounded_group_key(&group_key), } + } - match self.class { - AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { - ApplicationActionPolicy::Allow - } - // Confirmation has no daemon-owned remembered-decision store in the first release - AttributionClass::UserAssociated - | AttributionClass::TrustedRelay - | AttributionClass::Unknown - | AttributionClass::Conflict => ApplicationActionPolicy::Deny, + /// Policy for signals that belong to the authenticated application + #[must_use] + pub const fn application_action_policy(&self) -> ApplicationActionPolicy { + if matches!(self.status, AttributionStatus::Verified) { + ApplicationActionPolicy::Allow + } else { + ApplicationActionPolicy::Deny } } } @@ -170,6 +253,16 @@ fn bounded_text(value: &str) -> String { util::truncate_utf8_bytes(clean.trim(), MAX_ATTRIBUTION_TEXT_BYTES) } +fn bounded_group_key(value: &str) -> String { + let clean = util::sanitize_inline_display_text(value); + let bounded = util::truncate_utf8_bytes(clean.trim(), MAX_GROUP_KEY_BYTES); + if bounded.is_empty() { + "unknown".to_string() + } else { + bounded + } +} + fn display_name_or_unknown(value: &str) -> String { let value = bounded_text(value); if value.is_empty() { diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index b97c5e701..9a37610dd 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -10,7 +10,8 @@ mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. pub use attribution::{ - ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationAttribution, + ApplicationActionPolicy, AttributionReason, AttributionStatus, InlineReplyPolicy, + NotificationAttribution, }; pub use diagnostics::{ AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs index d200b0ff8..11992a3dd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs @@ -1,17 +1,15 @@ //! Interaction decisions kept independent from presentation association -use unixnotis_core::{AttributionClass, InlineReplyPolicy}; +use unixnotis_core::{AttributionStatus, InlineReplyPolicy}; -pub(super) const fn inline_reply_policy(class: AttributionClass) -> InlineReplyPolicy { - // Text entry stays disabled unless system or portal evidence identifies the application - match class { - AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { - InlineReplyPolicy::Allow - } - AttributionClass::UserAssociated - | AttributionClass::TrustedRelay - | AttributionClass::Unknown - | AttributionClass::Conflict => InlineReplyPolicy::Deny, +pub(super) const fn inline_reply_policy(status: AttributionStatus) -> InlineReplyPolicy { + // Text entry stays disabled unless strong evidence identifies the application + match status { + AttributionStatus::Verified => InlineReplyPolicy::Allow, + AttributionStatus::Recognized + | AttributionStatus::Unresolved + | AttributionStatus::Conflict + | AttributionStatus::Relay => InlineReplyPolicy::Deny, } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs index c29d9ceb7..347415bff 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs @@ -1,25 +1,37 @@ -//! Ordered application association from desktop hints, bus ownership, and file identity +//! Ordered application attribution from process, portal, and desktop evidence use std::collections::HashSet; use unixnotis_core::{ - AttributionClass, AttributionDiagnostics, InlineReplyPolicy, NotificationAttribution, - RecordTrust, + AttributionDiagnostics, AttributionReason, AttributionStatus, InlineReplyPolicy, + NotificationAttribution, RecordTrust, }; use zbus::fdo::DBusProxy; use zbus::Connection; use super::desktop_index::{ normalize_desktop_id, normalize_name, verify_record_launch, DesktopIdentityIndex, - DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, + DesktopRecord, InstallProvenance, LaunchFailure, LaunchVerification, VerifiedLaunch, }; use super::executable::{executable_evidence_for_path, FileIdentity}; use super::policy::inline_reply_policy; -use super::sender::{refresh_sender_security_evidence, SenderMetadata}; +use super::sender::{refresh_sender_security_evidence, CommandLineEvidence, SenderMetadata}; +mod candidates; mod diagnostics; +mod evidence; +mod resolution; -use diagnostics::{launch_failure_label, with_diagnostics}; +use candidates::{ + extend_unique_records, preferred_record, resolve_unverified_candidates, + strongest_verified_result, trusted_relay_resolution, +}; +use diagnostics::with_diagnostics; +use evidence::{current_system_identity_matches_sender, verify_record_sender}; +use resolution::{ + policy_resolution, resolution_for_portal_record, resolution_for_record, sender_claim_group_key, + trusted_portal_path, +}; const MAX_DESKTOP_ID_BYTES: usize = 256; @@ -44,30 +56,50 @@ struct CandidateVerification<'record> { verification: LaunchVerification, } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum SenderClaimRelation { + ClaimedApplication, + DifferentVerifiedApplication, + SamePackageHelper, + UnknownExecutable, + TrustedRelay, +} + +impl CandidateVerification<'_> { + const fn is_definitive_mismatch(&self) -> bool { + matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) + } + + const fn failure(&self) -> LaunchFailure { + match self.verification { + LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, + LaunchVerification::InsufficientEvidence(reason) + | LaunchVerification::DefinitiveMismatch(reason) => reason, + } + } +} + pub(in crate::daemon) fn unknown_reply_denied( claim: AppClaim<'_>, sender: &SenderMetadata, reason: &str, ) -> AttributionResolution { - let source = sender.sender_executable.as_deref().map_or_else( + let detail = sender.sender_executable.as_deref().map_or_else( || reason.to_string(), |path| format!("{reason}; source {path}"), ); - let resolution = AttributionResolution { - attribution: NotificationAttribution::unknown( - claim.reported_name, - &source, - unknown_group_key(claim.reported_name, sender), - ), - diagnostics: AttributionDiagnostics::default(), - inline_reply_policy: InlineReplyPolicy::Deny, - }; + let resolution = policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::MissingSenderEvidence, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )); with_diagnostics( resolution, claim, sender, None, - LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine), + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence), ) } @@ -78,7 +110,7 @@ pub(in crate::daemon) async fn resolve_attribution( connection: &Connection, ) -> AttributionResolution { let mut owned_desktop_ids = HashSet::new(); - // Bus ownership is collected as diagnostic context and never replaces file evidence + // Well-known ownership remains supporting context rather than application authority if let (Some(sender_name), Some(desktop_id)) = ( sender.sender_name.as_deref(), claim.desktop_entry.and_then(validate_desktop_id), @@ -90,11 +122,51 @@ pub(in crate::daemon) async fn resolve_attribution( owned_desktop_ids.insert(normalize_desktop_id(&desktop_id)); } } - // Cached D-Bus metadata is refreshed before it can grant application authority - let sender = refresh_sender_security_evidence(sender); + + // Cached process data is refreshed before it affects attribution + let mut sender = refresh_sender_security_evidence(sender); + let initial = resolve_with_evidence(claim, &sender, index, &owned_desktop_ids); + if sender.install_provenance.is_known() + || !matches!( + initial.attribution.status, + unixnotis_core::AttributionStatus::Recognized + ) + { + return initial; + } + + // Ownership is needed only to distinguish a probable helper from a different installed app + enrich_sender_install_provenance(&mut sender, index).await; resolve_with_evidence(claim, &sender, index, &owned_desktop_ids) } +async fn enrich_sender_install_provenance( + sender: &mut SenderMetadata, + index: &DesktopIdentityIndex, +) { + if sender.install_provenance.is_known() { + return; + } + let (Some(path), Some(sender_identity)) = ( + sender.sender_executable.as_deref(), + sender.sender_executable_identity, + ) else { + return; + }; + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return; + } + let Some(current) = executable_evidence_for_path(std::path::Path::new(path)) else { + return; + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return; + } + sender.install_provenance = index + .install_provenance_for_path_async(current.canonical_path) + .await; +} + fn resolve_with_evidence( claim: AppClaim<'_>, sender: &SenderMetadata, @@ -110,12 +182,13 @@ fn resolve_with_evidence( && claim.reported_name.trim().is_empty() && trusted_portal_path(sender, index).is_some() { - // Portal backends forward a broker-verified app id as desktop-entry + // A trusted portal executable may forward its broker-owned application id + let record = preferred_record(&hint_records); let mut resolution = with_diagnostics( - resolution_for_portal_record(hint_records[0], sender, index), + resolution_for_portal_record(record, claim.reported_name, sender, index), claim, sender, - Some(hint_records[0]), + Some(record), LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), ); resolution.diagnostics.record_trust = RecordTrust::Portal; @@ -123,12 +196,17 @@ fn resolve_with_evidence( return resolution; } - // Hint and live-executable candidates are evaluated together so weak metadata cannot win early + // Hints, executable identity, and claimed names contribute candidates without granting trust let mut candidates = hint_records.clone(); if let Some(identity) = sender.sender_executable_identity { - candidates.extend(index.records_for_executable(identity)); + extend_unique_records(&mut candidates, index.records_for_executable(identity)); + } + if !claim.reported_name.trim().is_empty() { + extend_unique_records( + &mut candidates, + index.records_for_claim(claim.reported_name), + ); } - candidates.dedup_by(|left, right| std::ptr::eq(*left, *right)); let results = candidates .iter() .map(|record| CandidateVerification { @@ -136,7 +214,8 @@ fn resolve_with_evidence( verification: verify_record_sender(record, sender, index), }) .collect::>(); - if let Some(record) = strongest_verified_result(&results, claim.reported_name) { + + if let Some(record) = strongest_verified_result(&results, claim.reported_name, index) { return with_diagnostics( resolution_for_record(record, claim.reported_name, sender, index), claim, @@ -146,6 +225,7 @@ fn resolve_with_evidence( ); } + // A verified relay identifies itself but never authenticates the forwarded label if let Some(resolution) = trusted_relay_resolution(claim, sender, index) { return resolution; } @@ -153,350 +233,7 @@ fn resolve_with_evidence( resolve_unverified_candidates(claim, sender, index, &hint_records, &results) } -fn resolve_unverified_candidates( - claim: AppClaim<'_>, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, - hint_records: &[&DesktopRecord], - results: &[CandidateVerification<'_>], -) -> AttributionResolution { - let hint_is_definitive = !hint_records.is_empty() - && results - .iter() - .filter(|result| { - hint_records - .iter() - .any(|record| std::ptr::eq(*record, result.record)) - }) - .all(CandidateVerification::is_definitive_mismatch); - let matching_system_is_definitive = results.iter().any(|result| { - result.record.system_association - && result.record.claim_matches(claim.reported_name) - && result.is_definitive_mismatch() - }); - if hint_is_definitive || matching_system_is_definitive { - let mismatch = results - .iter() - .find(|result| result.is_definitive_mismatch()); - let failure = mismatch.map_or( - LaunchFailure::DesktopClaimMismatch, - CandidateVerification::failure, - ); - return with_diagnostics( - conflict_resolution(claim.reported_name, sender, launch_failure_label(failure)), - claim, - sender, - mismatch.map(|result| result.record), - LaunchVerification::DefinitiveMismatch(failure), - ); - } - - let matching_claim_has_insufficient_evidence = results.iter().any(|result| { - result.record.claim_matches(claim.reported_name) - && matches!( - result.verification, - LaunchVerification::InsufficientEvidence(_) - ) - }); - if index.claim_matches_system_app(claim.reported_name) - && !matching_claim_has_insufficient_evidence - { - // Protected branding without the matching executable is an explicit conflict - return with_diagnostics( - conflict_resolution(claim.reported_name, sender, "executable identity mismatch"), - claim, - sender, - None, - LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), - ); - } - - let source = sender - .sender_executable - .as_deref() - .map(|path| format!("Source: {path}")) - .unwrap_or_default(); - let group_key = unknown_group_key(claim.reported_name, sender); - let insufficient = results.iter().find(|result| { - result.record.claim_matches(claim.reported_name) - && matches!( - result.verification, - LaunchVerification::InsufficientEvidence(_) - ) - }); - with_diagnostics( - policy_resolution(NotificationAttribution::unknown( - claim.reported_name, - &source, - group_key, - )), - claim, - sender, - insufficient.map(|result| result.record), - insufficient.map_or( - LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation), - |result| result.verification, - ), - ) -} - -fn trusted_relay_resolution( - claim: AppClaim<'_>, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, -) -> Option { - let identity = sender.sender_executable_identity?; - let path = index.trusted_relay_path(identity)?; - // Relay groups include both relay identity and the relayed claim - let group_key = format!( - "relay:{}:{}", - identity.group_fragment(), - normalize_name(claim.reported_name) - ); - let attribution = NotificationAttribution::trusted_relay( - claim.reported_name, - &format!("Sent via {}", path.display()), - index.claim_matches_system_app(claim.reported_name), - group_key, - ); - let mut resolution = with_diagnostics( - policy_resolution(attribution), - claim, - sender, - None, - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - ); - resolution.diagnostics.reason = "verified trusted relay executable".to_string(); - Some(resolution) -} - -impl CandidateVerification<'_> { - const fn is_definitive_mismatch(&self) -> bool { - matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) - } - - const fn failure(&self) -> LaunchFailure { - match self.verification { - LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, - LaunchVerification::InsufficientEvidence(reason) - | LaunchVerification::DefinitiveMismatch(reason) => reason, - } - } -} - -fn strongest_verified_result<'record>( - results: &[CandidateVerification<'record>], - reported_name: &str, -) -> Option> { - let missing_name = reported_name.trim().is_empty(); - let mut verified = results.iter().filter(|result| { - matches!(result.verification, LaunchVerification::Verified(_)) - && (missing_name || result.record.claim_matches(reported_name)) - }); - let first = verified.next()?; - let mut preferred = first; - for candidate in verified { - let preferred_rank = record_trust_rank(preferred.record); - let candidate_rank = record_trust_rank(candidate.record); - if candidate_rank > preferred_rank { - preferred = candidate; - continue; - } - if candidate_rank == preferred_rank - && normalize_desktop_id(&candidate.record.id) - != normalize_desktop_id(&preferred.record.id) - { - // Equal-strength records for distinct applications remain ambiguous - return None; - } - } - let LaunchVerification::Verified(launch) = preferred.verification else { - return None; - }; - Some(VerifiedDesktopRecord(preferred.record, launch)) -} - -const fn record_trust_rank(record: &DesktopRecord) -> u8 { - if record.system_association { - 2 - } else { - 1 - } -} - -fn resolution_for_portal_record( - record: &DesktopRecord, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, -) -> AttributionResolution { - let portal = sender - .sender_executable_identity - .and_then(|_| trusted_portal_path(sender, index)) - .map_or_else( - || "desktop portal".to_string(), - |path| path.display().to_string(), - ); - let group_key = format!("portal-desktop:{}", record.id); - let attribution = NotificationAttribution::associated( - &record.display_name, - &record.id, - &record.badge_icon, - &format!("Mediated by {portal}"), - AttributionClass::PortalAssociated, - false, - group_key, - ); - policy_resolution(attribution) -} - -fn trusted_portal_path<'index>( - sender: &SenderMetadata, - index: &'index DesktopIdentityIndex, -) -> Option<&'index std::path::Path> { - let identity = sender.sender_executable_identity?; - let path = std::path::Path::new(sender.sender_executable.as_deref()?); - index.trusted_portal_path(identity, path) -} - -fn resolution_for_record( - verified: VerifiedDesktopRecord<'_>, - reported_name: &str, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, -) -> AttributionResolution { - let record = verified.0; - // Display metadata is projected only after the record and sender identities agree - if !reported_name.trim().is_empty() && !record.claim_matches(reported_name) { - return conflict_resolution(reported_name, sender, "application claim mismatch"); - } - let class = if record.system_association { - AttributionClass::SystemAssociated - } else { - AttributionClass::UserAssociated - }; - let source_label = record - .executable_path - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_default(); - let shadows_system_id = !record.system_origin && index.has_system_record_for_id(&record.id); - let source_label = if shadows_system_id { - format!("Shadows a system desktop entry; source {source_label}") - } else { - source_label - }; - let group_prefix = if record.system_association { - "system-desktop" - } else if record.system_origin { - "system-unverified-desktop" - } else { - "user-desktop" - }; - let origin = record.desktop_identity.map_or_else( - || "unknown".to_string(), - super::executable::FileIdentity::group_fragment, - ); - let group_key = if record.system_association { - format!("{group_prefix}:{}", record.id) - } else { - format!("{group_prefix}:{origin}:{}", record.id) - }; - let attribution = NotificationAttribution::associated( - &record.display_name, - &record.id, - &record.badge_icon, - &source_label, - class, - shadows_system_id, - group_key, - ); - policy_resolution(attribution) -} - -fn conflict_resolution( - reported_name: &str, - sender: &SenderMetadata, - reason: &str, -) -> AttributionResolution { - let source = sender.sender_executable.as_deref().map_or_else( - || reason.to_string(), - |path| format!("{reason}; source {path}"), - ); - policy_resolution(NotificationAttribution::conflict( - reported_name, - &source, - unknown_group_key(reported_name, sender), - )) -} - -fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { - // Interaction policy remains separate so presentation changes cannot enable replies - AttributionResolution { - inline_reply_policy: inline_reply_policy(attribution.class), - attribution, - diagnostics: AttributionDiagnostics::default(), - } -} - -fn verify_record_sender( - record: &DesktopRecord, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, -) -> LaunchVerification { - if !record.association_eligible { - return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); - } - let (Some(record_identity), Some(sender_identity)) = ( - record.executable_identity, - sender.sender_executable_identity, - ) else { - return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); - }; - if !record_identity.same_file(sender_identity) { - return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); - } - - if record.system_association { - // Cached inode equality cannot carry root ownership across inode reuse - if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { - return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); - } - let Some(path) = record.executable_path.as_deref() else { - return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); - }; - // Reopen the installed path so stale index authority cannot outlive replacement - let Some(current) = executable_evidence_for_path(path) else { - return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); - }; - if !current_system_identity_matches_sender(current.identity, sender_identity) { - return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); - } - } - - verify_record_launch(record, index, sender_identity, &sender.command_line) -} - -const fn current_system_identity_matches_sender( - current: FileIdentity, - sender_identity: FileIdentity, -) -> bool { - // Every property is checked again because the cached inode may have changed in place - current.same_file(sender_identity) - && current.is_system_managed() - && current.is_executable_regular() -} - -fn unknown_group_key(reported_name: &str, sender: &SenderMetadata) -> String { - // Unknown senders cannot merge into a trusted desktop group by copying its name - let claim = normalize_name(reported_name); - sender.sender_executable_identity.map_or_else( - || format!("unknown:{claim}"), - |identity| format!("executable:{}:{claim}", identity.group_fragment()), - ) -} - async fn sender_owns_name(connection: &Connection, sender_name: &str, desktop_id: &str) -> bool { - // Invalid well-known names are rejected before contacting the bus daemon let Ok(bus_name) = zbus::names::BusName::try_from(desktop_id) else { return false; }; @@ -510,7 +247,6 @@ async fn sender_owns_name(connection: &Connection, sender_name: &str, desktop_id } pub(super) fn validate_desktop_id(value: &str) -> Option { - // Desktop hints stay short, single-component, and safe for later lookups let value = value.trim(); if value.is_empty() || value.len() > MAX_DESKTOP_ID_BYTES diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs new file mode 100644 index 000000000..fd2de486e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs @@ -0,0 +1,289 @@ +//! Candidate filtering, ranking, and ambiguity handling + +use std::collections::HashSet; + +use unixnotis_core::{AttributionReason, AttributionStatus, NotificationAttribution}; + +use super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::evidence::{candidate_proves_conflict, lineage_association}; +use super::resolution::{ + conflict_from_candidate, policy_resolution, recognized_resolution, sender_claim_group_key, +}; +use super::{ + normalize_desktop_id, normalize_name, AppClaim, AttributionResolution, CandidateVerification, + DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, SenderMetadata, + VerifiedDesktopRecord, VerifiedLaunch, +}; + +pub(super) fn resolve_unverified_candidates( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + hint_records: &[&DesktopRecord], + results: &[CandidateVerification<'_>], +) -> AttributionResolution { + let matching_results = results + .iter() + .filter(|result| { + index.record_matches_claim(result.record, claim.reported_name) + || (claim.reported_name.trim().is_empty() + && hint_records + .iter() + .any(|record| std::ptr::eq(*record, result.record))) + }) + .collect::>(); + + // A same-user ancestor can explain a helper without authenticating helper-owned actions + if let Some((record, detail)) = lineage_association(sender, index, &matching_results) { + let failure = matching_results + .iter() + .find(|result| std::ptr::eq(result.record, record)) + .map_or(LaunchFailure::ExecutableMismatch, |result| result.failure()); + return with_diagnostics( + recognized_resolution(claim, sender, record, index, failure, &detail), + claim, + sender, + Some(record), + LaunchVerification::InsufficientEvidence(failure), + ); + } + + // Only protected records can turn a caller-provided label into a conflict + let protected_mismatches = matching_results + .iter() + .copied() + .filter(|result| { + result.is_definitive_mismatch() + && result.record.system_origin + && result.record.system_association + && candidate_proves_conflict(sender, index, result) + }) + .collect::>(); + if let Some(first) = protected_mismatches.first().copied() { + // Distinct protected families with the same label are ambiguous, not suspicious + if !protected_mismatches + .iter() + .all(|candidate| index.records_share_family(first.record, candidate.record)) + { + let detail = "Multiple protected desktop application families matched the claim"; + return with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::AmbiguousDesktopRecords, + detail, + sender_claim_group_key( + AttributionStatus::Unresolved, + claim.reported_name, + sender, + ), + )), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence( + LaunchFailure::AmbiguousDesktopAssociation, + ), + ); + } + let mismatch = protected_mismatches + .into_iter() + .max_by_key(|candidate| { + normalize_desktop_id(&candidate.record.id) + == normalize_desktop_id(index.canonical_id_for_record(candidate.record)) + }) + .unwrap_or(first); + return conflict_from_candidate(claim, sender, index, mismatch.record, mismatch.failure()); + } + + if let Some(resolution) = + ambiguous_protected_family_resolution(claim, sender, index, &matching_results) + { + return resolution; + } + + // A known application with incomplete evidence remains useful but non-authoritative + if let Some(candidate) = matching_results + .iter() + .max_by_key(|result| record_trust_rank(result.record)) + { + let failure = candidate.failure(); + return with_diagnostics( + recognized_resolution( + claim, + sender, + candidate.record, + index, + failure, + launch_failure_label(failure), + ), + claim, + sender, + Some(candidate.record), + candidate.verification, + ); + } + + unresolved_candidate_resolution(claim, sender, index) +} + +fn ambiguous_protected_family_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + matching_results: &[&CandidateVerification<'_>], +) -> Option { + let protected_families = matching_results + .iter() + .filter(|result| result.record.system_association) + .filter_map(|result| index.family_index_for_record(result.record)) + .collect::>(); + if protected_families.len() <= 1 { + return None; + } + + let detail = "Multiple protected desktop application families matched the claim"; + Some(with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::AmbiguousDesktopRecords, + detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation), + )) +} + +fn unresolved_candidate_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let reason = if index.claim_matches_system_app(claim.reported_name) { + AttributionReason::NoDesktopCandidate + } else if sender.sender_executable_identity.is_none() { + AttributionReason::MissingSenderEvidence + } else { + AttributionReason::NoDesktopCandidate + }; + let detail = sender.sender_executable.as_deref().map_or_else( + || "No reliable desktop application candidate was found".to_string(), + |path| format!("No desktop application matched source {path}"), + ); + with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + reason, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::NoDesktopCandidate), + ) +} + +pub(super) fn trusted_relay_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option { + let identity = sender.sender_executable_identity?; + let path = index.trusted_relay_path(identity)?; + let group_key = format!( + "relay:{}:{}", + identity.group_fragment(), + normalize_name(claim.reported_name) + ); + let attribution = NotificationAttribution::relay( + claim.reported_name, + &format!("Sent through {}", path.display()), + group_key, + ); + let mut resolution = with_diagnostics( + policy_resolution(attribution), + claim, + sender, + None, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.reason = "verified trusted relay executable".to_string(); + Some(resolution) +} + +pub(super) fn strongest_verified_result<'record>( + results: &[CandidateVerification<'record>], + reported_name: &str, + index: &DesktopIdentityIndex, +) -> Option> { + let missing_name = reported_name.trim().is_empty(); + // Rank every verified candidate before deciding whether the strongest tier is ambiguous + let verified = results + .iter() + .filter(|result| { + matches!(result.verification, LaunchVerification::Verified(_)) + && (missing_name || index.record_matches_claim(result.record, reported_name)) + }) + .collect::>(); + let maximum_rank = verified + .iter() + .map(|result| record_trust_rank(result.record)) + .max()?; + let strongest = verified + .into_iter() + .filter(|result| record_trust_rank(result.record) == maximum_rank) + .collect::>(); + let families = strongest + .iter() + .filter_map(|result| index.family_index_for_record(result.record)) + .collect::>(); + // maximum_rank guarantees at least one strongest candidate + if families.len() != 1 { + return None; + } + let preferred = strongest.into_iter().min_by_key(|candidate| { + let canonical = index.canonical_id_for_record(candidate.record); + let normalized_id = normalize_desktop_id(&candidate.record.id); + let is_alias = normalized_id != normalize_desktop_id(canonical); + (is_alias, normalized_id) + })?; + let LaunchVerification::Verified(launch) = preferred.verification else { + return None; + }; + Some(VerifiedDesktopRecord(preferred.record, launch)) +} + +pub(super) const fn record_trust_rank(record: &DesktopRecord) -> u8 { + if record.system_association { + 2 + } else { + 1 + } +} + +pub(super) fn preferred_record<'record>( + records: &[&'record DesktopRecord], +) -> &'record DesktopRecord { + records + .iter() + .copied() + .max_by_key(|record| record_trust_rank(record)) + .expect("caller checks that a desktop candidate exists") +} + +pub(super) fn extend_unique_records<'record>( + records: &mut Vec<&'record DesktopRecord>, + additions: Vec<&'record DesktopRecord>, +) { + for record in additions { + if !records + .iter() + .any(|existing| std::ptr::eq(*existing, record)) + { + records.push(record); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs index 589ea3813..10d45864f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs @@ -1,8 +1,8 @@ //! Conversion from daemon launch evidence into stable diagnostic wire values use unixnotis_core::{ - AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, - RecordTrust, + AttributionDiagnostics, AttributionStatus, CommandLineQualityView, LaunchAuthorityView, + LaunchVerificationView, RecordTrust, }; use super::{ @@ -29,16 +29,21 @@ pub(super) fn with_diagnostics( LaunchAuthorityView::ProtectedPayload, "verified by executable and protected payload identity", ), - LaunchVerification::InsufficientEvidence(failure) => ( + LaunchVerification::DefinitiveMismatch(failure) + if resolution.attribution.status == AttributionStatus::Conflict => + { + ( + LaunchVerificationView::DefinitiveMismatch, + launch_authority_for_failure(failure), + launch_failure_label(failure), + ) + } + LaunchVerification::InsufficientEvidence(failure) + | LaunchVerification::DefinitiveMismatch(failure) => ( LaunchVerificationView::InsufficientEvidence, launch_authority_for_failure(failure), launch_failure_label(failure), ), - LaunchVerification::DefinitiveMismatch(failure) => ( - LaunchVerificationView::DefinitiveMismatch, - launch_authority_for_failure(failure), - launch_failure_label(failure), - ), }; resolution.diagnostics = AttributionDiagnostics { claimed_name: claim.reported_name.to_string(), @@ -62,6 +67,7 @@ pub(super) fn with_diagnostics( pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str { match reason { + LaunchFailure::MissingSenderEvidence => "missing sender process evidence", LaunchFailure::MissingCommandLine => "missing command-line evidence", LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", @@ -71,6 +77,7 @@ pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str LaunchFailure::ProtectedPayloadMismatch => "protected application payload mismatch", LaunchFailure::RequiredArgumentMismatch => "required launch argument mismatch", LaunchFailure::DesktopClaimMismatch => "desktop claim mismatch", + LaunchFailure::NoDesktopCandidate => "no desktop application candidate", } } @@ -81,7 +88,9 @@ const fn launch_authority_for_failure(failure: LaunchFailure) -> LaunchAuthority LaunchFailure::ProtectedPayloadMismatch | LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine => LaunchAuthorityView::ProtectedPayload, - LaunchFailure::ExecutableMismatch + LaunchFailure::MissingSenderEvidence + | LaunchFailure::NoDesktopCandidate + | LaunchFailure::ExecutableMismatch | LaunchFailure::RequiredArgumentMismatch | LaunchFailure::DesktopClaimMismatch | LaunchFailure::UnsupportedWrapper => LaunchAuthorityView::None, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs new file mode 100644 index 000000000..d9c55ad8c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs @@ -0,0 +1,168 @@ +//! Sender, lineage, and contradiction evidence evaluation + +use super::{ + executable_evidence_for_path, verify_record_launch, CandidateVerification, CommandLineEvidence, + DesktopIdentityIndex, DesktopRecord, FileIdentity, InstallProvenance, LaunchFailure, + LaunchVerification, SenderClaimRelation, SenderMetadata, VerifiedLaunch, +}; + +pub(super) fn lineage_association<'record>( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + results: &[&CandidateVerification<'record>], +) -> Option<(&'record DesktopRecord, String)> { + for ancestor in &sender.ancestors { + for result in results { + let record = result.record; + if !record.system_association + || !record + .executable_identity + .is_some_and(|identity| identity.same_file(ancestor.executable_identity)) + { + continue; + } + let verification = verify_ancestor_record(record, index, ancestor.executable_identity); + if matches!( + verification, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ) { + return Some(( + record, + format!( + "Same-user ancestor {} matched the application executable", + ancestor.executable + ), + )); + } + } + } + None +} + +fn verify_ancestor_record( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + identity: FileIdentity, +) -> LaunchVerification { + let Some(path) = record.executable_path.as_deref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + let Some(current) = executable_evidence_for_path(path) else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + if !current_system_identity_matches_sender(current.identity, identity) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + verify_record_launch(record, index, identity, &CommandLineEvidence::default()) +} + +pub(super) fn verify_record_sender( + record: &DesktopRecord, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> LaunchVerification { + if !record.association_eligible { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + } + let Some(record_identity) = record.executable_identity else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + }; + let Some(sender_identity) = sender.sender_executable_identity else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + if !record_identity.same_file(sender_identity) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); + } + + if record.system_association { + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + let Some(path) = record.executable_path.as_deref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + }; + let Some(current) = executable_evidence_for_path(path) else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + return verify_record_launch(record, index, sender_identity, &sender.command_line); + } + + // Exact user-local executable identity is recognition evidence without action authority + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) +} + +pub(super) fn candidate_proves_conflict( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + candidate: &CandidateVerification<'_>, +) -> bool { + match candidate.failure() { + // A structured protected payload or verified application claim is direct evidence + LaunchFailure::ProtectedPayloadMismatch | LaunchFailure::DesktopClaimMismatch => true, + // Executable inequality matters only after another immutable owner is established + LaunchFailure::ExecutableMismatch => matches!( + sender_claim_relation(sender, index, candidate.record), + SenderClaimRelation::DifferentVerifiedApplication + ), + LaunchFailure::MissingSenderEvidence + | LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine + | LaunchFailure::UnsupportedWrapper + | LaunchFailure::AmbiguousDesktopAssociation + | LaunchFailure::DynamicOnlyContract + | LaunchFailure::RequiredArgumentMismatch + | LaunchFailure::NoDesktopCandidate => false, + } +} + +pub(super) fn sender_claim_relation( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + claimed_record: &DesktopRecord, +) -> SenderClaimRelation { + let Some(sender_identity) = sender.sender_executable_identity else { + return SenderClaimRelation::UnknownExecutable; + }; + if index.trusted_relay_path(sender_identity).is_some() { + return SenderClaimRelation::TrustedRelay; + } + if claimed_record + .executable_identity + .is_some_and(|identity| identity.same_file(sender_identity)) + { + return SenderClaimRelation::ClaimedApplication; + } + if index + .records_for_executable(sender_identity) + .into_iter() + .any(|record| record.system_association) + { + // Exact same-family executable identity returned above before this lookup + return SenderClaimRelation::DifferentVerifiedApplication; + } + + let sender_provenance = sender_install_provenance(sender); + if sender_provenance.same_application_source(&claimed_record.executable_provenance) { + return SenderClaimRelation::SamePackageHelper; + } + if sender_provenance.is_known() && claimed_record.executable_provenance.is_known() { + return SenderClaimRelation::DifferentVerifiedApplication; + } + SenderClaimRelation::UnknownExecutable +} + +fn sender_install_provenance(sender: &SenderMetadata) -> InstallProvenance { + sender.install_provenance.clone() +} + +pub(super) const fn current_system_identity_matches_sender( + current: FileIdentity, + sender_identity: FileIdentity, +) -> bool { + current.same_file(sender_identity) + && current.is_system_managed() + && current.is_executable_regular() +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs new file mode 100644 index 000000000..605359a26 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -0,0 +1,229 @@ +//! Structured attribution construction and trust-domain grouping + +use unixnotis_core::{ + AttributionDiagnostics, AttributionReason, AttributionStatus, NotificationAttribution, +}; + +use super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::{ + inline_reply_policy, normalize_name, AppClaim, AttributionResolution, DesktopIdentityIndex, + DesktopRecord, LaunchFailure, LaunchVerification, SenderMetadata, VerifiedDesktopRecord, + VerifiedLaunch, +}; + +pub(super) fn resolution_for_portal_record( + record: &DesktopRecord, + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let portal = trusted_portal_path(sender, index).map_or_else( + || "desktop portal".to_string(), + |path| path.display().to_string(), + ); + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let attribution = NotificationAttribution::verified( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + AttributionReason::VerifiedPortalAppId, + &format!("Mediated by {portal}"), + format!("verified:portal-app:{canonical_id}"), + ); + policy_resolution(attribution) +} + +pub(super) fn trusted_portal_path<'index>( + sender: &SenderMetadata, + index: &'index DesktopIdentityIndex, +) -> Option<&'index std::path::Path> { + let identity = sender.sender_executable_identity?; + let path = std::path::Path::new(sender.sender_executable.as_deref()?); + index.trusted_portal_path(identity, path) +} + +pub(super) fn resolution_for_record( + verified: VerifiedDesktopRecord<'_>, + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let record = verified.0; + if !reported_name.trim().is_empty() && !index.record_matches_claim(record, reported_name) { + return conflict_resolution( + reported_name, + sender, + record, + index, + LaunchFailure::DesktopClaimMismatch, + ); + } + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let source = record + .executable_path + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + if record.system_association { + let reason = match verified.1 { + VerifiedLaunch::DedicatedExecutable => AttributionReason::ExactSystemExecutable, + VerifiedLaunch::ProtectedPayload => AttributionReason::VerifiedProtectedPayload, + }; + return policy_resolution(NotificationAttribution::verified( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + reason, + &source, + format!("verified:system-app:{canonical_id}"), + )); + } + + let origin = record.desktop_identity.map_or_else( + || "unknown".to_string(), + super::super::executable::FileIdentity::group_fragment, + ); + policy_resolution(NotificationAttribution::recognized( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + AttributionReason::ExactUserExecutable, + &source, + format!( + "recognized:user-app:{origin}:{canonical_id}:{}", + sender_identity_fragment(sender) + ), + )) +} + +pub(super) fn recognized_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + record: &DesktopRecord, + index: &DesktopIdentityIndex, + failure: LaunchFailure, + detail: &str, +) -> AttributionResolution { + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let source = sender.sender_executable.as_deref().map_or_else( + || detail.to_string(), + |path| format!("{detail}; source {path}"), + ); + let group_key = if record.system_origin { + format!( + "recognized:system-app:{canonical_id}:{}", + sender_identity_fragment(sender) + ) + } else { + let origin = record.desktop_identity.map_or_else( + || "unknown".to_string(), + super::super::executable::FileIdentity::group_fragment, + ); + format!( + "recognized:user-app:{origin}:{canonical_id}:{}", + sender_identity_fragment(sender) + ) + }; + policy_resolution(NotificationAttribution::recognized( + &canonical.display_name, + claim.reported_name, + canonical_id, + &canonical.badge_icon, + attribution_reason_for_failure(failure), + &source, + group_key, + )) +} + +pub(super) fn conflict_from_candidate( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + record: &DesktopRecord, + failure: LaunchFailure, +) -> AttributionResolution { + with_diagnostics( + conflict_resolution(claim.reported_name, sender, record, index, failure), + claim, + sender, + Some(record), + LaunchVerification::DefinitiveMismatch(failure), + ) +} + +fn conflict_resolution( + reported_name: &str, + sender: &SenderMetadata, + record: &DesktopRecord, + index: &DesktopIdentityIndex, + failure: LaunchFailure, +) -> AttributionResolution { + let label = launch_failure_label(failure); + let detail = sender.sender_executable.as_deref().map_or_else( + || label.to_string(), + |path| format!("{label}; source {path}"), + ); + let desktop_id = index.canonical_id_for_record(record); + policy_resolution(NotificationAttribution::conflict( + reported_name, + desktop_id, + attribution_reason_for_failure(failure), + &detail, + sender_claim_group_key(AttributionStatus::Conflict, reported_name, sender), + )) +} + +pub(super) fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { + AttributionResolution { + inline_reply_policy: inline_reply_policy(attribution.status), + attribution, + diagnostics: AttributionDiagnostics::default(), + } +} + +const fn attribution_reason_for_failure(failure: LaunchFailure) -> AttributionReason { + match failure { + LaunchFailure::MissingSenderEvidence => AttributionReason::MissingSenderEvidence, + LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine => { + AttributionReason::MissingCommandLine + } + LaunchFailure::UnsupportedWrapper => AttributionReason::UnsupportedWrapper, + LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::RequiredArgumentMismatch => { + AttributionReason::AmbiguousDesktopRecords + } + LaunchFailure::DynamicOnlyContract => AttributionReason::DynamicLaunchContract, + LaunchFailure::NoDesktopCandidate => AttributionReason::NoDesktopCandidate, + LaunchFailure::ExecutableMismatch => AttributionReason::ExecutableMismatch, + LaunchFailure::ProtectedPayloadMismatch => AttributionReason::ProtectedPayloadMismatch, + LaunchFailure::DesktopClaimMismatch => AttributionReason::ApplicationClaimMismatch, + } +} + +pub(super) fn sender_claim_group_key( + status: AttributionStatus, + reported_name: &str, + sender: &SenderMetadata, +) -> String { + let claim = normalize_name(reported_name); + let prefix = match status { + AttributionStatus::Unresolved => "unresolved", + AttributionStatus::Conflict => "conflict", + AttributionStatus::Verified | AttributionStatus::Recognized | AttributionStatus::Relay => { + "unknown" + } + }; + format!("{prefix}:{}:{claim}", sender_identity_fragment(sender)) +} + +fn sender_identity_fragment(sender: &SenderMetadata) -> String { + sender.sender_executable_identity.map_or_else( + || "missing".to_string(), + super::super::executable::FileIdentity::group_fragment, + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index d47236d22..c7827944f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -12,9 +12,11 @@ use zbus::Connection; use super::sender_cache::SenderMetadataCache; use super::{executable_evidence_for_pid, FileIdentity}; +use crate::daemon::notifications::identity::desktop_index::InstallProvenance; const MAX_PROCESS_CMDLINE_BYTES: u64 = 128 * 1024; const MAX_PROCESS_ARGUMENTS: usize = 256; +const MAX_PROCESS_ANCESTORS: usize = 8; #[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] pub(in crate::daemon::notifications) enum CommandLineQuality { @@ -31,6 +33,16 @@ pub(in crate::daemon::notifications) struct CommandLineEvidence { pub(in crate::daemon::notifications) quality: CommandLineQuality, } +/// Stable executable evidence for one same-user process ancestor +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) struct ProcessLineageEvidence { + pub(in crate::daemon::notifications) pid: u32, + pub(in crate::daemon::notifications) start_time: u64, + pub(in crate::daemon::notifications) uid: u32, + pub(in crate::daemon::notifications) executable: String, + pub(in crate::daemon::notifications) executable_identity: FileIdentity, +} + #[derive(Debug, Clone, Default)] pub(in crate::daemon) struct SenderMetadata { // Unique bus sender name (:1.x) used for ownership checks @@ -39,12 +51,18 @@ pub(in crate::daemon) struct SenderMetadata { pub(in crate::daemon::notifications) sender_pid: Option, // Linux start time identifies one concrete process lifetime pub(in crate::daemon::notifications) sender_start_time: Option, + // The bus credential is used to bound process-lineage inspection + pub(in crate::daemon::notifications) sender_uid: Option, // Executable path is presentation-only evidence for diagnostics and source labels pub(in crate::daemon::notifications) sender_executable: Option, // Device and inode bind policy to the open running executable rather than its basename pub(in crate::daemon::notifications) sender_executable_identity: Option, + // Package or bundle ownership is supporting evidence for helper and conflict decisions + pub(in crate::daemon::notifications) install_provenance: InstallProvenance, // Quality is explicit because processes may rewrite the visible procfs argument memory pub(in crate::daemon::notifications) command_line: CommandLineEvidence, + // Ancestors remain supporting evidence and never grant actions by themselves + pub(in crate::daemon::notifications) ancestors: Vec, } pub(in crate::daemon) async fn resolve_sender_metadata( @@ -59,9 +77,12 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_name, sender_pid: None, sender_start_time: None, + sender_uid: None, sender_executable: None, sender_executable_identity: None, + install_provenance: InstallProvenance::Unknown, command_line: CommandLineEvidence::default(), + ancestors: Vec::new(), }; }; @@ -76,9 +97,12 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_name, sender_pid: None, sender_start_time: None, + sender_uid: None, sender_executable: None, sender_executable_identity: None, + install_provenance: InstallProvenance::Unknown, command_line: CommandLineEvidence::default(), + ancestors: Vec::new(), }; }; @@ -87,15 +111,19 @@ pub(in crate::daemon) async fn resolve_sender_metadata( sender_name, sender_pid: None, sender_start_time: None, + sender_uid: None, sender_executable: None, sender_executable_identity: None, + install_provenance: InstallProvenance::Unknown, command_line: CommandLineEvidence::default(), + ancestors: Vec::new(), }; }; // PID and executable come from the bus owner, not caller-provided payload fields - let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); - let (sender_start_time, process_evidence) = sender_pid.map_or((None, None), |pid| { + let connection_user_id = proxy.get_connection_unix_user(bus_name.clone()).await.ok(); + let connection_process_id = proxy.get_connection_unix_process_id(bus_name).await.ok(); + let (sender_start_time, process_evidence) = connection_process_id.map_or((None, None), |pid| { let start_before = read_process_start_time(pid); let executable = executable_evidence_for_pid(pid); let command_line = read_process_cmdline(pid, executable.as_ref()); @@ -109,14 +137,26 @@ pub(in crate::daemon) async fn resolve_sender_metadata( .as_ref() .map(|evidence| evidence.canonical_path.display().to_string()); let sender_executable_identity = executable_evidence.map(|evidence| evidence.identity); + let stable_uid = connection_process_id + .zip(connection_user_id) + .and_then(|(pid, uid)| (read_process_real_uid(pid) == Some(uid)).then_some(uid)); + let ancestors = connection_process_id + .zip(sender_start_time) + .zip(stable_uid) + .map_or_else(Vec::new, |((pid, _start_time), uid)| { + collect_process_lineage(pid, uid) + }); let metadata = SenderMetadata { sender_name, - sender_pid, + sender_pid: connection_process_id, sender_start_time, + sender_uid: stable_uid, sender_executable, sender_executable_identity, + install_provenance: InstallProvenance::Unknown, command_line, + ancestors, }; // Failed lookups remain retryable instead of becoming persistent unknown identities if metadata.sender_start_time.is_some() && metadata.sender_executable_identity.is_some() { @@ -140,9 +180,24 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen if !process_lifetime_matches(start_before, expected_start, start_after) { // Stale cache entries retain bus context but lose all application identity authority refreshed.sender_start_time = None; + refreshed.sender_uid = None; + refreshed.sender_executable = None; + refreshed.sender_executable_identity = None; + refreshed.command_line = CommandLineEvidence::default(); + refreshed.ancestors.clear(); + return refreshed; + } + + if metadata + .sender_uid + .is_some_and(|uid| read_process_real_uid(pid) != Some(uid)) + { + refreshed.sender_start_time = None; + refreshed.sender_uid = None; refreshed.sender_executable = None; refreshed.sender_executable_identity = None; refreshed.command_line = CommandLineEvidence::default(); + refreshed.ancestors.clear(); return refreshed; } @@ -151,6 +206,9 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen .map(|evidence| evidence.canonical_path.display().to_string()); refreshed.sender_executable_identity = executable.map(|evidence| evidence.identity); refreshed.command_line = command_line; + refreshed.ancestors = metadata + .sender_uid + .map_or_else(Vec::new, |uid| collect_process_lineage(pid, uid)); refreshed } @@ -167,7 +225,60 @@ fn read_process_start_time(pid: u32) -> Option { // /proc//stat keeps the process lifetime tick count in field 22 let path = format!("/proc/{pid}/stat"); let contents = std::fs::read_to_string(path).ok()?; - parse_process_start_time(&contents) + parse_process_stat(&contents).map(|stat| stat.start_time) +} + +#[cfg(target_os = "linux")] +fn read_process_real_uid(pid: u32) -> Option { + let path = format!("/proc/{pid}/status"); + std::fs::read_to_string(path) + .ok()? + .lines() + .find_map(|line| line.strip_prefix("Uid:"))? + .split_whitespace() + .next()? + .parse() + .ok() +} + +#[cfg(target_os = "linux")] +fn collect_process_lineage(pid: u32, uid: u32) -> Vec { + let Some(sender_stat) = read_process_stat(pid) else { + return Vec::new(); + }; + let mut parent_pid = sender_stat.parent_pid; + let mut lineage = Vec::new(); + + for _ in 0..MAX_PROCESS_ANCESTORS { + if parent_pid <= 1 || read_process_real_uid(parent_pid) != Some(uid) { + break; + } + let Some(before) = read_process_stat(parent_pid) else { + break; + }; + // Crossing a login session is outside the sender's application launch scope + if before.session_id != sender_stat.session_id { + break; + } + let Some(executable) = executable_evidence_for_pid(parent_pid) else { + break; + }; + let Some(after) = read_process_stat(parent_pid) else { + break; + }; + if before != after { + break; + } + lineage.push(ProcessLineageEvidence { + pid: parent_pid, + start_time: before.start_time, + uid, + executable: executable.canonical_path.display().to_string(), + executable_identity: executable.identity, + }); + parent_pid = before.parent_pid; + } + lineage } #[cfg(target_os = "linux")] @@ -221,6 +332,16 @@ fn read_process_start_time(_pid: u32) -> Option { None } +#[cfg(not(target_os = "linux"))] +fn read_process_real_uid(_pid: u32) -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +fn collect_process_lineage(_pid: u32, _uid: u32) -> Vec { + Vec::new() +} + #[cfg(not(target_os = "linux"))] fn read_process_cmdline( _pid: u32, @@ -262,14 +383,37 @@ fn stable_process_evidence( } } -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", test))] fn parse_process_start_time(stat: &str) -> Option { + parse_process_stat(stat).map(|stat| stat.start_time) +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ProcessStat { + parent_pid: u32, + session_id: u32, + start_time: u64, +} + +#[cfg(target_os = "linux")] +fn read_process_stat(pid: u32) -> Option { + let path = format!("/proc/{pid}/stat"); + parse_process_stat(&std::fs::read_to_string(path).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_process_stat(stat: &str) -> Option { // The comm field is wrapped in parentheses and may contain spaces let end = stat.rfind(')')?; let remainder = stat.get(end + 2..)?; - // Field 3 starts here, so field 22 lives at index 19 - let start_time = remainder.split_whitespace().nth(19)?; - start_time.parse().ok() + let fields = remainder.split_whitespace().collect::>(); + // Field three starts here so parent, session, and start time use fixed offsets + Some(ProcessStat { + parent_pid: fields.get(1)?.parse().ok()?, + session_id: fields.get(3)?.parse().ok()?, + start_time: fields.get(19)?.parse().ok()?, + }) } #[cfg(test)] From 5669d04ab1874104eabb54d7894325485c4b669c Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:11:03 -0500 Subject: [PATCH 146/275] test(attribution): cover trust and launch evidence matrix Summary: cover trust and launch evidence matrix. Scope: attribution. --- .../src/model/tests/attribution.rs | 288 +++++++++------ .../src/model/tests/notification.rs | 41 ++- .../notifications/identity/tests/policy.rs | 15 +- .../notifications/identity/tests/resolver.rs | 41 ++- .../identity/tests/resolver/association.rs | 346 +----------------- .../tests/resolver/association/claims.rs | 106 ++++++ .../tests/resolver/association/dedicated.rs | 323 ++++++++++++++++ .../tests/resolver/association/families.rs | 178 +++++++++ .../tests/resolver/association/helpers.rs | 211 +++++++++++ .../identity/tests/resolver/portal.rs | 15 +- .../identity/tests/resolver/runtime.rs | 46 +-- .../identity/tests/resolver/spoof.rs | 183 +++++++-- .../identity/tests/sender_cache.rs | 4 + 13 files changed, 1241 insertions(+), 556 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 2206dafa6..9a1481798 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -1,28 +1,54 @@ use super::{ - ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationAttribution, + ApplicationActionPolicy, AttributionReason, AttributionStatus, InlineReplyPolicy, + NotificationAttribution, }; use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; #[test] -fn attribution_wire_enums_use_their_declared_one_byte_signature() { +fn attribution_wire_enums_use_declared_one_byte_values() { let context = Context::new_dbus(LE, 0); - for (class, discriminant) in [ - (AttributionClass::SystemAssociated, 0_u8), - (AttributionClass::PortalAssociated, 1), - (AttributionClass::UserAssociated, 2), - (AttributionClass::TrustedRelay, 3), - (AttributionClass::Unknown, 4), - (AttributionClass::Conflict, 5), + for (status, discriminant) in [ + (AttributionStatus::Verified, 0_u8), + (AttributionStatus::Recognized, 1), + (AttributionStatus::Unresolved, 2), + (AttributionStatus::Conflict, 3), + (AttributionStatus::Relay, 4), ] { - let encoded = to_bytes(context, &class).expect("serialize attribution class"); - assert_eq!(AttributionClass::signature(), u8::signature()); + let encoded = to_bytes(context, &status).expect("serialize attribution status"); + assert_eq!(AttributionStatus::signature(), u8::signature()); assert_eq!(encoded.bytes(), &[discriminant]); - let decoded: AttributionClass = encoded + let decoded: AttributionStatus = encoded .deserialize() - .expect("deserialize attribution class") + .expect("deserialize attribution status") .0; - assert_eq!(decoded, class); + assert_eq!(decoded, status); + } + + for (reason, discriminant) in [ + (AttributionReason::ExactSystemExecutable, 0_u8), + (AttributionReason::VerifiedPortalAppId, 1), + (AttributionReason::ExactUserExecutable, 2), + (AttributionReason::VerifiedProtectedPayload, 3), + (AttributionReason::TrustedRelayExecutable, 4), + (AttributionReason::MissingSenderEvidence, 10), + (AttributionReason::MissingCommandLine, 11), + (AttributionReason::AmbiguousDesktopRecords, 12), + (AttributionReason::DynamicLaunchContract, 13), + (AttributionReason::UnsupportedWrapper, 14), + (AttributionReason::NoDesktopCandidate, 15), + (AttributionReason::ExecutableMismatch, 20), + (AttributionReason::ProtectedPayloadMismatch, 21), + (AttributionReason::ApplicationClaimMismatch, 22), + ] { + let encoded = to_bytes(context, &reason).expect("serialize attribution reason"); + assert_eq!(AttributionReason::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: AttributionReason = encoded + .deserialize() + .expect("deserialize attribution reason") + .0; + assert_eq!(decoded, reason); } for (policy, discriminant) in [ @@ -32,149 +58,175 @@ fn attribution_wire_enums_use_their_declared_one_byte_signature() { let encoded = to_bytes(context, &policy).expect("serialize inline reply policy"); assert_eq!(InlineReplyPolicy::signature(), u8::signature()); assert_eq!(encoded.bytes(), &[discriminant]); - let decoded: InlineReplyPolicy = encoded - .deserialize() - .expect("deserialize inline reply policy") - .0; - assert_eq!(decoded, policy); } } #[test] fn attribution_wire_enums_reject_unknown_discriminants() { let context = Context::new_dbus(LE, 0); + let unknown = to_bytes(context, &u8::MAX).expect("serialize unknown byte"); - // Representation-aware deserialization must not invent policy for unknown wire values - let unknown_class = to_bytes(context, &u8::MAX).expect("serialize unknown class byte"); - assert!(unknown_class.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); - // The intentionally unused policy value must remain invalid on D-Bus - let unknown_policy = to_bytes(context, &1_u8).expect("serialize unused policy byte"); - assert!(unknown_policy.deserialize::().is_err()); + let unused_policy = to_bytes(context, &1_u8).expect("serialize unused policy byte"); + assert!(unused_policy.deserialize::().is_err()); } #[test] -fn associated_identity_keeps_presentation_and_grouping_fields_separate() { - let attribution = NotificationAttribution::associated( - "Signal", - "org.signal.Signal", - "org.signal.Signal", - "/usr/bin/signal-desktop", - AttributionClass::SystemAssociated, - false, - "desktop:org.signal.Signal".to_string(), +fn verified_identity_keeps_claim_and_diagnostics_structured() { + let attribution = NotificationAttribution::verified( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + AttributionReason::ExactSystemExecutable, + "/usr/bin/example-chat", + "system-app:org.example.Chat".to_string(), ); - assert_eq!(attribution.display_name, "Signal"); - assert_eq!(attribution.desktop_id, "org.signal.Signal"); - assert_eq!(attribution.class, AttributionClass::SystemAssociated); - assert!(!attribution.has_warning()); + assert_eq!(attribution.display_name, "Example Chat"); + assert_eq!(attribution.claimed_name, "Example Chat"); + assert_eq!(attribution.status, AttributionStatus::Verified); + assert_eq!(attribution.reason, AttributionReason::ExactSystemExecutable); + assert_eq!( + attribution.group_key, "system-app:org.example.Chat", + "a valid daemon group key must survive wire construction" + ); } #[test] -fn conflict_diagnostics_do_not_enter_the_primary_display_name() { - let attribution = NotificationAttribution::conflict( - "KeePassXC", - "source /tmp/keepassxc", - "executable:1:2".to_string(), +fn recognized_identity_preserves_canonical_application_fields() { + let attribution = NotificationAttribution::recognized( + "Example Chat", + "Caller label", + "org.example.Chat", + "org.example.Chat", + AttributionReason::MissingCommandLine, + "the sender command line was unavailable", + "recognized:system-app:org.example.Chat:7:11".to_string(), + ); + + assert_eq!(attribution.display_name, "Example Chat"); + assert_eq!(attribution.claimed_name, "Caller label"); + assert_eq!(attribution.desktop_id, "org.example.Chat"); + assert_eq!(attribution.badge_icon, "org.example.Chat"); + assert_eq!(attribution.status, AttributionStatus::Recognized); + assert_eq!(attribution.reason, AttributionReason::MissingCommandLine); + assert_eq!( + attribution.group_key, + "recognized:system-app:org.example.Chat:7:11" + ); +} + +#[test] +fn unresolved_identity_preserves_claim_reason_and_isolated_group() { + let attribution = NotificationAttribution::unresolved( + "Caller label", + AttributionReason::NoDesktopCandidate, + "no desktop candidate matched", + "unresolved:7:11:callerlabel".to_string(), ); assert_eq!(attribution.display_name, "Unknown application"); - assert!(attribution.source_label.contains("Claims to be KeePassXC")); - assert!(!attribution.display_name.contains("unverified claim")); - assert!(attribution.has_warning()); + assert_eq!(attribution.claimed_name, "Caller label"); + assert!(attribution.desktop_id.is_empty()); + assert_eq!(attribution.badge_icon, "application-x-executable-symbolic"); + assert_eq!(attribution.status, AttributionStatus::Unresolved); + assert_eq!(attribution.reason, AttributionReason::NoDesktopCandidate); + assert_eq!(attribution.group_key, "unresolved:7:11:callerlabel"); } #[test] -fn trusted_relay_keeps_the_callers_label_without_granting_association() { - let attribution = NotificationAttribution::trusted_relay( - "Screenshot", - "Sent via /usr/bin/notify-send", - false, - "relay:1:2:screenshot".to_string(), +fn empty_group_key_fails_closed_to_unknown() { + let attribution = NotificationAttribution::unresolved( + "Caller label", + AttributionReason::MissingSenderEvidence, + "", + " \n\t ".to_string(), ); - assert_eq!(attribution.display_name, "Screenshot"); - assert_eq!(attribution.class, AttributionClass::TrustedRelay); - assert!(!attribution.has_warning()); + assert_eq!( + attribution.group_key, "unknown", + "empty or display-control-only group keys must not escape construction" + ); } #[test] -fn unknown_sender_keeps_bounded_presentation_without_gaining_association() { - let attribution = NotificationAttribution::unknown( - "Local helper", - "Source: /opt/local-helper", - "executable:7:9:localhelper".to_string(), +fn conflict_keeps_claim_out_of_human_diagnostic_state() { + let attribution = NotificationAttribution::conflict( + "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, + "sender executable differs from the protected record", + "unknown:7:9:passwordmanager".to_string(), ); - assert_eq!(attribution.display_name, "Local helper"); - assert_eq!(attribution.source_label, "Source: /opt/local-helper"); - assert_eq!(attribution.class, AttributionClass::Unknown); - assert_eq!(attribution.group_key, "executable:7:9:localhelper"); - assert!(!attribution.has_warning()); + assert_eq!(attribution.display_name, "Unknown application"); + assert_eq!(attribution.claimed_name, "Password Manager"); + assert_eq!(attribution.status, AttributionStatus::Conflict); + assert!(!attribution.diagnostic_detail.contains("Claims to be")); } #[test] -fn application_actions_require_a_non_conflicting_desktop_association() { - for class in [ - AttributionClass::SystemAssociated, - AttributionClass::PortalAssociated, - ] { - let attribution = NotificationAttribution::associated( - "Associated", - "org.example.Associated", - "org.example.Associated", - "", - class, - false, - "associated".to_string(), - ); +fn relay_never_promotes_the_caller_label_to_primary_identity() { + let attribution = NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:1:2:examplechat".to_string(), + ); - assert_eq!( - attribution.application_action_policy(), - ApplicationActionPolicy::Allow, - "{class:?} should allow application actions", - ); - } + assert_eq!(attribution.display_name, "Command-line notification"); + assert_eq!(attribution.claimed_name, "Example Chat"); + assert_eq!(attribution.status, AttributionStatus::Relay); +} - for class in [ - AttributionClass::UserAssociated, - AttributionClass::TrustedRelay, - AttributionClass::Unknown, - AttributionClass::Conflict, - ] { - let attribution = NotificationAttribution::associated( - "Weak source", +#[test] +fn only_verified_identity_allows_application_actions() { + let verified = NotificationAttribution::verified( + "Verified", + "Verified", + "org.example.Verified", + "verified", + AttributionReason::ExactSystemExecutable, + "", + "system-app:verified".to_string(), + ); + assert_eq!( + verified.application_action_policy(), + ApplicationActionPolicy::Allow + ); + + for attribution in [ + NotificationAttribution::recognized( + "Local", + "Local", + "org.example.Local", + "local", + AttributionReason::ExactUserExecutable, "", - "dialog-information-symbolic", + "user-app:local".to_string(), + ), + NotificationAttribution::unresolved( + "Unknown", + AttributionReason::MissingSenderEvidence, "", - class, - false, - "weak".to_string(), - ); - + "unknown:unknown".to_string(), + ), + NotificationAttribution::conflict( + "Conflict", + "org.example.Conflict", + AttributionReason::ExecutableMismatch, + "", + "unknown:conflict".to_string(), + ), + NotificationAttribution::relay("Relay", "", "relay:relay".to_string()), + ] { assert_eq!( attribution.application_action_policy(), ApplicationActionPolicy::Deny, - "{class:?} should deny application actions", + "status {:?} must not emit application-owned signals", + attribution.status ); } } - -#[test] -fn warning_state_denies_actions_even_for_an_associated_desktop_entry() { - let attribution = NotificationAttribution::associated( - "Shadowed application", - "org.example.Shadowed", - "org.example.Shadowed", - "Shadows a system desktop entry", - AttributionClass::UserAssociated, - true, - "shadowed".to_string(), - ); - - assert_eq!( - attribution.application_action_policy(), - ApplicationActionPolicy::Deny - ); -} diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 3e18d4814..f675c7a17 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -4,8 +4,8 @@ use zbus::zvariant::{serialized::Context, to_bytes, Value, LE}; use super::{Notification, NotificationImage}; use crate::{ - Action, AttributionClass, ImageData, InlineReply, InlineReplyPolicy, NotificationAttribution, - Urgency, + Action, AttributionReason, AttributionStatus, ImageData, InlineReply, InlineReplyPolicy, + NotificationAttribution, Urgency, }; fn notification_with_image(image: NotificationImage) -> Notification { @@ -20,14 +20,14 @@ fn notification_with_image(image: NotificationImage) -> Notification { generation: 11, app_name: "Mail".to_string(), app_icon: "mail".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Mail", "Mail", "org.example.Mail", "mail", - "/usr/bin/mail", - AttributionClass::SystemAssociated, - false, - "desktop:org.example.Mail".to_string(), + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.Mail".to_string(), ), attribution_diagnostics: crate::AttributionDiagnostics::default(), summary: "Subject".to_string(), @@ -82,7 +82,7 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { // Live popup views keep enough information for UI actions and close policy assert_eq!(view.id, 42); assert_eq!(view.app_name, "Mail"); - assert_eq!(view.attribution.class, AttributionClass::SystemAssociated); + assert_eq!(view.attribution.status, AttributionStatus::Verified); assert_eq!(view.attribution.badge_icon, "mail"); assert_eq!(view.summary, "Subject"); assert_eq!(view.body, "Body"); @@ -97,17 +97,16 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { fn notification_view_round_trips_every_attribution_and_reply_policy_pair() { let context = Context::new_dbus(LE, 0); let cases = [ - (AttributionClass::SystemAssociated, InlineReplyPolicy::Allow), - (AttributionClass::PortalAssociated, InlineReplyPolicy::Allow), - (AttributionClass::UserAssociated, InlineReplyPolicy::Deny), - (AttributionClass::TrustedRelay, InlineReplyPolicy::Deny), - (AttributionClass::Unknown, InlineReplyPolicy::Deny), - (AttributionClass::Conflict, InlineReplyPolicy::Deny), + (AttributionStatus::Verified, InlineReplyPolicy::Allow), + (AttributionStatus::Recognized, InlineReplyPolicy::Deny), + (AttributionStatus::Relay, InlineReplyPolicy::Deny), + (AttributionStatus::Unresolved, InlineReplyPolicy::Deny), + (AttributionStatus::Conflict, InlineReplyPolicy::Deny), ]; - for (class, policy) in cases { + for (status, policy) in cases { let mut view = notification_with_image(image_with_raw_bytes()).to_view(); - view.attribution.class = class; + view.attribution.status = status; view.inline_reply_policy = policy; // This nested payload matches GetActiveNotification and exercises both wire enums @@ -127,6 +126,8 @@ fn notification_view_keeps_conflict_warning_separate_from_primary_name() { notification.sender_executable = Some("/usr/bin/unknown-client".to_string()); notification.attribution = NotificationAttribution::conflict( "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, "source /usr/bin/unknown-client", "executable:1:2".to_string(), ); @@ -134,8 +135,12 @@ fn notification_view_keeps_conflict_warning_separate_from_primary_name() { let view = notification.to_view(); assert_eq!(view.app_name, "Unknown application"); - assert_eq!(view.attribution.class, AttributionClass::Conflict); - assert!(view.attribution.source_label.contains("Password Manager")); + assert_eq!(view.attribution.status, AttributionStatus::Conflict); + assert_eq!(view.attribution.claimed_name, "Password Manager"); + assert_eq!( + view.attribution.reason, + AttributionReason::ExecutableMismatch + ); assert!(!view.app_name.contains("unverified claim")); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs index 6bac3d088..d4d7dd3c4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs @@ -1,13 +1,10 @@ -use unixnotis_core::{AttributionClass, InlineReplyPolicy}; +use unixnotis_core::{AttributionStatus, InlineReplyPolicy}; use super::inline_reply_policy; #[test] fn only_system_and_portal_associations_allow_inline_replies() { - for class in [ - AttributionClass::SystemAssociated, - AttributionClass::PortalAssociated, - ] { + for class in [AttributionStatus::Verified, AttributionStatus::Verified] { assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Allow); } } @@ -15,10 +12,10 @@ fn only_system_and_portal_associations_allow_inline_replies() { #[test] fn every_unconfirmed_attribution_class_denies_inline_replies() { for class in [ - AttributionClass::UserAssociated, - AttributionClass::TrustedRelay, - AttributionClass::Unknown, - AttributionClass::Conflict, + AttributionStatus::Recognized, + AttributionStatus::Relay, + AttributionStatus::Unresolved, + AttributionStatus::Conflict, ] { assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Deny); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs index 0bd0a3ddb..88a78980b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs @@ -2,16 +2,21 @@ use std::collections::HashSet; use std::path::PathBuf; use unixnotis_core::{ - AttributionClass, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, - LaunchVerificationView, + AttributionStatus, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, + LaunchVerificationView, RecordTrust, }; use super::*; use crate::daemon::notifications::identity::desktop_index::model::{ ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, }; -use crate::daemon::notifications::identity::desktop_index::{DesktopIdentityIndex, DesktopRecord}; -use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::{ + DesktopIdentityIndex, DesktopRecord, InstallProvenance, +}; +use crate::daemon::notifications::identity::sender::{ + CommandLineEvidence, CommandLineQuality, ProcessLineageEvidence, +}; use crate::daemon::notifications::identity::FileIdentity; trait DesktopRecordFixture { @@ -42,9 +47,28 @@ impl DesktopRecordFixture for DesktopRecord { id: id.to_string(), display_name: display_name.to_string(), badge_icon: id.to_string(), + desktop_path: Some(PathBuf::from(format!( + "/usr/share/applications/{id}.desktop" + ))), executable_path: Some(PathBuf::from(executable_path)), executable_identity: Some(identity), desktop_identity: Some(identity), + desktop_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, + executable_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, system_origin: system_entry, system_association: system_entry, association_eligible: true, @@ -148,6 +172,13 @@ fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { } } +fn package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} + fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { SenderMetadata { sender_name: Some(":1.42".to_string()), @@ -199,7 +230,7 @@ fn verified_executable_record<'record>( verification: verify_record_sender(record, sender, index), }) .collect::>(); - strongest_verified_result(&results, reported_name) + strongest_verified_result(&results, reported_name, index) } #[path = "resolver/association.rs"] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs index ae73525ca..2c764e20e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs @@ -1,338 +1,8 @@ -use super::*; - -#[test] -fn system_desktop_identity_allows_legitimate_signal_reply() { - let (signal_path, signal_identity) = installed_system_executable(); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.signal.Signal", - "Signal", - &signal_path, - signal_identity, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - desktop_entry: Some("org.signal.Signal.desktop"), - }, - &sender(&signal_path, signal_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!( - resolution.attribution.group_key, - "system-desktop:org.signal.Signal" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); - assert!(!resolution.attribution.source_label.contains("unverified")); -} - -#[test] -fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { - let (signal_path, signal_identity) = installed_system_executable(); - let record = system_record("signal", "Signal", &signal_path, signal_identity) - .with_launch_literals(&["--", "sgnl://expected"]); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - // Signal sends an empty app name and adds Electron flags after desktop activation - reported_name: "", - desktop_entry: None, - }, - &sender_with_arguments( - &signal_path, - signal_identity, - &["--password-store=kwallet6", "--ozone-platform=x11", "--"], - ), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { - let (app_path, app_identity) = installed_system_executable(); - let record = system_record("org.example.App", "Example App", &app_path, app_identity); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example App", - desktop_entry: Some("org.example.App"), - }, - &sender_with_arguments( - &app_path, - app_identity, - &["--display-backend=x11", "--tray"], - ), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); -} - -#[test] -fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { - let (app_path, app_identity) = installed_system_executable(); - let record = system_record("org.example.App", "Example App", &app_path, app_identity); - let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - let mut rewritten = sender(&app_path, app_identity); - rewritten.command_line = CommandLineEvidence { - argv: vec![format!("{app_path} --runtime-flag").into_bytes()], - quality: CommandLineQuality::RewrittenProcessTitle, - }; - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Example App", - desktop_entry: Some("org.example.App"), - }, - &rewritten, - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_ne!(resolution.attribution.class, AttributionClass::Conflict); - assert_eq!( - resolution.diagnostics.command_line_quality, - CommandLineQualityView::RewrittenProcessTitle - ); - assert_eq!( - resolution.diagnostics.verification, - LaunchVerificationView::Verified - ); - assert_eq!( - resolution.diagnostics.launch_authority, - LaunchAuthorityView::DedicatedExecutable - ); -} - -#[test] -fn verified_executable_recovers_from_stale_desktop_hint() { - let (signal_path, signal_identity) = installed_system_executable(); - let mut stale_user_entry = DesktopRecord::fixture( - "signal-desktop", - "Signal", - "/usr/bin/env", - identity(90, 900, 0), - false, - false, - ); - // An env wrapper cannot associate the user entry with the dedicated Signal process - stale_user_entry.association_eligible = false; - stale_user_entry.system_association = false; - let system_entry = system_record("signal", "Signal", &signal_path, signal_identity); - let index = - DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Signal", - // Electron derives this hint from a differently named local desktop file - desktop_entry: Some("signal-desktop"), - }, - &sender(&signal_path, signal_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!(resolution.attribution.desktop_id, "signal"); -} - -#[test] -fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { - let (runtime_path, runtime_identity) = installed_system_executable(); - let first = system_record( - "org.example.First", - "First App", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["--app-id=first"]); - let second = system_record( - "org.example.Second", - "Second App", - &runtime_path, - runtime_identity, - ) - .with_launch_literals(&["--app-id=second"]); - let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "", - desktop_entry: None, - }, - &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), - &index, - &HashSet::new(), - ); - - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); -} - -#[test] -fn duplicate_desktop_id_prefers_the_protected_record() { - let (app_path, app_identity) = installed_system_executable(); - let user_record = - DesktopRecord::fixture("signal", "Signal", &app_path, app_identity, false, false); - let mut system_record = system_record("signal", "Signal", &app_path, app_identity); - system_record.badge_icon = "protected-signal".to_string(); - let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); - let records = index.records_for_executable(app_identity); - - let verified = - verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) - .expect("duplicate desktop id should keep one verified record"); - - assert!(verified.0.system_association); - assert_eq!(verified.0.badge_icon, "protected-signal"); -} - -#[test] -fn duplicate_protected_desktop_id_keeps_stable_index_order() { - let (app_path, app_identity) = installed_system_executable(); - let mut first = system_record("signal", "Signal", &app_path, app_identity); - first.badge_icon = "first-signal".to_string(); - let mut second = system_record("signal", "Signal", &app_path, app_identity); - second.badge_icon = "second-signal".to_string(); - let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); - let records = index.records_for_executable(app_identity); - - let verified = - verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) - .expect("duplicate protected records should keep one verified record"); - - assert_eq!(verified.0.badge_icon, "first-signal"); -} - -#[test] -fn reopened_system_identity_must_remain_protected_and_executable() { - let (_, trusted) = installed_system_executable(); - let unprotected = FileIdentity { - uid: 1_000, - ..trusted - }; - let non_executable = FileIdentity { - mode: 0o100_644, - ..trusted - }; - - assert!(current_system_identity_matches_sender(trusted, trusted)); - assert!(!current_system_identity_matches_sender( - unprotected, - trusted - )); - assert!(!current_system_identity_matches_sender( - non_executable, - trusted - )); -} - -#[test] -fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { - let (system_path, cached_identity) = installed_system_executable(); - let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.example.Protected", - "Protected App", - &system_path, - cached_identity, - )], - Vec::new(), - ); - let untrusted_identities = [ - FileIdentity { - uid: 1_000, - ..cached_identity - }, - FileIdentity { - mode: 0o100_777, - ..cached_identity - }, - ]; - - for desktop_entry in [Some("org.example.Protected"), None] { - for sender_identity in untrusted_identities { - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Protected App", - desktop_entry, - }, - &sender(&system_path, sender_identity), - &index, - &HashSet::new(), - ); - - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, - "stale system identity accepted for hint {desktop_entry:?}" - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - } - } -} - -#[test] -fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { - let app_identity = identity(6, 60, 1000); - let index = DesktopIdentityIndex::from_records( - vec![DesktopRecord::fixture( - "org.example.LocalApp", - "Local App", - "/home/user/bin/local-app", - app_identity, - false, - false, - )], - Vec::new(), - ); - - let resolution = resolve_with_evidence( - AppClaim { - reported_name: "Local App", - desktop_entry: Some("org.example.LocalApp"), - }, - &sender("/home/user/bin/local-app", app_identity), - &index, - &HashSet::new(), - ); - - assert_eq!( - resolution.attribution.class, - AttributionClass::UserAssociated - ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(!resolution.attribution.has_warning()); -} +#[path = "association/claims.rs"] +mod claims; +#[path = "association/dedicated.rs"] +mod dedicated; +#[path = "association/families.rs"] +mod families; +#[path = "association/helpers.rs"] +mod helpers; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs new file mode 100644 index 000000000..1c1ac9029 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs @@ -0,0 +1,106 @@ +use super::super::*; + +#[test] +fn mismatched_desktop_hint_does_not_become_claim_evidence() { + let protected_identity = identity(100, 1_000, 0); + let record = system_record( + "org.example.Protected", + "Protected App", + "/usr/bin/protected", + protected_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let hint_records = index.records_for_id("org.example.Protected"); + let results = hint_records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + }) + .collect::>(); + let mut different = sender("/usr/bin/different", identity(101, 1_010, 0)); + different.install_provenance = package("different-app"); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Unrelated label", + desktop_entry: Some("org.example.Protected"), + }, + &different, + &index, + &hint_records, + &results, + ); + + assert_eq!( + resolution.attribution.status, + AttributionStatus::Unresolved, + "a caller-controlled desktop hint cannot make a different label contradictory" + ); +} + +#[test] +fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { + let executable = identity(102, 1_020, 0); + let mut canonical = system_record( + "org.example.Canonical", + "Example App", + "/usr/bin/example", + executable, + ); + let mut alias = system_record( + "org.example.Canonical.NewWindow", + "Example App", + "/usr/bin/example", + executable, + ); + for record in [&mut canonical, &mut alias] { + record.desktop_provenance = package("example-app"); + record.executable_provenance = package("example-app"); + } + let index = DesktopIdentityIndex::from_records(vec![alias, canonical], Vec::new()); + let records = index.records_for_executable(executable); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: if record.id == "org.example.Canonical" { + LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch) + } else { + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) + }, + }) + .collect::>(); + let mut different = sender("/usr/bin/different", identity(103, 1_030, 0)); + different.install_provenance = package("different-app"); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &different, + &index, + &[], + &results, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!( + resolution.attribution.reason, + unixnotis_core::AttributionReason::ExecutableMismatch + ); +} + +#[test] +fn sender_claim_group_key_is_nonempty_and_bound_to_sender_identity() { + let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + + let unresolved = + sender_claim_group_key(AttributionStatus::Unresolved, "Example App", &metadata); + let conflict = sender_claim_group_key(AttributionStatus::Conflict, "Example App", &metadata); + + assert_eq!(unresolved, "unresolved:106:1060:exampleapp"); + assert_eq!(conflict, "conflict:106:1060:exampleapp"); + assert_ne!(unresolved, conflict); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs new file mode 100644 index 000000000..a7ba26c5b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs @@ -0,0 +1,323 @@ +use super::super::*; + +#[test] +fn dedicated_system_identity_allows_legitimate_reply() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "True Chat", + &app_path, + app_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "True Chat", + desktop_entry: Some("org.example.True.desktop"), + }, + &sender(&app_path, app_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.display_name, "True Chat"); + assert_eq!( + resolution.attribution.group_key, + "verified:system-app:org.example.True" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("unverified")); +} + +#[test] +fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { + let (signal_path, signal_identity) = installed_system_executable(); + let record = system_record("signal", "Signal", &signal_path, signal_identity) + .with_launch_literals(&["--", "sgnl://expected"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + // Signal sends an empty app name and adds Electron flags after desktop activation + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments( + &signal_path, + signal_identity, + &["--password-store=kwallet6", "--ozone-platform=x11", "--"], + ), + &index, + &HashSet::new(), + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.True", "Example App", &app_path, app_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender_with_arguments( + &app_path, + app_identity, + &["--display-backend=x11", "--tray"], + ), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); +} + +#[test] +fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.True", "Example App", &app_path, app_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut rewritten = sender(&app_path, app_identity); + rewritten.command_line = CommandLineEvidence { + argv: vec![format!("{app_path} --runtime-flag").into_bytes()], + quality: CommandLineQuality::RewrittenProcessTitle, + }; + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &rewritten, + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!( + resolution.diagnostics.command_line_quality, + CommandLineQualityView::RewrittenProcessTitle + ); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::Verified + ); + assert_eq!( + resolution.diagnostics.launch_authority, + LaunchAuthorityView::DedicatedExecutable + ); +} + +#[test] +fn verified_executable_recovers_from_stale_desktop_hint() { + let (signal_path, signal_identity) = installed_system_executable(); + let mut stale_user_entry = DesktopRecord::fixture( + "signal-desktop", + "Signal", + "/usr/bin/env", + identity(90, 900, 0), + false, + false, + ); + // An env wrapper cannot associate the user entry with the dedicated Signal process + stale_user_entry.association_eligible = false; + stale_user_entry.system_association = false; + let system_entry = system_record("signal-true", "Signal", &signal_path, signal_identity); + let index = + DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Signal", + // Electron derives this hint from a differently named local desktop file + desktop_entry: Some("signal-desktop"), + }, + &sender(&signal_path, signal_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.display_name, "Signal"); + assert_eq!(resolution.attribution.desktop_id, "signal-true"); +} + +#[test] +fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let first = system_record( + "org.example.First", + "First App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=first"]); + let second = system_record( + "org.example.Second", + "Second App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=second"]); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn duplicate_desktop_id_prefers_the_protected_record() { + let (app_path, app_identity) = installed_system_executable(); + let user_record = + DesktopRecord::fixture("true", "Example App", &app_path, app_identity, false, false); + let mut system_record = system_record("true", "Example App", &app_path, app_identity); + system_record.badge_icon = "protected-example".to_string(); + let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate desktop id should keep one verified record"); + + assert!(verified.0.system_association); + assert_eq!(verified.0.badge_icon, "protected-example"); +} + +#[test] +fn duplicate_protected_desktop_id_keeps_stable_index_order() { + let (app_path, app_identity) = installed_system_executable(); + let mut first = system_record("true", "Example App", &app_path, app_identity); + first.badge_icon = "first-example".to_string(); + let mut second = system_record("true", "Example App", &app_path, app_identity); + second.badge_icon = "second-example".to_string(); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate protected records should keep one verified record"); + + assert_eq!(verified.0.badge_icon, "first-example"); +} + +#[test] +fn reopened_system_identity_must_remain_protected_and_executable() { + let (_, trusted) = installed_system_executable(); + let unprotected = FileIdentity { + uid: 1_000, + ..trusted + }; + let non_executable = FileIdentity { + mode: 0o100_644, + ..trusted + }; + + assert!(current_system_identity_matches_sender(trusted, trusted)); + assert!(!current_system_identity_matches_sender( + unprotected, + trusted + )); + assert!(!current_system_identity_matches_sender( + non_executable, + trusted + )); +} + +#[test] +fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { + let (system_path, cached_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected App", + &system_path, + cached_identity, + )], + Vec::new(), + ); + let untrusted_identities = [ + FileIdentity { + uid: 1_000, + ..cached_identity + }, + FileIdentity { + mode: 0o100_777, + ..cached_identity + }, + ]; + + for desktop_entry in [Some("org.example.Protected"), None] { + for sender_identity in untrusted_identities { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry, + }, + &sender(&system_path, sender_identity), + &index, + &HashSet::new(), + ); + + assert_ne!( + resolution.attribution.status, + AttributionStatus::Verified, + "stale system identity accepted for hint {desktop_entry:?}" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } + } +} + +#[test] +fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { + let app_identity = identity(6, 60, 1000); + let index = DesktopIdentityIndex::from_records( + vec![DesktopRecord::fixture( + "org.example.LocalApp", + "Local App", + "/home/user/bin/local-app", + app_identity, + false, + false, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.LocalApp"), + }, + &sender("/home/user/bin/local-app", app_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs new file mode 100644 index 000000000..f64ec7ba4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs @@ -0,0 +1,178 @@ +use super::super::*; + +#[test] +fn equivalent_desktop_aliases_use_one_canonical_application_identity() { + let (app_path, app_identity) = installed_system_executable(); + let mut canonical = system_record("org.example.True", "Example App", &app_path, app_identity); + canonical.badge_icon = "example-app".to_string(); + let mut alias = system_record( + "org.example.True.NewWindow", + "Example App New Window", + &app_path, + app_identity, + ); + alias.badge_icon = "example-app-new-window".to_string(); + canonical.desktop_provenance = package("example-app"); + canonical.executable_provenance = package("example-app"); + alias.desktop_provenance = package("example-app"); + alias.executable_provenance = package("example-app"); + + let resolve_alias = |records| { + let index = DesktopIdentityIndex::from_records(records, Vec::new()); + resolve_with_evidence( + AppClaim { + reported_name: "Example App New Window", + desktop_entry: Some("org.example.True.NewWindow"), + }, + &sender(&app_path, app_identity), + &index, + &HashSet::new(), + ) + }; + let canonical_first = resolve_alias(vec![canonical.clone(), alias.clone()]); + let alias_first = resolve_alias(vec![alias, canonical]); + + for resolution in [&canonical_first, &alias_first] { + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.display_name, "Example App"); + assert_eq!(resolution.attribution.badge_icon, "example-app"); + assert_eq!( + resolution.attribution.group_key, + "verified:system-app:org.example.True" + ); + } + assert_eq!( + canonical_first.attribution.group_key, alias_first.attribution.group_key, + "family grouping must not depend on desktop-index insertion order" + ); +} + +#[test] +fn fuzzy_name_substrings_do_not_merge_distinct_application_families() { + let executable = identity(93, 930, 0); + let mut first = system_record( + "org.example.Primary", + "Example", + "/usr/bin/example", + executable, + ); + let mut second = system_record( + "org.example.Remote", + "Example Remote", + "/usr/bin/example", + executable, + ); + for record in [&mut first, &mut second] { + record.desktop_provenance = package("example-suite"); + record.executable_provenance = package("example-suite"); + } + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(executable); + + assert_eq!(records.len(), 2); + assert!( + !index.records_share_family(records[0], records[1]), + "substring-overlapping display names are not application identity evidence" + ); +} + +#[test] +fn duplicate_desktop_ids_do_not_make_distinct_families_equal() { + let first_identity = identity(94, 940, 0); + let second_identity = identity(95, 950, 0); + let first = system_record( + "org.example.Duplicate", + "First application", + "/usr/bin/first", + first_identity, + ); + let second = system_record( + "org.example.Duplicate", + "Second application", + "/usr/bin/second", + second_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_id("org.example.Duplicate"); + + assert_eq!(records.len(), 2); + assert!( + !index.records_share_family(records[0], records[1]), + "a reused desktop id cannot replace concrete family identity" + ); +} + +#[test] +fn stronger_verified_family_wins_after_weaker_families_are_ambiguous() { + let first = DesktopRecord::fixture( + "org.example.UserOne", + "Shared App", + "/home/user/one", + identity(96, 960, 1_000), + false, + false, + ); + let second = DesktopRecord::fixture( + "org.example.UserTwo", + "Shared App", + "/home/user/two", + identity(97, 970, 1_000), + false, + false, + ); + let system = system_record( + "org.example.System", + "Shared App", + "/usr/bin/system-app", + identity(98, 980, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![first, second, system], Vec::new()); + let records = index.records_for_claim("Shared App"); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }) + .collect::>(); + + let selected = strongest_verified_result(&results, "Shared App", &index) + .expect("the strongest unambiguous family should be selected"); + + assert_eq!(selected.0.id, "org.example.System"); +} + +#[test] +fn strongest_verified_family_selects_its_canonical_record() { + let executable = identity(99, 990, 0); + let mut canonical = system_record( + "org.example.Canonical", + "Example App", + "/usr/bin/example", + executable, + ); + let mut alias = system_record( + "org.example.Canonical.NewWindow", + "Example App New Window", + "/usr/bin/example", + executable, + ); + for record in [&mut canonical, &mut alias] { + record.desktop_provenance = package("example-app"); + record.executable_provenance = package("example-app"); + } + let index = DesktopIdentityIndex::from_records(vec![alias, canonical], Vec::new()); + let records = index.records_for_executable(executable); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }) + .collect::>(); + + let selected = strongest_verified_result(&results, "Example App New Window", &index) + .expect("one verified application family should have a canonical selection"); + + assert_eq!(selected.0.id, "org.example.Canonical"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs new file mode 100644 index 000000000..4e0bb5683 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs @@ -0,0 +1,211 @@ +use super::super::super::evidence::sender_claim_relation; +use super::super::*; + +#[test] +fn helper_process_lineage_is_recognized_without_becoming_suspicious() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let helper_identity = identity(88, 880, 0); + let mut helper = sender("/usr/libexec/example-helper", helper_identity); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_080, + start_time: 7_070, + uid: 0, + executable: app_path, + executable_identity: app_identity, + }); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.display_name, "Example App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); +} + +#[test] +fn helper_without_lineage_is_recognized_when_no_contradictory_owner_is_known() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let helper = sender("/opt/example/helper", identity(89, 890, 1_000)); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.status, + AttributionStatus::Recognized, + "missing lineage cannot prove that a helper belongs to another application" + ); + assert_eq!(resolution.attribution.display_name, "Example App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn verified_and_recognized_senders_never_share_an_application_group() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let verified = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender(&app_path, app_identity), + &index, + &HashSet::new(), + ); + let recognized = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender("/opt/example/helper", identity(90, 900, 1_000)), + &index, + &HashSet::new(), + ); + + assert_eq!(verified.attribution.status, AttributionStatus::Verified); + assert_eq!(recognized.attribution.status, AttributionStatus::Recognized); + assert_ne!( + verified.attribution.group_key, recognized.attribution.group_key, + "different trust domains must remain separate even for one canonical application" + ); +} + +#[test] +fn package_owned_helper_for_the_claimed_application_is_recognized() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let mut helper = sender("/usr/lib/example/helper", identity(91, 910, 0)); + helper.install_provenance = package("org.example.True"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn different_verified_package_is_concrete_conflict_evidence() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let mut different = sender("/usr/bin/different-app", identity(92, 920, 0)); + different.install_provenance = package("org.example.Different"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &different, + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::DefinitiveMismatch, + "only the concrete different-package relation should remain a definitive mismatch" + ); +} + +#[test] +fn user_record_owning_sender_executable_cannot_prove_a_conflict() { + let claimed_identity = identity(104, 1_040, 0); + let user_identity = identity(105, 1_050, 1_000); + let claimed = system_record( + "org.example.Claimed", + "Claimed App", + "/usr/bin/claimed", + claimed_identity, + ); + let user = DesktopRecord::fixture( + "org.example.Local", + "Local App", + "/home/user/bin/local", + user_identity, + false, + false, + ); + let index = DesktopIdentityIndex::from_records(vec![claimed, user], Vec::new()); + let claimed_record = index + .records_for_id("org.example.Claimed") + .into_iter() + .next() + .expect("claimed record should be indexed"); + + assert_eq!( + sender_claim_relation( + &sender("/home/user/bin/local", user_identity), + &index, + claimed_record, + ), + SenderClaimRelation::UnknownExecutable, + "a user desktop record is not immutable contradictory ownership" + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs index 19c63cc9e..f13e8c37a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs @@ -23,10 +23,7 @@ fn unmediated_flatpak_process_cannot_become_portal_associated() { &HashSet::new(), ); - assert_ne!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -54,10 +51,7 @@ fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { &HashSet::new(), ); - assert_ne!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -87,10 +81,7 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { &HashSet::new(), ); - assert_eq!( - resolution.attribution.class, - AttributionClass::PortalAssociated - ); + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.attribution.display_name, "Flatpak App"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs index df6560dcd..47da7ffbf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs @@ -23,10 +23,7 @@ fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { &HashSet::new(), ); - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -68,8 +65,8 @@ fn unlisted_runtimes_cannot_associate_a_different_application_payload() { ); assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, + resolution.attribution.status, + AttributionStatus::Verified, "{executable} accepted a different application payload" ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); @@ -98,10 +95,7 @@ fn java_cannot_associate_a_different_jar() { &HashSet::new(), ); - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -129,10 +123,7 @@ fn matching_fixed_system_application_argument_allows_association() { &HashSet::new(), ); - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } @@ -162,10 +153,7 @@ fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { &HashSet::new(), ); - assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -173,7 +161,7 @@ fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { fn dedicated_executable_remains_verified_when_command_line_is_unavailable() { let (launcher_path, launcher_identity) = installed_system_executable(); let record = system_record( - "org.example.CommandLine", + "org.example.True", "Command Line App", &launcher_path, launcher_identity, @@ -185,17 +173,14 @@ fn dedicated_executable_remains_verified_when_command_line_is_unavailable() { let resolution = resolve_with_evidence( AppClaim { reported_name: "Command Line App", - desktop_entry: Some("org.example.CommandLine"), + desktop_entry: Some("org.example.True"), }, &missing_command_line, &index, &HashSet::new(), ); - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } @@ -264,8 +249,8 @@ fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { ); assert_ne!( - resolution.attribution.class, - AttributionClass::SystemAssociated, + resolution.attribution.status, + AttributionStatus::Verified, "{executable} accepted a different no-hint application payload" ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); @@ -296,10 +281,7 @@ fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { &HashSet::new(), ); - assert_eq!( - resolution.attribution.class, - AttributionClass::SystemAssociated - ); + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } @@ -325,7 +307,7 @@ fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -361,7 +343,7 @@ fn dynamic_only_contract_is_unverified_instead_of_suspicious() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_eq!( resolution.diagnostics.verification, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs index e9ba5d233..49f962fbc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs @@ -1,5 +1,35 @@ use super::*; +#[test] +fn sender_metadata_timeout_is_recognized_not_conflict() { + let protected_identity = identity(39, 390, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected", + "/usr/bin/protected", + protected_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected", + desktop_entry: None, + }, + &SenderMetadata::default(), + &index, + &HashSet::new(), + ); + + assert_eq!( + resolution.attribution.status, + AttributionStatus::Recognized, + "a timed-out sender lookup cannot prove impersonation" + ); +} + #[test] fn user_shadow_cannot_join_the_system_desktop_group() { let system_identity = identity(30, 300, 0); @@ -31,23 +61,123 @@ fn user_shadow_cannot_join_the_system_desktop_group() { &HashSet::new(), ); - assert_eq!( - resolution.attribution.class, - AttributionClass::UserAssociated - ); - assert!(resolution.attribution.has_warning()); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); assert!(resolution .attribution .group_key - .starts_with("user-desktop:")); + .starts_with("recognized:user-app:")); assert_ne!( resolution.attribution.group_key, - "system-desktop:org.signal.Signal" + "verified:system-app:org.signal.Signal" + ); +} + +#[test] +fn user_desktop_mismatch_cannot_manufacture_a_conflict() { + let user_identity = identity(34, 340, 1_000); + let hostile_identity = identity(35, 350, 1_000); + let mut user = DesktopRecord::fixture( + "org.example.Local", + "Local App", + "/home/user/bin/local-app", + user_identity, + false, + false, + ); + user.desktop_identity = Some(identity(36, 360, 1_000)); + let index = DesktopIdentityIndex::from_records(vec![user], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.Local"), + }, + &sender("/tmp/unrelated", hostile_identity), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn protected_conflict_evidence_outranks_a_user_desktop_shadow() { + let protected_identity = identity(37, 370, 0); + let user_identity = identity(38, 380, 1_000); + let hostile_identity = identity(39, 390, 0); + let protected = system_record( + "org.example.Protected", + "Protected App", + "/usr/bin/protected-app", + protected_identity, + ); + let mut user = DesktopRecord::fixture( + "org.example.Protected.Handler", + "Protected App", + "/home/user/bin/protected-handler", + user_identity, + false, + false, ); + user.desktop_identity = Some(identity(40, 400, 1_000)); + let index = DesktopIdentityIndex::from_records(vec![user, protected], Vec::new()); + let mut different = sender("/usr/bin/unrelated", hostile_identity); + different.install_provenance = package("org.example.Unrelated"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry: Some("org.example.Protected.Handler"), + }, + &different, + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.attribution.desktop_id, "org.example.Protected"); + assert_eq!(resolution.diagnostics.record_trust, RecordTrust::System); } #[test] -fn visually_confusable_system_brand_is_a_conflict() { +fn ambiguous_protected_records_are_unresolved_not_conflicting() { + let first = system_record( + "org.example.First", + "Shared Label", + "/usr/bin/first-app", + identity(41, 410, 0), + ); + let second = system_record( + "org.example.Second", + "Shared Label", + "/usr/bin/second-app", + identity(42, 420, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Shared Label", + desktop_entry: None, + }, + &sender("/usr/bin/unrelated", identity(43, 430, 0)), + &index, + &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!( + resolution.attribution.reason, + unixnotis_core::AttributionReason::AmbiguousDesktopRecords + ); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); +} + +#[test] +fn visually_confusable_system_brand_without_contradictory_owner_is_recognized() { let signal_identity = identity(40, 400, 0); let hostile_identity = identity(41, 410, 1000); let index = DesktopIdentityIndex::from_records( @@ -71,13 +201,13 @@ fn visually_confusable_system_brand_is_a_conflict() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } } #[test] -fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { +fn basename_spoof_without_immutable_owner_is_recognized_without_actions() { let signal_identity = identity(1, 10, 0); let hostile_identity = identity(7, 70, 1000); let index = DesktopIdentityIndex::from_records( @@ -100,11 +230,11 @@ fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_eq!( resolution.diagnostics.verification, - LaunchVerificationView::DefinitiveMismatch + LaunchVerificationView::InsufficientEvidence ); assert_ne!( resolution.attribution.group_key, @@ -113,7 +243,7 @@ fn basename_spoof_is_conflicting_and_cannot_join_or_reply_as_signal() { } #[test] -fn exact_keepassxc_name_spoof_never_becomes_system_associated() { +fn exact_protected_name_without_contradictory_owner_stays_recognized() { let keepass_identity = identity(2, 20, 0); let hostile_identity = identity(8, 80, 1000); let index = DesktopIdentityIndex::from_records( @@ -136,7 +266,7 @@ fn exact_keepassxc_name_spoof_never_becomes_system_associated() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Conflict); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -158,15 +288,20 @@ fn exact_system_notify_send_identity_is_a_non_replying_relay() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); - assert_eq!(resolution.attribution.display_name, "Screenshot"); + assert_eq!(resolution.attribution.status, AttributionStatus::Relay); + assert_eq!( + resolution.attribution.display_name, + "Command-line notification" + ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(!resolution.attribution.has_warning()); - assert!(!resolution.attribution.source_label.contains("unverified")); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("unverified")); } #[test] -fn trusted_relay_claiming_a_system_app_keeps_the_relay_class_and_adds_a_warning() { +fn trusted_relay_claiming_a_system_app_stays_relay_without_conflict() { let signal_identity = identity(1, 10, 0); let relay_identity = identity(3, 30, 0); let index = DesktopIdentityIndex::from_records( @@ -189,9 +324,9 @@ fn trusted_relay_claiming_a_system_app_keeps_the_relay_class_and_adds_a_warning( &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::TrustedRelay); + assert_eq!(resolution.attribution.status, AttributionStatus::Relay); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); - assert!(resolution.attribution.has_warning()); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); assert_ne!( resolution.attribution.group_key, "desktop:org.signal.Signal" @@ -217,7 +352,7 @@ fn malicious_notify_send_basename_is_not_a_trusted_relay() { &HashSet::new(), ); - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -246,11 +381,11 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() &owned, ); - assert_eq!(resolution.attribution.class, AttributionClass::Unknown); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert!(resolution .attribution - .source_label + .diagnostic_detail .contains("/usr/lib/example-launcher")); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index 5d61b1f1c..386de1bf8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -6,9 +6,13 @@ fn metadata(sender: &str, pid: u32) -> SenderMetadata { sender_name: Some(sender.to_string()), sender_pid: Some(pid), sender_start_time: Some(u64::from(pid)), + sender_uid: None, sender_executable: Some(format!("/usr/bin/app-{pid}")), sender_executable_identity: None, + install_provenance: + crate::daemon::notifications::identity::desktop_index::InstallProvenance::default(), command_line: CommandLineEvidence::default(), + ancestors: Vec::new(), } } From b31fddda2247630231b98a0919d9efa88a7591a6 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:11:12 -0500 Subject: [PATCH 147/275] feat(presentation): separate trust from notification kind Summary: separate trust from notification kind. Scope: presentation. --- .../unixnotis-ui/src/presentation/badges.rs | 4 +- crates/unixnotis-ui/src/presentation/build.rs | 148 +++++++++--------- .../src/presentation/tests/presentation.rs | 108 ++++++++++--- .../src/presentation/tests/support.rs | 14 +- crates/unixnotis-ui/src/presentation/types.rs | 28 ++-- 5 files changed, 176 insertions(+), 126 deletions(-) diff --git a/crates/unixnotis-ui/src/presentation/badges.rs b/crates/unixnotis-ui/src/presentation/badges.rs index 97720f3fe..f7b74048e 100644 --- a/crates/unixnotis-ui/src/presentation/badges.rs +++ b/crates/unixnotis-ui/src/presentation/badges.rs @@ -45,7 +45,9 @@ pub fn apply_semantic_badge(image: >k::Image, badge: BadgePresentation, size: icon_theme.add_resource_path(RESOURCE_ROOT); let icon_name = match badge { // Verified applications retain the authenticated desktop badge - BadgePresentation::AuthenticatedApplication => return false, + BadgePresentation::AuthenticatedApplication | BadgePresentation::RecognizedApplication => { + return false + } BadgePresentation::UnknownApplication => "unixnotis-app-unknown-symbolic", BadgePresentation::SuspiciousApplication => "unixnotis-shield-warning-symbolic", BadgePresentation::CommandLine => "unixnotis-terminal-symbolic", diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 045adf7d6..e5496048c 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unixnotis_core::{ - Action, ApplicationActionPolicy, AttributionClass, InlineReplyPolicy, NotificationView, + Action, ApplicationActionPolicy, AttributionStatus, InlineReplyPolicy, NotificationView, PopupAdmissionView, Urgency, }; @@ -45,7 +45,7 @@ impl NotificationPresentation { #[must_use] pub fn from_view_at(notification: &NotificationView, now: i64) -> Self { let trust = trust_presentation(notification); - let kind = notification_kind(notification, trust.level); + let kind = notification_kind(notification); let identity = identity_presentation(notification, trust.level); Self { @@ -71,35 +71,43 @@ fn popup_status(notification: &NotificationView) -> Option { if decision.decided_at_unix_ms <= 0 { return None; } - if decision.delivery_stage == unixnotis_core::PopupDeliveryStage::Rendered { - return (decision.admission_at_commit == PopupAdmissionView::RendererUnavailable) - .then(|| "Shown after popup renderer recovered".to_string()); - } - let status = match decision.delivery_stage { - unixnotis_core::PopupDeliveryStage::FanoutFailed => { - "Not shown — notification delivery failed" + match decision.admission_at_commit { + PopupAdmissionView::Rule => { + return Some("Not shown — matched a notification rule".to_string()); } - _ => match decision.admission_at_commit { - PopupAdmissionView::Show => return None, - PopupAdmissionView::Rule => "Not shown — matched notification rule", - PopupAdmissionView::Dnd => "Not shown — Do Not Disturb was enabled", - PopupAdmissionView::Inhibitor => "Not shown — notifications were inhibited", - PopupAdmissionView::RendererUnavailable => "Not shown — popup renderer was unavailable", - PopupAdmissionView::RendererDisabled => "Not shown — popups are disabled", - }, - }; - Some(status.to_string()) + PopupAdmissionView::Dnd => { + return Some("Not shown — Do Not Disturb was enabled".to_string()); + } + PopupAdmissionView::Inhibitor => { + return Some("Not shown — notifications were inhibited".to_string()); + } + PopupAdmissionView::RendererDisabled => { + return Some("Not shown — popups are disabled".to_string()); + } + PopupAdmissionView::RendererUnavailable => { + if decision.delivery_stage != unixnotis_core::PopupDeliveryStage::Visible { + return Some("Not shown — popup renderer was unavailable".to_string()); + } + } + PopupAdmissionView::Show => {} + } + + matches!( + decision.delivery_stage, + unixnotis_core::PopupDeliveryStage::FanoutFailed + ) + .then(|| "Not shown — live notification delivery failed".to_string()) } pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresentation { let level = trust_level(notification); let short_label = match level { - // Verified and command-line primary labels already communicate their source clearly - TrustLevel::Verified | TrustLevel::CommandLine => None, - TrustLevel::Unverified => Some("Unverified".to_string()), - TrustLevel::Suspicious => Some("Suspicious".to_string()), + // Verified and relay primary labels already communicate their source clearly + TrustLevel::Verified | TrustLevel::Relay => None, + TrustLevel::Recognized | TrustLevel::Unresolved => Some("Unverified".to_string()), + TrustLevel::Conflict => Some("Suspicious".to_string()), }; - let details_label = nonempty_text(¬ification.attribution.source_label); + let details_label = nonempty_text(¬ification.attribution.diagnostic_detail); let has_reply_action = notification .actions .iter() @@ -126,24 +134,12 @@ pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresen } const fn trust_level(notification: &NotificationView) -> TrustLevel { - match notification.attribution.class { - AttributionClass::SystemAssociated | AttributionClass::PortalAssociated => { - if notification.attribution.has_warning() { - TrustLevel::Suspicious - } else { - TrustLevel::Verified - } - } - AttributionClass::UserAssociated | AttributionClass::Unknown => { - if notification.attribution.has_warning() { - TrustLevel::Suspicious - } else { - TrustLevel::Unverified - } - } - // A verified relay remains a relay even when its caller-controlled label names an app - AttributionClass::TrustedRelay => TrustLevel::CommandLine, - AttributionClass::Conflict => TrustLevel::Suspicious, + match notification.attribution.status { + AttributionStatus::Verified => TrustLevel::Verified, + AttributionStatus::Recognized => TrustLevel::Recognized, + AttributionStatus::Unresolved => TrustLevel::Unresolved, + AttributionStatus::Conflict => TrustLevel::Conflict, + AttributionStatus::Relay => TrustLevel::Relay, } } @@ -151,31 +147,34 @@ fn identity_presentation( notification: &NotificationView, level: TrustLevel, ) -> IdentityPresentation { - let claimed_label = + let display_name = clamp_label_text(¬ification.attribution.display_name, APP_LABEL_MAX_CHARS); - let (primary_label, secondary_claim) = match notification.attribution.class { - AttributionClass::TrustedRelay => ( + let claimed_name = + clamp_label_text(¬ification.attribution.claimed_name, APP_LABEL_MAX_CHARS); + let (primary_label, secondary_claim) = match notification.attribution.status { + AttributionStatus::Verified | AttributionStatus::Recognized => ( + display_name.into_owned(), + differing_claim(¬ification.attribution.display_name, &claimed_name), + ), + AttributionStatus::Relay => ( "Command-line notification".to_string(), - visible_claim(&claimed_label).map(|claim| format!("App label: {claim}")), + visible_claim(&claimed_name).map(|claim| format!("App label: {claim}")), ), - AttributionClass::Conflict => ( + AttributionStatus::Conflict => ( "Unknown application".to_string(), - claimed_identity(¬ification.attribution.source_label) - .map(|claim| format!("Claims “{claim}”")), + visible_claim(&claimed_name).map(|claim| format!("Claimed app: {claim}")), ), - AttributionClass::Unknown => ( + AttributionStatus::Unresolved => ( "Unknown application".to_string(), - visible_claim(&claimed_label).map(|claim| format!("App label: {claim}")), + visible_claim(&claimed_name).map(|claim| format!("App label: {claim}")), ), - AttributionClass::SystemAssociated - | AttributionClass::PortalAssociated - | AttributionClass::UserAssociated => (claimed_label.into_owned(), None), }; let badge = match level { TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, - TrustLevel::Unverified => BadgePresentation::UnknownApplication, - TrustLevel::Suspicious => BadgePresentation::SuspiciousApplication, - TrustLevel::CommandLine => BadgePresentation::CommandLine, + TrustLevel::Recognized => BadgePresentation::RecognizedApplication, + TrustLevel::Unresolved => BadgePresentation::UnknownApplication, + TrustLevel::Conflict => BadgePresentation::SuspiciousApplication, + TrustLevel::Relay => BadgePresentation::CommandLine, }; IdentityPresentation { primary_label, @@ -184,14 +183,9 @@ fn identity_presentation( } } -fn claimed_identity(source: &str) -> Option { - let claim = source - .split(';') - .next() - .map(str::trim) - .and_then(|value| value.strip_prefix("Claims to be "))? - .trim(); - visible_claim(claim).map(ToString::to_string) +fn differing_claim(display_name: &str, claimed_name: &str) -> Option { + let claim = visible_claim(claimed_name)?; + (!claim.eq_ignore_ascii_case(display_name.trim())).then(|| format!("App label: {claim}")) } fn visible_claim(claim: &str) -> Option<&str> { @@ -199,15 +193,7 @@ fn visible_claim(claim: &str) -> Option<&str> { (!claim.is_empty() && claim != "Unknown application").then_some(claim) } -pub(super) fn notification_kind( - notification: &NotificationView, - trust_level: TrustLevel, -) -> NotificationKind { - match trust_level { - TrustLevel::Suspicious => return NotificationKind::Warning, - TrustLevel::Unverified | TrustLevel::CommandLine => return NotificationKind::Utility, - TrustLevel::Verified => {} - } +pub(super) fn notification_kind(notification: &NotificationView) -> NotificationKind { let category_class = notification .category .split('.') @@ -222,11 +208,19 @@ pub(super) fn notification_kind( .any(|action| action.key == "inline-reply") { NotificationKind::Communication + } else if media_category_class(category_class) { + NotificationKind::Media } else { NotificationKind::Utility } } +fn media_category_class(category_class: &str) -> bool { + ["image", "media", "photo", "video", "audio"] + .iter() + .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) +} + fn communication_category_class(category_class: &str) -> bool { [ "call", @@ -280,10 +274,8 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { .unwrap_or_default() .eq_ignore_ascii_case(category) }); - let identity_is_verified = matches!( - notification.attribution.class, - AttributionClass::SystemAssociated | AttributionClass::PortalAssociated - ) && !notification.attribution.has_warning(); + let identity_is_verified = + matches!(notification.attribution.status, AttributionStatus::Verified); if !identity_is_verified { // Untrusted senders need an explicit media category before large imagery is shown return if category_is_media { diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index fa1e99336..68bd216d7 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -1,5 +1,6 @@ use unixnotis_core::{ - Action, AttributionClass, ImageData, InlineReplyPolicy, NotificationAttribution, Urgency, + Action, AttributionReason, AttributionStatus, ImageData, InlineReplyPolicy, + NotificationAttribution, Urgency, }; use super::super::{ @@ -50,6 +51,8 @@ fn shared_model_downgrades_conflicts_and_denies_application_interaction() { let mut view = notification(); view.attribution = NotificationAttribution::conflict( "Known application", + "org.example.Known", + AttributionReason::ExecutableMismatch, "sender executable differs", "conflict:known".to_string(), ); @@ -60,15 +63,15 @@ fn shared_model_downgrades_conflicts_and_denies_application_interaction() { let presentation = NotificationPresentation::from_view_at(&view, 1_000); - assert_eq!(presentation.kind, NotificationKind::Warning); - assert_eq!(presentation.trust.level, TrustLevel::Suspicious); + assert_eq!(presentation.kind, NotificationKind::Utility); + assert_eq!(presentation.trust.level, TrustLevel::Conflict); assert_eq!( presentation.identity.badge, BadgePresentation::SuspiciousApplication ); assert_eq!( presentation.identity.secondary_claim.as_deref(), - Some("Claims “Known application”") + Some("Claimed app: Known application") ); assert!(presentation.actions.primary.is_empty()); assert!(presentation.actions.overflow.is_empty()); @@ -78,18 +81,17 @@ fn shared_model_downgrades_conflicts_and_denies_application_interaction() { fn trusted_relay_claim_never_becomes_the_primary_application_identity() { let mut view = notification(); view.category = "im.received".to_string(); - view.attribution = NotificationAttribution::trusted_relay( + view.attribution = NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - true, "relay:notify-send:signal".to_string(), ); view.image.icon_name = "signal-desktop".to_string(); let presentation = NotificationPresentation::from_view_at(&view, 1_000); - assert_eq!(presentation.kind, NotificationKind::Utility); - assert_eq!(presentation.trust.level, TrustLevel::CommandLine); + assert_eq!(presentation.kind, NotificationKind::Communication); + assert_eq!(presentation.trust.level, TrustLevel::Relay); assert!(presentation.trust.short_label.is_none()); assert_eq!( presentation.identity.primary_label, @@ -106,15 +108,16 @@ fn trusted_relay_claim_never_becomes_the_primary_application_identity() { #[test] fn unknown_claim_stays_secondary_and_unverified() { let mut view = notification(); - view.attribution = NotificationAttribution::unknown( + view.attribution = NotificationAttribution::unresolved( "Local helper", + AttributionReason::NoDesktopCandidate, "Source: /tmp/local-helper", "unknown:local-helper".to_string(), ); let presentation = NotificationPresentation::from_view_at(&view, 1_000); - assert_eq!(presentation.trust.level, TrustLevel::Unverified); + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); assert_eq!(presentation.identity.primary_label, "Unknown application"); assert_eq!( presentation.identity.secondary_claim.as_deref(), @@ -122,13 +125,33 @@ fn unknown_claim_stays_secondary_and_unverified() { ); } +#[test] +fn communication_layout_is_preserved_for_unverified_sender() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::unresolved( + "Local chat", + AttributionReason::MissingSenderEvidence, + "sender evidence unavailable", + "unknown:local-chat".to_string(), + ); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!( + presentation.kind, + NotificationKind::Communication, + "attribution uncertainty must not erase message semantics" + ); + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); +} + #[test] fn untrusted_non_media_notification_cannot_render_content_art() { let mut view = notification(); - view.attribution = NotificationAttribution::trusted_relay( + view.attribution = NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - false, "relay:notify-send:signal".to_string(), ); view.image.image_path = "/tmp/signal-logo.png".to_string(); @@ -173,9 +196,9 @@ fn popup_status_uses_the_committed_reason_instead_of_current_state() { fn popup_status_distinguishes_renderer_recovery_and_delivery_failure() { for (stage, admission, expected) in [ ( - unixnotis_core::PopupDeliveryStage::Rendered, + unixnotis_core::PopupDeliveryStage::Visible, unixnotis_core::PopupAdmissionView::RendererUnavailable, - Some("Shown after popup renderer recovered"), + None, ), ( unixnotis_core::PopupDeliveryStage::RendererFetched, @@ -185,10 +208,10 @@ fn popup_status_distinguishes_renderer_recovery_and_delivery_failure() { ( unixnotis_core::PopupDeliveryStage::FanoutFailed, unixnotis_core::PopupAdmissionView::Show, - Some("Not shown — notification delivery failed"), + Some("Not shown — live notification delivery failed"), ), ( - unixnotis_core::PopupDeliveryStage::Rendered, + unixnotis_core::PopupDeliveryStage::Visible, unixnotis_core::PopupAdmissionView::Show, None, ), @@ -211,14 +234,47 @@ fn popup_status_distinguishes_renderer_recovery_and_delivery_failure() { } } +#[test] +fn suppression_reason_survives_a_later_fanout_failure() { + for (admission, expected) in [ + ( + unixnotis_core::PopupAdmissionView::Dnd, + "Not shown — Do Not Disturb was enabled", + ), + ( + unixnotis_core::PopupAdmissionView::Rule, + "Not shown — matched a notification rule", + ), + ( + unixnotis_core::PopupAdmissionView::Inhibitor, + "Not shown — notifications were inhibited", + ), + ] { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: admission, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::FanoutFailed, + ..unixnotis_core::PopupDecisionRecord::default() + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + Some(expected), + "the arrival decision must outrank later delivery state" + ); + } +} + #[test] fn empty_and_generic_claims_never_create_secondary_identity_copy() { for claim in ["", "Unknown application"] { let mut view = notification(); - view.attribution = NotificationAttribution::trusted_relay( + view.attribution = NotificationAttribution::relay( claim, "Sent via /usr/bin/notify-send", - false, format!("relay:notify-send:{claim}"), ); @@ -254,13 +310,13 @@ fn verified_media_category_or_pixel_data_can_override_duplicate_badge_suppressio #[test] fn shared_model_keeps_user_association_unverified_and_noninteractive() { let mut view = notification(); - view.attribution = NotificationAttribution::associated( + view.attribution = NotificationAttribution::recognized( + "Local application", "Local application", "org.example.Local", "org.example.Local", - "", - AttributionClass::UserAssociated, - false, + AttributionReason::ExactUserExecutable, + "user-local desktop association", "user:local".to_string(), ); view.actions.push(Action { @@ -270,10 +326,10 @@ fn shared_model_keeps_user_association_unverified_and_noninteractive() { let presentation = NotificationPresentation::from_view_at(&view, 1_000); - assert_eq!(presentation.trust.level, TrustLevel::Unverified); + assert_eq!(presentation.trust.level, TrustLevel::Recognized); assert_eq!( presentation.identity.badge, - BadgePresentation::UnknownApplication + BadgePresentation::RecognizedApplication ); assert!(presentation.actions.primary.is_empty()); } @@ -324,12 +380,12 @@ fn shared_model_requires_verified_identity_and_exact_critical_urgency() { label: "Reply".to_string(), }); - view.attribution.class = AttributionClass::UserAssociated; + view.attribution.status = AttributionStatus::Recognized; let unverified = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!(unverified.trust.reply, ReplyPresentation::Unavailable); assert!(!unverified.critical); - view.attribution.class = AttributionClass::SystemAssociated; + view.attribution.status = AttributionStatus::Verified; view.urgency = Urgency::Critical as u8; let critical = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!(critical.trust.reply, ReplyPresentation::Available); diff --git a/crates/unixnotis-ui/src/presentation/tests/support.rs b/crates/unixnotis-ui/src/presentation/tests/support.rs index b053651ff..5da54d6df 100644 --- a/crates/unixnotis-ui/src/presentation/tests/support.rs +++ b/crates/unixnotis-ui/src/presentation/tests/support.rs @@ -1,5 +1,5 @@ use unixnotis_core::{ - AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -8,14 +8,14 @@ pub(super) fn notification() -> NotificationView { id: 7, generation: 11, app_name: "Example".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Example", "Example", "org.example.App", - "org.example.App", - "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.App".to_string(), + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "New message".to_string(), body: "Are you coming?".to_string(), diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index e36dbaeb7..1bee70682 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -5,23 +5,20 @@ pub enum NotificationKind { Communication, Utility, - Warning, + Media, } impl NotificationKind { #[must_use] - pub fn for_notification( - notification: &unixnotis_core::NotificationView, - trust_level: TrustLevel, - ) -> Self { - super::build::notification_kind(notification, trust_level) + pub fn for_notification(notification: &unixnotis_core::NotificationView) -> Self { + super::build::notification_kind(notification) } #[must_use] pub const fn action_limit(self) -> usize { // Two visible actions preserve room for content; remaining actions use overflow match self { - Self::Communication | Self::Utility | Self::Warning => 2, + Self::Communication | Self::Utility | Self::Media => 2, } } @@ -30,7 +27,7 @@ impl NotificationKind { match self { Self::Communication => "communication", Self::Utility => "utility", - Self::Warning => "warning", + Self::Media => "media", } } } @@ -39,9 +36,10 @@ impl NotificationKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustLevel { Verified, - Unverified, - Suspicious, - CommandLine, + Recognized, + Unresolved, + Conflict, + Relay, } impl TrustLevel { @@ -49,9 +47,10 @@ impl TrustLevel { pub const fn css_class(self) -> &'static str { match self { Self::Verified => "verified", - Self::Unverified => "unverified", - Self::Suspicious => "suspicious", - Self::CommandLine => "command-line", + Self::Recognized => "recognized", + Self::Unresolved => "unresolved", + Self::Conflict => "conflict", + Self::Relay => "relay", } } } @@ -60,6 +59,7 @@ impl TrustLevel { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BadgePresentation { AuthenticatedApplication, + RecognizedApplication, UnknownApplication, SuspiciousApplication, CommandLine, From 1b0598ed614969c04da1f3011ded342cb7bb4143 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:12:11 -0500 Subject: [PATCH 148/275] fix(control): bind UI effects to notification generations Summary: bind UI effects to notification generations. Scope: control. --- crates/noticenterctl/src/app/runner.rs | 5 +- crates/noticenterctl/src/dbus/client.rs | 18 ++- crates/noticenterctl/src/dbus/commands.rs | 1 + .../noticenterctl/src/output/diagnostics.rs | 17 ++- .../src/output/tests/diagnostics.rs | 21 +++ crates/unixnotis-center/src/control/model.rs | 5 +- .../unixnotis-center/src/control/reconnect.rs | 9 +- .../src/control/tests/model.rs | 14 +- .../unixnotis-core/src/control/constants.rs | 2 + crates/unixnotis-core/src/control/mod.rs | 2 + .../src/control/notification.rs | 11 +- crates/unixnotis-core/src/control/proxy.rs | 12 +- .../src/control/tests/notification.rs | 3 +- crates/unixnotis-core/src/control/version.rs | 35 +++++ .../src/daemon/control/action.rs | 17 --- .../src/daemon/control/popup.rs | 24 +-- .../src/daemon/control/server.rs | 49 +++--- .../src/daemon/control/tests/action.rs | 38 ++--- .../src/daemon/control/tests/server.rs | 25 ++-- .../daemon/state/notification_lifecycle.rs | 31 ---- .../state/tests/notification_lifecycle.rs | 49 ++---- crates/unixnotis-daemon/src/store/mod.rs | 4 +- crates/unixnotis-daemon/src/store/model.rs | 7 + .../src/store/notifications/lifecycle.rs | 20 --- crates/unixnotis-daemon/src/store/runtime.rs | 24 ++- .../src/store/tests/runtime.rs | 130 +++++++++++++--- crates/unixnotis-popups/src/app/command.rs | 4 +- crates/unixnotis-popups/src/dbus/commands.rs | 13 +- .../src/dbus/runtime/connection.rs | 7 +- crates/unixnotis-popups/src/dbus/types.rs | 11 +- .../src/ui/entry/visibility.rs | 49 ++++++ .../src/ui/popups/mutation.rs | 19 ++- .../src/ui/popups/visibility.rs | 27 ++-- .../src/ui/state/tests/mutation.rs | 141 +++++++++++++++++- 34 files changed, 578 insertions(+), 266 deletions(-) create mode 100644 crates/noticenterctl/src/output/tests/diagnostics.rs create mode 100644 crates/unixnotis-core/src/control/version.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/visibility.rs diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index 4da8c5466..4b31d3c6f 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::Parser; -use unixnotis_core::{log_session_bus_identity, ControlProxy}; +use unixnotis_core::{ensure_control_api_version, log_session_bus_identity, ControlProxy}; use zbus::Connection; use crate::cli::{Args, Command}; @@ -64,6 +64,9 @@ async fn run_async(command: Command) -> Result<()> { let proxy = ControlProxy::new(&connection) .await .context("connect to unixnotis control interface")?; + ensure_control_api_version(&proxy) + .await + .context("validate UnixNotis component version")?; crate::dbus::handle_command(&proxy, command).await } diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index 6a672c0e6..67a068ee2 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::pin::Pin; -use anyhow::Result; +use anyhow::{anyhow, Result}; use unixnotis_core::{ ControlProxy, InhibitorInfo, NotificationDiagnosticsView, NotificationView, PanelDebugLevel, }; @@ -108,8 +108,20 @@ impl ControlClient for ControlProxy<'_> { } fn dismiss(&self, id: u32) -> ControlFuture<'_, ()> { - // Send the id so the daemon knows exactly which notification to remove - Box::pin(run_control_call(ControlProxy::dismiss(self, id))) + Box::pin(async move { + // Resolve one exact active generation before issuing the mutating call + let mut candidates = + run_control_call(ControlProxy::get_active_notification(self, id)).await?; + let notification = candidates + .pop() + .ok_or_else(|| anyhow!("notification {id} is not active"))?; + run_control_call(ControlProxy::dismiss_generation( + self, + notification.id, + notification.generation, + )) + .await + }) } fn notification_diagnostics( diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index 148b3677c..11e67d764 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -129,6 +129,7 @@ pub(super) async fn handle_command_with_debug_logs( Command::CssCheck { .. } | Command::Doctor { .. } | Command::Preset { .. } + | Command::Theme { .. } | Command::SyncSessionEnvironment { .. } => {} } diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs index 186412d6a..abef1e7fa 100644 --- a/crates/noticenterctl/src/output/diagnostics.rs +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -11,6 +11,10 @@ use unixnotis_core::{ use super::write_stdout; pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result<()> { + write_stdout(&format_notification_diagnostics(view)?) +} + +fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result { let diagnostics = &view.attribution; let mut output = String::new(); writeln!(output, "Notification: {}:{}", view.id, view.generation)?; @@ -51,12 +55,12 @@ pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Res )?; writeln!( output, - "Identity result: {}", + "Launch verification: {}", verification(diagnostics.verification) )?; writeln!( output, - "Identity reason: {}", + "Launch detail: {}", value_or_none(&diagnostics.reason) )?; writeln!(output, "Stored: {}", yes_no(view.stored))?; @@ -99,7 +103,7 @@ pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Res "Delivery stage: {}", popup_delivery_stage(view.delivery_stage) )?; - write_stdout(&output) + Ok(output) } const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { @@ -108,7 +112,8 @@ const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { PopupDeliveryStage::Admitted => "admitted", PopupDeliveryStage::FanoutFailed => "fanout failed", PopupDeliveryStage::RendererFetched => "renderer fetched", - PopupDeliveryStage::Rendered => "rendered", + PopupDeliveryStage::Materialized => "materialized", + PopupDeliveryStage::Visible => "visible", } } @@ -174,3 +179,7 @@ const fn popup_admission(value: PopupAdmissionView) -> &'static str { PopupAdmissionView::RendererDisabled => "renderer disabled", } } + +#[cfg(test)] +#[path = "tests/diagnostics.rs"] +mod tests; diff --git a/crates/noticenterctl/src/output/tests/diagnostics.rs b/crates/noticenterctl/src/output/tests/diagnostics.rs new file mode 100644 index 000000000..569bd0dfd --- /dev/null +++ b/crates/noticenterctl/src/output/tests/diagnostics.rs @@ -0,0 +1,21 @@ +use super::format_notification_diagnostics; + +#[test] +fn diagnostics_keep_launch_verification_distinct_from_attribution_status() { + let output = + format_notification_diagnostics(&unixnotis_core::NotificationDiagnosticsView::default()) + .expect("default diagnostics should render"); + + assert!( + output.contains("Launch verification: unverified"), + "diagnostics should name the launch evidence being reported" + ); + assert!( + output.contains("Launch detail: none"), + "diagnostics should label the launch evidence detail" + ); + assert!( + !output.contains("Identity result:"), + "launch evidence must not masquerade as the final attribution state" + ); +} diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index ceb713f87..e04824f2c 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -38,9 +38,8 @@ pub enum UiEvent { WidgetsCollapsed(bool), CssReload, ConfigReload, - ThemeMigrationPreview, - ThemeMigrationApply, - ThemeMigrationKeepCurrent, + UseStockTheme, + OpenThemeFolder, } /// Commands sent from GTK handlers to the D-Bus runtime. diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index 6d61312be..7d6d8fe6b 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -7,7 +7,8 @@ use std::time::Duration; use futures_util::{Stream, StreamExt}; use tokio::sync::mpsc; use unixnotis_core::{ - log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, INTERNAL_DBUS_CALL_TIMEOUT, + ensure_control_api_version, log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, + INTERNAL_DBUS_CALL_TIMEOUT, }; use zbus::fdo::DBusProxy; use zbus::names::{BusName, UniqueName}; @@ -71,6 +72,12 @@ pub(super) async fn run_control_loop( continue; } }; + if let Err(err) = ensure_control_api_version(&proxy).await { + connect_log.warn_or_debug(&err, "control API version mismatch, retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } let mut owner_changes = match proxy.inner().receive_owner_changed().await { Ok(stream) => stream, Err(err) => { diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index 83e761b97..9f356b4a5 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -18,18 +18,8 @@ fn dismiss_command_preserves_notification_generation() { fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); assert!(matches!(UiEvent::ConfigReload, UiEvent::ConfigReload)); - assert!(matches!( - UiEvent::ThemeMigrationPreview, - UiEvent::ThemeMigrationPreview - )); - assert!(matches!( - UiEvent::ThemeMigrationApply, - UiEvent::ThemeMigrationApply - )); - assert!(matches!( - UiEvent::ThemeMigrationKeepCurrent, - UiEvent::ThemeMigrationKeepCurrent - )); + assert!(matches!(UiEvent::UseStockTheme, UiEvent::UseStockTheme)); + assert!(matches!(UiEvent::OpenThemeFolder, UiEvent::OpenThemeFolder)); } #[test] diff --git a/crates/unixnotis-core/src/control/constants.rs b/crates/unixnotis-core/src/control/constants.rs index 2ddf75076..20ed23d51 100644 --- a/crates/unixnotis-core/src/control/constants.rs +++ b/crates/unixnotis-core/src/control/constants.rs @@ -6,6 +6,8 @@ pub const CONTROL_BUS_NAME: &str = "com.unixnotis.Control"; pub const CONTROL_OBJECT_PATH: &str = "/com/unixnotis/Control"; /// D-Bus interface name for control calls pub const CONTROL_INTERFACE: &str = "com.unixnotis.Control"; +/// Coordinated private interface version shared by daemon and UI binaries +pub const CONTROL_API_VERSION: u32 = 2; /// Freedesktop notification service name owned by the active notification daemon pub const NOTIFICATIONS_BUS_NAME: &str = "org.freedesktop.Notifications"; /// Inhibit scope meaning all notification output diff --git a/crates/unixnotis-core/src/control/mod.rs b/crates/unixnotis-core/src/control/mod.rs index bbd5f3ea0..9768abc6b 100644 --- a/crates/unixnotis-core/src/control/mod.rs +++ b/crates/unixnotis-core/src/control/mod.rs @@ -7,6 +7,7 @@ mod panel; mod policy; mod proxy; mod state; +mod version; pub use constants::*; pub use diagnostics::*; @@ -15,3 +16,4 @@ pub use panel::*; pub use policy::*; pub use proxy::*; pub use state::*; +pub use version::*; diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index 71101bf4a..5318b41c9 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -46,7 +46,16 @@ pub enum PopupDeliveryStage { Admitted = 1, FanoutFailed = 2, RendererFetched = 3, - Rendered = 4, + Materialized = 4, + Visible = 5, +} + +impl PopupDeliveryStage { + /// Monotonic ordering for retained delivery history + #[must_use] + pub const fn rank(self) -> u8 { + self as u8 + } } /// Immutable arrival decision plus later delivery progress for one generation diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 5b4f864bf..6d1dc0a9f 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -21,6 +21,8 @@ use super::{ default_path = "/com/unixnotis/Control" )] trait Control { + /// Coordinated private interface version + fn get_api_version(&self) -> zbus::Result; /// Current daemon state fn get_state(&self) -> zbus::Result; /// Readiness of the daemon-managed center and popup clients @@ -60,12 +62,8 @@ trait Control { fn uninhibit(&self, id: u64) -> zbus::Result<()>; /// List active inhibitors fn list_inhibitors(&self) -> zbus::Result>; - /// Remove a notification by identifier - fn dismiss(&self, id: u32) -> zbus::Result<()>; /// Remove only the exact notification generation represented by a UI row fn dismiss_generation(&self, id: u32, generation: u64) -> zbus::Result<()>; - /// Invoke an action key for a notification - fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; /// Invoke an action only for the exact notification generation represented by a UI row fn invoke_action_generation( &self, @@ -91,8 +89,10 @@ trait Control { /// Clear popup readiness during orderly shutdown without activating the daemon #[zbus(no_autostart)] fn mark_popups_not_ready(&self) -> zbus::Result<()>; - /// Confirm that the popup renderer materialized one exact generation - fn mark_popup_rendered(&self, id: u32, generation: u64) -> zbus::Result<()>; + /// Confirm that GTK attached one exact generation to the popup stack + fn mark_popup_materialized(&self, id: u32, generation: u64) -> zbus::Result<()>; + /// Confirm that one exact generation became visible on a mapped popup surface + fn mark_popup_visible(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] fn notification_added(&self, id: u32, generation: u64) -> zbus::Result<()>; diff --git a/crates/unixnotis-core/src/control/tests/notification.rs b/crates/unixnotis-core/src/control/tests/notification.rs index 5096ffdcf..1272f1606 100644 --- a/crates/unixnotis-core/src/control/tests/notification.rs +++ b/crates/unixnotis-core/src/control/tests/notification.rs @@ -28,7 +28,8 @@ fn popup_delivery_stage_wire_values_remain_stable_and_complete() { (PopupDeliveryStage::Admitted, 1), (PopupDeliveryStage::FanoutFailed, 2), (PopupDeliveryStage::RendererFetched, 3), - (PopupDeliveryStage::Rendered, 4), + (PopupDeliveryStage::Materialized, 4), + (PopupDeliveryStage::Visible, 5), ] { let encoded = to_bytes(Context::new_dbus(LE, 0), &stage) .expect("popup delivery stage should serialize"); diff --git a/crates/unixnotis-core/src/control/version.rs b/crates/unixnotis-core/src/control/version.rs new file mode 100644 index 000000000..d810dbb42 --- /dev/null +++ b/crates/unixnotis-core/src/control/version.rs @@ -0,0 +1,35 @@ +//! Private control-interface version negotiation + +use thiserror::Error; + +use super::{ControlProxy, CONTROL_API_VERSION}; +use crate::timed_dbus_call; + +/// Failure to prove that `UnixNotis` components share one control contract +#[derive(Debug, Error)] +pub enum ControlApiVersionError { + #[error("read UnixNotis control API version: {0}")] + Transport(#[from] zbus::Error), + #[error("UnixNotis component version mismatch: expected {expected}, got {actual}")] + Mismatch { expected: u32, actual: u32 }, +} + +/// Require the daemon and client to use the same private interface version +/// +/// # Errors +/// +/// Returns a transport error when the version cannot be read or a mismatch error when the daemon +/// and client use different private control contracts +pub async fn ensure_control_api_version( + proxy: &ControlProxy<'_>, +) -> Result<(), ControlApiVersionError> { + let actual = timed_dbus_call(proxy.get_api_version()).await?; + if actual == CONTROL_API_VERSION { + Ok(()) + } else { + Err(ControlApiVersionError::Mismatch { + expected: CONTROL_API_VERSION, + actual, + }) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index 854b3903b..5e1e1eac7 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -11,23 +11,6 @@ use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH} use super::ControlServer; impl ControlServer { - pub(super) async fn invoke_validated_action( - &self, - id: u32, - action_key: &str, - ) -> zbus::fdo::Result<()> { - let target = { - let store = self.state.store.lock().await; - store.active_action_target(id, action_key).ok_or_else(|| { - zbus::fdo::Error::InvalidArgs( - "notification is not live or does not advertise this action".to_string(), - ) - })? - }; - self.invoke_validated_action_generation(target.key(), action_key) - .await - } - pub(super) async fn invoke_validated_action_generation( &self, notification: NotificationKey, diff --git a/crates/unixnotis-daemon/src/daemon/control/popup.rs b/crates/unixnotis-daemon/src/daemon/control/popup.rs index 7a9ac3933..7d3439458 100644 --- a/crates/unixnotis-daemon/src/daemon/control/popup.rs +++ b/crates/unixnotis-daemon/src/daemon/control/popup.rs @@ -22,24 +22,28 @@ impl ControlServer { Ok(()) } - pub(super) async fn mark_popup_generation_rendered( + pub(super) async fn mark_popup_generation_stage( &self, key: NotificationKey, + stage: PopupDeliveryStage, + method: &'static str, header: &Header<'_>, ) -> zbus::fdo::Result<()> { - auth::authorize_popup_readiness_call(&self.state, header, "MarkPopupRendered").await?; - let recorded = self + auth::authorize_popup_readiness_call(&self.state, header, method).await?; + let update = self .state .store .lock() .await - .record_popup_delivery_stage(key, PopupDeliveryStage::Rendered); - if recorded { - Ok(()) - } else { - Err(zbus::fdo::Error::InvalidArgs( - "notification generation is no longer retained".to_string(), - )) + .record_popup_delivery_stage(key, stage); + match update { + crate::store::DeliveryStageUpdate::Advanced + | crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond => Ok(()), + crate::store::DeliveryStageUpdate::MissingGeneration => { + Err(zbus::fdo::Error::InvalidArgs( + "notification generation is no longer retained".to_string(), + )) + } } } } diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 6c8b93702..e49646ba2 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -71,6 +71,10 @@ impl ControlServer { #[interface(name = "com.unixnotis.Control")] impl ControlServer { + async fn get_api_version(&self) -> u32 { + unixnotis_core::CONTROL_API_VERSION + } + async fn get_state(&self) -> zbus::fdo::Result { self.query_state().await } @@ -195,15 +199,6 @@ impl ControlServer { self.query_inhibitors(&header).await } - async fn dismiss(&self, id: u32, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "Dismiss").await?; - // Delegate to shared state helper so all close signals stay consistent - self.state - .dismiss_from_panel(id) - .await - .map_err(to_fdo_error) - } - pub(super) async fn dismiss_generation( &self, id: u32, @@ -218,16 +213,6 @@ impl ControlServer { .map_err(to_fdo_error) } - pub(super) async fn invoke_action( - &self, - id: u32, - action_key: &str, - #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "InvokeAction").await?; - self.invoke_validated_action(id, action_key).await - } - pub(super) async fn invoke_action_generation( &self, id: u32, @@ -310,14 +295,34 @@ impl ControlServer { .await } - async fn mark_popup_rendered( + async fn mark_popup_materialized( &self, id: u32, generation: u64, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - self.mark_popup_generation_rendered(NotificationKey { id, generation }, &header) - .await + self.mark_popup_generation_stage( + NotificationKey { id, generation }, + unixnotis_core::PopupDeliveryStage::Materialized, + "MarkPopupMaterialized", + &header, + ) + .await + } + + async fn mark_popup_visible( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.mark_popup_generation_stage( + NotificationKey { id, generation }, + unixnotis_core::PopupDeliveryStage::Visible, + "MarkPopupVisible", + &header, + ) + .await } #[zbus(signal)] diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index d62589ccf..ac9f3701b 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use chrono::Utc; use futures_util::TryStreamExt; use unixnotis_core::{ - Action, AttributionClass, Notification, NotificationAttribution, NotificationImage, Urgency, + Action, AttributionReason, Notification, NotificationAttribution, NotificationImage, Urgency, }; use zbus::message::Type; use zbus::zvariant::OwnedValue; @@ -18,22 +18,22 @@ async fn validated_action_emits_only_an_advertised_live_action() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = action_signal_stream(&sender).await; - let id = { + let notification = { let mut store = state.store.lock().await; store .insert(action_notification(&sender, "open"), 0) .notification - .id + .key() }; ControlServer::new(state) - .invoke_validated_action(id, "open") + .invoke_validated_action_generation(notification, "open") .await .expect("invoke advertised action"); assert_eq!( next_action_signal(&mut stream).await, - (id, "open".to_string()) + (notification.id, "open".to_string()) ); } @@ -44,22 +44,22 @@ async fn action_signal_reaches_owner_but_not_unrelated_observer() { let observer = Connection::session().await.expect("observer session bus"); let mut owner_stream = action_signal_stream(&owner).await; let mut observer_stream = action_signal_stream(&observer).await; - let id = { + let notification = { let mut store = state.store.lock().await; store .insert(action_notification(&owner, "open"), 0) .notification - .id + .key() }; ControlServer::new(state) - .invoke_validated_action(id, "open") + .invoke_validated_action_generation(notification, "open") .await .expect("invoke owner action"); assert_eq!( next_action_signal(&mut owner_stream).await, - (id, "open".to_string()) + (notification.id, "open".to_string()) ); assert!( tokio::time::timeout( @@ -86,7 +86,7 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { let server = ControlServer::new(state.clone()); server - .invoke_validated_action(id, "missing") + .invoke_validated_action_generation(notification, "missing") .await .expect_err("unadvertised action must fail"); let replacement_state = state.clone(); @@ -137,10 +137,12 @@ async fn stale_action_does_not_target_same_id_replacement() { async fn validated_action_rejects_a_conflicting_application_claim() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let id = { + let notification = { let mut notification = action_notification(&sender, "open"); notification.attribution = NotificationAttribution::conflict( "Signal", + "org.signal.Signal", + AttributionReason::ApplicationClaimMismatch, "application claim mismatch; source /tmp/fake", "conflict:signal".to_string(), ); @@ -150,11 +152,11 @@ async fn validated_action_rejects_a_conflicting_application_claim() { .await .insert(notification, 0) .notification - .id + .key() }; ControlServer::new(state) - .invoke_validated_action(id, "open") + .invoke_validated_action_generation(notification, "open") .await .expect_err("conflicting attribution must not receive an action signal"); } @@ -165,14 +167,14 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { generation: 0, app_name: "ActionApp".to_string(), app_icon: String::new(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "ActionApp", "ActionApp", - "org.example.ActionApp", "org.example.ActionApp", "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.ActionApp".to_string(), + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.ActionApp".to_string(), ), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "Action".to_string(), diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index de3e318fa..ec284a972 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -192,18 +192,6 @@ async fn clear_history_rejects_unauthorized_sender_before_mutating_state() { .any(|view| view.id == id)); } -#[tokio::test] -async fn invoke_action_rejects_unauthorized_sender_before_signal_emit() { - let state = daemon_state_for_test(false).await; - let server = ControlServer::new(state); - let message = control_header_message("InvokeAction"); - - server - .invoke_action(7, "default", message.header()) - .await - .expect_err("unauthorized action should fail"); -} - #[tokio::test] async fn generation_dismiss_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; @@ -257,12 +245,17 @@ async fn popup_render_acknowledgement_rejects_unauthorized_sender() { .notification .key(); let server = ControlServer::new(state.clone()); - let message = control_header_message("MarkPopupRendered"); + let message = control_header_message("MarkPopupVisible"); server - .mark_popup_generation_rendered(key, &message.header()) + .mark_popup_generation_stage( + key, + unixnotis_core::PopupDeliveryStage::Visible, + "MarkPopupVisible", + &message.header(), + ) .await - .expect_err("unauthorized render acknowledgement should fail"); + .expect_err("unauthorized visibility acknowledgement should fail"); assert_ne!( state @@ -272,7 +265,7 @@ async fn popup_render_acknowledgement_rejects_unauthorized_sender() { .notification_diagnostics(key.id, &unixnotis_core::UiHealth::default()) .expect("notification diagnostics should remain available") .delivery_stage, - unixnotis_core::PopupDeliveryStage::Rendered + unixnotis_core::PopupDeliveryStage::Visible ); } diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index 324a7ea53..4b08a3d30 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -33,37 +33,6 @@ impl DaemonState { Ok(()) } - pub async fn dismiss_from_panel(&self, id: u32) -> zbus::Result<()> { - let outcome = { - let mut store = self.store.lock().await; - let outcome = store.dismiss_from_panel(id); - if let Some(key) = outcome.removed_active { - self.cancel_expiration(key); - } - outcome - }; - - if !outcome.removed_any() { - return Ok(()); - } - - let removed_active = outcome.removed_active.is_some(); - let key = outcome - .removed_active - .or(outcome.removed_history) - .expect("a removed notification must retain its generation"); - if let Err(err) = self - .publish_notification_dismissed(key, removed_active) - .await - { - warn!( - ?err, - id, "panel dismiss committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } - pub async fn dismiss_generation(&self, key: NotificationKey) -> zbus::Result<()> { let outcome = { let mut store = self.store.lock().await; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 0204a0f35..73bfdf91c 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -52,46 +52,22 @@ async fn next_cancel_id( } #[tokio::test] -async fn dismiss_from_panel_removes_active_notification_and_cancels_timer() { +async fn generation_dismiss_removes_matching_history_without_canceling_timer() { let state = daemon_state_for_test(false).await; let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); - let id = { - let mut store = state.store.lock().await; - store.insert(notification("active"), 0).notification.id - }; - - state - .dismiss_from_panel(id) - .await - .expect("panel dismiss should succeed"); - - assert_eq!(next_cancel_id(&mut receiver).await, id); - assert!(state - .store - .lock() - .await - .active_notification_view(id) - .is_none()); -} - -#[tokio::test] -async fn dismiss_from_panel_removes_history_without_canceling_timer() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - let id = { + let key = { let mut store = state.store.lock().await; let inserted = store.insert(notification("history"), 0); - let id = inserted.notification.id; - store.close(id, CloseReason::DismissedByUser); - id + let key = inserted.notification.key(); + store.close(key.id, CloseReason::Expired); + key }; state - .dismiss_from_panel(id) + .dismiss_generation(key) .await - .expect("history dismiss should succeed"); + .expect("matching history generation dismiss should succeed"); assert!(receiver.try_recv().is_err()); assert!(state @@ -100,19 +76,22 @@ async fn dismiss_from_panel_removes_history_without_canceling_timer() { .await .list_history() .into_iter() - .all(|view| view.id != id)); + .all(|view| view.key() != key)); } #[tokio::test] -async fn dismiss_from_panel_missing_id_is_noop() { +async fn generation_dismiss_rejects_a_missing_notification() { let state = daemon_state_for_test(false).await; let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); state - .dismiss_from_panel(999) + .dismiss_generation(unixnotis_core::NotificationKey { + id: 999, + generation: 1, + }) .await - .expect("missing dismiss should succeed"); + .expect_err("missing generation dismiss should fail"); assert!(receiver.try_recv().is_err()); } diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index ec75777d5..56da7b251 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -7,8 +7,8 @@ mod notifications; mod runtime; pub use model::{ - DismissOutcome, DndWrite, ExpirationTicket, InsertOutcome, NotificationStore, PopupAdmission, - PopupSuppressionReason, + DeliveryStageUpdate, DismissOutcome, DndWrite, ExpirationTicket, InsertOutcome, + NotificationStore, PopupAdmission, PopupSuppressionReason, }; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index 79369e05e..3b5f2657c 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -98,6 +98,13 @@ pub enum PopupSuppressionReason { DropAllInhibitor, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeliveryStageUpdate { + Advanced, + AlreadyAtOrBeyond, + MissingGeneration, +} + pub struct DndWrite { // True when the in-memory DND value changed pub(crate) changed: bool, diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index ff2c491fc..ab6fb067c 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -18,26 +18,6 @@ impl NotificationStore { removed } - pub fn dismiss_from_panel(&mut self, id: u32) -> DismissOutcome { - // Panel dismissal can target active, history, or both - let removed_active = self.active.shift_remove(&id); - if removed_active.is_some() { - self.expirations.remove(&id); - } - - let removed_history = self - .history - .remove(&id) - .map(|notification| notification.key()); - - let outcome = DismissOutcome { - removed_active: removed_active.map(|notification| notification.key()), - removed_history, - }; - self.prune_popup_decisions(); - outcome - } - pub fn dismiss_generation(&mut self, key: NotificationKey) -> DismissOutcome { // Validate the generation before mutating either active or retained history state let active_matches = self diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index aed90a9d0..57e1fbf34 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -10,7 +10,7 @@ use unixnotis_core::{ }; use super::dnd::{DndStateStore, DND_STATE_VERSION}; -use super::model::NotificationStore; +use super::model::{DeliveryStageUpdate, NotificationStore}; use super::notifications::HistoryStore; impl NotificationStore { @@ -223,13 +223,17 @@ impl NotificationStore { pub fn record_popup_delivery_stage( &mut self, key: NotificationKey, - stage: PopupDeliveryStage, - ) -> bool { + next: PopupDeliveryStage, + ) -> DeliveryStageUpdate { let Some(decision) = self.popup_decisions.get_mut(&key) else { - return false; + return DeliveryStageUpdate::MissingGeneration; }; - decision.delivery_stage = stage; - true + // Duplicate or delayed acknowledgements cannot rewrite retained history + if next.rank() <= decision.delivery_stage.rank() { + return DeliveryStageUpdate::AlreadyAtOrBeyond; + } + decision.delivery_stage = next; + DeliveryStageUpdate::Advanced } pub(super) fn prune_popup_decisions(&mut self) { @@ -275,14 +279,6 @@ impl NotificationStore { .then(|| Arc::clone(notification)) } - pub fn active_action_target(&self, id: u32, action_key: &str) -> Option> { - let generation = self.active.get(&id)?.generation; - self.active_action_target_generation( - unixnotis_core::NotificationKey { id, generation }, - action_key, - ) - } - pub fn active_action_target_generation( &self, key: unixnotis_core::NotificationKey, diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 4a0ed1e34..3807b9fb6 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use unixnotis_core::{ - Action, AttributionClass, CloseReason, Config, InlineReply, InlineReplyPolicy, + Action, AttributionReason, CloseReason, Config, InlineReply, InlineReplyPolicy, NotificationAttribution, PopupAdmissionView, }; @@ -205,16 +205,89 @@ fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { unixnotis_core::PopupDeliveryStage::RendererFetched ); - assert!(store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::Rendered, - )); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); assert_eq!( store .notification_diagnostics(notification.id, &ready) .expect("rendered diagnostics") .delivery_stage, - unixnotis_core::PopupDeliveryStage::Rendered + unixnotis_core::PopupDeliveryStage::Visible + ); +} + +#[test] +fn delivery_stage_never_moves_backward() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::RendererFetched, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond + ); + + assert_eq!( + store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("delivery diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible, + "later duplicate fetches must not regress delivery history" + ); +} + +#[test] +fn duplicate_popup_stage_acknowledgement_is_idempotent() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond, + "a retained generation must accept a duplicate renderer callback" + ); +} + +#[test] +fn popup_stage_acknowledgement_rejects_a_missing_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("original"), 0).notification; + let _replacement = store + .insert(make_notification("replacement"), original.id) + .notification; + + assert_eq!( + store.record_popup_delivery_stage( + original.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::MissingGeneration, + "a stale generation must remain distinct from an idempotent current callback" ); } @@ -303,14 +376,14 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { fn active_action_target_requires_an_exact_action_on_the_live_generation() { let mut store = make_store_with_limits(12, 20); let mut notification = make_notification("action"); - notification.attribution = NotificationAttribution::associated( + notification.attribution = NotificationAttribution::verified( + "Action source", "Action source", - "org.example.ActionSource", "org.example.ActionSource", "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.ActionSource".to_string(), + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.ActionSource".to_string(), ); notification.actions.push(Action { key: "open".to_string(), @@ -318,42 +391,53 @@ fn active_action_target_requires_an_exact_action_on_the_live_generation() { }); let original = store.insert(notification, 0).notification; let id = original.id; + let key = original.key(); let target = store - .active_action_target(id, "open") + .active_action_target_generation(key, "open") .expect("stored action should resolve"); assert!(Arc::ptr_eq(&target, &original)); - assert!(store.active_action_target(id, "missing").is_none()); + assert!(store + .active_action_target_generation(key, "missing") + .is_none()); assert!(store.is_active_notification_generation(id, &original)); let replacement = store.insert(make_notification("replacement"), id); assert!(replacement.replaced); assert!(!store.is_active_notification_generation(id, &original)); - assert!(store.active_action_target(id, "open").is_none()); + assert!(store.active_action_target_generation(key, "open").is_none()); } #[test] fn active_action_target_denies_every_unverified_sender_class() { for attribution in [ - NotificationAttribution::associated( + NotificationAttribution::recognized( + "User application", "User application", - "org.example.UserApplication", "org.example.UserApplication", "", - AttributionClass::UserAssociated, - false, - "user-desktop:org.example.UserApplication".to_string(), + AttributionReason::ExactUserExecutable, + "exact user executable", + "user-app:org.example.UserApplication".to_string(), ), - NotificationAttribution::unknown( + NotificationAttribution::unresolved( "Signal", + AttributionReason::NoDesktopCandidate, "source /tmp/fake", "unknown:signal".to_string(), ), NotificationAttribution::conflict( "Signal", + "org.signal.Signal", + AttributionReason::ExecutableMismatch, "source /tmp/fake", "conflict:signal".to_string(), ), + NotificationAttribution::relay( + "Signal", + "trusted relay /usr/bin/notify-send", + "relay:notify-send:signal".to_string(), + ), ] { let mut store = make_store_with_limits(12, 20); let mut notification = make_notification("untrusted action"); @@ -362,10 +446,12 @@ fn active_action_target_denies_every_unverified_sender_class() { key: "default".to_string(), label: "Open".to_string(), }); - let id = store.insert(notification, 0).notification.id; + let key = store.insert(notification, 0).notification.key(); assert!( - store.active_action_target(id, "default").is_none(), + store + .active_action_target_generation(key, "default") + .is_none(), "weak attribution should not expose application actions" ); } diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index 6429f78d5..6b5605ea2 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -58,9 +58,7 @@ pub fn run(args: Args) -> Result<()> { let theme_paths = config .resolve_theme_paths_from(&theme_base) .context("resolve theme paths")?; - config - .ensure_theme_files(&theme_paths) - .context("ensure theme files")?; + // Popup startup never creates or migrates user-editable theme files let app = gtk::Application::new(Some("com.unixnotis.Popups"), Default::default()); // Activation can happen more than once in one process, so runtime setup diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index b0c19e7be..5524b96ec 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -35,8 +35,12 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu let _ = outcome.send(reply_result); result } - UiCommand::Rendered(notification) => { - timed_dbus_call(proxy.mark_popup_rendered(notification.id, notification.generation)) + UiCommand::Materialized(notification) => { + timed_dbus_call(proxy.mark_popup_materialized(notification.id, notification.generation)) + .await + } + UiCommand::Visible(notification) => { + timed_dbus_call(proxy.mark_popup_visible(notification.id, notification.generation)) .await } UiCommand::Shutdown(_) => Ok(()), @@ -52,7 +56,10 @@ pub fn drain_offline_commands( UiCommand::Reply { outcome, .. } => { let _ = outcome.send(Err("notification service is unavailable".to_string())); } - UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } | UiCommand::Rendered(_) => {} + UiCommand::Dismiss(_) + | UiCommand::InvokeAction { .. } + | UiCommand::Materialized(_) + | UiCommand::Visible(_) => {} } // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); diff --git a/crates/unixnotis-popups/src/dbus/runtime/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/connection.rs index 6b179d356..18262eefe 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/connection.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/connection.rs @@ -6,7 +6,8 @@ use futures_util::StreamExt; use tokio::sync::{mpsc, watch}; use tracing::warn; use unixnotis_core::{ - log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, INTERNAL_DBUS_CALL_TIMEOUT, + ensure_control_api_version, log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, + INTERNAL_DBUS_CALL_TIMEOUT, }; use zbus::fdo::DBusProxy; use zbus::names::BusName; @@ -91,6 +92,10 @@ async fn run_connection_once( return Some(subscribe_backoff.next_sleep()); } }; + if let Err(error) = ensure_control_api_version(&proxy).await { + subscribe_log.warn_or_debug(&error, "control API version mismatch; retrying"); + return Some(subscribe_backoff.next_sleep()); + } let mut owner_changes = match proxy.inner().receive_owner_changed().await { Ok(stream) => stream, Err(error) => { diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 12c1430f2..bf0572cec 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -36,7 +36,8 @@ pub enum UiCommand { text: String, outcome: tokio::sync::oneshot::Sender>, }, - Rendered(NotificationKey), + Materialized(NotificationKey), + Visible(NotificationKey), // A synchronous acknowledgement lets GTK wait for MarkPopupsNotReady before process exit Shutdown(std::sync::mpsc::SyncSender<()>), } @@ -63,8 +64,12 @@ impl std::fmt::Debug for UiCommand { // Reply text is private message content and must never enter debug logs .field("text", &"") .finish_non_exhaustive(), - Self::Rendered(notification) => formatter - .debug_tuple("Rendered") + Self::Materialized(notification) => formatter + .debug_tuple("Materialized") + .field(notification) + .finish(), + Self::Visible(notification) => formatter + .debug_tuple("Visible") .field(notification) .finish(), Self::Shutdown(_) => formatter.write_str("Shutdown(..)"), diff --git a/crates/unixnotis-popups/src/ui/entry/visibility.rs b/crates/unixnotis-popups/src/ui/entry/visibility.rs new file mode 100644 index 000000000..cca2aad59 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/visibility.rs @@ -0,0 +1,49 @@ +//! Generation-bound popup visibility reporting + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +use super::try_send_command; +use crate::dbus::UiCommand; + +#[derive(Clone)] +pub(in crate::ui) struct PopupVisibilityBinding { + key: Rc>, + reported: Rc>>, +} + +impl PopupVisibilityBinding { + pub(in crate::ui) fn new(key: NotificationKey) -> Self { + Self { + key: Rc::new(Cell::new(key)), + reported: Rc::new(Cell::new(None)), + } + } + + pub(in crate::ui) fn bind_generation(&self, key: NotificationKey) { + if self.key.get() == key { + return; + } + // A same-ID replacement needs its own visibility acknowledgement + self.key.set(key); + self.reported.set(None); + } + + pub(in crate::ui) fn report_if_visible( + &self, + revealer: >k::Revealer, + window: >k::ApplicationWindow, + command_tx: &tokio::sync::mpsc::Sender, + ) { + let key = self.key.get(); + if self.reported.get() == Some(key) || !window.is_mapped() || !revealer.is_child_revealed() + { + return; + } + self.reported.set(Some(key)); + try_send_command(command_tx, UiCommand::Visible(key)); + } +} diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 6f18b1f95..14a23283d 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -170,6 +170,10 @@ impl UiState { let Some(old_root) = self.popups.get(&id).and_then(|entry| entry.root.clone()) else { return false; }; + let visibility = self + .popups + .get(&id) + .and_then(|entry| entry.visibility.clone()); // Reuse the current revealer so one id still has one stack row let new_root = self.build_popup_root(notification); @@ -198,7 +202,15 @@ impl UiState { if let Some(entry) = self.popups.get_mut(&id) { entry.root = Some(new_root); } - try_send_command(&self.command_tx, UiCommand::Rendered(notification.key())); + try_send_command( + &self.command_tx, + UiCommand::Materialized(notification.key()), + ); + if let Some(visibility) = visibility { + // Replacements reuse one revealer but never reuse its generation identity + visibility.bind_generation(notification.key()); + visibility.report_if_visible(&revealer, &self.popup_window, &self.command_tx); + } rebuilt_visible_row } @@ -215,7 +227,7 @@ impl UiState { // Swap in the fresh GTK nodes while keeping the cached payload untouched entry.revealer = built.revealer; entry.root = built.root; - try_send_command(&self.command_tx, UiCommand::Rendered(notification.key())); + entry.visibility = built.visibility; } pub(super) fn dematerialize_popup(&mut self, id: u32) { @@ -225,11 +237,14 @@ impl UiState { }; let Some(root) = entry.root.take() else { entry.revealer = None; + entry.visibility = None; return; }; let Some(revealer) = entry.revealer.take() else { + entry.visibility = None; return; }; + entry.visibility = None; // Hidden overflow rows should not retain GTK trees or CSS state root.remove_css_class("unixnotis-popup-visible"); root.set_visible(false); diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index e551164bd..205f76f15 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -7,6 +7,9 @@ use gtk::prelude::*; use tracing::{debug, warn}; use unixnotis_ui::CutCorner; +use crate::dbus::UiCommand; +use crate::ui::entry::try_send_command; + impl UiState { pub(in super::super) fn update_popup_visibility(&mut self, force_region_refresh: bool) { // Visibility contract is driven strictly by configured max_visible count @@ -55,23 +58,16 @@ impl UiState { } pub(in super::super) fn refresh_after_config_reload(&mut self) { - // Only built rows have GTK roots that need a width refresh - let resized_roots = self + // Only built rows have GTK wrappers that may need a corner refresh + let materialized_roots = self .popups .values() .filter(|entry| entry.root.is_some()) .count(); - // Prefer the live width when GTK has already measured the stack - let popup_width = self - .popup_stack - .width() - .max(self.popup_stack.width_request()) - .max(1); for entry in self.popups.values() { let Some(root) = entry.root.as_ref() else { continue; }; - root.set_size_request(popup_width, -1); let Some(revealer) = entry.revealer.as_ref() else { continue; }; @@ -98,7 +94,7 @@ impl UiState { // Re-run visibility so max_visible changes take effect right away self.update_popup_visibility(true); debug!( - resized_roots, + materialized_roots, visible_target = visible_popup_target(self.popups.len(), self.config.popups.max_visible), total = self.popups.len(), @@ -125,6 +121,10 @@ impl UiState { // Attach or move only the rows that actually changed order let mut previous_revealer: Option = None; for id in &desired_visible { + let was_materialized = self + .popups + .get(id) + .is_some_and(super::super::entry::PopupEntry::is_materialized); self.materialize_popup(*id); let Some(entry) = self.popups.get(id) else { warn!(id, "popup marked visible but entry is missing"); @@ -149,6 +149,13 @@ impl UiState { } previous_revealer = Some(revealer.clone()); + if !was_materialized { + // Materialization is complete only after the row joins the live stack + try_send_command( + &self.command_tx, + UiCommand::Materialized(entry.notification.key()), + ); + } applied_visible.push(*id); } diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index c3be57133..8806628cc 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -128,6 +128,56 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { assert!(state.build_app_icon_widget(&missing_content, 20).is_some()); } +#[gtk::test] +fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { + let mut state = popup_state("org.unixnotis.PopupWidgetTree"); + let mut relayed = notification(12, 1, "Build finished"); + relayed.attribution = unixnotis_core::NotificationAttribution::relay( + "Builder", + "Sent through /usr/bin/notify-send", + "relay:notify-send:builder".to_string(), + ); + let root = state.build_popup_root(&relayed); + let overlay = root + .first_child() + .and_downcast::() + .expect("popup root should contain one overlay"); + let content = overlay + .child() + .and_downcast::() + .expect("overlay should own the measured popup content"); + let grid = content + .first_child() + .and_downcast::() + .expect("popup content should start with the identity grid"); + + assert!(grid.has_css_class("unixnotis-popup-content-grid")); + assert_eq!(grid.column_spacing(), 10); + assert_eq!(grid.row_spacing(), 2); + assert_eq!( + grid.property::("accessible-role"), + gtk::AccessibleRole::Group + ); + assert_eq!( + descendant_class_count(root.upcast_ref(), "unixnotis-identity-avatar"), + 1, + "one provenance-controlled avatar must own application identity" + ); + assert!(descendant_has_text( + root.upcast_ref(), + "Command-line notification" + )); + assert!(descendant_has_text(root.upcast_ref(), "App label: Builder")); + assert!(!descendant_has_class( + content.upcast_ref(), + "unixnotis-popup-close" + )); + assert!(descendant_has_class( + overlay.upcast_ref(), + "unixnotis-popup-close" + )); +} + #[gtk::test] fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation() { let (mut state, mut command_rx) = @@ -146,7 +196,7 @@ fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation .clone() .expect("visible popup should have a root"); assert!(original_root.is_visible()); - assert_rendered_command(&mut command_rx, original.key()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); let replacement = notification(21, 2, "replacement"); state.update_popup(replacement.clone(), true); @@ -160,15 +210,85 @@ fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation replacement_root.upcast_ref(), "replacement" )); - assert_rendered_command(&mut command_rx, replacement.key()); + assert_materialized_and_visible_commands(&mut command_rx, replacement.key()); } -fn assert_rendered_command( +#[gtk::test] +fn visible_popup_callbacks_report_each_generation_only_once() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupVisibleOnce", 1); + let original = notification(24, 1, "original"); + state.add_popup(original.clone()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); + + let entry = state + .popups + .get(&original.id) + .expect("visible popup should be stored"); + let revealer = entry + .revealer + .as_ref() + .expect("visible popup should have a revealer"); + let visibility = entry + .visibility + .as_ref() + .expect("visible popup should retain its visibility binding"); + visibility.report_if_visible(revealer, &state.popup_window, &state.command_tx); + visibility.report_if_visible(revealer, &state.popup_window, &state.command_tx); + assert!( + command_rx.try_recv().is_err(), + "duplicate map and reveal callbacks must not send another acknowledgement" + ); + + let replacement = notification(24, 2, "replacement"); + state.update_popup(replacement.clone(), true); + assert_materialized_and_visible_commands(&mut command_rx, replacement.key()); + assert!( + command_rx.try_recv().is_err(), + "one replacement generation should produce one visibility acknowledgement" + ); +} + +#[gtk::test] +fn mapped_window_does_not_acknowledge_an_unrevealed_popup_row() { + let (mut state, mut command_rx) = popup_state_with_commands("org.unixnotis.PopupHiddenRow", 1); + let visible = notification(25, 1, "visible"); + state.add_popup(visible.clone()); + assert_materialized_and_visible_commands(&mut command_rx, visible.key()); + assert!(state.popup_window.is_mapped()); + + let hidden_revealer = gtk::Revealer::new(); + hidden_revealer.set_child(Some(>k::Label::new(Some("hidden")))); + hidden_revealer.set_reveal_child(false); + let hidden_key = NotificationKey { + id: 26, + generation: 1, + }; + let visibility = crate::ui::entry::PopupVisibilityBinding::new(hidden_key); + + visibility.report_if_visible(&hidden_revealer, &state.popup_window, &state.command_tx); + + assert!( + command_rx.try_recv().is_err(), + "a mapped window cannot make an unrevealed row visible" + ); +} + +fn assert_materialized_and_visible_commands( command_rx: &mut tokio::sync::mpsc::Receiver, expected: NotificationKey, ) { - match command_rx.try_recv().expect("render acknowledgement") { - crate::dbus::UiCommand::Rendered(notification) => { + match command_rx + .try_recv() + .expect("materialization acknowledgement") + { + crate::dbus::UiCommand::Materialized(notification) => { + assert_eq!(notification, expected); + } + command => panic!("unexpected command: {command:?}"), + } + match command_rx.try_recv().expect("visibility acknowledgement") { + crate::dbus::UiCommand::Visible(notification) => { assert_eq!(notification, expected); } command => panic!("unexpected command: {command:?}"), @@ -186,6 +306,17 @@ fn descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { false } +fn descendant_class_count(widget: >k::Widget, class_name: &str) -> usize { + let own = usize::from(widget.has_css_class(class_name)); + let mut count = own; + let mut child = widget.first_child(); + while let Some(current) = child { + count += descendant_class_count(¤t, class_name); + child = current.next_sibling(); + } + count +} + fn descendant_has_text(widget: >k::Widget, expected: &str) -> bool { if widget .downcast_ref::() From 9514fd693d0ec5d31261b017979aea7962346fa7 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:12:35 -0500 Subject: [PATCH 149/275] feat(ui): rebuild notification identity and grouping surfaces Summary: rebuild notification identity and grouping surfaces. Scope: ui. --- .../src/css_check/geometry/stock/classes.rs | 3 - .../src/ui/icons/tests/theme.rs | 6 +- .../src/ui/notifications/model/grouping.rs | 5 +- .../src/ui/notifications/model/item.rs | 19 +- .../src/ui/notifications/model/tests/item.rs | 14 +- .../src/ui/notifications/row/group.rs | 45 +++- .../notifications/row/notification/build.rs | 103 +++++---- .../ui/notifications/row/notification/mod.rs | 1 + .../notifications/row/notification/stack.rs | 19 ++ .../notifications/row/notification/state.rs | 7 +- .../row/notification/tests/stack.rs | 36 +++ .../row/notification/tests/support.rs | 15 +- .../row/notification/update/metadata.rs | 18 +- .../row/notification/update/row.rs | 6 +- .../row/notification/update/tests/actions.rs | 5 +- .../row/notification/update/tests/state.rs | 91 ++++++-- .../row/notification/update/visual.rs | 40 +++- .../src/ui/notifications/row/tests/group.rs | 50 ++++- .../src/ui/notifications/store/blocks.rs | 22 +- .../src/ui/notifications/store/lifecycle.rs | 1 - .../src/ui/notifications/store/mutation.rs | 17 +- .../ui/notifications/store/tests/blocks.rs | 10 +- .../ui/notifications/store/tests/mutation.rs | 18 +- .../src/ui/notifications/store/update.rs | 2 +- .../ui/notifications/view/tests/widgets.rs | 4 - crates/unixnotis-core/assets/base.css | 2 +- crates/unixnotis-core/assets/panel.css | 209 +++++++++--------- crates/unixnotis-core/assets/popup.css | 94 ++++---- .../unixnotis-core/src/css/hooks/classes.rs | 7 +- crates/unixnotis-core/src/css/hooks/mod.rs | 5 +- .../src/css/hooks/tests/hooks.rs | 34 +-- crates/unixnotis-core/src/css/tests/tokens.rs | 4 +- crates/unixnotis-core/src/css/tokens.rs | 12 +- .../unixnotis-core/src/embedded/tests/css.rs | 14 +- crates/unixnotis-popups/src/ui/entry/build.rs | 52 +++-- .../src/ui/entry/builders/common.rs | 62 ++++-- .../src/ui/entry/builders/communication.rs | 51 +---- .../src/ui/entry/builders/layout.rs | 91 ++++++++ .../src/ui/entry/builders/mod.rs | 9 +- .../src/ui/entry/builders/reply/tests/mod.rs | 14 +- .../src/ui/entry/builders/tests/common.rs | 60 +++-- .../src/ui/entry/builders/tests/layout.rs | 58 +++++ .../src/ui/entry/builders/utility.rs | 48 +--- .../src/ui/entry/builders/warning.rs | 52 ----- crates/unixnotis-popups/src/ui/entry/mod.rs | 2 + .../src/ui/entry/presentation/tests/kind.rs | 40 ++-- .../ui/entry/presentation/tests/support.rs | 14 +- .../src/ui/entry/presentation/tests/trust.rs | 21 +- .../ui/entry/presentation/tests/view_model.rs | 36 +-- .../src/ui/entry/tests/build.rs | 14 +- crates/unixnotis-popups/src/ui/icon_state.rs | 2 +- .../src/ui/icons/tests/resolver/candidates.rs | 2 +- .../src/ui/state/tests/constructor.rs | 50 +++-- .../unixnotis-popups/src/ui/window/build.rs | 23 +- crates/unixnotis-popups/src/ui/window/mod.rs | 1 + .../src/ui/window/tests/width_constraint.rs | 45 ++++ .../src/ui/window/width_constraint.rs | 124 +++++++++++ .../icons/unixnotis-app-unknown-symbolic.svg | 2 +- .../unixnotis-ui/src/css/tests/overrides.rs | 4 +- 59 files changed, 1162 insertions(+), 653 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/layout.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs delete mode 100644 crates/unixnotis-popups/src/ui/entry/builders/warning.rs create mode 100644 crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs create mode 100644 crates/unixnotis-popups/src/ui/window/width_constraint.rs diff --git a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs index f1a8a3899..5cd62b164 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs @@ -144,9 +144,6 @@ const fn hook_unixnotis_classes() -> &'static [&'static str] { hooks::group_row::CHEVRON, hooks::empty_row::ROOT, hooks::empty_row::LABEL, - hooks::ghost_row::ROOT, - "unixnotis-stack-ghost-1", - "unixnotis-stack-ghost-2", "unixnotis-media-stack-player", "unixnotis-media-row-player", "unixnotis-media-card-player", diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 75cc460b0..5a7cc6309 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -38,9 +38,11 @@ fn badge_candidates_exclude_caller_content_icon() { "sender-bin", unixnotis_core::NotificationAttribution { display_name: "Unknown application".to_string(), + claimed_name: "Claimed Brand".to_string(), badge_icon: "sender-bin".to_string(), - source_label: "Claims to be Claimed Brand".to_string(), - class: unixnotis_core::AttributionClass::Conflict, + status: unixnotis_core::AttributionStatus::Conflict, + reason: unixnotis_core::AttributionReason::ExecutableMismatch, + diagnostic_detail: "sender executable mismatch".to_string(), group_key: "executable:1:2".to_string(), ..unixnotis_core::NotificationAttribution::default() }, diff --git a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs index 23215d610..c404cedf6 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs @@ -21,7 +21,7 @@ impl NotificationList { &self, key: &'a str, ) -> Cow<'a, str> { - // Trim outer whitespace to avoid duplicate stacks from padded app names + // Trim outer whitespace to avoid duplicate groups from padded app names let trimmed = key.trim(); if trimmed.is_empty() { return Cow::Borrowed(""); @@ -152,7 +152,8 @@ impl NotificationList { return true; }; contains_casefold(&view.attribution.display_name, query) - || contains_casefold(&view.attribution.source_label, query) + || contains_casefold(&view.attribution.claimed_name, query) + || contains_casefold(&view.attribution.diagnostic_detail, query) || contains_casefold(&view.summary, query) || contains_casefold(&view.body, query) } diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 595c661bd..80dd6975c 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -66,9 +66,9 @@ pub struct RowData { // Position flags let CSS form one continuous grouped surface pub group_first: bool, pub group_last: bool, - // True when this notification is the visible card for a collapsed group - pub stacked: bool, - // Number of internal ghost cards shown under the visible notification card + // True when this notification previews a collapsed multi-item group + pub collapsed_group_preview: bool, + // Collapsed groups render at most two shallow rear cards pub stack_depth: u8, pub is_active: bool, pub presentation: RowPresentation, @@ -86,7 +86,7 @@ impl Default for RowData { expanded: false, group_first: false, group_last: false, - stacked: false, + collapsed_group_preview: false, stack_depth: 0, is_active: false, presentation: RowPresentation::default(), @@ -111,7 +111,7 @@ impl RowData { expanded, group_first: false, group_last: false, - stacked: false, + collapsed_group_preview: false, stack_depth: 0, is_active: false, presentation: RowPresentation::default(), @@ -122,8 +122,7 @@ impl RowData { pub fn notification( group_key: Rc, notification: Rc, - stacked: bool, - stack_depth: u8, + collapsed_group_preview: bool, expanded: bool, is_active: bool, presentation: RowPresentation, @@ -137,8 +136,8 @@ impl RowData { expanded, group_first: false, group_last: false, - stacked, - stack_depth, + collapsed_group_preview, + stack_depth: 0, is_active, presentation, notification: Some(notification), @@ -154,7 +153,7 @@ impl RowData { && self.expanded == other.expanded && self.group_first == other.group_first && self.group_last == other.group_last - && self.stacked == other.stacked + && self.collapsed_group_preview == other.collapsed_group_preview && self.stack_depth == other.stack_depth && self.is_active == other.is_active && self.presentation == other.presentation diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index d803922e4..9cff5b7d5 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -54,7 +54,6 @@ fn row_data_notification_sets_expected_fields() { Rc::from("terminal"), view.clone(), true, - 2, false, true, presentation.clone(), @@ -62,8 +61,7 @@ fn row_data_notification_sets_expected_fields() { assert_eq!(data.kind, RowKind::Notification); assert_eq!(data.id, 42); - assert!(data.stacked); - assert_eq!(data.stack_depth, 2); + assert!(data.collapsed_group_preview); assert!(data.is_active); assert_eq!(data.presentation, presentation); assert!(Rc::ptr_eq(data.notification.as_ref().expect("view"), &view)); @@ -75,7 +73,6 @@ fn row_item_update_emits_only_for_changed_data() { Rc::from("terminal"), notification(1), false, - 0, false, true, RowPresentation::default(), @@ -95,7 +92,6 @@ fn row_item_update_emits_only_for_changed_data() { Rc::from("terminal"), notification(2), false, - 0, false, true, RowPresentation::default(), @@ -111,7 +107,6 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { group.clone(), view.clone(), false, - 0, false, true, RowPresentation { @@ -145,11 +140,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { assert!(!base.is_equivalent(&changed)); let mut changed = base.clone(); - changed.stacked = true; - assert!(!base.is_equivalent(&changed)); - - let mut changed = base.clone(); - changed.stack_depth = 2; + changed.collapsed_group_preview = true; assert!(!base.is_equivalent(&changed)); let mut changed = base.clone(); @@ -170,7 +161,6 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { group, view, false, - 0, false, true, RowPresentation { diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 42e3697db..0407fc24f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -21,6 +21,7 @@ const GROUP_AVATAR_SIZE: i32 = 26; const GROUP_ICON_SIZE: i32 = 18; pub(in crate::ui::notifications) struct GroupRowWidgets { + pub(super) button: gtk::Button, pub(super) avatar: gtk::Box, pub(super) icon: gtk::Image, pub(super) title: gtk::Label, @@ -129,6 +130,7 @@ pub(in crate::ui::notifications) fn build_group_row( ( root, GroupRowWidgets { + button, avatar, icon, title, @@ -173,6 +175,16 @@ pub(in crate::ui::notifications) fn update_group_row( set_widget_visible_if_changed(&group.trust_chip, !trust_label.is_empty()); let next_count = data.count.to_string(); set_label_text_if_changed(&group.count, &next_count); + let accessible_label = group_accessible_label( + display_name, + trust_label, + secondary, + data.count, + data.expanded, + ); + group + .button + .update_property(&[gtk::accessible::Property::Label(&accessible_label)]); let chevron_name = if data.expanded { "pan-up-symbolic" } else { @@ -197,13 +209,14 @@ pub(in crate::ui::notifications) fn update_group_row( set_class_state( root, "unixnotis-attribution-warning", - presentation.trust.level == TrustLevel::Suspicious, + presentation.trust.level == TrustLevel::Conflict, ); for (level, class_name) in [ (TrustLevel::Verified, "verified"), - (TrustLevel::Unverified, "unverified"), - (TrustLevel::Suspicious, "suspicious"), - (TrustLevel::CommandLine, "command-line"), + (TrustLevel::Recognized, "recognized"), + (TrustLevel::Unresolved, "unresolved"), + (TrustLevel::Conflict, "conflict"), + (TrustLevel::Relay, "relay"), ] { set_class_state(root, class_name, presentation.trust.level == level); } @@ -224,6 +237,30 @@ pub(in crate::ui::notifications) fn update_group_row( } } +fn group_accessible_label( + display_name: &str, + trust_label: &str, + secondary: &str, + count: u32, + expanded: bool, +) -> String { + let mut parts = vec![display_name.trim().to_string()]; + if !trust_label.trim().is_empty() { + parts.push(trust_label.trim().to_string()); + } + if !secondary.trim().is_empty() { + parts.push(secondary.trim().to_string()); + } + let count_label = if count == 1 { + "1 notification".to_string() + } else { + format!("{count} notifications") + }; + parts.push(count_label); + parts.push(if expanded { "Expanded" } else { "Collapsed" }.to_string()); + parts.join(". ") +} + fn set_label_text_if_changed(label: >k::Label, text: &str) { // Repeated model refreshes often land on the same text // Skip the setter when the rendered value already matches diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 203debfe0..e2b8a222e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -21,7 +21,7 @@ use super::state::NotificationRowWidgets; pub(in crate::ui::notifications) fn build_notification_row( command_tx: mpsc::Sender, ) -> (gtk::Box, NotificationRowWidgets) { - // Root owns the full collapsed-stack shape as one ListView row + // Root owns the full collapsed group preview as one ListView row let root = gtk::Box::new(gtk::Orientation::Vertical, 0); root.add_css_class(hooks::panel_card::ROW); root.set_hexpand(true); @@ -34,8 +34,6 @@ pub(in crate::ui::notifications) fn build_notification_row( let meta_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta_top.add_css_class(hooks::panel_card::META_TOP); meta_top.set_hexpand(true); - // Overlay dismiss control occupies the card's top-right corner - meta_top.set_margin_end(30); meta_top.set_visible(false); let meta_label = gtk::Label::new(None); @@ -43,18 +41,22 @@ pub(in crate::ui::notifications) fn build_notification_row( meta_label.set_xalign(0.0); meta_label.set_single_line_mode(true); - let meta_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - meta_spacer.set_hexpand(true); - let time_badge = gtk::Label::new(None); time_badge.add_css_class(hooks::panel_card::TIME_BADGE); - time_badge.set_xalign(0.5); + time_badge.set_halign(gtk::Align::End); + time_badge.set_xalign(1.0); time_badge.set_single_line_mode(true); + time_badge.set_visible(false); meta_top.append(&meta_label); - meta_top.append(&meta_spacer); - meta_top.append(&time_badge); - // Header packs the identity shown only for standalone rows + // The dismiss control stays in the measured header like the stable master layout + let close_button = gtk::Button::from_icon_name("window-close-symbolic"); + close_button.set_halign(gtk::Align::End); + close_button.set_valign(gtk::Align::Center); + close_button.add_css_class("unixnotis-panel-close"); + close_button.update_property(&[gtk::accessible::Property::Label("Dismiss notification")]); + + // Header owns identity, chronology, and dismiss without covering message content let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); header.add_css_class(hooks::panel_card::HEADER); let icon = gtk::Image::new(); @@ -91,13 +93,6 @@ pub(in crate::ui::notifications) fn build_notification_row( urgency_badge.set_single_line_mode(true); urgency_badge.set_visible(false); - let close_button = gtk::Button::from_icon_name("window-close-symbolic"); - close_button.set_halign(gtk::Align::End); - close_button.set_valign(gtk::Align::Start); - close_button.set_margin_top(6); - close_button.set_margin_end(6); - close_button.add_css_class("unixnotis-panel-close"); - identity_top.append(&app_label); identity_top.append(&trust_chip); identity_top.append(&urgency_badge); @@ -105,6 +100,8 @@ pub(in crate::ui::notifications) fn build_notification_row( identity.append(&secondary_claim); header.append(&icon); header.append(&identity); + header.append(&time_badge); + header.append(&close_button); let body_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); body_row.set_hexpand(true); @@ -115,13 +112,14 @@ pub(in crate::ui::notifications) fn build_notification_row( thumbnail.set_size_request(56, 56); thumbnail.set_visible(false); - let text_stack = gtk::Box::new(gtk::Orientation::Vertical, 6); + let text_stack = gtk::Box::new(gtk::Orientation::Vertical, 2); text_stack.add_css_class(hooks::panel_card::TEXT); text_stack.set_hexpand(true); // Summary is optional, so the update path decides later if the row should exist let summary_label = gtk::Label::new(None); summary_label.set_xalign(0.0); + summary_label.set_hexpand(true); // One title line keeps short grouped rows compact summary_label.set_wrap(true); summary_label.set_wrap_mode(WrapMode::WordChar); @@ -192,37 +190,21 @@ pub(in crate::ui::notifications) fn build_notification_row( // The wrapper clips the complete styled card while the inner box keeps all CSS hooks let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); + card_plate.add_css_class("unixnotis-panel-card-foreground"); - // Overlay controls never change the card's natural height - let card_overlay = gtk::Overlay::new(); - card_overlay.add_css_class("unixnotis-panel-card-overlay"); - card_overlay.set_child(Some(&card_plate)); - card_overlay.add_overlay(&close_button); - - // One content card keeps grouped rows calm without decorative fake stack layers - root.append(&card_overlay); + // Rear layers follow master's paint order so the readable card always stays on top + let stack_ghost_back = build_stack_ghost(2); + let stack_ghost_middle = build_stack_ghost(1); + root.append(&stack_ghost_back); + root.append(&stack_ghost_middle); + root.append(&card_plate); let notify_key = Rc::new(Cell::new(NotificationKey { id: 0, generation: 0, })); - // Close click always targets the exact generation assigned to this recycled row - let close_tx = command_tx; - let notify_key_clone = notify_key.clone(); - close_button.connect_clicked(move |_| { - let notification = notify_key_clone.get(); - if notification.id == 0 { - // Ignore clicks before first binding - return; - } - debug!( - id = notification.id, - generation = notification.generation, - "dismiss clicked" - ); - // Non-blocking enqueue avoids GTK stalls during D-Bus backpressure - try_send_command(&close_tx, UiCommand::Dismiss(notification)); - }); + // Recycled rows retain the exact generation rather than targeting a reused numeric id + connect_dismiss_button(&close_button, command_tx, notify_key.clone()); // The reusable widget bundle is returned with the root so the list factory // can keep the GTK tree and the cached row state together @@ -231,12 +213,15 @@ pub(in crate::ui::notifications) fn build_notification_row( NotificationRowWidgets { card, card_plate, + stack_ghost_middle, + stack_ghost_back, icon, header, app_label, secondary_claim, trust_chip, urgency_badge, + close_button, meta_top, meta_label, time_badge, @@ -264,3 +249,35 @@ pub(in crate::ui::notifications) fn build_notification_row( }, ) } + +fn build_stack_ghost(depth: u8) -> gtk::Box { + let ghost = gtk::Box::new(gtk::Orientation::Vertical, 0); + // Rear cards deliberately contain no content or controls + ghost.add_css_class("unixnotis-panel-card"); + ghost.add_css_class("unixnotis-stack-ghost"); + ghost.add_css_class(&format!("unixnotis-stack-ghost-{depth}")); + ghost.set_hexpand(true); + ghost.set_visible(false); + ghost +} + +fn connect_dismiss_button( + button: >k::Button, + command_tx: mpsc::Sender, + notify_key: Rc>, +) { + button.connect_clicked(move |_| { + let notification = notify_key.get(); + if notification.id == 0 { + // Ignore clicks before first binding + return; + } + debug!( + id = notification.id, + generation = notification.generation, + "dismiss clicked" + ); + // Non-blocking enqueue avoids GTK stalls during D-Bus backpressure + try_send_command(&command_tx, UiCommand::Dismiss(notification)); + }); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index b6d349be1..3ec06b1d9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -5,6 +5,7 @@ mod build; mod reply; +mod stack; mod state; #[cfg(test)] #[path = "tests/support.rs"] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs new file mode 100644 index 000000000..db5a45b51 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -0,0 +1,19 @@ +//! Collapsed notification stack state + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct StackLayerVisibility { + pub(super) middle: bool, + pub(super) back: bool, +} + +pub(super) const fn layer_visibility(stack_depth: u8) -> StackLayerVisibility { + // Depth one uses the back slot because its card starts without overlap + StackLayerVisibility { + middle: stack_depth >= 2, + back: stack_depth >= 1, + } +} + +#[cfg(test)] +#[path = "tests/stack.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 161896aaf..0a2649b90 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -16,6 +16,9 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing pub(super) card_plate: unixnotis_ui::CutCorner, + // Shallow rear cards reproduce the stable collapsed-group depth from master + pub(super) stack_ghost_middle: gtk::Box, + pub(super) stack_ghost_back: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, // Identity header collapses completely for rows owned by a group header @@ -27,11 +30,13 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) trust_chip: gtk::Label, // Critical badge remains allocated so urgency changes only toggle visibility pub(super) urgency_badge: gtk::Label, + // Dismiss remains in the measured header and targets the exact generation + pub(super) close_button: gtk::Button, // Optional metadata rows are present for themes but hidden unless config enables them pub(super) meta_top: gtk::Box, // Optional top metadata label for category/urgency styling pub(super) meta_label: gtk::Label, - // Compact relative time badge shown on the top metadata lane + // Compact relative time badge shown beside the summary pub(super) time_badge: gtk::Label, // Optional large image preview for notifications with image hints pub(super) thumbnail: gtk::Image, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs new file mode 100644 index 000000000..61d318589 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -0,0 +1,36 @@ +//! Collapsed notification stack tests + +use super::{layer_visibility, StackLayerVisibility}; + +#[test] +fn one_hidden_notification_uses_only_the_back_layer() { + assert_eq!( + layer_visibility(1), + StackLayerVisibility { + middle: false, + back: true, + } + ); +} + +#[test] +fn two_or_more_hidden_notifications_use_both_rear_layers() { + let expected = StackLayerVisibility { + middle: true, + back: true, + }; + + assert_eq!(layer_visibility(2), expected); + assert_eq!(layer_visibility(u8::MAX), expected); +} + +#[test] +fn expanded_or_single_notification_rows_hide_rear_layers() { + assert_eq!( + layer_visibility(0), + StackLayerVisibility { + middle: false, + back: false, + } + ); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 4cf035ea7..fd9af6854 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -19,8 +19,10 @@ pub(super) fn sample_notification() -> NotificationView { app_name: "demo".to_string(), attribution: unixnotis_core::NotificationAttribution { display_name: "demo".to_string(), + claimed_name: "demo".to_string(), badge_icon: "demo".to_string(), - class: unixnotis_core::AttributionClass::SystemAssociated, + status: unixnotis_core::AttributionStatus::Verified, + reason: unixnotis_core::AttributionReason::ExactSystemExecutable, group_key: "test:demo".to_string(), ..unixnotis_core::NotificationAttribution::default() }, @@ -58,7 +60,7 @@ pub(super) fn notification_row_with_receiver() -> ( #[derive(Default)] pub(super) struct RowFlags { pub(super) is_active: bool, - pub(super) stacked: bool, + pub(super) collapsed_group_preview: bool, pub(super) stack_depth: u8, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, @@ -68,11 +70,10 @@ pub(super) struct RowFlags { } pub(super) fn row_data(notification: Rc, flags: RowFlags) -> RowData { - RowData::notification( + let mut row = RowData::notification( Rc::from(notification.app_name.to_ascii_lowercase()), notification, - flags.stacked, - flags.stack_depth, + flags.collapsed_group_preview, false, flags.is_active, RowPresentation { @@ -83,7 +84,9 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R metadata: Rc::new(flags.metadata.unwrap_or_default()), card_corners: flags.card_corners, }, - ) + ); + row.stack_depth = flags.stack_depth; + row } pub(super) fn current_millis() -> i64 { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index 70877ff13..5fefad998 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -17,17 +17,14 @@ pub(super) fn update_metadata_labels( ) { let metadata = data.presentation.metadata.as_ref(); let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); - // Relative time is core chronology; optional metadata controls only the extra labels - set_widget_visible_if_changed( - &row.meta_top, - data.presentation.show_metadata || !time_badge.is_empty(), - ); + // Relative time stays on the title lane while optional diagnostics get their own row + set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); + set_label_text_if_changed(&row.time_badge, &time_badge); set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); if !data.presentation.show_metadata { - // Optional labels collapse while compact per-notification chronology remains + // Optional labels collapse while per-notification chronology remains + set_widget_visible_if_changed(&row.meta_top, false); set_label_visible_if_changed(&row.meta_label, false); - set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); - set_label_text_if_changed(&row.time_badge, &time_badge); set_label_visible_if_changed(&row.footer_left, false); set_label_visible_if_changed(&row.footer_right, false); return; @@ -35,13 +32,10 @@ pub(super) fn update_metadata_labels( // Urgency copy comes from one config block so themes can rename every lane together let meta = notification_meta_label(notification, metadata); + set_widget_visible_if_changed(&row.meta_top, !meta.is_empty()); set_label_visible_if_changed(&row.meta_label, !meta.is_empty()); set_label_text_if_changed(&row.meta_label, meta); - // Missing or invalid timestamps hide the badge instead of showing stale text - set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); - set_label_text_if_changed(&row.time_badge, &time_badge); - // The left footer distinguishes live cards from retained history at a glance let footer_left = if notification.is_transient { metadata.transient_label.as_str() diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 0f8473b58..48f99a9eb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -29,7 +29,7 @@ pub(in crate::ui::notifications) fn update_notification_row( }; let notification = notification_snapshot.as_ref(); let presentation = NotificationPresentation::from_view(notification); - let show_identity = !data.stacked && !data.expanded; + let show_identity = !data.collapsed_group_preview && !data.expanded; let has_actions = visible_action_count(notification, data.is_active) > 0; let has_thumbnail = data.presentation.show_thumbnail && notification_has_thumbnail(notification); @@ -92,7 +92,9 @@ pub(in crate::ui::notifications) fn update_notification_row( } set_widget_visible_if_changed(&row.icon, show_identity); set_widget_visible_if_changed(&row.app_label, show_identity); - set_widget_visible_if_changed(&row.header, show_identity); + // Group rows keep the measured top lane so dismiss never covers message text + set_widget_visible_if_changed(&row.header, true); + set_widget_visible_if_changed(&row.close_button, true); if has_thumbnail { // Reapply visible thumbnails so config reloads cannot leave stale previews let scale = row.card.scale_factor(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 373e4eed3..297958ec4 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -95,8 +95,9 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { fn unverified_panel_row_hides_application_actions_like_the_popup() { let (_root, row) = notification_row(); let mut notification = sample_notification(); - notification.attribution = unixnotis_core::NotificationAttribution::unknown( + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( "Claimed application", + unixnotis_core::AttributionReason::MissingSenderEvidence, "unverified sender", "unknown:claimed".to_string(), ); @@ -116,7 +117,7 @@ fn unverified_panel_row_hides_application_actions_like_the_popup() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); assert_eq!(child_count(&row.actions_box), 0); - assert!(row.card.has_css_class("unverified")); + assert!(row.card.has_css_class("unresolved")); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 7b1f43766..3ca5e20fb 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -18,7 +18,7 @@ fn icon_signature_changes_when_trust_presentation_changes() { let verified = sample_notification(); let mut suspicious = verified.clone(); // Keep resolver inputs unchanged to isolate the trust-state regression - suspicious.attribution.warning = true; + suspicious.attribution.status = unixnotis_core::AttributionStatus::Conflict; assert_ne!( IconSignature::from(&verified), @@ -29,22 +29,19 @@ fn icon_signature_changes_when_trust_presentation_changes() { #[gtk::test] fn close_control_ignores_unbound_rows_and_keeps_the_bound_generation() { - let (root, row, mut command_rx) = notification_row_with_receiver(); - let close = descendant_with_class(root.upcast_ref(), "unixnotis-panel-close") - .and_downcast::() - .expect("panel close button"); + let (_root, row, mut command_rx) = notification_row_with_receiver(); - close.emit_clicked(); + row.close_button.emit_clicked(); assert!( command_rx.try_recv().is_err(), - "an unbound recycled row must not dismiss notification zero" + "an unbound recycled control must not dismiss notification zero" ); row.notify_key.set(unixnotis_core::NotificationKey { id: 7, generation: 11, }); - close.emit_clicked(); + row.close_button.emit_clicked(); assert!(matches!( command_rx.try_recv(), Ok(crate::control::UiCommand::Dismiss(notification)) @@ -63,8 +60,7 @@ fn update_notification_row_applies_state_classes_and_text() { notification, RowFlags { is_active: true, - stacked: true, - stack_depth: 2, + collapsed_group_preview: true, ..Default::default() }, ); @@ -74,12 +70,14 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row.card.has_css_class(hooks::shared_state::CRITICAL)); assert!(row.card.has_css_class(hooks::shared_state::ACTIVE)); - assert!(row.card.has_css_class(hooks::shared_state::STACKED)); + assert!(row + .card + .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW)); assert!(row.card.has_css_class(hooks::panel_card::GROUP_COLLAPSED)); assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); assert!(!row.app_label.get_visible()); assert!(!row.icon.get_visible()); - assert!(!row.header.get_visible()); + assert!(row.header.get_visible()); assert!(row.urgency_badge.get_visible()); assert_eq!(row.urgency_badge.text().as_str(), "Critical"); assert_eq!(row.app_label.text().as_str(), "demo"); @@ -116,6 +114,7 @@ fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { assert!(row.app_label.get_visible()); assert!(row.header.get_visible()); + assert!(row.close_button.get_visible()); assert_eq!(row.app_label.text().as_str(), "demo"); assert!(row.icon_sig.borrow().is_some()); } @@ -124,10 +123,9 @@ fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { let (_root, row) = notification_row(); let mut notification = sample_notification(); - notification.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + notification.attribution = unixnotis_core::NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - true, "relay:notify-send:signal".to_string(), ); let data = row_data(Rc::new(notification), RowFlags::default()); @@ -139,8 +137,8 @@ fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { assert_eq!(row.secondary_claim.text().as_str(), "App label: Signal"); assert!(row.secondary_claim.get_visible()); assert!(!row.trust_chip.get_visible()); - assert!(row.card.has_css_class("command-line")); - assert!(!row.card.has_css_class("suspicious")); + assert!(row.card.has_css_class("relay")); + assert!(!row.card.has_css_class("conflict")); } #[gtk::test] @@ -151,25 +149,22 @@ fn panel_text_limits_keep_compact_rows_content_driven() { assert_eq!(row.summary_label.lines(), 1); assert_eq!(row.body_label.lines(), 3); - assert!(close - .parent() - .is_some_and(|parent| parent.is::())); + assert_eq!(close.parent().as_ref(), Some(row.header.upcast_ref())); } #[gtk::test] fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { let (_root, row) = notification_row(); let mut notification = sample_notification(); - notification.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + notification.attribution = unixnotis_core::NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - true, "relay:notify-send:signal".to_string(), ); let data = row_data( Rc::new(notification), RowFlags { - stacked: true, + collapsed_group_preview: true, ..Default::default() }, ); @@ -181,10 +176,58 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { assert!(!row.secondary_claim.get_visible()); assert!(!row.trust_chip.get_visible()); assert!(!row.icon.get_visible()); + assert!(row.header.get_visible()); + assert!(row.close_button.get_visible()); +} + +#[gtk::test] +fn collapsed_group_preview_shows_master_style_rear_layers() { + let (_root, row) = notification_row(); + let data = row_data( + Rc::new(sample_notification()), + RowFlags { + collapsed_group_preview: true, + stack_depth: 2, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.stack_ghost_middle.get_visible()); + assert!(row.stack_ghost_back.get_visible()); } #[gtk::test] -fn compact_metadata_keeps_only_a_valid_relative_timestamp_lane() { +fn recycled_standalone_row_clears_identity_cache_when_it_becomes_grouped() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let standalone = row_data(notification.clone(), RowFlags::default()); + let grouped = row_data( + notification, + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &standalone, &IconResolver::new(), &command_tx); + assert!( + row.icon_sig.borrow().is_some(), + "standalone rows should cache their resolved identity icon" + ); + + update_notification_row(&row, &grouped, &IconResolver::new(), &command_tx); + assert!( + row.icon_sig.borrow().is_none(), + "grouped rows must release identity state owned by their group header" + ); +} + +#[gtk::test] +fn compact_rows_place_relative_time_in_the_non_overlapping_header_lane() { let (_root, row) = notification_row(); let notification = Rc::new(sample_notification()); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); @@ -192,7 +235,7 @@ fn compact_metadata_keeps_only_a_valid_relative_timestamp_lane() { update_notification_row(&row, ¤t, &IconResolver::new(), &command_tx); - assert!(row.meta_top.get_visible()); + assert!(!row.meta_top.get_visible()); assert!(row.time_badge.get_visible()); assert!(!row.meta_label.get_visible()); assert!(!row.footer.get_visible()); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index f14103de9..1151b5f1b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -1,10 +1,11 @@ -//! Card classes, stack depth, and widget visibility +//! Card state classes and widget visibility use gtk::prelude::*; use unixnotis_core::{hooks, NotificationView, Urgency}; use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use super::super::super::super::item::RowData; +use super::super::stack::layer_visibility; use super::super::state::NotificationRowWidgets; use super::labels::has_visible_text; @@ -24,21 +25,44 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::shared_state::CRITICAL, is_critical); for (level, class_name) in [ (TrustLevel::Verified, "verified"), - (TrustLevel::Unverified, "unverified"), - (TrustLevel::Suspicious, "suspicious"), - (TrustLevel::CommandLine, "command-line"), + (TrustLevel::Recognized, "recognized"), + (TrustLevel::Unresolved, "unresolved"), + (TrustLevel::Conflict, "conflict"), + (TrustLevel::Relay, "relay"), ] { set_class_state(card, class_name, presentation.trust.level == level); } set_widget_visible_if_changed(&row.urgency_badge, is_critical); set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); - set_class_state(card, hooks::shared_state::STACKED, data.stacked); - let grouped = data.stacked || data.expanded; + set_class_state( + card, + hooks::shared_state::COLLAPSED_GROUP_PREVIEW, + data.collapsed_group_preview, + ); + let grouped = data.collapsed_group_preview || data.expanded; set_class_state(card, hooks::panel_card::GROUPED, grouped); - set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); + set_class_state( + card, + hooks::panel_card::GROUP_COLLAPSED, + data.collapsed_group_preview, + ); set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); set_class_state(card, hooks::panel_card::GROUP_FIRST, data.group_first); set_class_state(card, hooks::panel_card::GROUP_LAST, data.group_last); + set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); + set_class_state( + &row.card_plate, + hooks::panel_card::GROUP_COLLAPSED, + data.collapsed_group_preview, + ); + set_class_state( + &row.card_plate, + hooks::panel_card::GROUP_EXPANDED, + data.expanded, + ); + let layers = layer_visibility(data.stack_depth); + set_widget_visible_if_changed(&row.stack_ghost_middle, layers.middle); + set_widget_visible_if_changed(&row.stack_ghost_back, layers.back); set_class_state( card, @@ -56,7 +80,7 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); } -fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { +fn set_class_state>(root: &W, class_name: &str, enabled: bool) { // Guard CSS churn so GTK does not reprocess matching classes if enabled { if !root.has_css_class(class_name) { diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index ac34b6339..a1e083f30 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; use unixnotis_core::{NotificationImage, NotificationView}; -use super::{build_group_row, update_group_row}; +use super::{build_group_row, group_accessible_label, update_group_row}; use crate::control::UiEvent; use crate::ui::icons::IconResolver; use crate::ui::notifications::item::{RowData, RowKind}; @@ -15,14 +15,14 @@ fn notification(app_name: &str) -> Rc { id: 1, generation: 1, app_name: app_name.to_string(), - attribution: unixnotis_core::NotificationAttribution::associated( + attribution: unixnotis_core::NotificationAttribution::verified( + app_name, app_name, "org.example.App", - "org.example.App", - "", - unixnotis_core::AttributionClass::SystemAssociated, - false, - "system:org.example.App".to_string(), + "example-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "summary".to_string(), body: "body".to_string(), @@ -69,6 +69,10 @@ fn update_group_row_sets_title_count_and_expanded_state() { assert_eq!(widgets.avatar.height_request(), 26); assert_eq!(widgets.icon.pixel_size(), 18); assert_eq!(widgets.count.text().as_str(), "3"); + assert!(gtk::test_accessible_has_property( + &widgets.button, + gtk::AccessibleProperty::Label + )); assert_eq!( widgets.chevron.icon_name().as_deref(), Some("pan-down-symbolic") @@ -88,6 +92,24 @@ fn update_group_row_sets_title_count_and_expanded_state() { assert!(root.has_css_class("unixnotis-group-row-expanded")); } +#[test] +fn group_accessible_name_keeps_identity_trust_count_and_state() { + assert_eq!( + group_accessible_label( + "Unknown application", + "Suspicious", + "Claimed app: Signal", + 4, + true, + ), + "Unknown application. Suspicious. Claimed app: Signal. 4 notifications. Expanded" + ); + assert_eq!( + group_accessible_label("Signal", "", "", 1, false), + "Signal. 1 notification. Collapsed" + ); +} + #[gtk::test] fn update_group_row_falls_back_to_group_key_without_sample() { support::init_gtk(); @@ -116,6 +138,8 @@ fn update_group_row_keeps_conflict_warning_out_of_the_title() { let mut conflicting = notification("Unknown application").as_ref().clone(); conflicting.attribution = unixnotis_core::NotificationAttribution::conflict( "Trusted Brand", + "org.example.TrustedBrand", + unixnotis_core::AttributionReason::ExecutableMismatch, "source /tmp/sender-bin", "executable:1:2".to_string(), ); @@ -127,12 +151,15 @@ fn update_group_row_keeps_conflict_warning_out_of_the_title() { assert!(widgets .title .tooltip_text() - .is_some_and(|text| text.contains("Trusted Brand"))); + .is_some_and(|text| text.contains("/tmp/sender-bin"))); assert_eq!( widgets.icon.icon_name().as_deref(), Some("unixnotis-shield-warning-symbolic") ); - assert_eq!(widgets.secondary.text().as_str(), "Claims “Trusted Brand”"); + assert_eq!( + widgets.secondary.text().as_str(), + "Claimed app: Trusted Brand" + ); assert_eq!(widgets.trust_chip.text().as_str(), "Suspicious"); assert!(widgets.secondary.get_visible()); assert!(widgets.trust_chip.get_visible()); @@ -145,10 +172,9 @@ fn relay_group_header_keeps_claim_below_command_line_identity() { let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); let mut relayed = notification("Signal").as_ref().clone(); - relayed.attribution = unixnotis_core::NotificationAttribution::trusted_relay( + relayed.attribution = unixnotis_core::NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - true, "relay:notify-send:signal".to_string(), ); let data = RowData::group_header( @@ -170,7 +196,7 @@ fn relay_group_header_keeps_claim_below_command_line_identity() { assert_eq!(widgets.secondary.text().as_str(), "App label: Signal"); assert!(widgets.secondary.get_visible()); assert!(!widgets.trust_chip.get_visible()); - assert!(root.has_css_class("command-line")); + assert!(root.has_css_class("relay")); assert!(!root.has_css_class("unixnotis-attribution-warning")); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 3846e9dab..5a81123d4 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -43,7 +43,8 @@ impl NotificationList { } // Collapsed groups render the newest content row under their shared header - let stacked = !expanded && ids.len() > 1; + let collapsed_group_preview = !expanded && ids.len() > 1; + let stack_depth = collapsed_stack_depth(ids.len(), expanded); for (index, id) in ids.iter().enumerate() { if !expanded && index > 0 { break; @@ -62,8 +63,7 @@ impl NotificationList { let mut row = RowData::notification( entry.app_key.clone(), entry.view.clone(), - stacked, - 0, + collapsed_group_preview, expanded, entry.is_active, presentation, @@ -72,6 +72,7 @@ impl NotificationList { row.group_first = index == 0; row.group_last = !expanded || index + 1 == ids.len(); } + row.stack_depth = stack_depth; entry.item.update(row); items.push(entry.item.clone()); keys.push(RowKey::Notification { id: *id }); @@ -154,6 +155,21 @@ impl NotificationList { } } +pub(in crate::ui::notifications) const fn collapsed_stack_depth( + count: usize, + expanded: bool, +) -> u8 { + if expanded { + return 0; + } + // One rear card represents the second item while larger groups cap at two + if count >= 3 { + 2 + } else { + count.saturating_sub(1) as u8 + } +} + pub(in crate::ui::notifications) fn common_prefix_suffix( current: &[RowKey], next: &[RowKey], diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 102d3b806..cfec80226 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -106,7 +106,6 @@ impl NotificationList { app_key.clone(), view.clone(), false, - 0, false, is_active, presentation, diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 92c466360..b4fece55d 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -79,18 +79,18 @@ impl NotificationList { // Header count and card depth must move as one visible update self.dirty_groups.insert(entry.app_key.clone()); self.request_rebuild(); - debug!(id, active = is_active, "notification stack shape changed"); + debug!(id, active = is_active, "notification group shape changed"); return; } - // Compute stack state from cached grouping instead of rebuilding the store + // Compute preview state from cached grouping instead of rebuilding the store let expanded = self .group_expanded .get(&entry.app_key) .copied() .unwrap_or(false); let group_len = self.grouped_cache.get(&entry.app_key).map_or(0, Vec::len); - let stacked = collapsed_group_is_stacked(expanded, group_len); + let collapsed_group_preview = is_collapsed_group_preview(expanded, group_len); let presentation = super::item::RowPresentation { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, @@ -100,15 +100,16 @@ impl NotificationList { card_corners: self.notification_corners, }; // Update the row object in-place when the visible span stays identical - entry.item.update(super::item::RowData::notification( + let mut row = super::item::RowData::notification( entry.app_key.clone(), entry.view.clone(), - stacked, - 0, + collapsed_group_preview, expanded, entry.is_active, presentation, - )); + ); + row.stack_depth = super::blocks::collapsed_stack_depth(group_len, expanded); + entry.item.update(row); if let Some(ids) = self.grouped_cache.get(&entry.app_key) { if ids.first().copied() == Some(id) { let expanded = self @@ -256,7 +257,7 @@ const fn should_move_active_to_front( was_in_history || !was_in_active || !was_front } -const fn collapsed_group_is_stacked(expanded: bool, group_len: usize) -> bool { +const fn is_collapsed_group_preview(expanded: bool, group_len: usize) -> bool { !expanded && group_len > 1 } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index e285e70e0..1093b680f 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -75,15 +75,15 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert_eq!(header.count, 3); assert!(!header.expanded); let visible = items[1].data(); - assert!(visible.stacked); - assert_eq!(visible.stack_depth, 0); + assert!(visible.collapsed_group_preview); + assert_eq!(visible.stack_depth, 2); assert!(!visible.expanded); assert!(visible.group_first); assert!(visible.group_last); } #[gtk::test] -fn build_group_block_keeps_single_collapsed_notification_unstacked() { +fn build_group_block_keeps_single_notification_outside_collapsed_group_preview() { let mut list = support::make_list(); list.seed(vec![support::notification(1, "Terminal")], Vec::new()); let key = list.entries.get(&1).expect("entry").app_key.clone(); @@ -93,7 +93,7 @@ fn build_group_block_keeps_single_collapsed_notification_unstacked() { assert_eq!(items.len(), 1); let visible = items[0].data(); - assert!(!visible.stacked); + assert!(!visible.collapsed_group_preview); assert_eq!(visible.stack_depth, 0); assert!(!visible.group_first); assert!(!visible.group_last); @@ -129,7 +129,7 @@ fn build_group_block_expands_group_to_all_notifications() { ); for item in items.iter().skip(1) { let data = item.data(); - assert!(!data.stacked); + assert!(!data.collapsed_group_preview); assert_eq!(data.stack_depth, 0); assert!(data.expanded); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 81cdde272..f7cab344b 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -71,11 +71,19 @@ fn active_move_policy_covers_history_new_and_non_front_rows() { } #[test] -fn collapsed_group_stacked_policy_requires_collapsed_group_with_multiple_rows() { - assert!(!collapsed_group_is_stacked(false, 0)); - assert!(!collapsed_group_is_stacked(false, 1)); - assert!(collapsed_group_is_stacked(false, 2)); - assert!(!collapsed_group_is_stacked(true, 2)); +fn collapsed_group_preview_requires_a_collapsed_group_with_multiple_rows() { + assert!(!is_collapsed_group_preview(false, 0)); + assert!(!is_collapsed_group_preview(false, 1)); + assert!(is_collapsed_group_preview(false, 2)); + assert!(!is_collapsed_group_preview(true, 2)); +} + +#[test] +fn collapsed_group_depth_matches_master_and_caps_at_two_layers() { + assert_eq!(super::super::blocks::collapsed_stack_depth(1, false), 0); + assert_eq!(super::super::blocks::collapsed_stack_depth(2, false), 1); + assert_eq!(super::super::blocks::collapsed_stack_depth(4, false), 2); + assert_eq!(super::super::blocks::collapsed_stack_depth(4, true), 0); } #[test] diff --git a/crates/unixnotis-center/src/ui/notifications/store/update.rs b/crates/unixnotis-center/src/ui/notifications/store/update.rs index 0484b9151..27f095229 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/update.rs @@ -253,7 +253,7 @@ impl NotificationList { }) .count(); if range_count_mismatch(self.group_ranges.len(), expected_ranges) { - // Missing ranges leave later stack edits dependent on a full expand/collapse rebuild + // Missing ranges leave later group edits dependent on a full expand/collapse rebuild debug!( expected_ranges, actual_ranges = self.group_ranges.len(), diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index 274ec4894..0a5b8b3a3 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -88,7 +88,6 @@ fn bind_row_refreshes_notification_widget_and_tracks_item_updates() { Rc::from("terminal"), notification, false, - 0, false, true, RowPresentation::default(), @@ -111,7 +110,6 @@ fn bind_row_refreshes_notification_widget_and_tracks_item_updates() { Rc::from("terminal"), changed, false, - 0, false, true, RowPresentation::default(), @@ -133,7 +131,6 @@ fn unbind_disconnects_row_item_update_handler() { Rc::from("terminal"), notification, false, - 0, false, true, RowPresentation::default(), @@ -155,7 +152,6 @@ fn unbind_disconnects_row_item_update_handler() { Rc::from("terminal"), changed, false, - 0, false, true, RowPresentation::default(), diff --git a/crates/unixnotis-core/assets/base.css b/crates/unixnotis-core/assets/base.css index bd8d8398d..e3d44724e 100644 --- a/crates/unixnotis-core/assets/base.css +++ b/crates/unixnotis-core/assets/base.css @@ -105,7 +105,7 @@ .unixnotis-panel-window, .unixnotis-popup-window { background: transparent; - font-family: "Manrope", "SF Pro Text", "CaskaydiaCove Nerd Font Propo", "Noto Sans", sans-serif; + font-family: "Inter", "SF Pro Text", "Noto Sans", sans-serif; font-family: var(--unixnotis-ui-font-family); } diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index aa46499cf..99530c798 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -361,20 +361,21 @@ entry selection { min-height: 28px; min-height: var(--unixnotis-panel-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - opacity: 0; - transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } -.unixnotis-panel-card-overlay:hover .unixnotis-panel-close, -.unixnotis-panel-close:focus, .unixnotis-panel-close:hover { - opacity: 1; background: alpha(#fb7185, 0.16); border-color: alpha(#fb7185, 0.45); color: #fb7185; box-shadow: 0 0 8px alpha(#fb7185, 0.35); } +.unixnotis-panel-close:focus-visible { + border-color: alpha(@unixnotis-accent, 0.45); + outline: none; +} + .unixnotis-panel-list { background: transparent; } @@ -390,30 +391,23 @@ entry selection { */ .unixnotis-group { background: transparent; - margin-top: 12px; - margin-bottom: 0; + margin-top: 14px; + margin-bottom: 8px; } .unixnotis-group-header { - background: alpha(#ffffff, 0.035); + background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); color: @unixnotis-text; - border-radius: 14px 14px 0 0; - padding: 7px 8px; - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.03); - border-bottom: 1px solid alpha(#ffffff, 0.01); + border-radius: 999px; + padding: 6px 12px; + border: 1px solid @unixnotis-card-border; box-shadow: none; outline: none; transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-group-header:hover { - background: alpha(#ffffff, 0.05); - border-top-color: alpha(#ffffff, 0.14); - border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.05); - border-bottom-color: alpha(#ffffff, 0.02); + border-color: alpha(@unixnotis-accent, 0.22); } .unixnotis-group-header:focus, @@ -446,13 +440,21 @@ entry selection { color: alpha(#ffffff, 0.9); } -.unixnotis-group.command-line .unixnotis-group-avatar, -.unixnotis-group.unverified .unixnotis-group-avatar { +.unixnotis-group.relay .unixnotis-group-avatar { background: alpha(#fbbf24, 0.08); color: alpha(#fde68a, 0.90); } -.unixnotis-group.suspicious .unixnotis-group-avatar { +.unixnotis-group.unresolved .unixnotis-group-avatar { + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.82); +} + +.unixnotis-group.recognized .unixnotis-group-avatar { + border: 1px solid alpha(#ffffff, 0.10); +} + +.unixnotis-group.conflict .unixnotis-group-avatar { background: alpha(#fb7185, 0.11); color: #fecdd3; } @@ -472,7 +474,7 @@ entry selection { border: 1px solid alpha(#fbbf24, 0.18); } -.unixnotis-group.suspicious .unixnotis-group-trust-chip { +.unixnotis-group.conflict .unixnotis-group-trust-chip { background: alpha(#fb7185, 0.12); color: #fecdd3; border-color: alpha(#fb7185, 0.30); @@ -512,64 +514,70 @@ entry selection { } .unixnotis-panel-card { - background-image: linear-gradient(135deg, alpha(#17253f, 0.90), alpha(#12182c, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - padding: 10px 12px; + background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); + border: 1px solid @unixnotis-card-border; + border-radius: var(--unixnotis-notification-card-radius); padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); - margin-top: 12px; - margin-bottom: 0; - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); - transition: border-color 0.15s ease-out; + margin: 0; + box-shadow: + 0 12px 26px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.04); + transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } -.unixnotis-panel-card.stacked { - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); +.unixnotis-panel-card.collapsed-group-preview { + box-shadow: + 0 12px 24px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.04); } -.unixnotis-panel-card.unixnotis-panel-card-group-collapsed { - margin-top: 0; - margin-bottom: 12px; - border-radius: 0 0 14px 14px; - padding-top: 9px; - padding-bottom: 9px; - box-shadow: none; +.unixnotis-panel-card-foreground { + margin-bottom: 8px; } -.unixnotis-panel-card.unixnotis-panel-card-group-expanded { - margin-top: 0; - margin-bottom: 0; - border-radius: 0; - padding-top: 9px; - padding-bottom: 9px; - border-top-color: alpha(#ffffff, 0.065); - box-shadow: none; +.unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { + margin-left: 8px; } -.unixnotis-panel-card.unixnotis-panel-card-group-last { - border-radius: 0 0 14px 14px; - margin-bottom: 12px; +.unixnotis-panel-card-foreground.unixnotis-panel-card-group-collapsed { + margin-top: -58px; } -.unixnotis-panel-card.unixnotis-panel-card-group-first { - border-top-color: alpha(#ffffff, 0.07); +.unixnotis-panel-card-foreground.unixnotis-panel-card-group-expanded { + margin-bottom: var(--unixnotis-panel-card-gap); } -.unixnotis-panel-card.active { - background-image: linear-gradient(135deg, alpha(#1a2e50, 0.93), alpha(#111627, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.09); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.03), 0 6px 16px -8px alpha(#000000, 0.8); +.unixnotis-stack-ghost { + background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.10); + border-radius: 18px; + padding: 0; + min-height: 68px; + margin-right: 10px; + margin-bottom: 0; + margin-left: 10px; + border: 1px solid alpha(@unixnotis-card-border, 0.62); + box-shadow: + 0 -2px 10px -8px alpha(@unixnotis-accent, 0.22), + 0 8px 14px -14px @unixnotis-shadow-soft; } -.unixnotis-panel-card.stacked.active { - /* Active state should not erase the collapsed-stack shadow */ - box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); +.unixnotis-stack-ghost-1 { + margin-top: -58px; +} + +.unixnotis-stack-ghost-2 { + background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.06); + margin-top: 0; + margin-right: 20px; + margin-left: 20px; + border-color: alpha(@unixnotis-card-border, 0.42); + box-shadow: + 0 -2px 10px -9px alpha(@unixnotis-accent, 0.14), + 0 10px 16px -15px @unixnotis-shadow-soft; +} + +.unixnotis-panel-card.active { + border-color: alpha(@unixnotis-card-border, 0.95); } /* Critical state composes after the ordinary active and stack rules */ @@ -603,23 +611,6 @@ entry selection { color: #ffffff; } -.unixnotis-panel-card.stacked.critical, -.unixnotis-panel-card.stacked.active.critical { - /* Urgent stacks keep the same depth while using the critical border color */ - background-image: linear-gradient( - 145deg, - alpha(@unixnotis-critical-surface, 0.82), - alpha(#12182c, 0.97) - ); - border-color: alpha(@unixnotis-critical-border, 0.48); - box-shadow: - 0 8px 0 -4px alpha(@unixnotis-critical-border, 0.18), - 0 16px 0 -8px alpha(@unixnotis-critical-border, 0.12), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 20px -16px alpha(@unixnotis-critical-border, 0.32), - inset 0 0 0 1px alpha(#ffffff, 0.05); -} - .unixnotis-panel-card-has-actions .unixnotis-notification-actions { margin-top: calc(var(--unixnotis-panel-action-gap) - 4px); } @@ -659,11 +650,11 @@ entry selection { } .unixnotis-panel-app { - font-weight: 700; - color: #a3b3cc; - font-size: 10px; - letter-spacing: 0.07em; - text-transform: uppercase; + color: alpha(@unixnotis-text, 0.78); + font-size: 12px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; } .unixnotis-panel-secondary-claim { @@ -681,7 +672,7 @@ entry selection { border: 1px solid alpha(#fbbf24, 0.18); } -.unixnotis-panel-card.suspicious .unixnotis-panel-trust-chip { +.unixnotis-panel-card.conflict .unixnotis-panel-trust-chip { background: alpha(#fb7185, 0.12); color: #fecdd3; border-color: alpha(#fb7185, 0.30); @@ -689,12 +680,12 @@ entry selection { .unixnotis-panel-summary { font-size: 13px; - color: #ffffff; - font-weight: 700; + color: @unixnotis-text; + font-weight: 650; } .unixnotis-panel-body { - color: #cbd5e1; + color: @unixnotis-muted; font-size: 12px; } @@ -714,13 +705,21 @@ entry selection { color: alpha(#ffffff, 0.90); } -.unixnotis-panel-card.command-line .unixnotis-panel-icon, -.unixnotis-panel-card.unverified .unixnotis-panel-icon { +.unixnotis-panel-card.relay .unixnotis-panel-icon { background: alpha(#fbbf24, 0.08); color: alpha(#fde68a, 0.90); } -.unixnotis-panel-card.suspicious .unixnotis-panel-icon { +.unixnotis-panel-card.unresolved .unixnotis-panel-icon { + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.82); +} + +.unixnotis-panel-card.recognized .unixnotis-panel-icon { + border: 1px solid alpha(#ffffff, 0.10); +} + +.unixnotis-panel-card.conflict .unixnotis-panel-icon { background: alpha(#fb7185, 0.11); color: #fecdd3; } @@ -781,21 +780,17 @@ entry selection { } .unixnotis-panel-card:hover { - background-image: linear-gradient(135deg, alpha(#21355a, 0.92), alpha(#161e38, 0.97)); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04), 0 10px 24px -16px alpha(#000000, 0.85); + border-color: alpha(@unixnotis-accent, 0.22); + box-shadow: + 0 14px 28px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.05); } .unixnotis-panel-card.active:hover { - background-image: linear-gradient(135deg, alpha(#264375, 0.95), alpha(#151b32, 0.97)); - border-top: 1px solid alpha(#ffffff, 0.18); - border-left: 1px solid alpha(#ffffff, 0.14); - border-right: 1px solid alpha(#ffffff, 0.08); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05), 0 12px 28px -14px alpha(#000000, 0.9); + border-color: alpha(@unixnotis-accent, 0.28); + box-shadow: + 0 14px 28px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.05); } /* diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index a293207ba..8ab0cbe90 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -2,7 +2,7 @@ /* Shared close button styling for popup surfaces */ .unixnotis-popup-close { - background: alpha(#ffffff, 0.045); + background: alpha(#ffffff, 0.055); border-radius: 999px; border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); @@ -15,12 +15,12 @@ min-height: var(--unixnotis-popup-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.70); - opacity: 0.62; - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; + opacity: 0; + transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-popup-card:hover .unixnotis-popup-close, -.unixnotis-popup-close:focus { +.unixnotis-popup-close:focus-visible { opacity: 1; } @@ -46,16 +46,15 @@ } .unixnotis-popup-card { - background-image: linear-gradient(135deg, alpha(#141d30, 0.94), alpha(#0a0e1a, 0.98)); - color: #ffffff; - border-radius: 20px; + background-image: linear-gradient(165deg, alpha(@unixnotis-popup-bg-2, 0.94), alpha(@unixnotis-popup-bg-1, 0.98)); + color: @unixnotis-text; border-radius: var(--unixnotis-popup-card-radius); - padding: 14px; padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); - border: 1px solid alpha(#ffffff, 0.10); + border: 1px solid alpha(@unixnotis-card-border, 0.90); + font-family: "Inter", "Noto Sans", sans-serif; box-shadow: - 0 12px 32px -12px alpha(#000000, 0.56), - 0 2px 8px -4px alpha(#000000, 0.36); + 0 14px 34px -18px @unixnotis-shadow-strong, + inset 0 1px 0 alpha(#ffffff, 0.035); } .unixnotis-popup-card.utility { @@ -64,24 +63,27 @@ } .unixnotis-popup-communication-content, -.unixnotis-popup-utility-content, -.unixnotis-popup-warning-content { +.unixnotis-popup-utility-content { background: transparent; } -.unixnotis-popup-header-row { - min-height: 20px; - margin-bottom: 1px; +.unixnotis-popup-content-grid { + min-width: 0; +} + +.unixnotis-popup-identity-row, +.unixnotis-popup-message { + min-width: 0; } .unixnotis-popup-app-name { - color: alpha(#ffffff, 0.76); + color: alpha(@unixnotis-text, 0.78); font-weight: 600; font-size: 12px; } .unixnotis-popup-time { - color: alpha(#ffffff, 0.56); + color: alpha(@unixnotis-text, 0.52); font-weight: 400; font-size: 11px; } @@ -93,22 +95,23 @@ font-weight: 600; } -.unixnotis-popup-trust-chip.unverified, -.unixnotis-popup-trust-chip.command-line { +.unixnotis-popup-trust-chip.recognized, +.unixnotis-popup-trust-chip.unresolved, +.unixnotis-popup-trust-chip.relay { background: alpha(#fbbf24, 0.10); color: alpha(#fde68a, 0.84); border: 1px solid alpha(#fbbf24, 0.20); } -.unixnotis-popup-trust-chip.suspicious { +.unixnotis-popup-trust-chip.conflict { background: alpha(#fb7185, 0.13); color: #fecdd3; border: 1px solid alpha(#fb7185, 0.34); } .unixnotis-popup-summary { - font-weight: 700; - font-size: 16px; + font-weight: 650; + font-size: 15px; margin-top: 1px; } @@ -117,34 +120,36 @@ } .unixnotis-identity-avatar { - min-width: 36px; - min-height: 36px; + min-width: 38px; + min-height: 38px; border-radius: 11px; background: alpha(#ffffff, 0.07); color: alpha(#ffffff, 0.92); } -.unixnotis-identity-avatar.verified { - min-width: 44px; - min-height: 44px; - border-radius: 13px; +.unixnotis-identity-avatar.recognized { + border: 1px solid alpha(#ffffff, 0.10); } -.unixnotis-identity-avatar.command-line, -.unixnotis-identity-avatar.unverified { +.unixnotis-identity-avatar.relay { background: alpha(#fbbf24, 0.08); color: alpha(#fde68a, 0.90); } -.unixnotis-identity-avatar.suspicious { +.unixnotis-identity-avatar.unresolved { + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.82); +} + +.unixnotis-identity-avatar.conflict { background: alpha(#fb7185, 0.12); color: #fecdd3; } .unixnotis-popup-body { - color: #cbd5e1; + color: @unixnotis-muted; font-weight: 400; - font-size: 14px; + font-size: 13px; margin-top: 2px; } @@ -155,30 +160,31 @@ } .unixnotis-popup-secondary-claim { - color: alpha(#ffffff, 0.58); + color: alpha(@unixnotis-text, 0.58); font-size: 12px; font-weight: 400; margin-top: 1px; } .unixnotis-popup-content-image { - min-width: 48px; - min-height: 48px; + min-width: 64px; + min-height: 64px; margin-top: 6px; border-radius: 9px; } -.unixnotis-popup-card.unverified { - border-color: alpha(#ffffff, 0.10); +.unixnotis-popup-card.recognized, +.unixnotis-popup-card.unresolved { + border-color: alpha(@unixnotis-card-border, 0.90); } -.unixnotis-popup-card.command-line { - border-color: alpha(#ffffff, 0.12); +.unixnotis-popup-card.relay { + border-color: alpha(@unixnotis-card-border, 0.96); } -.unixnotis-popup-card.suspicious { - border-color: alpha(#fb7185, 0.42); - box-shadow: 0 12px 32px -12px alpha(#000000, 0.58); +.unixnotis-popup-card.conflict { + border-color: alpha(@unixnotis-critical-border, 0.48); + box-shadow: 0 14px 34px -18px @unixnotis-shadow-strong; } .unixnotis-popup-actions { diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 97a63313c..ca1ca9056 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -6,7 +6,7 @@ pub mod shared_state { pub const CRITICAL: &str = "critical"; pub const EMPTY: &str = "empty"; pub const PLAYING: &str = "playing"; - pub const STACKED: &str = "stacked"; + pub const COLLAPSED_GROUP_PREVIEW: &str = "collapsed-group-preview"; } pub mod urgency { @@ -212,11 +212,6 @@ pub mod empty_row { pub const LABEL: &str = "unixnotis-empty-label"; } -pub mod ghost_row { - pub const ROOT: &str = "unixnotis-stack-ghost"; - pub const DEPTH_PREFIX: &str = "unixnotis-stack-ghost-"; -} - pub mod media_card { pub const EMPTY_ARTIST: &str = "unixnotis-media-card-empty-artist"; pub const HAS_ART: &str = "unixnotis-media-card-has-art"; diff --git a/crates/unixnotis-core/src/css/hooks/mod.rs b/crates/unixnotis-core/src/css/hooks/mod.rs index 1186386f1..ca2b39cd7 100644 --- a/crates/unixnotis-core/src/css/hooks/mod.rs +++ b/crates/unixnotis-core/src/css/hooks/mod.rs @@ -3,9 +3,8 @@ mod classes; pub use self::classes::{ - cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, - panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, - toggle_card, urgency, + cut_corner, dnd_menu, empty_row, group_row, info_card, media_card, media_shell, panel_action, + panel_card, panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, urgency, }; #[cfg(test)] diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index d5ce32c96..ef40957f7 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -3,9 +3,8 @@ use std::collections::HashSet; use super::{ - cut_corner, dnd_menu, empty_row, ghost_row, group_row, info_card, media_card, media_shell, - panel_action, panel_card, panel_shell, popup_card, shared_state, slider, stat_card, - toggle_card, urgency, + cut_corner, dnd_menu, empty_row, group_row, info_card, media_card, media_shell, panel_action, + panel_card, panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, urgency, }; #[test] @@ -27,7 +26,7 @@ fn hook_names_stay_unique() { shared_state::CRITICAL, shared_state::EMPTY, shared_state::PLAYING, - shared_state::STACKED, + shared_state::COLLAPSED_GROUP_PREVIEW, urgency::BADGE, panel_action::FOCUS, panel_action::PRIMARY, @@ -180,8 +179,6 @@ fn hook_names_stay_unique() { group_row::NO_ICON, empty_row::ROOT, empty_row::LABEL, - ghost_row::ROOT, - ghost_row::DEPTH_PREFIX, media_card::EMPTY_ARTIST, media_card::HAS_ART, media_card::HAS_ARTIST, @@ -252,11 +249,11 @@ fn stock_panel_css_targets_real_group_card_hooks() { assert!(css.contains(&format!(".{}", panel_card::GROUP_COLLAPSED))); assert!(css.contains(&format!(".{}", panel_card::GROUP_EXPANDED))); assert!(css.contains(&format!( - ".unixnotis-panel-card.{}", + ".unixnotis-panel-card-foreground.{}", panel_card::GROUP_COLLAPSED ))); assert!(css.contains(&format!( - ".unixnotis-panel-card.{}", + ".unixnotis-panel-card-foreground.{}", panel_card::GROUP_EXPANDED ))); @@ -279,19 +276,26 @@ fn stock_group_count_stays_neutral_during_header_hover() { } #[test] -fn stock_panel_css_avoids_decorative_stack_ghosts_and_negative_overlap() { +fn stock_panel_css_preserves_master_style_collapsed_stack_layers() { let css = crate::theme::DEFAULT_PANEL_CSS; - assert!(!css.contains(".unixnotis-stack-ghost")); - assert!(!css.contains("margin-top: -58px;")); + // Rear silhouettes make a collapsed group visibly different from one card + assert!(css.contains(".unixnotis-stack-ghost {")); + assert!(css.contains(".unixnotis-stack-ghost-1 {")); + assert!(css.contains(".unixnotis-stack-ghost-2 {")); + + // Only the foreground overlaps the rear layers + assert!(css.contains( + ".unixnotis-panel-card-foreground.unixnotis-panel-card-group-collapsed {\n margin-top: -58px;" + )); } #[test] -fn stock_panel_close_control_stays_quiet_until_hover_or_focus() { +fn stock_panel_close_control_remains_available_without_hover() { let css = crate::theme::DEFAULT_PANEL_CSS; assert!(css.contains(".unixnotis-panel-close {\n")); - assert!(css.contains("opacity: 0;")); - assert!(css.contains(".unixnotis-panel-card-overlay:hover .unixnotis-panel-close")); - assert!(css.contains(".unixnotis-panel-close:focus")); + assert!(!css.contains(".unixnotis-panel-card-overlay:hover .unixnotis-panel-close")); + assert!(css.contains(".unixnotis-panel-close:hover")); + assert!(css.contains(".unixnotis-panel-close:focus-visible")); } diff --git a/crates/unixnotis-core/src/css/tests/tokens.rs b/crates/unixnotis-core/src/css/tests/tokens.rs index e9c754ab6..c4c85bf00 100644 --- a/crates/unixnotis-core/src/css/tests/tokens.rs +++ b/crates/unixnotis-core/src/css/tests/tokens.rs @@ -48,11 +48,11 @@ fn modern_theme_custom_properties_stay_additive() { assert!(overrides.contains(":root {")); assert!(overrides.contains("--unixnotis-border-width: 2px;")); assert!(overrides.contains("--unixnotis-card-radius: 12px;")); - assert!(overrides.contains("--unixnotis-panel-card-padding-y: 10px;")); + assert!(overrides.contains("--unixnotis-panel-card-padding-y: 9px;")); assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); assert!(overrides.contains("--unixnotis-media-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-media-title-font-size: 13px;")); - assert!(overrides.contains("--unixnotis-ui-font-family: \"Manrope\", \"SF Pro Text\",")); + assert!(overrides.contains("--unixnotis-ui-font-family: \"Inter\", \"SF Pro Text\",")); assert!( overrides.contains("--unixnotis-monospace-font-family: \"CaskaydiaCove Nerd Font Mono\",") ); diff --git a/crates/unixnotis-core/src/css/tokens.rs b/crates/unixnotis-core/src/css/tokens.rs index f4475af31..524b5e502 100644 --- a/crates/unixnotis-core/src/css/tokens.rs +++ b/crates/unixnotis-core/src/css/tokens.rs @@ -214,7 +214,7 @@ const fn layout_tokens() -> &'static [(&'static str, &'static str)] { &[ ( "--unixnotis-ui-font-family", - r#""Manrope", "SF Pro Text", "CaskaydiaCove Nerd Font Propo", "Noto Sans", sans-serif"#, + r#""Inter", "SF Pro Text", "Noto Sans", sans-serif"#, ), ( "--unixnotis-monospace-font-family", @@ -224,19 +224,19 @@ const fn layout_tokens() -> &'static [(&'static str, &'static str)] { ("--unixnotis-panel-padding", "16px"), ("--unixnotis-panel-header-radius", "18px"), ("--unixnotis-panel-header-padding", "12px"), - ("--unixnotis-panel-card-padding-y", "10px"), - ("--unixnotis-panel-card-padding-x", "12px"), + ("--unixnotis-panel-card-padding-y", "9px"), + ("--unixnotis-panel-card-padding-x", "11px"), ("--unixnotis-panel-card-gap", "8px"), ("--unixnotis-panel-action-gap", "6px"), ("--unixnotis-panel-close-size", "28px"), ("--unixnotis-panel-search-min-height", "34px"), ("--unixnotis-panel-search-padding-x", "10px"), - ("--unixnotis-notification-card-radius", "20px"), + ("--unixnotis-notification-card-radius", "14px"), ("--unixnotis-notification-action-padding-y", "4px"), ("--unixnotis-notification-action-padding-x", "10px"), ("--unixnotis-popup-stack-padding", "8px"), - ("--unixnotis-popup-card-radius", "22px"), - ("--unixnotis-popup-card-padding-y", "14px"), + ("--unixnotis-popup-card-radius", "18px"), + ("--unixnotis-popup-card-padding-y", "12px"), ("--unixnotis-popup-card-padding-x", "14px"), ("--unixnotis-popup-actions-gap", "6px"), ("--unixnotis-popup-close-size", "24px"), diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 0d3b5bd85..d7be44597 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -118,7 +118,6 @@ fn critical_alert_assets_define_composed_popup_and_panel_states() { ".unixnotis-popup-card.critical", ".unixnotis-popup-card.critical .unixnotis-popup-icon", ".unixnotis-panel-card.critical,\n.unixnotis-panel-card.active.critical", - ".unixnotis-panel-card.stacked.critical,\n.unixnotis-panel-card.stacked.active.critical", ".unixnotis-panel-card.critical .unixnotis-panel-icon", ] { let css = if selector.contains("popup") { @@ -140,9 +139,10 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { ".unixnotis-popup-card.utility", ".unixnotis-popup-communication-content", ".unixnotis-popup-utility-content", - ".unixnotis-popup-warning-content", - ".unixnotis-popup-trust-chip.unverified", - ".unixnotis-popup-trust-chip.suspicious", + ".unixnotis-popup-trust-chip.recognized", + ".unixnotis-popup-trust-chip.unresolved", + ".unixnotis-popup-trust-chip.relay", + ".unixnotis-popup-trust-chip.conflict", ".unixnotis-popup-time", ] { assert!( @@ -153,7 +153,7 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { // Default popups must not restore the old raw provenance body row assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 36px")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 44px")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 48px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 38px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); + assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); } diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 88b95bdda..dd9847703 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -2,7 +2,7 @@ use gtk::prelude::*; use gtk::Align; -use unixnotis_core::{hooks, NotificationView}; +use unixnotis_core::{hooks, NotificationKey, NotificationView}; use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; @@ -12,6 +12,7 @@ use super::builders::{ }; use super::commands::try_send_command; use super::presentation::PopupEntryViewModel; +use super::PopupVisibilityBinding; use crate::dbus::UiCommand; pub(in crate::ui) struct PopupEntry { @@ -20,6 +21,7 @@ pub(in crate::ui) struct PopupEntry { // Hidden backlog rows stay lightweight until they enter the visible slice pub(in crate::ui) revealer: Option, pub(in crate::ui) root: Option, + pub(in crate::ui) visibility: Option, } impl PopupEntry { @@ -29,6 +31,7 @@ impl PopupEntry { notification, revealer: None, root: None, + visibility: None, } } @@ -45,19 +48,20 @@ impl UiState { ) -> PopupEntry { // Build the GTK row first so the revealer always wraps a ready child let root = self.build_popup_root(notification); - let revealer = self.build_popup_revealer(&root); + let (revealer, visibility) = self.build_popup_revealer(&root, notification.key()); PopupEntry { // Store the payload used to build this row so later seeds can compare safely notification: notification.clone(), revealer: Some(revealer), root: Some(root), + visibility: Some(visibility), } } pub(in crate::ui) fn build_popup_root(&mut self, notification: &NotificationView) -> gtk::Box { let view = PopupEntryViewModel::for_notification(notification); - let root = build_card_root(self, &view); + let root = build_card_root(&view); let close = build_close_button(); let rendered = build_popup_content(self, notification, &view); let content = gtk::Box::new(gtk::Orientation::Vertical, 6); @@ -91,7 +95,11 @@ impl UiState { root } - fn build_popup_revealer(&self, root: >k::Box) -> gtk::Revealer { + fn build_popup_revealer( + &self, + root: >k::Box, + key: NotificationKey, + ) -> (gtk::Revealer, PopupVisibilityBinding) { // Revealers keep entry animations out of the popup list bookkeeping let revealer = gtk::Revealer::new(); revealer.add_css_class("unixnotis-popup-revealer"); @@ -118,30 +126,40 @@ impl UiState { let popup_window = self.popup_window.clone(); let popup_stack = self.popup_stack.clone(); let popup_input_region = self.popup_input_region.clone(); - revealer.connect_notify_local(Some("child-revealed"), move |_, _| { - // Refresh after reveal so action rows never inherit an earlier empty input region - refresh_popup_input_region(&popup_window, &popup_stack, &popup_input_region); + let command_tx = self.command_tx.clone(); + let visibility = PopupVisibilityBinding::new(key); + revealer.connect_notify_local(Some("child-revealed"), { + let reveal_window = popup_window.clone(); + let reveal_command_tx = command_tx.clone(); + let reveal_visibility = visibility.clone(); + move |revealer, _| { + // Refresh after reveal so actions never inherit an earlier empty input region + refresh_popup_input_region(&reveal_window, &popup_stack, &popup_input_region); + reveal_visibility.report_if_visible(revealer, &reveal_window, &reveal_command_tx); + } + }); + revealer.connect_map({ + let visibility = visibility.clone(); + move |revealer| { + // Reduced-motion rows may finish revealing before their surface maps + visibility.report_if_visible(revealer, &popup_window, &command_tx); + } }); - revealer + (revealer, visibility) } } -fn build_card_root(state: &UiState, view: &PopupEntryViewModel) -> gtk::Box { +fn build_card_root(view: &PopupEntryViewModel) -> gtk::Box { let root = gtk::Box::new(gtk::Orientation::Vertical, 6); root.add_css_class("unixnotis-popup-card"); root.add_css_class(view.kind.css_class()); root.add_css_class(view.trust.level.css_class()); - // Use the live stack width when a row is built or rebuilt - let popup_width = state - .popup_stack - .width() - .max(state.popup_stack.width_request()) - .max(1); - root.set_size_request(popup_width, -1); + // The stack owns the outer width and its CSS padding + // Cards fill the remaining allocation without requesting the outer width again root.set_halign(Align::Fill); - root.set_hexpand(false); + root.set_hexpand(true); // New roots stay hidden until visibility logic decides otherwise root.set_visible(false); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index f88b348ee..f99b12360 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -20,12 +20,16 @@ pub(super) fn build_identity_avatar( notification: &NotificationView, view: &PopupEntryViewModel, size: i32, -) -> Option { +) -> IdentityAvatar { let icon_size = (size - 14).max(18); let icon = build_semantic_badge(view.badge, icon_size) - .or_else(|| state.build_app_icon_widget(notification, icon_size))?; + .or_else(|| state.build_app_icon_widget(notification, icon_size)) + .unwrap_or_else(|| gtk::Image::from_icon_name("application-x-executable-symbolic")); + icon.set_pixel_size(icon_size); + icon.set_size_request(icon_size, icon_size); icon.set_valign(Align::Center); icon.set_halign(Align::Center); + icon.set_accessible_role(gtk::AccessibleRole::Presentation); icon.add_css_class("unixnotis-popup-icon"); let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); @@ -35,16 +39,24 @@ pub(super) fn build_identity_avatar( avatar.add_css_class("unixnotis-identity-avatar"); avatar.add_css_class(view.trust.level.css_class()); avatar.append(&icon); - Some(IdentityAvatar { widget: avatar }) + IdentityAvatar { widget: avatar } } -pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> gtk::Box { - let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); - header.add_css_class("unixnotis-popup-header-row"); - header.set_margin_end(30); +pub(super) struct IdentityHeader { + pub(super) identity: gtk::Box, + pub(super) trailing: gtk::Box, +} + +pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeader { + let identity = gtk::Box::new(gtk::Orientation::Horizontal, 6); + identity.add_css_class("unixnotis-popup-identity-row"); + identity.set_hexpand(true); + identity.set_halign(Align::Fill); let app = gtk::Label::new(Some(&view.app_label)); app.set_xalign(0.0); + app.set_hexpand(true); + app.set_halign(Align::Fill); app.set_single_line_mode(true); app.set_ellipsize(EllipsizeMode::End); app.add_css_class("unixnotis-popup-app-name"); @@ -52,20 +64,28 @@ pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> gtk::Box { // Raw paths remain available on demand without entering normal card content app.set_tooltip_text(Some(details)); } - header.append(&app); + identity.append(&app); if let Some(chip) = build_trust_chip(&view.trust) { - header.append(&chip); + identity.append(&chip); } - header.append(&build_header_spacer()); - header.append(&build_urgency_badge(view.critical)); + let trailing = gtk::Box::new(gtk::Orientation::Vertical, 2); + trailing.add_css_class("unixnotis-popup-trailing"); + trailing.set_halign(Align::End); + trailing.set_valign(Align::Start); + trailing.set_margin_end(26); let time = gtk::Label::new(Some(&view.timestamp_label)); time.set_single_line_mode(true); + time.set_halign(Align::End); time.add_css_class("unixnotis-popup-time"); - header.append(&time); - header + trailing.append(&time); + + let urgency = build_urgency_badge(view.critical); + urgency.set_halign(Align::End); + trailing.append(&urgency); + IdentityHeader { identity, trailing } } pub(super) fn build_title_label(view: &PopupEntryViewModel) -> Option { @@ -110,8 +130,9 @@ pub(super) fn build_secondary_claim(view: &PopupEntryViewModel) -> Option gtk::Label { - let badge = gtk::Label::new(Some("Critical")); + let badge = gtk::Label::new(Some("!")); // The stable node keeps header spacing predictable across urgency changes badge.add_css_class(hooks::urgency::BADGE); badge.set_single_line_mode(true); + badge.set_tooltip_text(Some("Critical notification")); + badge.update_property(&[gtk::accessible::Property::Label("Critical notification")]); badge.set_visible(is_critical); badge } @@ -219,13 +242,6 @@ fn build_trust_chip(trust: &PopupTrustPresentation) -> Option { Some(chip) } -fn build_header_spacer() -> gtk::Box { - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // The expanding spacer anchors time and close controls to the trailing edge - spacer.set_hexpand(true); - spacer -} - #[cfg(test)] #[path = "tests/common.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs index 637f292b2..557c31609 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs @@ -1,52 +1,25 @@ //! Communication popup with quiet application identity and message-first hierarchy -use gtk::prelude::*; use unixnotis_core::NotificationView; -use super::common::{ - build_body_label, build_identity_avatar, build_identity_header, build_reply_note, - build_secondary_claim, build_title_label, -}; -use super::{append_thumbnail, RenderedPopup}; +use super::layout::{build_popup_grid, PopupLayout}; +use super::RenderedPopup; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const COMMUNICATION_AVATAR_SIZE: i32 = 44; - pub(super) fn build_communication_popup( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, ) -> RenderedPopup { - let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); - main.add_css_class("unixnotis-popup-communication-content"); - let avatar = build_identity_avatar(state, notification, view, COMMUNICATION_AVATAR_SIZE); - if let Some(avatar) = avatar.as_ref() { - main.append(&avatar.widget); - } - let content = gtk::Box::new(gtk::Orientation::Vertical, 3); - content.set_hexpand(true); - - // Communication cards read as app identity, sender, then message preview - content.append(&build_identity_header(view)); - if let Some(claim) = build_secondary_claim(view) { - content.append(&claim); - } - if let Some(title) = build_title_label(view) { - content.append(&title); - } - if let Some(body) = build_body_label(view, 3) { - content.append(&body); - } - let has_image = append_thumbnail(state, notification, view, &content); - if let Some(note) = build_reply_note(view) { - content.append(¬e); - } - main.append(&content); - - RenderedPopup { - widget: main, - has_icon: avatar.is_some(), - has_image, - } + build_popup_grid( + state, + notification, + view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 3, + show_reply_note: true, + }, + ) } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs new file mode 100644 index 000000000..b0752eacf --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -0,0 +1,91 @@ +//! Shared three-column popup composition + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::common::{ + build_body_label, build_identity_avatar, build_identity_header, build_reply_note, + build_secondary_claim, build_title_label, +}; +use super::{append_thumbnail, RenderedPopup}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +const POPUP_IDENTITY_SIZE: i32 = 38; + +pub(super) struct PopupLayout { + pub(super) css_class: &'static str, + pub(super) body_lines: i32, + pub(super) show_reply_note: bool, +} + +pub(super) fn build_popup_grid( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + layout: PopupLayout, +) -> RenderedPopup { + let grid = gtk::Grid::new(); + grid.add_css_class(layout.css_class); + grid.add_css_class("unixnotis-popup-content-grid"); + grid.set_column_spacing(10); + grid.set_row_spacing(2); + grid.set_hexpand(true); + grid.set_accessible_role(gtk::AccessibleRole::Group); + let accessible_label = popup_accessible_label(view); + grid.update_property(&[gtk::accessible::Property::Label(&accessible_label)]); + + let avatar = build_identity_avatar(state, notification, view, POPUP_IDENTITY_SIZE); + grid.attach(&avatar.widget, 0, 0, 1, 2); + + let header = build_identity_header(view); + grid.attach(&header.identity, 1, 0, 1, 1); + grid.attach(&header.trailing, 2, 0, 1, 1); + + let message = gtk::Box::new(gtk::Orientation::Vertical, 2); + message.add_css_class("unixnotis-popup-message"); + message.set_hexpand(true); + if let Some(claim) = build_secondary_claim(view) { + message.append(&claim); + } + if let Some(title) = build_title_label(view) { + message.append(&title); + } + if let Some(body) = build_body_label(view, layout.body_lines) { + message.append(&body); + } + let has_image = append_thumbnail(state, notification, view, &message); + if layout.show_reply_note { + if let Some(note) = build_reply_note(view) { + message.append(¬e); + } + } + grid.attach(&message, 1, 1, 2, 1); + + RenderedPopup { + widget: grid, + has_icon: true, + has_image, + } +} + +fn popup_accessible_label(view: &PopupEntryViewModel) -> String { + let mut parts = vec![view.app_label.trim()]; + if let Some(trust) = view.trust.short_label.as_deref() { + parts.push(trust.trim()); + } + if let Some(claim) = view.secondary_claim.as_deref() { + parts.push(claim.trim()); + } + if !view.title.trim().is_empty() { + parts.push(view.title.trim()); + } + if let Some(body) = view.body.as_deref().filter(|body| !body.trim().is_empty()) { + parts.push(body.trim()); + } + parts.join(". ") +} + +#[cfg(test)] +#[path = "tests/layout.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index f6c78d6a6..c27c83889 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -2,9 +2,9 @@ mod common; mod communication; +mod layout; mod reply; mod utility; -mod warning; use gtk::prelude::*; use unixnotis_core::NotificationView; @@ -17,7 +17,7 @@ pub(in crate::ui::entry) use reply::build_inline_reply; /// Result of building one kind-specific card body pub(super) struct RenderedPopup { - pub(super) widget: gtk::Box, + pub(super) widget: gtk::Grid, pub(super) has_icon: bool, pub(super) has_image: bool, } @@ -32,8 +32,9 @@ pub(super) fn build_popup_content( PopupKind::Communication => { communication::build_communication_popup(state, notification, view) } - PopupKind::Utility => utility::build_utility_popup(state, notification, view), - PopupKind::Warning => warning::build_warning_popup(state, notification, view), + PopupKind::Utility | PopupKind::Media => { + utility::build_utility_popup(state, notification, view) + } } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs index b2d81af7e..85b161816 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs @@ -1,6 +1,6 @@ use gtk::prelude::*; use unixnotis_core::{ - Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -82,14 +82,14 @@ fn notification() -> NotificationView { id: 7, generation: 11, app_name: "Example".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Example", "Example", "org.example.App", - "org.example.App", - "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.App".to_string(), + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "New message".to_string(), body: "Are you coming?".to_string(), diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index e54b4185d..ffa10a011 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -1,10 +1,11 @@ use super::{ - build_action_row, build_body_label, build_close_button, build_header_spacer, - build_identity_avatar, build_reply_note, build_title_label, build_urgency_badge, + build_action_row, build_body_label, build_close_button, build_identity_avatar, + build_identity_header, build_reply_note, build_secondary_claim, build_title_label, + build_urgency_badge, }; use gtk::prelude::*; use unixnotis_core::{ - Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -20,7 +21,11 @@ fn popup_critical_badge_uses_shared_hook_and_visibility() { let normal = build_urgency_badge(false); assert!(critical.has_css_class(unixnotis_core::hooks::urgency::BADGE)); - assert_eq!(critical.text().as_str(), "Critical"); + assert_eq!(critical.text().as_str(), "!"); + assert_eq!( + critical.tooltip_text().as_deref(), + Some("Critical notification") + ); assert!(critical.get_visible()); assert!(!normal.get_visible()); } @@ -45,6 +50,19 @@ fn title_and_body_builders_keep_text_classes_and_line_limits() { assert!(build_body_label(&view, 3).is_none()); } +#[gtk::test] +fn secondary_claim_stays_on_one_compact_metadata_line() { + let mut view = view_model(); + view.secondary_claim = Some("Claimed app: Signal".to_string()); + + let claim = build_secondary_claim(&view).expect("secondary claim"); + + assert_eq!(claim.text().as_str(), "Claimed app: Signal"); + assert!(claim.is_single_line_mode()); + assert_eq!(claim.ellipsize(), gtk::pango::EllipsizeMode::End); + assert!(!claim.wraps()); +} + #[gtk::test] fn reply_note_exists_only_when_the_policy_explanation_is_needed() { let mut view = view_model(); @@ -58,16 +76,26 @@ fn reply_note_exists_only_when_the_policy_explanation_is_needed() { } #[gtk::test] -fn close_button_and_header_spacer_keep_their_interaction_contracts() { +fn close_button_and_identity_header_keep_their_interaction_contracts() { let close = build_close_button(); - let spacer = build_header_spacer(); + let header = build_identity_header(&view_model()); assert!(close.has_css_class("unixnotis-popup-close")); assert_eq!( close.tooltip_text().as_deref(), Some("Dismiss notification") ); - assert!(spacer.hexpands()); + assert!(header.identity.hexpands()); + assert_eq!(header.trailing.margin_end(), 26); + assert_eq!(header.trailing.orientation(), gtk::Orientation::Vertical); + assert!(header + .trailing + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-time"))); + assert!(header + .trailing + .last_child() + .is_some_and(|child| { child.has_css_class(unixnotis_core::hooks::urgency::BADGE) })); } #[gtk::test] @@ -145,16 +173,14 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); let mut notification = notification(); - notification.attribution = NotificationAttribution::trusted_relay( + notification.attribution = NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - false, "relay:notify-send:signal".to_string(), ); let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); - let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36) - .expect("relay avatar should use a semantic badge"); + let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); let icon = avatar .widget .first_child() @@ -184,14 +210,14 @@ fn notification() -> NotificationView { id: 41, generation: 3, app_name: "Example".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Example", "Example", "org.example.App", - "org.example.App", - "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.App".to_string(), + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "Primary title".to_string(), body: "Supporting body".to_string(), diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs new file mode 100644 index 000000000..a338d66ef --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -0,0 +1,58 @@ +use super::popup_accessible_label; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use unixnotis_ui::presentation::{BadgePresentation, ThumbnailKind, TrustLevel, TrustPresentation}; + +#[test] +fn popup_accessible_name_keeps_identity_and_message_context() { + let mut view = view_model(); + + assert_eq!( + popup_accessible_label(&view), + "Command-line notification. App label: Builder. Build finished" + ); + + view.title.clear(); + assert_eq!( + popup_accessible_label(&view), + "Command-line notification. App label: Builder" + ); +} + +#[test] +fn conflict_accessible_name_includes_trust_claim_and_body() { + let mut view = view_model(); + view.app_label = "Unknown application".to_string(); + view.secondary_claim = Some("Claimed app: Signal".to_string()); + view.badge = BadgePresentation::SuspiciousApplication; + view.body = Some("Hey, did this go through?".to_string()); + view.trust.level = TrustLevel::Conflict; + view.trust.short_label = Some("Suspicious".to_string()); + + assert_eq!( + popup_accessible_label(&view), + "Unknown application. Suspicious. Claimed app: Signal. Build finished. \ + Hey, did this go through?" + ); +} + +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel { + kind: PopupKind::Communication, + app_label: "Command-line notification".to_string(), + secondary_claim: Some("App label: Builder".to_string()), + badge: BadgePresentation::CommandLine, + timestamp_label: "now".to_string(), + title: "Build finished".to_string(), + body: None, + thumbnail: ThumbnailKind::None, + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Relay, + short_label: None, + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs index 1b751a393..b58a1777c 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -1,49 +1,25 @@ //! Compact utility popup for device, transfer, clipboard, and generic events -use gtk::prelude::*; use unixnotis_core::NotificationView; -use super::common::{ - build_body_label, build_identity_avatar, build_identity_header, build_secondary_claim, - build_title_label, -}; -use super::{append_thumbnail, RenderedPopup}; +use super::layout::{build_popup_grid, PopupLayout}; +use super::RenderedPopup; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const UTILITY_AVATAR_SIZE: i32 = 36; - pub(super) fn build_utility_popup( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, ) -> RenderedPopup { - let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); - main.add_css_class("unixnotis-popup-utility-content"); - - let avatar = build_identity_avatar(state, notification, view, UTILITY_AVATAR_SIZE); - if let Some(avatar) = avatar.as_ref() { - main.append(&avatar.widget); - } - - let content = gtk::Box::new(gtk::Orientation::Vertical, 2); - content.set_hexpand(true); - content.append(&build_identity_header(view)); - if let Some(claim) = build_secondary_claim(view) { - content.append(&claim); - } - if let Some(title) = build_title_label(view) { - content.append(&title); - } - if let Some(body) = build_body_label(view, 2) { - content.append(&body); - } - let has_image = append_thumbnail(state, notification, view, &content); - main.append(&content); - - RenderedPopup { - widget: main, - has_icon: avatar.is_some(), - has_image, - } + build_popup_grid( + state, + notification, + view, + PopupLayout { + css_class: "unixnotis-popup-utility-content", + body_lines: 2, + show_reply_note: false, + }, + ) } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs b/crates/unixnotis-popups/src/ui/entry/builders/warning.rs deleted file mode 100644 index 5be5d091f..000000000 --- a/crates/unixnotis-popups/src/ui/entry/builders/warning.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Restrained warning popup for conflicting application identity - -use gtk::prelude::*; -use unixnotis_core::NotificationView; - -use super::common::{ - build_body_label, build_identity_avatar, build_identity_header, build_reply_note, - build_secondary_claim, build_title_label, -}; -use super::{append_thumbnail, RenderedPopup}; -use crate::ui::entry::presentation::PopupEntryViewModel; -use crate::ui::UiState; - -const WARNING_AVATAR_SIZE: i32 = 36; - -pub(super) fn build_warning_popup( - state: &mut UiState, - notification: &NotificationView, - view: &PopupEntryViewModel, -) -> RenderedPopup { - let main = gtk::Box::new(gtk::Orientation::Horizontal, 12); - main.add_css_class("unixnotis-popup-warning-content"); - - let avatar = build_identity_avatar(state, notification, view, WARNING_AVATAR_SIZE); - if let Some(avatar) = avatar.as_ref() { - main.append(&avatar.widget); - } - - let content = gtk::Box::new(gtk::Orientation::Vertical, 3); - content.set_hexpand(true); - content.append(&build_identity_header(view)); - if let Some(claim) = build_secondary_claim(view) { - content.append(&claim); - } - if let Some(title) = build_title_label(view) { - content.append(&title); - } - if let Some(body) = build_body_label(view, 3) { - content.append(&body); - } - let has_image = append_thumbnail(state, notification, view, &content); - if let Some(note) = build_reply_note(view) { - content.append(¬e); - } - main.append(&content); - - RenderedPopup { - widget: main, - has_icon: avatar.is_some(), - has_image, - } -} diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index 3d92e2dfb..991dfb71e 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -4,6 +4,8 @@ mod build; mod builders; mod commands; mod presentation; +mod visibility; pub(in crate::ui) use build::PopupEntry; pub(in crate::ui) use commands::try_send_command; +pub(in crate::ui) use visibility::PopupVisibilityBinding; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs index dca540145..0abe20a5d 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs @@ -1,6 +1,6 @@ -use unixnotis_core::{Action, AttributionClass, NotificationAttribution}; +use unixnotis_core::{Action, AttributionReason, NotificationAttribution}; -use super::super::{PopupKind, PopupTrustPresentation}; +use super::super::PopupKind; use super::support::notification; #[test] @@ -13,10 +13,8 @@ fn standard_communication_category_classes_select_the_communication_layout() { ] { let mut view = notification(); view.category = category.to_string(); - let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!( - PopupKind::for_notification(&view, trust.level), + PopupKind::for_notification(&view), PopupKind::Communication, "{category} should use the communication layout" ); @@ -28,10 +26,8 @@ fn utility_categories_and_missing_categories_select_the_compact_layout() { for category in ["", "device.added", "network.connected", "transfer.complete"] { let mut view = notification(); view.category = category.to_string(); - let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!( - PopupKind::for_notification(&view, trust.level), + PopupKind::for_notification(&view), PopupKind::Utility, "{category:?} should use the utility layout" ); @@ -39,33 +35,26 @@ fn utility_categories_and_missing_categories_select_the_compact_layout() { } #[test] -fn suspicious_provenance_overrides_a_communication_category() { +fn suspicious_provenance_preserves_the_communication_category() { let mut view = notification(); view.category = "im.received".to_string(); - view.attribution = NotificationAttribution::associated( - "Unknown application", - "", - "dialog-warning-symbolic", - "Claims to be Signal; source /tmp/fake", - AttributionClass::Conflict, - true, + view.attribution = NotificationAttribution::conflict( + "Signal", + "org.signal.Signal", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", "conflict:signal".to_string(), ); - let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!( - PopupKind::for_notification(&view, trust.level), - PopupKind::Warning - ); + assert_eq!(PopupKind::for_notification(&view), PopupKind::Communication); } #[test] fn either_reply_contract_selects_the_communication_layout() { let mut metadata_reply = notification(); metadata_reply.inline_reply.available = true; - let trust = PopupTrustPresentation::for_notification(&metadata_reply); assert_eq!( - PopupKind::for_notification(&metadata_reply, trust.level), + PopupKind::for_notification(&metadata_reply), PopupKind::Communication ); @@ -74,9 +63,8 @@ fn either_reply_contract_selects_the_communication_layout() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let trust = PopupTrustPresentation::for_notification(&action_reply); assert_eq!( - PopupKind::for_notification(&action_reply, trust.level), + PopupKind::for_notification(&action_reply), PopupKind::Communication ); } @@ -85,5 +73,5 @@ fn either_reply_contract_selects_the_communication_layout() { fn each_popup_kind_keeps_its_intended_action_budget() { assert_eq!(PopupKind::Communication.action_limit(), 2); assert_eq!(PopupKind::Utility.action_limit(), 2); - assert_eq!(PopupKind::Warning.action_limit(), 2); + assert_eq!(PopupKind::Media.action_limit(), 2); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs index 76c5dbbeb..ce4afb7bb 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs @@ -1,5 +1,5 @@ use unixnotis_core::{ - AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -8,14 +8,14 @@ pub(super) fn notification() -> NotificationView { id: 7, generation: 11, app_name: "Example".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Example", "Example", "org.example.App", - "org.example.App", - "/usr/bin/example", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.App".to_string(), + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "Primary title".to_string(), body: "Supporting body".to_string(), diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index c30ec99ba..f03cc0537 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -1,4 +1,4 @@ -use unixnotis_core::{Action, AttributionClass, InlineReplyPolicy, NotificationAttribution}; +use unixnotis_core::{Action, AttributionReason, InlineReplyPolicy, NotificationAttribution}; use unixnotis_ui::presentation::TrustLevel; use super::super::{PopupTrustPresentation, ReplyPresentation}; @@ -23,17 +23,16 @@ fn protected_desktop_association_stays_verified_and_visually_quiet() { #[test] fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { let mut view = notification(); - view.attribution = NotificationAttribution::trusted_relay( + view.attribution = NotificationAttribution::relay( "Screenshot", "Sent via /usr/bin/notify-send", - false, "relay:screenshot".to_string(), ); view.inline_reply_policy = InlineReplyPolicy::Deny; let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!(trust.level, TrustLevel::CommandLine); + assert_eq!(trust.level, TrustLevel::Relay); assert!(trust.short_label.is_none()); assert_eq!( trust.details_label.as_deref(), @@ -47,6 +46,8 @@ fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { let mut view = notification(); view.attribution = NotificationAttribution::conflict( "Signal", + "org.signal.Signal", + AttributionReason::ExecutableMismatch, "source /tmp/fake", "conflict:signal".to_string(), ); @@ -55,7 +56,7 @@ fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!(trust.level, TrustLevel::Suspicious); + assert_eq!(trust.level, TrustLevel::Conflict); assert_eq!(trust.short_label.as_deref(), Some("Suspicious")); assert_eq!(trust.reply, ReplyPresentation::Unavailable); } @@ -63,19 +64,19 @@ fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { #[test] fn user_writable_desktop_association_remains_unverified() { let mut view = notification(); - view.attribution = NotificationAttribution::associated( + view.attribution = NotificationAttribution::recognized( + "Local app", "Local app", "org.example.Local", - "org.example.Local", + "local-app", + AttributionReason::ExactUserExecutable, "user desktop association", - AttributionClass::UserAssociated, - false, "user-desktop:org.example.Local".to_string(), ); let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!(trust.level, TrustLevel::Unverified); + assert_eq!(trust.level, TrustLevel::Recognized); assert_eq!(trust.short_label.as_deref(), Some("Unverified")); assert_eq!(trust.reply, ReplyPresentation::Hidden); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 573cc1e3a..2aa4a68ab 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -1,4 +1,4 @@ -use unixnotis_core::{Action, AttributionClass, ImageData, NotificationAttribution}; +use unixnotis_core::{Action, AttributionReason, ImageData, NotificationAttribution}; use super::super::{PopupEntryViewModel, PopupKind, ThumbnailKind}; use super::support::notification; @@ -67,8 +67,9 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { #[test] fn weak_attribution_hides_every_application_directed_action() { let mut view = notification(); - view.attribution = NotificationAttribution::unknown( + view.attribution = NotificationAttribution::unresolved( "Signal", + AttributionReason::NoDesktopCandidate, "source /tmp/fake", "unknown:signal".to_string(), ); @@ -86,13 +87,13 @@ fn weak_attribution_hides_every_application_directed_action() { #[test] fn user_associated_attribution_hides_application_directed_actions() { let mut view = notification(); - view.attribution = NotificationAttribution::associated( + view.attribution = NotificationAttribution::recognized( + "User application", "User application", "org.example.UserApplication", - "org.example.UserApplication", - "", - AttributionClass::UserAssociated, - false, + "user-application", + AttributionReason::ExactUserExecutable, + "user desktop association", "user-desktop:org.example.UserApplication".to_string(), ); view.actions.push(Action { @@ -206,16 +207,14 @@ fn square_path_content_is_not_mistaken_for_embedded_icon_data() { } #[test] -fn conflicting_claim_uses_warning_layout_and_drops_actions() { +fn conflicting_claim_keeps_communication_layout_and_drops_actions() { let mut view = notification(); view.category = "im.received".to_string(); - view.attribution = NotificationAttribution::associated( - "Unknown application", - "", - "dialog-warning-symbolic", - "Claims to be Signal", - AttributionClass::Conflict, - true, + view.attribution = NotificationAttribution::conflict( + "Signal", + "org.signal.Signal", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", "conflict:signal".to_string(), ); view.actions.push(Action { @@ -225,8 +224,11 @@ fn conflicting_claim_uses_warning_layout_and_drops_actions() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); - assert_eq!(model.kind, PopupKind::Warning); - assert_eq!(model.secondary_claim.as_deref(), Some("Claims “Signal”")); + assert_eq!(model.kind, PopupKind::Communication); + assert_eq!( + model.secondary_claim.as_deref(), + Some("Claimed app: Signal") + ); assert!(model.primary_actions.is_empty()); assert!(model.overflow_actions.is_empty()); } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index 54a984dd9..68aa1c5e6 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -2,7 +2,7 @@ use super::{connect_close_action, connect_default_action, widget_type_blocks_def use gtk::glib::prelude::StaticType; use gtk::prelude::*; use unixnotis_core::{ - Action, AttributionClass, InlineReply, InlineReplyPolicy, NotificationAttribution, + Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -73,14 +73,14 @@ fn notification() -> NotificationView { id: 31, generation: 1, app_name: "Example".to_string(), - attribution: NotificationAttribution::associated( + attribution: NotificationAttribution::verified( + "Example", "Example", "org.example.App", - "org.example.App", - "", - AttributionClass::SystemAssociated, - false, - "system-desktop:org.example.App".to_string(), + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), ), summary: "Example".to_string(), body: String::new(), diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index 0fa5e8d7e..a88f349d1 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -22,7 +22,7 @@ const ICON_CACHE_MAX_ENTRIES: usize = 256; // Skip caching decoded textures above this size to avoid holding large buffers const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1_048_576; // Content stays visibly separate from the daemon-associated application badge -const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 48; +const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 64; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index 369efb110..e246e5b96 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -39,7 +39,7 @@ fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { #[test] fn collect_icon_candidates_does_not_fallback_to_unresolved_brand_claim() { let mut notification = notification("Trusted Brand", "dialog-warning-symbolic"); - notification.attribution.class = unixnotis_core::AttributionClass::Unknown; + notification.attribution.status = unixnotis_core::AttributionStatus::Unresolved; notification.attribution.desktop_id.clear(); let candidates = collect_icon_candidates(¬ification); diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 2919d60dd..1932d3249 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -127,6 +127,15 @@ fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { assert!(state.popups.is_empty()); assert!(state.popup_order.is_empty()); assert!(state.visible_popups.is_empty()); + assert!( + state.popup_window.is_resizable(), + "the layer window must accept content-driven height changes" + ); + assert_eq!( + state.popup_window.default_size().1, + -1, + "popup height must use the current stack's natural request" + ); } #[gtk::test] @@ -179,7 +188,9 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { root.has_css_class(hooks::popup_card::HAS_ICON), root.has_css_class(hooks::popup_card::NO_ICON) ); + assert_eq!(root.width_request(), -1); assert_eq!(root.height_request(), -1); + assert!(root.hexpands()); assert!(visible_descendant_has_class( root.upcast_ref(), hooks::urgency::BADGE @@ -209,12 +220,12 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { id: 3, generation: 3, app_name: "Signal".to_string(), - attribution: unixnotis_core::NotificationAttribution { - display_name: "Unverified application".to_string(), - source_label: "Claims to be Signal".to_string(), - class: unixnotis_core::AttributionClass::Unknown, - ..unixnotis_core::NotificationAttribution::default() - }, + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Signal", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "sender evidence unavailable", + "unknown:signal".to_string(), + ), summary: "John Doe".to_string(), body: "Are you free later?".to_string(), actions: Vec::new(), @@ -230,17 +241,21 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { let root = state.build_popup_root(¬ification); - assert!(root.has_css_class("unverified")); + assert!(root.has_css_class("unresolved")); assert!(root.has_css_class("utility")); assert!(visible_descendant_has_text(root.upcast_ref(), "Unverified")); assert!(!visible_descendant_has_text( root.upcast_ref(), - "Claims to be Signal" + "sender evidence unavailable" + )); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "App label: Signal" )); } #[gtk::test] -fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { +fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupSuspiciousProbe") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -264,6 +279,8 @@ fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { app_name: "Signal".to_string(), attribution: unixnotis_core::NotificationAttribution::conflict( "Signal", + "org.signal.Signal", + unixnotis_core::AttributionReason::ExecutableMismatch, "application claim mismatch; source /tmp/fake", "conflict:signal".to_string(), ), @@ -282,12 +299,12 @@ fn conflicting_attribution_uses_the_warning_layout_and_suspicious_chip() { let root = state.build_popup_root(¬ification); - assert!(root.has_css_class("warning")); - assert!(root.has_css_class("suspicious")); + assert!(root.has_css_class("communication")); + assert!(root.has_css_class("conflict")); assert!(visible_descendant_has_text(root.upcast_ref(), "Suspicious")); assert!(visible_descendant_has_text( root.upcast_ref(), - "Claims “Signal”" + "Claimed app: Signal" )); assert!(!visible_descendant_has_text( root.upcast_ref(), @@ -318,10 +335,9 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { id: 5, generation: 5, app_name: "Signal".to_string(), - attribution: unixnotis_core::NotificationAttribution::trusted_relay( + attribution: unixnotis_core::NotificationAttribution::relay( "Signal", "Sent via /usr/bin/notify-send", - true, "relay:notify-send:signal".to_string(), ), summary: "John Doe".to_string(), @@ -340,9 +356,9 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { let root = state.build_popup_root(¬ification); - assert!(root.has_css_class("command-line")); - assert!(root.has_css_class("utility")); - assert!(!root.has_css_class("suspicious")); + assert!(root.has_css_class("relay")); + assert!(root.has_css_class("communication")); + assert!(!root.has_css_class("conflict")); assert!(visible_descendant_has_text( root.upcast_ref(), "Command-line notification" diff --git a/crates/unixnotis-popups/src/ui/window/build.rs b/crates/unixnotis-popups/src/ui/window/build.rs index a4eca7074..93fc3c0ed 100644 --- a/crates/unixnotis-popups/src/ui/window/build.rs +++ b/crates/unixnotis-popups/src/ui/window/build.rs @@ -8,6 +8,7 @@ use unixnotis_core::Config; use super::anchor::apply_anchor; use super::input_region::{refresh_popup_input_region, PopupInputRegionState}; use super::monitor::{default_monitor, find_monitor}; +use super::width_constraint::PopupWidthConstraint; // Keep popup width proportional on compact displays to avoid oversized cards. const POPUP_WIDTH_MONITOR_RATIO_CAP: f32 = 0.28; @@ -21,7 +22,8 @@ pub(in crate::ui) fn build_popup_window( // Window lifecycle hooks are centralized here to keep popup setup deterministic let window = gtk::ApplicationWindow::new(app); window.set_decorated(false); - window.set_resizable(false); + // Layer-shell has no user resize chrome, but GTK must accept content-driven height changes + window.set_resizable(true); window.set_title(Some("UnixNotis Popups")); window.add_css_class("unixnotis-popup-window"); @@ -33,9 +35,15 @@ pub(in crate::ui) fn build_popup_window( // Stack owns popup layout and reveal order for visible entries let stack = gtk::Box::new(gtk::Orientation::Vertical, config.popups.spacing); stack.add_css_class("unixnotis-popup-stack"); - window.set_child(Some(&stack)); + let width_constraint = PopupWidthConstraint::new(&stack, config.popups.width); + window.set_child(Some(&width_constraint)); window.set_visible(false); + window.connect_default_width_notify(move |window| { + // Config and monitor changes update the height-for-width measurement hint + width_constraint.set_width_hint(window.default_width()); + }); + // Shared input-region state is reused by config reloads and runtime visibility updates let input_region = PopupInputRegionState::new(config.popups.allow_click_through); apply_popup_config(&window, &stack, config, &input_region); @@ -132,13 +140,12 @@ pub(in crate::ui) fn apply_popup_config( } // Width follows config but is capped by monitor geometry on smaller displays. let popup_width = resolve_popup_width(config, monitor.as_ref()); - // Width is fixed by config while height remains content-driven - window.set_default_size(popup_width, 1); + // A negative height asks GTK to use the stack's natural height + window.set_default_size(popup_width, -1); window.set_size_request(popup_width, -1); - // Stack width follows popup width exactly so children cannot request wider geometry. - // This keeps popup geometry pinned to config even with hostile payload text - stack.set_size_request(popup_width, -1); - stack.set_hexpand(false); + // The window and width constraint own horizontal geometry + stack.set_size_request(-1, -1); + stack.set_hexpand(true); stack.set_spacing(config.popups.spacing); apply_anchor(window, config.popups.anchor, config.popups.margin); diff --git a/crates/unixnotis-popups/src/ui/window/mod.rs b/crates/unixnotis-popups/src/ui/window/mod.rs index 3dda3ae0b..110fd33e9 100644 --- a/crates/unixnotis-popups/src/ui/window/mod.rs +++ b/crates/unixnotis-popups/src/ui/window/mod.rs @@ -4,6 +4,7 @@ mod anchor; mod build; mod input_region; mod monitor; +mod width_constraint; pub(super) use build::{apply_popup_config, build_popup_window}; pub(super) use input_region::{refresh_popup_input_region, PopupInputRegionState}; diff --git a/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs b/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs new file mode 100644 index 000000000..9c62042fe --- /dev/null +++ b/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs @@ -0,0 +1,45 @@ +use gtk::prelude::*; + +use super::PopupWidthConstraint; + +#[gtk::test] +fn unconstrained_vertical_measurement_uses_the_known_surface_width() { + let label = gtk::Label::new(Some( + "A wrapping popup body must measure against the fixed layer width", + )); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + let constraint = PopupWidthConstraint::new(&label, 240); + + assert_eq!( + constraint.measure(gtk::Orientation::Vertical, -1), + label.measure(gtk::Orientation::Vertical, 240) + ); + assert_eq!( + constraint.request_mode(), + gtk::SizeRequestMode::HeightForWidth + ); + assert_eq!( + constraint.measure(gtk::Orientation::Horizontal, 1), + (240, 240, -1, -1) + ); +} + +#[gtk::test] +fn updated_surface_width_changes_the_vertical_measurement_contract() { + let label = gtk::Label::new(Some( + "A longer wrapping body needs more lines when the popup becomes narrow", + )); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + let constraint = PopupWidthConstraint::new(&label, 280); + let wide = constraint.measure(gtk::Orientation::Vertical, -1); + + constraint.set_width_hint(120); + let narrow = constraint.measure(gtk::Orientation::Vertical, -1); + + assert!( + narrow.0 > wide.0, + "a narrower fixed surface must report a taller minimum" + ); +} diff --git a/crates/unixnotis-popups/src/ui/window/width_constraint.rs b/crates/unixnotis-popups/src/ui/window/width_constraint.rs new file mode 100644 index 000000000..6356f2f0b --- /dev/null +++ b/crates/unixnotis-popups/src/ui/window/width_constraint.rs @@ -0,0 +1,124 @@ +//! Fixed-width measurement bridge for height-for-width popup content + +use std::cell::{Cell, RefCell}; + +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; + +mod imp { + use super::{glib, Cell, RefCell}; + use gtk::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default)] + pub struct PopupWidthConstraint { + pub(super) child: RefCell>, + pub(super) width_hint: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PopupWidthConstraint { + const NAME: &'static str = "UnixNotisPopupWidthConstraint"; + type Type = super::PopupWidthConstraint; + type ParentType = gtk::Widget; + + fn class_init(class: &mut Self::Class) { + class.set_css_name("unixnotis-popup-width-constraint"); + } + } + + impl ObjectImpl for PopupWidthConstraint { + fn dispose(&self) { + if let Some(child) = self.child.borrow_mut().take() { + // Custom parenting must be released before GTK finalizes the wrapper + child.unparent(); + } + } + } + + impl WidgetImpl for PopupWidthConstraint { + fn request_mode(&self) -> gtk::SizeRequestMode { + gtk::SizeRequestMode::HeightForWidth + } + + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + let width_hint = self.width_hint.get().max(1); + if orientation == gtk::Orientation::Horizontal { + // The layer surface owns width, so content never expands or contracts it + return (width_hint, width_hint, -1, -1); + } + + let Some(child) = self.child.borrow().as_ref().cloned() else { + return (0, 0, -1, -1); + }; + + // GTK asks for an unconstrained vertical minimum before layer-shell + // supplies the fixed surface width. Reuse that known width so wrapping + // text reports the same height in both passes + let child_for_size = if for_size < 0 { width_hint } else { for_size }; + child.measure(orientation, child_for_size) + } + + fn size_allocate(&self, width: i32, height: i32, baseline: i32) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + // The wrapper has no visual box of its own, so the child receives all space + child.allocate(width, height, baseline, None); + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + if child.is_visible() { + // Custom parenting requires explicit snapshot delegation + self.obj().snapshot_child(&child, snapshot); + } + } + } +} + +glib::wrapper! { + pub struct PopupWidthConstraint(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl PopupWidthConstraint { + pub(super) fn new(child: &impl IsA, width_hint: i32) -> Self { + let constraint: Self = glib::Object::new(); + constraint.set_child(Some(child)); + constraint.set_width_hint(width_hint); + constraint + } + + pub(super) fn set_width_hint(&self, width_hint: i32) { + let width_hint = width_hint.max(1); + if self.imp().width_hint.replace(width_hint) != width_hint { + // A config or monitor change can alter line wrapping and total height + self.queue_resize(); + } + } + + fn set_child(&self, child: Option<&impl IsA>) { + let imp = self.imp(); + let next = child.map(|child| child.clone().upcast::()); + if imp.child.borrow().as_ref() == next.as_ref() { + return; + } + if let Some(current) = imp.child.borrow_mut().take() { + current.unparent(); + } + if let Some(next) = next { + next.set_parent(self); + imp.child.replace(Some(next)); + } + self.queue_resize(); + } +} + +#[cfg(test)] +#[path = "tests/width_constraint.rs"] +mod tests; diff --git a/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg index 416cbbbe6..c52f2a05d 100644 --- a/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg +++ b/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/unixnotis-ui/src/css/tests/overrides.rs b/crates/unixnotis-ui/src/css/tests/overrides.rs index 3d29dc56b..420d03cba 100644 --- a/crates/unixnotis-ui/src/css/tests/overrides.rs +++ b/crates/unixnotis-ui/src/css/tests/overrides.rs @@ -53,9 +53,9 @@ fn base_overrides_can_emit_modern_custom_properties() { assert!(overrides.contains("--unixnotis-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-card-alpha: 0.52;")); assert!(overrides.contains("--unixnotis-panel-header-radius: 18px;")); - assert!(overrides.contains("--unixnotis-notification-card-radius: 20px;")); + assert!(overrides.contains("--unixnotis-notification-card-radius: 14px;")); assert!(overrides.contains("--unixnotis-stat-card-radius: 18px;")); - assert!(overrides.contains("--unixnotis-panel-card-padding-y: 10px;")); + assert!(overrides.contains("--unixnotis-panel-card-padding-y: 9px;")); assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); assert!(overrides.contains("--unixnotis-accent-color: @unixnotis-accent;")); assert!(overrides.contains("@define-color unixnotis-surface alpha(@unixnotis-surface-base,")); From 79034d9fd6a0e65359461a08b707e227cafccfea Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:12:55 -0500 Subject: [PATCH 150/275] fix(daemon): retain notifications with oversized native icons Summary: retain notifications with oversized native icons. Scope: daemon. --- .../src/model/image/normalize.rs | 10 +- .../daemon/notifications/ingress/payload.rs | 13 +- .../notifications/ingress/tests/payload.rs | 33 +- .../src/daemon/notifications/server/flow.rs | 18 +- .../daemon/notifications/server/ingress.rs | 5 +- .../daemon/notifications/server/interface.rs | 5 +- .../src/daemon/notifications/server/mod.rs | 1 + .../server/notify_body/limits.rs | 7 +- .../notifications/server/notify_body/mod.rs | 2 +- .../server/notify_body/tests/actions.rs | 2 +- .../server/notify_body/tests/hints.rs | 66 ++-- .../server/notify_body/tests/limits.rs | 7 +- .../notifications/server/notify_body/value.rs | 6 +- .../daemon/notifications/server/tests/flow.rs | 16 +- .../notifications/server/tests/ingress.rs | 320 ++++++++++++++++-- .../notifications/server/tests/interface.rs | 4 +- .../notifications/server/wire_hints/decode.rs | 188 ++++++++++ .../server/wire_hints/image_bytes.rs | 76 +++++ .../notifications/server/wire_hints/mod.rs | 39 +++ 19 files changed, 709 insertions(+), 109 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs diff --git a/crates/unixnotis-core/src/model/image/normalize.rs b/crates/unixnotis-core/src/model/image/normalize.rs index 4a186f7d2..298b25599 100644 --- a/crates/unixnotis-core/src/model/image/normalize.rs +++ b/crates/unixnotis-core/src/model/image/normalize.rs @@ -3,6 +3,12 @@ use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION}; impl NotificationImage { + /// Returns the maximum decoded image payload retained by the notification model + #[must_use] + pub const fn retained_byte_limit() -> usize { + MAX_IMAGE_BYTES + } + pub(super) fn is_image_data_usable(data: &ImageData) -> bool { // Hard dimension caps keep texture creation and D-Bus payloads predictable if data.width > MAX_IMAGE_DIMENSION || data.height > MAX_IMAGE_DIMENSION { @@ -23,7 +29,9 @@ impl NotificationImage { .is_some() } - pub(super) fn normalize_image_data(image: ImageData) -> Option { + /// Validates raw pixels and normalizes supported RGB images to RGBA + #[must_use] + pub fn normalize_image_data(image: ImageData) -> Option { if image.bits_per_sample != 8 { return None; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index f0a4d571a..d30d07df2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use unixnotis_core::{ - util, Action, AttributionDiagnostics, Config, InlineReply, InlineReplyPolicy, Notification, - NotificationAttribution, NotificationImage, Urgency, + util, Action, AttributionDiagnostics, Config, ImageData, InlineReply, InlineReplyPolicy, + Notification, NotificationAttribution, NotificationImage, Urgency, }; use zbus::zvariant::{OwnedValue, Value}; @@ -26,6 +26,7 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) body: String, pub(in crate::daemon::notifications) actions: Vec, pub(in crate::daemon::notifications) hints: HashMap, + pub(in crate::daemon::notifications) image_data: Option, pub(in crate::daemon::notifications) sender: SenderMetadata, pub(in crate::daemon::notifications) attribution: NotificationAttribution, pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, @@ -43,6 +44,7 @@ pub(in crate::daemon::notifications) fn build_notification( body, actions, hints, + image_data, sender, attribution, attribution_diagnostics, @@ -70,7 +72,12 @@ pub(in crate::daemon::notifications) fn build_notification( .get("resident") .and_then(|value| bool::try_from(value).ok()) .unwrap_or(false); - let image = NotificationImage::from_hints(&app_name, &app_icon, &hints); + let mut image = NotificationImage::from_hints(&app_name, &app_icon, &hints); + if let Some(image_data) = image_data { + // The wire decoder already normalized this bounded image without dynamic byte expansion + image.has_image_data = true; + image.image_data = image_data; + } let actions = parse_actions(actions); // Protocol metadata is parsed independently from the daemon's interaction decision let inline_reply = parse_inline_reply(&actions, &hints); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index 4ff05646e..63befff72 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -8,7 +8,7 @@ use super::{ sanitize_hints_for_storage, string_to_owned_value, NotificationInput, SenderMetadata, MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES, }; -use unixnotis_core::{Config, NotificationImage, Urgency}; +use unixnotis_core::{AttributionReason, Config, NotificationImage, Urgency}; #[test] fn build_notification_clamps_summary_and_body_sizes() { @@ -22,6 +22,7 @@ fn build_notification_clamps_summary_and_body_sizes() { body, actions: Vec::new(), hints: HashMap::::new(), + image_data: None, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -49,6 +50,7 @@ fn build_notification_strips_display_spoofing_controls() { body: "line1\nline2\u{2066}tail".to_string(), actions: vec!["default".to_string(), "Open\u{202E}".to_string()], hints: HashMap::::new(), + image_data: None, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -92,18 +94,19 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { body: "Are you coming?".to_string(), actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints, + image_data: None, sender: SenderMetadata { sender_executable: Some("/usr/bin/messages".to_string()), ..SenderMetadata::default() }, - attribution: unixnotis_core::NotificationAttribution::associated( + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", "Messages", "org.example.Messages", "messages", - "/usr/bin/messages", - unixnotis_core::AttributionClass::SystemAssociated, - false, - "desktop:org.example.Messages".to_string(), + AttributionReason::ExactSystemExecutable, + "exact system executable /usr/bin/messages", + "system-app:org.example.Messages".to_string(), ), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, @@ -126,6 +129,7 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( body: "Enter the account password".to_string(), actions: vec!["inline-reply".to_string(), "Password".to_string()], hints: HashMap::new(), + image_data: None, sender: SenderMetadata { sender_name: Some(":1.hostile".to_string()), sender_executable: Some("/usr/bin/unknown-client".to_string()), @@ -133,6 +137,8 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( }, attribution: unixnotis_core::NotificationAttribution::conflict( "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, "source /usr/bin/unknown-client", "executable:1:2".to_string(), ), @@ -149,8 +155,8 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( let view = notification.to_view(); assert_eq!(view.app_name, "Unknown application"); assert_eq!( - view.attribution.class, - unixnotis_core::AttributionClass::Conflict + view.attribution.status, + unixnotis_core::AttributionStatus::Conflict ); } @@ -163,9 +169,11 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { body: String::new(), actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints: HashMap::new(), + image_data: None, sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::unknown( + attribution: unixnotis_core::NotificationAttribution::unresolved( "Messages", + AttributionReason::MissingSenderEvidence, "", "unknown:messages".to_string(), ), @@ -180,10 +188,10 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { unixnotis_core::InlineReplyPolicy::Deny ); let view = notification.to_view(); - assert_eq!(view.app_name, "Messages"); + assert_eq!(view.app_name, "Unknown application"); assert_eq!( - view.attribution.class, - unixnotis_core::AttributionClass::Unknown + view.attribution.status, + unixnotis_core::AttributionStatus::Unresolved ); } @@ -202,6 +210,7 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { body: String::new(), actions: vec!["default".to_string(), "Open".to_string()], hints, + image_data: None, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::default(), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 086c774cc..1b99baa91 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::time::Duration; use tracing::{debug, warn}; -use unixnotis_core::{Notification, NotificationKey}; +use unixnotis_core::{ImageData, Notification, NotificationKey}; use zbus::message::Header; use zbus::zvariant::OwnedValue; @@ -16,6 +16,7 @@ use crate::daemon::notifications::ingress::payload::{ use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; +use super::wire_hints::WireHints; use super::NotificationServer; struct StoredNotification { @@ -29,11 +30,12 @@ struct WireNotification { body: String, actions: Vec, hints: HashMap, + image_data: Option, expire_timeout: i32, } const SENDER_METADATA_TIMEOUT: Duration = Duration::from_millis(100); -const ATTRIBUTION_TIMEOUT: Duration = Duration::from_millis(100); +const ATTRIBUTION_TIMEOUT: Duration = Duration::from_millis(500); impl NotificationServer { #[expect( @@ -48,7 +50,7 @@ impl NotificationServer { summary: String, body: String, actions: Vec, - hints: HashMap, + hints: WireHints, header: &Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { @@ -59,6 +61,7 @@ impl NotificationServer { replaces_id, expire_timeout, ); + let (hints, image_data) = hints.into_parts(); let notification = self .notification_from_wire( WireNotification { @@ -68,6 +71,7 @@ impl NotificationServer { body, actions, hints, + image_data, expire_timeout, }, header, @@ -148,12 +152,15 @@ impl NotificationServer { warn!("notification attribution timed out and failed closed"); unknown_reply_denied(claim, &sender, "attribution timed out") }; - if resolution.attribution.has_warning() { + if matches!( + resolution.attribution.status, + unixnotis_core::AttributionStatus::Conflict + ) { debug!( app_name = %input.app_name, sender = sender.sender_name.as_deref().unwrap_or("unknown"), sender_executable = sender.sender_executable.as_deref().unwrap_or("unknown"), - source = %resolution.attribution.source_label, + detail = %resolution.attribution.diagnostic_detail, "notification application claim conflicts with sender evidence" ); } @@ -178,6 +185,7 @@ impl NotificationServer { body: input.body, actions: input.actions, hints: input.hints, + image_data: input.image_data, sender, attribution: resolution.attribution, attribution_diagnostics: resolution.diagnostics, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs index 036c1a372..43c173b7a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -8,12 +8,9 @@ use zbus::object_server::{DispatchResult, Interface, SignalContext}; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, Message, ObjectServer}; -use super::notify_body::{preflight_notify, PreflightError}; +use super::notify_body::{preflight_notify, PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES}; use super::NotificationServer; -// This leaves room for one maximum image plus bounded text, actions, hints, and wire overhead -pub(super) const MAX_NOTIFY_WIRE_BODY_BYTES: usize = 384 * 1024; - /// Object-server adapter that rejects oversized Notify bodies before typed allocation pub struct NotificationIngress { inner: NotificationServer, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index fff737bfb..4ff19483e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -1,18 +1,17 @@ //! Notification D-Bus interface implementation -use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; use tokio::sync::Semaphore; use tracing::debug; use zbus::message::Header; -use zbus::zvariant::OwnedValue; use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; +use super::wire_hints::WireHints; use crate::daemon::notifications::ingress::metrics::{IngressMetrics, RejectedRequest}; use crate::daemon::notifications::ingress::quota::NotificationQuota; use crate::daemon::DaemonState; @@ -68,7 +67,7 @@ impl NotificationServer { summary: String, body: String, actions: Vec, - hints: HashMap, + hints: WireHints, #[zbus(header)] header: Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index b8b7f882b..2b793d264 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -6,6 +6,7 @@ mod flow; mod ingress; mod interface; mod notify_body; +mod wire_hints; pub use ingress::NotificationIngress; pub use interface::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs index 84bfcbcc1..936b25d9a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs @@ -1,6 +1,11 @@ //! Limits and errors shared by raw Notify body readers -pub(super) const MAX_IMAGE_BYTES: usize = 256 * 1024; +// Common native clients send decoded 1024x1024 RGBA application or contact images +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_IMAGE_BYTES: usize = + 4 * 1024 * 1024; +// The image allowance plus bounded strings, actions, hints, and D-Bus alignment +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_BODY_BYTES: usize = + MAX_NOTIFY_WIRE_IMAGE_BYTES + 128 * 1024; pub(super) const MAX_NON_IMAGE_ARRAY_BYTES: usize = 16 * 1024; pub(super) const MAX_NON_IMAGE_STRING_BYTES: usize = 64 * 1024; pub(super) const MAX_NESTED_CONTAINER_ELEMENTS: usize = 64; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs index 15313e818..54c0d35a7 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs @@ -6,7 +6,7 @@ mod signature; mod validator; mod value; -pub(super) use limits::PreflightError; +pub(super) use limits::{PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES}; pub(super) use validator::preflight_notify; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs index 583345936..f5e1f0d25 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use super::super::{preflight_notify, PreflightError}; use super::support::notify_message; -use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; +use crate::daemon::notifications::server::notify_body::MAX_NOTIFY_WIRE_BODY_BYTES; #[test] fn under_wire_limit_tiny_action_flood_is_rejected() { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs index d35c0728c..5be493c81 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; -use zbus::zvariant::{OwnedValue, Structure, Value}; +use zbus::zvariant::{OwnedValue, SerializeValue, Value}; +use zbus::Message; use super::super::{preflight_notify, PreflightError}; use super::support::notify_message; -use crate::daemon::notifications::server::ingress::MAX_NOTIFY_WIRE_BODY_BYTES; +use crate::daemon::notifications::server::notify_body::MAX_NOTIFY_WIRE_BODY_BYTES; #[test] fn hint_entry_flood_is_rejected_before_map_allocation() { @@ -23,50 +24,51 @@ fn hint_entry_flood_is_rejected_before_map_allocation() { #[test] fn contiguous_image_array_keeps_its_separate_large_allowance() { - let image = Structure::from(( - 256_i32, - 256_i32, - 1024_i32, - true, - 8_i32, - 4_i32, - vec![0_u8; 256 * 1024], - )); - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - OwnedValue::try_from(Value::from(image)).expect("owned image hint"), - ); - let message = notify_message("app", "", "summary", "", Vec::new(), hints); + let message = notify_message_with_image(1024 * 1024); assert_eq!(preflight_notify(&message), Ok(())); } #[test] -fn image_array_above_its_allowance_is_rejected_below_the_wire_limit() { - let image = Structure::from(( +fn image_array_above_native_allowance_is_rejected_below_the_wire_limit() { + let message = notify_message_with_image(4 * 1024 * 1024 + 1); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance" + )) + ); +} + +fn notify_message_with_image(image_bytes: usize) -> Message { + let image = ( 256_i32, 256_i32, 1024_i32, true, 8_i32, 4_i32, - vec![0_u8; 256 * 1024 + 1], - )); - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + vec![0_u8; image_bytes], ); - let message = notify_message("app", "", "summary", "", Vec::new(), hints); + let hints = HashMap::from([("image-data", SerializeValue(&image))]); - assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); - assert_eq!( - preflight_notify(&message), - Err(PreflightError::LimitsExceeded( - "Notify byte array exceeds its allowance" + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&( + "app", + 0_u32, + "", + "summary", + "", + Vec::::new(), + hints, + 0_i32, )) - ); + .expect("Notify message") } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs index 1bcd0e242..4066c1bd4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs @@ -1,11 +1,12 @@ use super::super::limits::{ - MAX_IMAGE_BYTES, MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, - MAX_NON_IMAGE_STRING_BYTES, MAX_SIGNATURE_DEPTH, + MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, MAX_NON_IMAGE_STRING_BYTES, + MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_SIGNATURE_DEPTH, }; #[test] fn raw_body_limits_keep_the_reviewed_byte_and_depth_boundaries() { - assert_eq!(MAX_IMAGE_BYTES, 262_144); + assert_eq!(MAX_NOTIFY_WIRE_IMAGE_BYTES, 4_194_304); + assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 4_325_376); assert_eq!(MAX_NON_IMAGE_ARRAY_BYTES, 16_384); assert_eq!(MAX_NON_IMAGE_STRING_BYTES, 65_536); assert_eq!(MAX_NESTED_CONTAINER_ELEMENTS, 64); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs index 1e7784fd6..72a47b161 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs @@ -4,8 +4,8 @@ use crate::daemon::notifications::ingress::limits::MAX_HINT_STRING_BYTES; use super::cursor::Cursor; use super::limits::{ - PreflightError, StringBudget, MAX_IMAGE_BYTES, MAX_NESTED_CONTAINER_ELEMENTS, - MAX_NON_IMAGE_ARRAY_BYTES, MAX_SIGNATURE_DEPTH, + PreflightError, StringBudget, MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, + MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_SIGNATURE_DEPTH, }; use super::signature::{SignatureParser, SignatureType}; @@ -49,7 +49,7 @@ impl Cursor<'_> { // Raw bytes are skipped in place without constructing a vector let length = self.remaining_to(end)?; let limit = if image_hint { - MAX_IMAGE_BYTES + MAX_NOTIFY_WIRE_IMAGE_BYTES } else { MAX_NON_IMAGE_ARRAY_BYTES }; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index ab0e5e04b..4bf25936c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -12,6 +12,7 @@ use unixnotis_core::{ CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, }; use zbus::message::Type; +use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, MatchRule, Message, MessageStream}; use crate::daemon::{DaemonState, NotificationServer}; @@ -176,6 +177,8 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { let server = NotificationServer::new(state.clone(), scheduler); let message = notify_header_message(); let header = message.header(); + let category = OwnedValue::try_from(Value::from("im.received")).expect("category hint"); + let hints = HashMap::from([("category".to_string(), category)]); let id = server .ingest_notify( @@ -185,7 +188,7 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + hints.into(), &header, 0, ) @@ -199,7 +202,7 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { "next".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), &header, 0, ) @@ -212,6 +215,7 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { assert_eq!(second_id, 2); assert_eq!(active.id, id); assert_eq!(active.summary, "summary"); + assert_eq!(active.category, "im.received"); } #[tokio::test] @@ -230,7 +234,7 @@ async fn ingest_notify_schedules_expiration_for_positive_timeout() { "expires".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), &header, 25, ) @@ -270,7 +274,7 @@ async fn ingest_notify_emits_notification_added_signal() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), &header, 0, ) @@ -314,7 +318,7 @@ async fn ingest_notify_emits_control_close_for_evicted_active_notification() { "first".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), &header, 0, ) @@ -328,7 +332,7 @@ async fn ingest_notify_emits_control_close_for_evicted_active_notification() { "second".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), &header, 0, ) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index f28f84a07..bdf63aab3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; use std::os::fd::AsFd; +use std::time::Duration; -use zbus::zvariant::{OwnedValue, Structure, Value}; -use zbus::Connection; +use zbus::zvariant::{OwnedValue, SerializeValue, Structure, Value}; +use zbus::{Connection, Message}; use super::{ notify_body_is_oversized, notify_has_unix_fds, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES, @@ -12,10 +13,11 @@ use crate::expire::ExpirationScheduler; use crate::test_support::daemon_state_for_test; const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; +const TEST_NOTIFY_TIMEOUT: Duration = Duration::from_secs(2); #[test] fn notify_wire_limit_applies_only_to_oversized_notify_calls() { - assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 393_216); + assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 4_325_376); assert!(!notify_body_is_oversized( "Notify", MAX_NOTIFY_WIRE_BODY_BYTES @@ -109,43 +111,209 @@ async fn oversized_hint_map_is_rejected_before_notify_deserialization() { #[tokio::test] async fn oversized_image_array_is_rejected_before_notify_deserialization() { let (state, client) = notification_ingress().await; - let image = Structure::from(( - 1_i32, - 1_i32, - 4_i32, - true, - 8_i32, - 4_i32, - vec![0_u8; MAX_NOTIFY_WIRE_BODY_BYTES + 1], - )); - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + let error = send_image_notification(&state, &client, 1, 1, 4, MAX_NOTIFY_WIRE_BODY_BYTES + 1) + .await + .expect_err("image above the wire limit must fail"); + + assert!( + error.to_string().contains("LimitsExceeded"), + "unexpected D-Bus error: {error}" ); + assert!(state.store.lock().await.list_active().is_empty()); +} - assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +#[tokio::test] +async fn native_image_above_retained_limit_keeps_the_text_notification() { + let (state, client) = notification_ingress().await; + let reply = send_image_notification(&state, &client, 1_024, 1_024, 4_096, 1_024 * 1_024 * 4) + .await + .expect("normal native image must not discard the text notification"); + let id = reply.body().deserialize::().expect("notification id"); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + + assert_eq!(active.summary, "summary"); + assert!(!active.image.has_image_data); } #[tokio::test] -async fn under_wire_limit_image_above_its_allowance_never_reaches_typed_notify() { +async fn native_image_within_retained_limit_reaches_the_notification_model() { let (state, client) = notification_ingress().await; - let image = Structure::from(( - 256_i32, - 256_i32, - 1024_i32, - true, - 8_i32, - 4_i32, - vec![0_u8; 256 * 1024 + 1], - )); - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - OwnedValue::try_from(Value::from(image)).expect("owned image hint"), + let reply = send_image_notification(&state, &client, 128, 128, 512, 128 * 128 * 4) + .await + .expect("bounded native image should reach the typed interface"); + let id = reply.body().deserialize::().expect("notification id"); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + + assert!(active.image.has_image_data); + assert_eq!(active.image.image_data.data.len(), 128 * 128 * 4); +} + +#[tokio::test] +async fn bounded_unknown_variant_does_not_break_notification_delivery() { + let (state, client) = notification_ingress().await; + let hints = HashMap::from([("sender-pid".to_string(), OwnedValue::from(42_u32))]); + let reply = send_owned_hints_notification(&state, &client, hints) + .await + .expect("bounded unknown hint should be ignored"); + let id = reply.body().deserialize::().expect("notification id"); + + assert_eq!(id, 1); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +#[tokio::test] +async fn supported_wire_hints_keep_text_boolean_and_both_urgency_types() { + let (state, client) = notification_ingress().await; + let category = + OwnedValue::try_from(Value::from("im.received")).expect("owned category hint string"); + let first_hints = HashMap::from([ + ("category".to_string(), category), + ("transient".to_string(), OwnedValue::from(true)), + ("urgency".to_string(), OwnedValue::from(2_u8)), + ]); + let first_reply = send_owned_hints_notification(&state, &client, first_hints) + .await + .expect("supported byte urgency hints should reach the typed interface"); + let first_id = first_reply + .body() + .deserialize::() + .expect("first notification id"); + let second_hints = HashMap::from([("urgency".to_string(), OwnedValue::from(1_u32))]); + let second_reply = send_owned_hints_notification(&state, &client, second_hints) + .await + .expect("supported integer urgency hints should reach the typed interface"); + let second_id = second_reply + .body() + .deserialize::() + .expect("second notification id"); + let store = state.store.lock().await; + let first = store + .active_notification_view(first_id) + .expect("first notification should be retained"); + let second = store + .active_notification_view(second_id) + .expect("second notification should be retained"); + + assert_eq!(first.category, "im.received"); + assert!(first.is_transient); + assert_eq!(first.urgency, 2); + assert_eq!(second.urgency, 1); +} + +#[tokio::test] +async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order() { + let (state, client) = notification_ingress().await; + let all_aliases = HashMap::from([ + ("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255])), + ("image_data".to_string(), owned_rgba_pixel([2, 0, 0, 255])), + ("image-data".to_string(), owned_rgba_pixel([1, 0, 0, 255])), + ]); + let standard_id = send_owned_hints_notification(&state, &client, all_aliases) + .await + .expect("standard image alias should decode") + .body() + .deserialize::() + .expect("standard image notification id"); + let legacy_aliases = HashMap::from([ + ("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255])), + ("image_data".to_string(), owned_rgba_pixel([2, 0, 0, 255])), + ]); + let legacy_id = send_owned_hints_notification(&state, &client, legacy_aliases) + .await + .expect("legacy image alias should decode") + .body() + .deserialize::() + .expect("legacy image notification id"); + let icon_only = HashMap::from([("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255]))]); + let icon_id = send_owned_hints_notification(&state, &client, icon_only) + .await + .expect("legacy icon alias should decode") + .body() + .deserialize::() + .expect("legacy icon notification id"); + let store = state.store.lock().await; + + assert_eq!( + store + .active_notification_view(standard_id) + .expect("standard image notification") + .image + .image_data + .data, + [1, 0, 0, 255] + ); + assert_eq!( + store + .active_notification_view(legacy_id) + .expect("legacy image notification") + .image + .image_data + .data, + [2, 0, 0, 255] + ); + assert_eq!( + store + .active_notification_view(icon_id) + .expect("legacy icon notification") + .image + .image_data + .data, + [3, 0, 0, 255] ); +} - assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +#[tokio::test] +async fn supported_hint_with_wrong_signature_is_rejected_without_daemon_failure() { + let (state, client) = notification_ingress().await; + let invalid_hints = [ + HashMap::from([("category".to_string(), OwnedValue::from(true))]), + HashMap::from([( + "transient".to_string(), + OwnedValue::try_from(Value::from("yes")).expect("owned boolean mismatch"), + )]), + HashMap::from([( + "urgency".to_string(), + OwnedValue::try_from(Value::from("high")).expect("owned urgency mismatch"), + )]), + HashMap::from([( + "image-data".to_string(), + OwnedValue::try_from(Value::from("pixels")).expect("owned image mismatch"), + )]), + ]; + + for hints in invalid_hints { + let error = send_owned_hints_notification(&state, &client, hints) + .await + .expect_err("known hint with wrong signature must fail"); + assert!( + error + .to_string() + .contains("notification hint has an unexpected D-Bus signature"), + "unexpected mismatched-hint error: {error}" + ); + } + + assert!(state.store.lock().await.list_active().is_empty()); + let recovery = send_owned_hints_notification(&state, &client, HashMap::new()) + .await + .expect("valid notification should still work after rejected hints"); + assert_eq!( + recovery + .body() + .deserialize::() + .expect("recovery notification id"), + 1 + ); } #[tokio::test] @@ -199,6 +367,94 @@ async fn notification_ingress() -> (std::sync::Arc, (state, client) } +async fn send_image_notification( + state: &crate::daemon::DaemonState, + client: &Connection, + width: i32, + height: i32, + rowstride: i32, + image_bytes: usize, +) -> zbus::Result { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let image = ( + width, + height, + rowstride, + true, + 8_i32, + 4_i32, + vec![0_u8; image_bytes], + ); + let hints = HashMap::from([("image-data", SerializeValue(&image))]); + let payload = ( + "app", + 0_u32, + "", + "summary", + "body", + Vec::::new(), + hints, + 0_i32, + ); + + tokio::time::timeout( + TEST_NOTIFY_TIMEOUT, + client.call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ), + ) + .await + .expect("Notify response timed out") +} + +async fn send_owned_hints_notification( + state: &crate::daemon::DaemonState, + client: &Connection, + hints: HashMap, +) -> zbus::Result { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ( + "app", + 0_u32, + "", + "summary", + "body", + Vec::::new(), + hints, + 0_i32, + ); + + tokio::time::timeout( + TEST_NOTIFY_TIMEOUT, + client.call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ), + ) + .await + .expect("Notify response timed out") +} + +fn owned_rgba_pixel(data: [u8; 4]) -> OwnedValue { + let image = Structure::from((1_i32, 1_i32, 4_i32, true, 8_i32, 4_i32, data.to_vec())); + OwnedValue::try_from(Value::from(image)).expect("owned one-pixel image hint") +} + async fn assert_oversized_notify_rejected( state: &crate::daemon::DaemonState, client: &Connection, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs index 1a48a30c6..3b28d9455 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs @@ -67,7 +67,7 @@ async fn notify_wrapper_stores_notification_and_returns_assigned_id() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), header.clone(), 0, ) @@ -99,7 +99,7 @@ async fn close_notification_wrapper_removes_owned_active_notification() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), header.clone(), 0, ) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs new file mode 100644 index 000000000..c0c261e59 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs @@ -0,0 +1,188 @@ +//! Key-aware variant decoding for the freedesktop notification hint map + +use std::collections::HashMap; + +use serde::de::{DeserializeSeed, Deserializer, Error as _, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; +use unixnotis_core::ImageData; +use zbus::zvariant::{OwnedValue, Signature, Value}; + +use super::image_bytes::BoundedImageBytes; +use super::WireHints; + +impl<'de> Deserialize<'de> for WireHints { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(WireHintsVisitor) + } +} + +struct WireHintsVisitor; + +impl<'de> Visitor<'de> for WireHintsVisitor { + type Value = WireHints; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a freedesktop notification hint dictionary") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = HashMap::with_capacity(map.size_hint().unwrap_or_default()); + let mut standard_image = None; + let mut legacy_image = None; + let mut legacy_icon = None; + + while let Some(key) = map.next_key::()? { + let Some(kind) = HintKind::for_key(&key) else { + // Raw preflight bounds unknown values before this owned fallback runs + map.next_value::()?; + continue; + }; + let decoded = map.next_value_seed(HintVariantSeed { kind })?; + match decoded { + DecodedHint::Text(text) => { + values.insert(key, owned_string(&text).map_err(A::Error::custom)?); + } + DecodedHint::Bool(value) => { + values.insert(key, OwnedValue::from(value)); + } + DecodedHint::Urgency(value) => { + values.insert(key, OwnedValue::from(value)); + } + DecodedHint::Image(Some(image)) => { + // Keep each protocol alias separate so arrival order cannot change precedence + match key.as_str() { + "image-data" => standard_image = Some(image), + "image_data" => legacy_image = Some(image), + "icon_data" => legacy_icon = Some(image), + _ => {} + } + } + DecodedHint::Image(None) => {} + } + } + + Ok(WireHints { + values, + image_data: standard_image.or(legacy_image).or(legacy_icon), + }) + } +} + +#[derive(Clone, Copy)] +enum HintKind { + Text, + Bool, + Urgency, + Image, +} + +impl HintKind { + fn for_key(key: &str) -> Option { + match key { + "desktop-entry" + | "category" + | "image-path" + | "image_path" + | "sound-name" + | "sound-file" + | "x-kde-reply-placeholder-text" + | "x-kde-reply-submit-button-text" + | "x-kde-reply-submit-button-icon-name" => Some(Self::Text), + "transient" | "resident" | "suppress-sound" => Some(Self::Bool), + "urgency" => Some(Self::Urgency), + "image-data" | "image_data" | "icon_data" => Some(Self::Image), + _ => None, + } + } +} + +enum DecodedHint { + Text(String), + Bool(bool), + Urgency(u32), + Image(Option), +} + +struct HintVariantSeed { + kind: HintKind, +} + +impl<'de> DeserializeSeed<'de> for HintVariantSeed { + type Value = DecodedHint; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(HintVariantVisitor { kind: self.kind }) + } +} + +struct HintVariantVisitor { + kind: HintKind, +} + +impl<'de> Visitor<'de> for HintVariantVisitor { + type Value = DecodedHint; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a typed notification hint variant") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let signature = sequence + .next_element::>()? + .ok_or_else(|| A::Error::invalid_length(0, &self))?; + match self.kind { + HintKind::Text if signature.as_str() == "s" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Text(value)), + ) + } + HintKind::Bool if signature.as_str() == "b" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Bool(value)), + ) + } + HintKind::Urgency if signature.as_str() == "y" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Urgency(u32::from(value))), + ) + } + HintKind::Urgency if signature.as_str() == "u" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Urgency(value)), + ) + } + HintKind::Image if signature.as_str() == "(iiibiiay)" => { + let raw = sequence + .next_element::<(i32, i32, i32, bool, i32, i32, BoundedImageBytes)>()? + .ok_or_else(|| A::Error::invalid_length(1, &self))?; + let image = raw + .6 + .into_image_data(raw.0, raw.1, raw.2, raw.3, raw.4, raw.5); + Ok(DecodedHint::Image(image)) + } + HintKind::Text | HintKind::Bool | HintKind::Urgency | HintKind::Image => Err( + A::Error::custom("notification hint has an unexpected D-Bus signature"), + ), + } + } +} + +fn owned_string(value: &str) -> zbus::zvariant::Result { + OwnedValue::try_from(Value::from(value)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs new file mode 100644 index 000000000..d9a1aaad3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs @@ -0,0 +1,76 @@ +//! Allocation-bounded byte-array decoding for optional notification images + +use serde::de::{SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use unixnotis_core::ImageData; + +/// Raw images larger than the retained model limit are consumed but never allocated +#[derive(Debug, Default)] +pub(super) struct BoundedImageBytes { + data: Option>, +} + +impl BoundedImageBytes { + pub(super) fn into_image_data( + self, + width: i32, + height: i32, + rowstride: i32, + has_alpha: bool, + bits_per_sample: i32, + channels: i32, + ) -> Option { + let data = self.data?; + unixnotis_core::NotificationImage::normalize_image_data(ImageData { + width, + height, + rowstride, + has_alpha, + bits_per_sample, + channels, + data, + }) + } +} + +impl<'de> Deserialize<'de> for BoundedImageBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedImageBytesVisitor) + } +} + +struct BoundedImageBytesVisitor; + +impl<'de> Visitor<'de> for BoundedImageBytesVisitor { + type Value = BoundedImageBytes; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a bounded notification image byte array") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let retained_limit = unixnotis_core::NotificationImage::retained_byte_limit(); + let mut data = Some(Vec::new()); + + while let Some(byte) = sequence.next_element::()? { + let Some(retained) = data.as_mut() else { + continue; + }; + if retained.len() == retained_limit { + // Release a partial buffer as soon as the optional image crosses the limit + data = None; + continue; + } + retained.push(byte); + } + + Ok(BoundedImageBytes { data }) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs new file mode 100644 index 000000000..560379384 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs @@ -0,0 +1,39 @@ +//! Bounded deserialization for caller-provided notification hints + +mod decode; +mod image_bytes; + +use std::collections::HashMap; + +use unixnotis_core::ImageData; +use zbus::zvariant::{OwnedValue, Signature, Type}; + +/// Hints decoded without expanding large byte arrays into per-byte dynamic values +#[derive(Debug, Default)] +pub(super) struct WireHints { + values: HashMap, + image_data: Option, +} + +impl WireHints { + pub(super) fn into_parts(self) -> (HashMap, Option) { + (self.values, self.image_data) + } +} + +impl From> for WireHints { + fn from(values: HashMap) -> Self { + // Internal tests and helpers may still supply an already-decoded hint map + Self { + values, + image_data: None, + } + } +} + +impl Type for WireHints { + fn signature() -> Signature<'static> { + // This is the standard freedesktop notification hint dictionary + Signature::from_static_str_unchecked("a{sv}") + } +} From e52619f13b2b10ebf615f4fa2d1e0cd4b91317a2 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:31:50 -0500 Subject: [PATCH 151/275] fix(attribution): stop timed-out provider process trees Summary: stop timed-out provider process trees. Scope: attribution. --- .../identity/desktop_index/provenance.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs index 4952e6ea0..ab95d358f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs @@ -3,11 +3,14 @@ use std::collections::{HashMap, HashSet}; use std::io::Read; use std::os::unix::ffi::OsStrExt; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; +use rustix::process::{kill_process_group, Pid, Signal}; + use super::super::executable::executable_evidence_for_path; use wait_timeout::ChildExt; @@ -329,12 +332,16 @@ fn run_package_query_with_timeout( output_limit: usize, timeout: Duration, ) -> Option { + // A provider may launch helpers that keep the output pipe open after its leader exits + command.process_group(0); let mut child = command .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + // The child is its new process-group leader because process_group received zero + let process_group = Pid::from_child(&child); let stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { let limit = u64::try_from(output_limit) @@ -348,7 +355,10 @@ fn run_package_query_with_timeout( let status = if let Some(status) = child.wait_timeout(timeout).ok()? { status } else { - let _kill_result = child.kill(); + // Kill descendants before joining the reader so inherited pipe handles cannot stall it + if kill_process_group(process_group, Signal::KILL).is_err() { + let _kill_result = child.kill(); + } let _wait_result = child.wait(); let _reader_result = reader.join(); return None; From c83e0f810ecdd1828a92ae41b55e66ad882cae4d Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 04:58:59 -0500 Subject: [PATCH 152/275] ci: isolate the user runtime and D-Bus broker Summary: isolate the user runtime and D-Bus broker. Scope: repository. --- .github/workflows/ci.yml | 35 ++++++++++++++++++- .../identity/desktop_index/tests/launch.rs | 4 +++ .../notifications/server/tests/ingress.rs | 3 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f01e9403..3771275c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: ca-certificates \ curl \ dbus \ + dbus-broker \ git \ jq \ libgtk-4-dev \ @@ -51,6 +52,7 @@ jobs: pkg-config \ ripgrep \ shellcheck \ + socat \ xauth \ xvfb \ zstd @@ -120,7 +122,38 @@ jobs: run: tests/package-release.sh - name: Run workspace tests - run: xvfb-run -a dbus-run-session -- cargo test --workspace --all-targets --all-features + run: | + set -euo pipefail + ci_runtime_dir="${RUNNER_TEMP:?}/unixnotis-runtime" + ci_journal_socket="/run/systemd/journal/socket" + ci_journal_sink_pid="" + + cleanup_journal_sink() { + if [[ -n "${ci_journal_sink_pid}" ]]; then + kill "${ci_journal_sink_pid}" 2>/dev/null || true + wait "${ci_journal_sink_pid}" 2>/dev/null || true + fi + } + trap cleanup_journal_sink EXIT + + install -d -m 0700 "${ci_runtime_dir}" + if [[ ! -S "${ci_journal_socket}" ]]; then + install -d -m 0755 "$(dirname "${ci_journal_socket}")" + socat \ + "UNIX-RECVFROM:${ci_journal_socket},fork,mode=0666" \ + OPEN:/dev/null & + ci_journal_sink_pid=$! + for ((attempt = 0; attempt < 200; attempt++)); do + [[ -S "${ci_journal_socket}" ]] && break + kill -0 "${ci_journal_sink_pid}" + sleep 0.01 + done + [[ -S "${ci_journal_socket}" ]] + fi + + XDG_RUNTIME_DIR="${ci_runtime_dir}" \ + xvfb-run -a dbus-run-session -- \ + cargo test --workspace --all-targets --all-features - name: Run dependency audit run: cargo audit --deny warnings diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs index 6c5faed80..ac89eb596 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use super::super::launch::{ @@ -183,6 +184,9 @@ fn user_writable_literal_payload_cannot_support_a_system_association() { let root = TempRoot::new("launch-spec-user-payload"); let payload = root.join("application-script"); fs::write(&payload, "exit 0\n").expect("write user payload"); + // Make the fixture mutable even when the test runner itself uses uid zero + fs::set_permissions(&payload, fs::Permissions::from_mode(0o666)) + .expect("make payload user writable"); let desktop_path = root.join("org.example.UserPayload.desktop"); fs::write( &desktop_path, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index bdf63aab3..3733a5390 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -13,7 +13,8 @@ use crate::expire::ExpirationScheduler; use crate::test_support::daemon_state_for_test; const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; -const TEST_NOTIFY_TIMEOUT: Duration = Duration::from_secs(2); +// Four-megabyte D-Bus fixtures need headroom when the full test binary runs in parallel +const TEST_NOTIFY_TIMEOUT: Duration = Duration::from_secs(10); #[test] fn notify_wire_limit_applies_only_to_oversized_notify_calls() { From a77cfcce96d8ca41ec9ff3e3922521475366a9d9 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 06:43:14 -0500 Subject: [PATCH 153/275] fix(dbus): isolate lifecycle checks and unavailable ownership Summary: isolate lifecycle checks and unavailable ownership. Scope: dbus. --- .github/workflows/ci.yml | 32 +--- crates/unixnotis-daemon/src/runtime/daemon.rs | 20 +-- .../src/runtime/tests/dbus_lifecycle.rs | 161 ++++-------------- .../tests/dbus_lifecycle/private_bus.rs | 160 +++++++++++++++++ .../src/runtime/tests/runner.rs | 46 ++++- 5 files changed, 252 insertions(+), 167 deletions(-) create mode 100644 crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3771275c1..804837296 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,6 @@ jobs: ca-certificates \ curl \ dbus \ - dbus-broker \ git \ jq \ libgtk-4-dev \ @@ -52,7 +51,6 @@ jobs: pkg-config \ ripgrep \ shellcheck \ - socat \ xauth \ xvfb \ zstd @@ -124,35 +122,7 @@ jobs: - name: Run workspace tests run: | set -euo pipefail - ci_runtime_dir="${RUNNER_TEMP:?}/unixnotis-runtime" - ci_journal_socket="/run/systemd/journal/socket" - ci_journal_sink_pid="" - - cleanup_journal_sink() { - if [[ -n "${ci_journal_sink_pid}" ]]; then - kill "${ci_journal_sink_pid}" 2>/dev/null || true - wait "${ci_journal_sink_pid}" 2>/dev/null || true - fi - } - trap cleanup_journal_sink EXIT - - install -d -m 0700 "${ci_runtime_dir}" - if [[ ! -S "${ci_journal_socket}" ]]; then - install -d -m 0755 "$(dirname "${ci_journal_socket}")" - socat \ - "UNIX-RECVFROM:${ci_journal_socket},fork,mode=0666" \ - OPEN:/dev/null & - ci_journal_sink_pid=$! - for ((attempt = 0; attempt < 200; attempt++)); do - [[ -S "${ci_journal_socket}" ]] && break - kill -0 "${ci_journal_sink_pid}" - sleep 0.01 - done - [[ -S "${ci_journal_socket}" ]] - fi - - XDG_RUNTIME_DIR="${ci_runtime_dir}" \ - xvfb-run -a dbus-run-session -- \ + xvfb-run -a dbus-run-session -- \ cargo test --workspace --all-targets --all-features - name: Run dependency audit diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index cead46d64..cbb745e69 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -70,18 +70,16 @@ pub(super) async fn run_daemon( .await?; // The standard notification name is the first externally visible readiness gate - let reply = request_well_known_name(connection, args.trial).await?; + let reply = match request_well_known_name(connection, args.trial).await { + Ok(reply) => reply, + Err(zbus::Error::NameTaken) => { + return Err(anyhow!( + "org.freedesktop.Notifications is already owned and unavailable to this process" + )); + } + Err(error) => return Err(error.into()), + }; log_name_reply(&reply); - if !args.trial - && !matches!( - reply, - zbus::fdo::RequestNameReply::PrimaryOwner | zbus::fdo::RequestNameReply::AlreadyOwner - ) - { - return Err(anyhow!( - "org.freedesktop.Notifications is already owned; retry with --trial" - )); - } verify_name_owner(dbus_proxy, connection, NOTIFICATIONS_BUS_NAME).await?; // The private control name is published last and means the daemon is ready diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs index bf528c548..40e326e36 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -1,8 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use clap::Parser; use futures_util::StreamExt; @@ -16,100 +13,10 @@ use super::super::{run_with_builder, run_with_builder_inner}; use crate::cli::Args; use unixnotis_core::Config; -static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); +#[path = "dbus_lifecycle/private_bus.rs"] +mod private_bus; -struct PrivateBroker { - child: Child, - socket: PathBuf, - address: String, -} - -impl PrivateBroker { - fn start() -> Self { - let socket = broker_socket(); - let address = format!("unix:path={}", socket.display()); - let socket_activate = - unixnotis_core::util::trusted_system_program_path("systemd-socket-activate") - .expect("find trusted systemd-socket-activate"); - let broker = unixnotis_core::util::trusted_system_program_path("dbus-broker-launch") - .expect("find trusted dbus-broker-launch"); - let runtime_dir = std::env::var("XDG_RUNTIME_DIR") - .expect("private dbus-broker tests require XDG_RUNTIME_DIR"); - let mut command = Command::new(socket_activate); - command - .arg("--now") - .arg("--setenv") - .arg(format!("XDG_RUNTIME_DIR={runtime_dir}")); - if let Ok(bus_address) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { - // The launcher uses the existing user bus only for systemd activation control - command - .arg("--setenv") - .arg(format!("DBUS_SESSION_BUS_ADDRESS={bus_address}")); - } - let mut child = command - .arg("--listen") - .arg(&socket) - .arg("--fdname=dbus.socket") - .arg(broker) - .args(["--scope", "user"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("start private dbus-broker"); - - // Socket activation creates the isolated listener before clients are allowed to connect - let deadline = Instant::now() + Duration::from_secs(2); - while !socket.exists() && Instant::now() < deadline { - assert!( - child - .try_wait() - .expect("query private broker process") - .is_none(), - "private dbus-broker exited before creating its socket" - ); - std::thread::sleep(Duration::from_millis(10)); - } - assert!( - socket.exists(), - "private dbus-broker must create its isolated socket" - ); - Self { - child, - socket, - address, - } - } - - fn terminate(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -impl Drop for PrivateBroker { - fn drop(&mut self) { - self.terminate(); - let _ = std::fs::remove_file(&self.socket); - if let Some(parent) = self.socket.parent() { - let _ = std::fs::remove_dir(parent); - } - } -} - -fn broker_socket() -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock must be after the Unix epoch") - .as_nanos(); - let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "unixnotis-runtime-dbus-{}-{stamp}-{serial}", - std::process::id() - )); - std::fs::create_dir_all(&root).expect("create private broker directory"); - root.join("bus.sock") -} +use private_bus::PrivateBus; async fn connect(address: &str) -> Connection { ConnectionBuilder::address(address) @@ -186,9 +93,9 @@ async fn wait_for_both_owners(connection: &Connection) -> (String, String) { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn startup_publishes_both_names_with_one_ready_owner() { - let broker = PrivateBroker::start(); - let client = connect(&broker.address).await; - let daemon = spawn_daemon(broker.address.clone(), 1); + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 1); let (notifications_owner, control_owner) = wait_for_both_owners(&client).await; assert_eq!( @@ -278,14 +185,14 @@ async fn startup_publishes_both_names_with_one_ready_owner() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn strict_broker_accepts_full_notification_view_after_added_signal() { - let broker = PrivateBroker::start(); - let client = connect(&broker.address).await; +async fn private_session_bus_accepts_full_notification_view_after_added_signal() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; let trusted_sender = client .unique_name() - .expect("private broker assigns a unique client name") + .expect("private session bus assigns a unique client name") .to_string(); - let daemon = spawn_daemon_with_trusted_sender(broker.address.clone(), 3, trusted_sender); + let daemon = spawn_daemon_with_trusted_sender(bus.address.clone(), 3, trusted_sender); let owners_before = wait_for_both_owners(&client).await; let control = ControlProxy::new(&client) .await @@ -300,11 +207,11 @@ async fn strict_broker_accepts_full_notification_view_after_added_signal() { let id = notifications .notify( - "Strict broker wire test", + "Private bus wire test", 0, "", "Complete notification view", - "The strict broker must accept the nested enum payload", + "The private bus must accept the nested enum payload", Vec::new(), HashMap::from([("urgency".to_string(), OwnedValue::from(2_u8))]), 2_000, @@ -318,7 +225,7 @@ async fn strict_broker_accepts_full_notification_view_after_added_signal() { let signal_args = signal.args().expect("decode NotificationAdded arguments"); assert_eq!(*signal_args.id(), id); - // This is the exact authorized pull that previously made dbus-broker reject the body + // This is the exact authorized pull that previously exposed an invalid D-Bus body let views = control .get_active_notification(id) .await @@ -346,25 +253,28 @@ async fn strict_broker_accepts_full_notification_view_after_added_signal() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn broker_loss_makes_the_daemon_exit_with_failure() { - let mut broker = PrivateBroker::start(); - let client = connect(&broker.address).await; - let daemon = spawn_daemon(broker.address.clone(), 30); +async fn session_bus_loss_makes_the_daemon_exit_with_failure() { + let mut bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 30); let _owners = wait_for_both_owners(&client).await; - broker.terminate(); + bus.terminate(); let result = tokio::time::timeout(Duration::from_secs(8), daemon) .await - .expect("daemon must notice broker loss") + .expect("daemon must notice session bus loss") .expect("join daemon task"); - assert!(result.is_err(), "broker loss must return a daemon failure"); + assert!( + result.is_err(), + "session bus loss must return a daemon failure" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn notification_during_health_probing_keeps_daemon_generation_alive() { - let broker = PrivateBroker::start(); - let client = connect(&broker.address).await; - let daemon = spawn_daemon(broker.address.clone(), 4); + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 4); let owners_before = wait_for_both_owners(&client).await; let notifications = NotificationsProxy::new(&client) .await @@ -403,22 +313,25 @@ async fn notification_during_health_probing_keeps_daemon_generation_alive() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn competing_notification_owner_prevents_control_publication() { - let broker = PrivateBroker::start(); - let competitor = connect(&broker.address).await; + let bus = PrivateBus::start(); + let competitor = connect(&bus.address).await; competitor .request_name(NOTIFICATIONS_BUS_NAME) .await .expect("competitor owns notification name"); - let observer = connect(&broker.address).await; - let daemon = spawn_daemon(broker.address.clone(), 5); + let observer = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 5); let result = tokio::time::timeout(Duration::from_secs(10), daemon) .await .expect("competing owner should fail startup promptly") .expect("join daemon task"); + let error = result.expect_err("competing notification owner must fail startup"); assert!( - result.is_err(), - "competing notification owner must fail startup" + error + .to_string() + .contains("already owned and unavailable to this process"), + "unexpected competing-owner error: {error:#}" ); let dbus = DBusProxy::new(&observer) .await diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs new file mode 100644 index 000000000..8fe3c070e --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs @@ -0,0 +1,160 @@ +use std::io::{self, BufRead, BufReader, Read}; +use std::path::PathBuf; +use std::process::{Child, ChildStdout, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{sync_channel, RecvTimeoutError}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const BUS_READY_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_BUS_ADDRESS_BYTES: usize = 4 * 1024; + +// Parallel lifecycle tests need independent socket directories +static NEXT_BUS: AtomicUsize = AtomicUsize::new(0); + +pub(super) struct PrivateBus { + child: Child, + socket: PathBuf, + pub(super) address: String, +} + +impl PrivateBus { + pub(super) fn start() -> Self { + let socket = bus_socket(); + let listen_address = format!("unix:path={}", socket.display()); + + // Resolve from protected roots because tests may temporarily replace PATH + let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") + .expect("find dbus-daemon in a trusted system directory"); + let mut child = Command::new(daemon) + .args([ + "--session", + "--nofork", + "--nopidfile", + "--nosyslog", + "--print-address=1", + &format!("--address={listen_address}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("start private D-Bus session bus"); + + // The first output line proves that the requested listener is ready + let stdout = child.stdout.take().expect("capture private bus address"); + let address = read_bus_address(&mut child, stdout, &listen_address) + .expect("read private D-Bus session bus address promptly"); + + Self { + child, + socket, + address, + } + } + + pub(super) fn terminate(&mut self) { + // Reaping the daemon prevents process and socket leaks between tests + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for PrivateBus { + fn drop(&mut self) { + self.terminate(); + let _ = std::fs::remove_file(&self.socket); + if let Some(parent) = self.socket.parent() { + let _ = std::fs::remove_dir(parent); + } + } +} + +fn read_bus_address( + child: &mut Child, + stdout: ChildStdout, + expected_prefix: &str, +) -> io::Result { + let (sender, receiver) = sync_channel(1); + + // A worker keeps the pipe read from blocking the test indefinitely + std::thread::spawn(move || { + let mut address = String::new(); + let limit = u64::try_from(MAX_BUS_ADDRESS_BYTES + 1) + .expect("private bus address limit should fit in u64"); + let result = BufReader::new(stdout) + .take(limit) + .read_line(&mut address) + .and_then(|read| validate_address_line(read, address)); + let _ = sender.send(result); + }); + + let result = match receiver.recv_timeout(BUS_READY_TIMEOUT) { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + "private D-Bus session bus did not report its address promptly", + )), + Err(RecvTimeoutError::Disconnected) => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "private D-Bus address reader stopped unexpectedly", + )), + } + .and_then(|address| validate_listener(address, expected_prefix)); + + if result.is_err() { + // Startup failures occur before a guard exists, so cleanup happens here + let _ = child.kill(); + let _ = child.wait(); + } + + result +} + +fn validate_address_line(read: usize, address: String) -> io::Result { + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "private D-Bus session bus closed before reporting its address", + )); + } + if read > MAX_BUS_ADDRESS_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "private D-Bus session bus address exceeded the test limit", + )); + } + if !address.ends_with('\n') { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "private D-Bus session bus address did not end with a newline", + )); + } + + Ok(address.trim().to_string()) +} + +fn validate_listener(address: String, expected_prefix: &str) -> io::Result { + if address.starts_with(expected_prefix) { + Ok(address) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "private D-Bus session bus returned an unexpected address", + )) + } +} + +fn bus_socket() -> PathBuf { + // Time, process, and serial values keep concurrent test roots independent + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after the Unix epoch") + .as_nanos(); + let serial = NEXT_BUS.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-runtime-dbus-{}-{stamp}-{serial}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create private D-Bus directory"); + root.join("bus.sock") +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/runner.rs b/crates/unixnotis-daemon/src/runtime/tests/runner.rs index 64a6732c0..364a2457d 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/runner.rs @@ -1,6 +1,8 @@ use clap::Parser; -use super::{run_with_builder, trial_requested}; +use std::process::Command; + +use super::{run, run_with_builder, trial_requested}; use crate::cli::Args; use unixnotis_core::Config; use zbus::connection::Builder; @@ -8,6 +10,10 @@ use zbus::connection::Builder; #[path = "dbus_lifecycle.rs"] mod dbus_lifecycle; +const RUNTIME_CHILD_ENV: &str = "UNIXNOTIS_RUNTIME_TEST_CHILD"; +const RUNTIME_ERROR_TEST: &str = + "runtime::runner::tests::public_runtime_returns_error_when_session_bus_is_unreachable"; + #[test] fn trial_preparation_is_enabled_only_by_the_trial_flag() { let normal = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); @@ -33,3 +39,41 @@ async fn runtime_reports_an_unreachable_session_bus() { "unexpected error: {error:#}" ); } + +#[test] +fn public_runtime_returns_error_when_session_bus_is_unreachable() { + if std::env::var_os(RUNTIME_CHILD_ENV).is_some() { + // The child owns its environment, so no parallel test can observe the fake bus address + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build isolated runtime test executor"); + let args = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); + let error = runtime + .block_on(Box::pin(run(&args, Config::default()))) + .expect_err("public runtime must propagate an unreachable session bus"); + assert!( + error.to_string().contains("session bus"), + "unexpected public runtime error: {error:#}" + ); + return; + } + + // A child process scopes the D-Bus environment mutation to this one regression + let test_binary = std::env::current_exe().expect("resolve current daemon test binary"); + let status = Command::new(test_binary) + .args(["--exact", RUNTIME_ERROR_TEST, "--nocapture"]) + .env(RUNTIME_CHILD_ENV, "1") + .env( + "DBUS_SESSION_BUS_ADDRESS", + "unix:path=/nonexistent/unixnotis-public-runtime-session-bus", + ) + .env_remove("DBUS_STARTER_ADDRESS") + .status() + .expect("run isolated public runtime regression"); + + assert!( + status.success(), + "isolated public runtime regression must pass" + ); +} From 9c05518a4780eac7704b84d718f34f9ce71100ae Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 06:43:31 -0500 Subject: [PATCH 154/275] fix(installer): bound process-handle fallback waits Summary: bound process-handle fallback waits. Scope: installer. --- .../src/actions/daemon/process_handle.rs | 26 +++-- .../actions/daemon/tests/process_handle.rs | 94 +++++++++++++++---- 2 files changed, 95 insertions(+), 25 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs index 5ec4813de..e358557a0 100644 --- a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs @@ -3,7 +3,7 @@ use std::fs; use std::os::fd::OwnedFd; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{anyhow, Context, Result}; use rustix::event::{poll, PollFd, PollFlags, Timespec}; @@ -87,15 +87,18 @@ impl ProcessHandle { return wait_for_pidfd(pidfd, self.exit_timeout); } - let started = Instant::now(); - while started.elapsed() < self.exit_timeout { + // A finite poll budget keeps fallback shutdown bounded even if the clock changes + for poll_index in 0..fallback_poll_count(self.exit_timeout) { match read_process_start_time(self.pid.as_raw_pid().cast_unsigned())? { None => return Ok(()), // A new lifetime means the original target exited and must not be inspected Some(current) if current != self.start_time => return Ok(()), - Some(_) => thread::sleep( - FALLBACK_POLL_INTERVAL.min(self.exit_timeout.saturating_sub(started.elapsed())), - ), + Some(_) => { + let elapsed = FALLBACK_POLL_INTERVAL.saturating_mul(poll_index); + thread::sleep( + FALLBACK_POLL_INTERVAL.min(self.exit_timeout.saturating_sub(elapsed)), + ); + } } } @@ -118,6 +121,13 @@ impl ProcessHandle { } } +fn fallback_poll_count(timeout: Duration) -> u32 { + let polls = timeout + .as_nanos() + .div_ceil(FALLBACK_POLL_INTERVAL.as_nanos()); + u32::try_from(polls).unwrap_or(u32::MAX) +} + fn wait_for_pidfd(pidfd: &OwnedFd, timeout: Duration) -> Result<()> { let mut descriptors = [PollFd::new(pidfd, PollFlags::IN)]; let timeout = Timespec { @@ -147,6 +157,10 @@ fn process_matches_program(pid: u32, expected: &str) -> bool { fn read_proc_comm(pid: u32) -> Option { let contents = fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + parse_proc_comm(&contents) +} + +fn parse_proc_comm(contents: &str) -> Option { let comm = contents.trim(); (!comm.is_empty()).then(|| comm.to_string()) } diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs index 2edc727d7..c6eb17f35 100644 --- a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs +++ b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs @@ -1,7 +1,45 @@ -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; use super::*; +const CHILD_EXEC_TIMEOUT: Duration = Duration::from_secs(2); +const CHILD_EXEC_POLL_INTERVAL: Duration = Duration::from_millis(1); + +fn spawn_ready_sleep_child() -> Child { + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep child"); + let deadline = Instant::now() + CHILD_EXEC_TIMEOUT; + + // Command::spawn can return before the child replaces the test executable + while !process_matches_program(child.id(), "sleep") { + match child.try_wait() { + Ok(Some(status)) => panic!("sleep child exited before exec completed: {status}"), + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("inspect sleep child before exec completed: {error}"); + } + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("sleep child did not complete exec within {CHILD_EXEC_TIMEOUT:?}"); + } + std::thread::sleep(CHILD_EXEC_POLL_INTERVAL); + } + + child +} + #[test] fn process_start_time_parser_handles_spaces_in_the_command_name() { let stat = "42 (daemon with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; @@ -29,17 +67,43 @@ fn process_handle_rejects_a_mismatched_program_before_signaling() { .contains("no longer matches expected daemon")); } +#[test] +fn fallback_poll_budget_rounds_up_and_keeps_zero_immediate() { + assert_eq!(fallback_poll_count(Duration::ZERO), 0); + assert_eq!(fallback_poll_count(Duration::from_nanos(1)), 1); + assert_eq!(fallback_poll_count(FALLBACK_POLL_INTERVAL), 1); + assert_eq!( + fallback_poll_count(FALLBACK_POLL_INTERVAL + Duration::from_nanos(1)), + 2 + ); +} + +#[test] +fn proc_comm_reader_reports_the_live_name_and_rejects_missing_processes() { + let expected = std::fs::read_to_string("/proc/self/comm") + .expect("read current process comm") + .trim() + .to_string(); + + assert_eq!( + read_proc_comm(std::process::id()).as_deref(), + Some(expected.as_str()) + ); + assert_eq!(read_proc_comm(i32::MAX as u32), None); +} + +#[test] +fn proc_comm_parser_rejects_blank_names_and_trims_kernel_newlines() { + assert_eq!( + parse_proc_comm("unixnotis-daemon\n").as_deref(), + Some("unixnotis-daemon") + ); + assert_eq!(parse_proc_comm(" \n\t"), None); +} + #[test] fn pidfd_signal_and_wait_stop_the_exact_child_process() { - let sleep = unixnotis_core::util::trusted_system_program_path("sleep") - .expect("find sleep in a trusted system directory"); - let mut child = Command::new(sleep) - .arg("30") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn sleep child"); + let mut child = spawn_ready_sleep_child(); let pid = child.id(); let handle = match ProcessHandle::open(pid, "sleep").expect("open sleep process handle") { @@ -124,15 +188,7 @@ fn fallback_lifetime_check_accepts_current_and_rejects_stale_start_times() { #[test] fn pidfd_wait_times_out_while_the_exact_process_is_still_running() { - let sleep = unixnotis_core::util::trusted_system_program_path("sleep") - .expect("find sleep in a trusted system directory"); - let mut child = Command::new(sleep) - .arg("30") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn sleep child"); + let mut child = spawn_ready_sleep_child(); let mut handle = match ProcessHandle::open(child.id(), "sleep").expect("open sleep handle") { ProcessState::Running(handle) => handle, ProcessState::Gone => panic!("sleep child should still be running"), From c8b6748abea4655d3ab5e0920f6ab3fc98f6623f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 06:43:52 -0500 Subject: [PATCH 155/275] test(cli): harden trusted-tool routing Summary: harden trusted-tool routing. Scope: cli. --- .../noticenterctl/src/system_tools/lookup.rs | 6 +---- .../src/system_tools/tests/command.rs | 16 +++++++++++++ .../src/system_tools/tests/routing.rs | 23 ++++++++++--------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/noticenterctl/src/system_tools/lookup.rs b/crates/noticenterctl/src/system_tools/lookup.rs index 7018664ac..a0ee8f722 100644 --- a/crates/noticenterctl/src/system_tools/lookup.rs +++ b/crates/noticenterctl/src/system_tools/lookup.rs @@ -3,10 +3,6 @@ use std::path::PathBuf; pub(super) fn trusted_program_path(program: &str) -> Option { - // A plain program name prevents callers from smuggling an alternate directory - if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { - return None; - } - // Core owns the fixed directory policy shared by every UnixNotis executable + // Core validates plain names and owns the fixed directory policy used by every binary unixnotis_core::util::trusted_system_program_path(program) } diff --git a/crates/noticenterctl/src/system_tools/tests/command.rs b/crates/noticenterctl/src/system_tools/tests/command.rs index f7bfb6939..f191a9446 100644 --- a/crates/noticenterctl/src/system_tools/tests/command.rs +++ b/crates/noticenterctl/src/system_tools/tests/command.rs @@ -65,6 +65,22 @@ fn trusted_command_rejects_program_names_with_path_separators() { assert_eq!(error.kind(), std::io::ErrorKind::NotFound); } +#[test] +fn production_lookup_returns_the_core_resolved_trusted_program() { + let expected = unixnotis_core::util::trusted_system_program_path("sh") + .expect("find sh in a trusted system directory"); + + assert_eq!( + super::super::lookup::trusted_program_path("sh"), + Some(expected) + ); + let path_like_name = format!("bin{}sh", std::path::MAIN_SEPARATOR); + assert_eq!( + super::super::lookup::trusted_program_path(&path_like_name), + None + ); +} + #[test] fn typed_command_preserves_literal_arguments_and_environment() { let root = TempDirGuard::new("typed"); diff --git a/crates/noticenterctl/src/system_tools/tests/routing.rs b/crates/noticenterctl/src/system_tools/tests/routing.rs index e7500231e..c6c7f0e34 100644 --- a/crates/noticenterctl/src/system_tools/tests/routing.rs +++ b/crates/noticenterctl/src/system_tools/tests/routing.rs @@ -31,17 +31,11 @@ fn executable_mode(_metadata: &std::fs::Metadata) -> bool { } fn fake_tool_bin_is_set() -> bool { - fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .is_some() + lock_fake_tool_bin().is_some() } fn fake_program_path(program: &str) -> Option { - let configured_bin = fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .clone()?; + let configured_bin = lock_fake_tool_bin().clone()?; let candidate = configured_bin.join(program); executable_file(&candidate).then_some(candidate) } @@ -53,15 +47,16 @@ pub struct FakeToolBinGuard { impl Drop for FakeToolBinGuard { fn drop(&mut self) { - *fake_tool_bin().lock().expect("fake tool bin lock") = self.previous.take(); + *lock_fake_tool_bin() = self.previous.take(); } } pub fn use_fake_tool_bin(path: &Path) -> FakeToolBinGuard { + // Recovering a poisoned fixture lock preserves isolation after another test unwinds let lock = fake_tool_bin_test_lock() .lock() - .expect("fake tool bin test lock"); - let mut fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut fake_bin = lock_fake_tool_bin(); let previous = fake_bin.replace(path.to_path_buf()); FakeToolBinGuard { _lock: lock, @@ -74,6 +69,12 @@ fn fake_tool_bin() -> &'static Mutex> { FAKE_TOOL_BIN.get_or_init(|| Mutex::new(None)) } +fn lock_fake_tool_bin() -> MutexGuard<'static, Option> { + fake_tool_bin() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn fake_tool_bin_test_lock() -> &'static Mutex<()> { static FAKE_TOOL_BIN_TEST_LOCK: OnceLock> = OnceLock::new(); FAKE_TOOL_BIN_TEST_LOCK.get_or_init(|| Mutex::new(())) From 3ff97167d263c39b0638eb13d52542078f317261 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 12:41:01 -0500 Subject: [PATCH 156/275] fix(provenance): bound package ownership discovery Summary: bound package ownership discovery. Scope: provenance. --- .../identity/desktop_index/provenance.rs | 391 ++++++++++++++---- .../desktop_index/tests/provenance.rs | 249 ++++++++++- 2 files changed, 558 insertions(+), 82 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs index ab95d358f..b72bdbf2f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs @@ -6,8 +6,9 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use rustix::process::{kill_process_group, Pid, Signal}; @@ -20,6 +21,12 @@ const MAX_COMMAND_PATHS: usize = 4_096; const MAX_OWNERSHIP_OUTPUT_BYTES: usize = 8 * 1024 * 1024; const MAX_PACKAGE_ID_BYTES: usize = 256; const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); +const PACKAGE_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_millis(50); +const TRANSIENT_NEGATIVE_TTL: Duration = Duration::from_secs(30); +const NOT_OWNED_NEGATIVE_TTL: Duration = Duration::from_mins(5); +const MAX_RPM_QUERY_PATHS: usize = 4_096; +const MAX_RPM_QUERY_WORKERS: usize = 8; +const RPM_TOTAL_QUERY_TIMEOUT: Duration = Duration::from_secs(2); /// System database that established package ownership #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] @@ -92,10 +99,75 @@ impl InstallProvenance { } } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum NegativeCause { + NotOwned, + Timeout, + ProviderFailure, + MalformedOutput, + ProcessTermination, +} + +#[derive(Debug, Clone)] +enum CachedProvenance { + Known(InstallProvenance), + Negative { + retry_after: Instant, + cause: NegativeCause, + }, +} + +impl CachedProvenance { + fn from_lookup(lookup: OwnershipLookup, now: Instant) -> Self { + match lookup { + OwnershipLookup::Known(provenance) => Self::Known(provenance), + OwnershipLookup::Negative(cause) => Self::Negative { + retry_after: now.checked_add(negative_ttl(cause)).unwrap_or(now), + cause, + }, + } + } + + fn needs_refresh(&self, now: Instant) -> bool { + let Self::Negative { retry_after, cause } = self else { + return false; + }; + // Keeping the cause live preserves the distinction used to select retry windows + debug_assert!( + !negative_ttl(*cause).is_zero(), + "negative package-provenance results must remain retryable" + ); + now >= *retry_after + } + + fn provenance(&self) -> InstallProvenance { + match self { + Self::Known(provenance) => provenance.clone(), + Self::Negative { .. } => InstallProvenance::Unknown, + } + } +} + +const fn negative_ttl(cause: NegativeCause) -> Duration { + match cause { + NegativeCause::NotOwned => NOT_OWNED_NEGATIVE_TTL, + NegativeCause::Timeout + | NegativeCause::ProviderFailure + | NegativeCause::MalformedOutput + | NegativeCause::ProcessTermination => TRANSIENT_NEGATIVE_TTL, + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum OwnershipLookup { + Known(InstallProvenance), + Negative(NegativeCause), +} + #[derive(Debug, Default)] pub(super) struct PackageOwnershipCache { provider: OnceLock>, - entries: Mutex>, + entries: Mutex>, } impl PackageOwnershipCache { @@ -108,12 +180,17 @@ impl PackageOwnershipCache { .into_iter() .take(MAX_OWNERSHIP_PATHS) .collect::>(); + let now = Instant::now(); let missing = self.entries.lock().map_or_else( |_| paths.iter().cloned().collect::>(), |entries| { paths .iter() - .filter(|path| !entries.contains_key(*path)) + .filter(|path| { + entries + .get(*path) + .is_none_or(|entry| entry.needs_refresh(now)) + }) .cloned() .collect::>() }, @@ -124,18 +201,29 @@ impl PackageOwnershipCache { .provider .get_or_init(detect_package_provider) .as_ref() - .map_or_else(HashMap::new, |provider| { - query_package_ownership(provider, &missing) - }); + .map_or_else( + || { + missing + .iter() + .cloned() + .map(|path| { + ( + path, + OwnershipLookup::Negative(NegativeCause::ProviderFailure), + ) + }) + .collect() + }, + |provider| query_package_ownership(provider, &missing), + ); + let resolved_at = Instant::now(); if let Ok(mut entries) = self.entries.lock() { for path in missing { - entries.insert( - path.clone(), - resolved - .get(&path) - .cloned() - .unwrap_or(InstallProvenance::Unknown), - ); + let lookup = resolved + .get(&path) + .cloned() + .unwrap_or(OwnershipLookup::Negative(NegativeCause::ProviderFailure)); + entries.insert(path, CachedProvenance::from_lookup(lookup, resolved_at)); } } } @@ -148,8 +236,7 @@ impl PackageOwnershipCache { .map(|path| { let provenance = entries .get(&path) - .cloned() - .unwrap_or(InstallProvenance::Unknown); + .map_or(InstallProvenance::Unknown, CachedProvenance::provenance); (path, provenance) }) .collect() @@ -186,21 +273,12 @@ fn detect_package_provider() -> Option { fn query_package_ownership( provider: &PackageProviderCommand, paths: &[PathBuf], -) -> HashMap { +) -> HashMap { match provider.provider { PackageProvider::Pacman => query_in_chunks(provider, paths, &["-Qo"], parse_pacman_output), PackageProvider::Dpkg => query_in_chunks(provider, paths, &["--search"], parse_dpkg_output), - PackageProvider::Rpm => { - // RPM does not retain the queried path in batch output - // Single-path lookups remain safe while bulk indexing fails closed - if paths.len() == 1 { - query_rpm_owner(provider, &paths[0]) - .map(|owner| HashMap::from([(paths[0].clone(), owner)])) - .unwrap_or_default() - } else { - HashMap::new() - } - } + // RPM output does not retain each selector, so bounded workers query paths separately + PackageProvider::Rpm => query_rpm_ownership(provider, paths), } } @@ -209,35 +287,68 @@ fn query_in_chunks( paths: &[PathBuf], arguments: &[&str], parser: OwnershipOutputParser, -) -> HashMap { +) -> HashMap { let mut resolved = HashMap::new(); - let mut start = 0; - while start < paths.len() { - let mut bytes = 0_usize; - let mut end = start; - while end < paths.len() && end.saturating_sub(start) < MAX_COMMAND_PATHS { - let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); - if end > start && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { - break; - } - bytes = bytes.saturating_add(next); - end = end.saturating_add(1); - } - let chunk = &paths[start..end]; + let mut remaining = paths; + while remaining.split_first().is_some() { + // A one-path floor preserves progress even if a future chunk policy returns zero + let chunk_len = ownership_chunk_len(remaining).max(1).min(remaining.len()); + let (chunk, next) = remaining.split_at(chunk_len); let mut command = Command::new(&provider.executable); command .args(arguments) .args(chunk) .env_clear() .env("LC_ALL", "C"); - if let Some(output) = run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { - resolved.extend(parser(&output.stdout, chunk, provider.provider)); + match run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { + Ok(output) => { + let parsed = parser(&output.stdout, chunk, provider.provider); + for path in chunk { + let lookup = parsed.get(path).cloned().map_or_else( + || { + if output.status.success() && output.stdout.is_empty() { + OwnershipLookup::Negative(NegativeCause::NotOwned) + } else if output.status.success() { + OwnershipLookup::Negative(NegativeCause::MalformedOutput) + } else { + OwnershipLookup::Negative(NegativeCause::ProviderFailure) + } + }, + OwnershipLookup::Known, + ); + resolved.insert(path.clone(), lookup); + } + } + Err(error) => { + let cause = error.negative_cause(); + resolved.extend( + chunk + .iter() + .cloned() + .map(|path| (path, OwnershipLookup::Negative(cause))), + ); + } } - start = end; + remaining = next; } resolved } +fn ownership_chunk_len(paths: &[PathBuf]) -> usize { + let mut bytes = 0_usize; + let mut end = 0_usize; + while end < paths.len() && end < MAX_COMMAND_PATHS { + let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); + // The first path always advances so even an oversized selector cannot stall the scan + if end > 0 && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { + break; + } + bytes = bytes.saturating_add(next); + end = end.saturating_add(1); + } + end +} + type OwnershipOutputParser = fn(&[u8], &[PathBuf], PackageProvider) -> HashMap; @@ -285,20 +396,92 @@ fn parse_dpkg_output( .collect() } -fn query_rpm_owner(provider: &PackageProviderCommand, path: &Path) -> Option { +fn query_rpm_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + query_rpm_ownership_with(paths, RPM_TOTAL_QUERY_TIMEOUT, &|path, timeout| { + query_rpm_owner(provider, path, timeout) + }) +} + +fn query_rpm_ownership_with( + paths: &[PathBuf], + total_timeout: Duration, + query: &Query, +) -> HashMap +where + Query: Fn(&Path, Duration) -> OwnershipLookup + Sync, +{ + let bounded_len = paths.len().min(MAX_RPM_QUERY_PATHS); + let bounded = &paths[..bounded_len]; + let next = AtomicUsize::new(0); + let results = Mutex::new(HashMap::with_capacity(bounded_len)); + let deadline = Instant::now() + .checked_add(total_timeout) + .unwrap_or_else(Instant::now); + let worker_count = bounded_len.min(MAX_RPM_QUERY_WORKERS); + + std::thread::scope(|scope| { + let mut workers = Vec::with_capacity(worker_count); + for worker in 0..worker_count { + let spawn = std::thread::Builder::new() + .name(format!("unixnotis-rpm-owner-{worker}")) + .spawn_scoped(scope, || loop { + let path_index = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = bounded.get(path_index) else { + break; + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let lookup = query(path, remaining.min(PACKAGE_QUERY_TIMEOUT)); + if let Ok(mut results) = results.lock() { + results.insert(path.clone(), lookup); + } + }); + if let Ok(worker) = spawn { + workers.push(worker); + } + } + for worker in workers { + let _worker_result = worker.join(); + } + }); + + results.into_inner().unwrap_or_default() +} + +fn query_rpm_owner( + provider: &PackageProviderCommand, + path: &Path, + timeout: Duration, +) -> OwnershipLookup { let mut command = Command::new(&provider.executable); command .args(["-qf", "--queryformat", "%{NAME}\n"]) .arg(path) .env_clear() .env("LC_ALL", "C"); - let output = run_package_query(&mut command, MAX_PACKAGE_ID_BYTES.saturating_add(1))?; - if !output.status.success() || output.stdout.len() > MAX_PACKAGE_ID_BYTES.saturating_add(1) { - return None; + let output = match run_package_query_with_timeout( + &mut command, + MAX_PACKAGE_ID_BYTES.saturating_add(1), + timeout, + ) { + Ok(output) => output, + Err(error) => return OwnershipLookup::Negative(error.negative_cause()), + }; + if !output.status.success() { + return OwnershipLookup::Negative(NegativeCause::ProviderFailure); + } + let package = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); + if package.is_empty() { + return OwnershipLookup::Negative(NegativeCause::NotOwned); } - package_provenance( - provider.provider, - output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout), + package_provenance(provider.provider, package).map_or( + OwnershipLookup::Negative(NegativeCause::MalformedOutput), + OwnershipLookup::Known, ) } @@ -323,7 +506,30 @@ struct PackageQueryOutput { stdout: Vec, } -fn run_package_query(command: &mut Command, output_limit: usize) -> Option { +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PackageQueryFailure { + Spawn, + Wait, + Timeout, + Reader, + PipeDrainTimeout, + OutputLimit, +} + +impl PackageQueryFailure { + const fn negative_cause(self) -> NegativeCause { + match self { + Self::Timeout | Self::PipeDrainTimeout => NegativeCause::Timeout, + Self::OutputLimit => NegativeCause::MalformedOutput, + Self::Spawn | Self::Wait | Self::Reader => NegativeCause::ProcessTermination, + } + } +} + +fn run_package_query( + command: &mut Command, + output_limit: usize, +) -> Result { run_package_query_with_timeout(command, output_limit, PACKAGE_QUERY_TIMEOUT) } @@ -331,7 +537,7 @@ fn run_package_query_with_timeout( command: &mut Command, output_limit: usize, timeout: Duration, -) -> Option { +) -> Result { // A provider may launch helpers that keep the output pipe open after its leader exits command.process_group(0); let mut child = command @@ -339,35 +545,68 @@ fn run_package_query_with_timeout( .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() - .ok()?; + .map_err(|_error| PackageQueryFailure::Spawn)?; // The child is its new process-group leader because process_group received zero let process_group = Pid::from_child(&child); - let stdout = child.stdout.take()?; - let reader = std::thread::spawn(move || { - let limit = u64::try_from(output_limit) - .unwrap_or(u64::MAX) - .saturating_add(1); - let mut output = Vec::new(); - stdout.take(limit).read_to_end(&mut output).ok()?; - Some(output) - }); + let Some(stdout) = child.stdout.take() else { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Reader); + }; + let (reader_tx, reader_rx) = mpsc::sync_channel(1); + let reader = std::thread::Builder::new() + .name("unixnotis-package-output".to_string()) + .spawn(move || { + let limit = u64::try_from(output_limit) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut output = Vec::new(); + let read_result = stdout.take(limit).read_to_end(&mut output); + let _send_result = reader_tx.send(read_result.map(|_bytes| output)); + }) + .map_err(|_error| { + terminate_package_query(&mut child, process_group); + PackageQueryFailure::Reader + })?; + // The result channel owns completion; dropping the handle avoids every unbounded join path + drop(reader); - let status = if let Some(status) = child.wait_timeout(timeout).ok()? { - status - } else { - // Kill descendants before joining the reader so inherited pipe handles cannot stall it - if kill_process_group(process_group, Signal::KILL).is_err() { - let _kill_result = child.kill(); + let started = Instant::now(); + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Timeout); + } + Err(_error) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Wait); + } + }; + let remaining = timeout.saturating_sub(started.elapsed()); + let drain_timeout = remaining.min(PACKAGE_PIPE_DRAIN_TIMEOUT); + let stdout = match reader_rx.recv_timeout(drain_timeout) { + Ok(Ok(stdout)) => stdout, + Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(PackageQueryFailure::Reader); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // The leader exited, so only inherited pipe holders remain in its process group + let _kill_result = kill_process_group(process_group, Signal::KILL); + return Err(PackageQueryFailure::PipeDrainTimeout); } - let _wait_result = child.wait(); - let _reader_result = reader.join(); - return None; }; - let stdout = reader.join().ok()??; if stdout.len() > output_limit { - return None; + return Err(PackageQueryFailure::OutputLimit); + } + Ok(PackageQueryOutput { status, stdout }) +} + +fn terminate_package_query(child: &mut std::process::Child, process_group: Pid) { + // Group termination closes ordinary inherited pipes while the bounded reap avoids startup hangs + if kill_process_group(process_group, Signal::KILL).is_err() { + let _kill_result = child.kill(); } - Some(PackageQueryOutput { status, stdout }) + let _wait_result = child.wait_timeout(PACKAGE_PIPE_DRAIN_TIMEOUT); } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs index 989c0ef8e..d5a41ab49 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs @@ -1,10 +1,15 @@ -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use super::{ - package_provenance, parse_dpkg_output, parse_pacman_output, run_package_query, - run_package_query_with_timeout, InstallProvenance, PackageProvider, + ownership_chunk_len, package_provenance, parse_dpkg_output, parse_pacman_output, + query_package_ownership, query_rpm_owner, query_rpm_ownership_with, run_package_query, + run_package_query_with_timeout, CachedProvenance, InstallProvenance, NegativeCause, + OwnershipLookup, PackageOwnershipCache, PackageProvider, PackageProviderCommand, + PackageQueryFailure, MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMAND_PATHS, NOT_OWNED_NEGATIVE_TTL, + TRANSIENT_NEGATIVE_TTL, }; #[test] @@ -107,7 +112,10 @@ fn package_query_deadline_stops_a_stalled_provider() { let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(20)); - assert!(output.is_none()); + assert!( + matches!(output, Err(PackageQueryFailure::Timeout)), + "a stalled provider should report its deadline" + ); assert!( started.elapsed() < Duration::from_secs(1), "the package provider deadline should stop a stalled process promptly" @@ -120,7 +128,7 @@ fn package_query_rejects_output_beyond_the_declared_limit() { command.args(["-c", "printf 12345"]); assert!( - run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_none(), + run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_err(), "oversized provider output must fail closed" ); } @@ -136,3 +144,232 @@ fn package_query_accepts_successful_output_at_the_exact_limit() { assert!(output.status.success()); assert_eq!(output.stdout, b"1234"); } + +#[test] +fn package_query_returns_when_descendant_holds_stdout_open() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "(sleep 2) & exit 0"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + + assert!( + matches!(output, Err(PackageQueryFailure::PipeDrainTimeout)), + "an inherited output pipe should report a bounded drain timeout" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "an inherited output pipe must not block desktop-index construction" + ); +} + +#[test] +fn rpm_bulk_resolution_maps_each_queried_path() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + let ownership = query_rpm_ownership_with(&paths, Duration::from_secs(1), &|path, _timeout| { + let package_id = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name") + .to_string(); + OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id, + }) + }); + + for path in paths { + let expected = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name"); + assert_eq!( + ownership.get(&path), + Some(&OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id: expected.to_string(), + })), + "each RPM query result must remain bound to its requested path" + ); + } +} + +#[test] +fn transient_ownership_failures_expire_before_confirmed_not_owned_entries() { + let now = Instant::now(); + let transient = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::Timeout), now); + let not_owned = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::NotOwned), now); + + assert!(!transient.needs_refresh(now)); + assert!(transient.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(!not_owned.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(not_owned.needs_refresh(now + NOT_OWNED_NEGATIVE_TTL)); + assert_eq!(transient.provenance(), InstallProvenance::Unknown); +} + +#[test] +fn cached_known_provenance_is_returned_for_every_requested_path() { + let path = PathBuf::from("/usr/bin/example-cache-entry"); + let expected = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-cache-entry".to_string(), + }; + let cache = PackageOwnershipCache::default(); + cache + .entries + .lock() + .expect("package cache should be writable") + .insert(path.clone(), CachedProvenance::Known(expected.clone())); + + let resolved = cache.resolve_many([path.clone()]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get(&path), Some(&expected)); +} + +#[test] +fn short_package_paths_share_one_bounded_provider_query() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + assert_eq!(ownership_chunk_len(&paths), paths.len()); +} + +#[test] +fn package_query_chunk_never_exceeds_the_path_count_limit() { + let paths = (0..=MAX_COMMAND_PATHS) + .map(|index| PathBuf::from(format!("p{index}"))) + .collect::>(); + + assert_eq!(ownership_chunk_len(&paths), MAX_COMMAND_PATHS); +} + +#[test] +fn oversized_first_package_selector_still_advances_exactly_one_path() { + let paths = [ + PathBuf::from("x".repeat(MAX_COMMAND_ARGUMENT_BYTES.saturating_add(1))), + PathBuf::from("next"), + ]; + + assert_eq!(ownership_chunk_len(&paths), 1); +} + +#[test] +fn package_query_chunk_accepts_the_exact_argument_byte_limit() { + let first_bytes = MAX_COMMAND_ARGUMENT_BYTES.saturating_sub(3); + let paths = [PathBuf::from("x".repeat(first_bytes)), PathBuf::from("y")]; + + assert_eq!(ownership_chunk_len(&paths), 2); +} + +#[test] +fn ownership_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Pacman, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert_eq!( + resolved.get(&path), + Some(&OwnershipLookup::Negative(NegativeCause::MalformedOutput)), + "successful but unrecognized provider output must remain a transient failure" + ); + } +} + +#[test] +fn rpm_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert!( + resolved.contains_key(&path), + "each RPM selector should receive a classified result" + ); + } +} + +#[test] +fn failed_rpm_process_is_not_reported_as_a_confirmed_unowned_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/false"), + }; + + let result = query_rpm_owner( + &provider, + Path::new("/usr/bin/example"), + Duration::from_secs(1), + ); + + assert_eq!( + result, + OwnershipLookup::Negative(NegativeCause::ProviderFailure) + ); +} + +#[test] +fn timed_out_package_provider_is_terminated_before_returning() { + let serial = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-package-timeout-{}-{}", + std::process::id(), + serial + )); + fs::create_dir_all(&root).expect("package timeout test root should be created"); + let pid_file = root.join("provider.pid"); + let mut command = Command::new("/bin/sh"); + command + .args([ + "-c", + "printf '%s' \"$$\" > \"$1\"; exec sleep 2", + "unixnotis-package-timeout", + ]) + .arg(&pid_file); + + let result = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + assert!(matches!(result, Err(PackageQueryFailure::Timeout))); + let pid = fs::read_to_string(&pid_file).expect("provider should publish its process id"); + let process_path = Path::new("/proc").join(pid.trim()); + let reap_deadline = Instant::now() + Duration::from_millis(250); + while process_path.exists() && Instant::now() < reap_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + !process_path.exists(), + "a timed-out provider must not continue after the ownership query returns" + ); + fs::remove_dir_all(root).expect("package timeout test root should be removable"); +} From 7fb3c899a6848826781c79c59c2f9bb4a68547cb Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 12:41:35 -0500 Subject: [PATCH 157/275] fix(attribution): require affirmative application evidence Summary: require affirmative application evidence. Scope: attribution. --- .../identity/desktop_index/model.rs | 2 +- .../identity/desktop_index/record.rs | 1 - .../desktop_index/tests/verification.rs | 151 +++++++--- .../identity/desktop_index/verification.rs | 22 +- .../daemon/notifications/identity/resolver.rs | 267 ------------------ .../identity/resolver/candidates.rs | 48 +++- .../identity/resolver/diagnostics.rs | 12 +- .../identity/resolver/evidence.rs | 26 +- .../notifications/identity/resolver/mod.rs | 17 ++ .../notifications/identity/resolver/model.rs | 55 ++++ .../identity/resolver/pipeline.rs | 118 ++++++++ .../identity/resolver/resolution.rs | 41 ++- .../identity/resolver/sender_context.rs | 35 +++ .../identity/resolver/tests/candidates.rs | 4 + .../tests/candidates}/claims.rs | 28 +- .../tests/candidates}/families.rs | 5 +- .../identity/resolver/tests/diagnostics.rs | 56 ++++ .../identity/resolver/tests/evidence.rs | 5 + .../tests/evidence}/helpers.rs | 159 +++++++++-- .../resolver/tests/evidence/identity.rs | 27 ++ .../tests/evidence}/runtime.rs | 43 ++- .../identity/resolver/tests/mod.rs | 41 +++ .../identity/resolver/tests/model.rs | 29 ++ .../identity/resolver/tests/pipeline.rs | 6 + .../tests/pipeline}/dedicated.rs | 44 +-- .../tests/pipeline}/portal.rs | 65 ++++- .../resolver/tests/pipeline/provenance.rs | 49 ++++ .../tests/pipeline}/spoof.rs | 49 +--- .../identity/resolver/tests/resolution.rs | 64 +++++ .../identity/resolver/tests/sender_context.rs | 71 +++++ .../resolver.rs => resolver/tests/support.rs} | 72 ++--- .../identity/resolver/tests/validation.rs | 20 ++ .../identity/resolver/validation.rs | 20 ++ .../identity/tests/resolver/association.rs | 8 - .../src/daemon/notifications/server/flow.rs | 7 +- crates/unixnotis-daemon/src/main.rs | 1 - 36 files changed, 1120 insertions(+), 548 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver/association => resolver/tests/candidates}/claims.rs (79%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver/association => resolver/tests/candidates}/families.rs (99%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver/association => resolver/tests/evidence}/helpers.rs (53%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver => resolver/tests/evidence}/runtime.rs (91%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver/association => resolver/tests/pipeline}/dedicated.rs (90%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver => resolver/tests/pipeline}/portal.rs (63%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver => resolver/tests/pipeline}/spoof.rs (90%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/{tests/resolver.rs => resolver/tests/support.rs} (78%) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index a5f36cc4d..1bed1220a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -66,6 +66,7 @@ pub(in crate::daemon::notifications::identity) enum LaunchFailure { MissingSenderEvidence, MissingCommandLine, UnstructuredCommandLine, + EmptyContractNeedsCommandLine, UnsupportedWrapper, AmbiguousDesktopAssociation, DynamicOnlyContract, @@ -98,7 +99,6 @@ pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) system_origin: bool, pub(in crate::daemon::notifications::identity) system_association: bool, pub(in crate::daemon::notifications::identity) association_eligible: bool, - pub(in crate::daemon::notifications::identity) dbus_activatable: bool, pub(in crate::daemon::notifications::identity) launch_spec: Option, pub(in crate::daemon::notifications::identity) names: HashSet, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index 8434b8da3..affd559ed 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -60,7 +60,6 @@ impl DesktopIdentityIndex { system_origin, system_association, association_eligible, - dbus_activatable: desktop.boolean("DBusActivatable"), launch_spec, names, }); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs index 9bbda2e7f..dfdd4f594 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -274,7 +274,7 @@ fn dedicated_contract_does_not_accept_reordered_fixed_options() { } #[test] -fn empty_dedicated_contract_accepts_application_owned_cli_arguments() { +fn empty_dedicated_contract_rejects_positional_payload() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { @@ -287,22 +287,78 @@ fn empty_dedicated_contract_accepts_application_owned_cli_arguments() { assert_eq!( verify_dedicated( - &structured_command(&[ - "/usr/bin/true", - "--title", - "Native application", - "--passive-popup", - "Message body", - "30", - ]), + &structured_command(&["/usr/bin/true", "/tmp/attacker-payload"]), &spec, ), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) ); } #[test] -fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { +fn empty_contract_with_unstructured_argv_is_not_verified() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + for quality in [ + CommandLineQuality::RewrittenProcessTitle, + CommandLineQuality::Truncated, + CommandLineQuality::Unavailable, + ] { + let command_line = CommandLineEvidence { + argv: Vec::new(), + quality, + }; + + assert_eq!( + verify_dedicated(&command_line, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine), + "an empty contract with {quality:?} argv must stay non-authoritative" + ); + } +} + +#[test] +fn empty_contract_accepts_only_non_positional_switches() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + + for arguments in [ + vec!["/usr/bin/true"], + vec!["/usr/bin/true", "--verbose"], + vec!["/usr/bin/true", "--display=x11", "-q"], + ] { + assert_eq!( + verify_dedicated(&structured_command(&arguments), &spec), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "standalone switches should remain compatible: {arguments:?}" + ); + } + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--title", "untrusted-value"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a separate option value is positional without an ordered contract" + ); +} + +#[test] +fn dynamic_contract_without_shared_provenance_is_not_dedicated() { for field_code in [FieldCode::Files, FieldCode::Urls] { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); @@ -326,7 +382,6 @@ fn single_record_dynamic_file_and_url_contracts_are_not_dedicated() { system_origin: true, system_association: true, association_eligible: true, - dbus_activatable: false, launch_spec: Some(spec.clone()), names: HashSet::new(), }; @@ -376,7 +431,6 @@ fn dedicated_system_application_accepts_dynamic_url_field() { system_origin: true, system_association: true, association_eligible: true, - dbus_activatable: false, launch_spec: Some(spec.clone()), names: HashSet::from(["true".to_string()]), }; @@ -419,7 +473,6 @@ fn dynamic_runtime_requires_matching_immutable_installation_provenance() { system_origin: true, system_association: true, association_eligible: true, - dbus_activatable: false, launch_spec: Some(spec.clone()), names: HashSet::from(["true".to_string()]), }; @@ -439,32 +492,50 @@ fn dynamic_runtime_requires_matching_immutable_installation_provenance() { } #[test] -fn same_package_dynamic_file_runtime_is_not_dedicated() { +fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - executable: executable.identity, - arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], - environment: Vec::new(), - wrappers: Vec::new(), - literal_files_are_system_managed: true, - }; - let mut record = record_for_spec("org.example.Runtime", &spec); - record.desktop_provenance = test_package("runtime"); - record.executable_provenance = test_package("runtime"); - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Runtime") - .into_iter() - .next() - .expect("runtime application record"); + for (field_code, actual) in [ + (FieldCode::File, vec!["/usr/bin/true", "/tmp/image.png"]), + ( + FieldCode::Files, + vec!["/usr/bin/true", "/tmp/first.png", "/tmp/second.png"], + ), + ] { + let spec = LaunchSpec { + executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Viewer", &spec); + record.desktop_provenance = test_package("example-viewer"); + record.executable_provenance = test_package("example-viewer"); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Viewer") + .into_iter() + .next() + .expect("file application record"); - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DynamicOnly, - "package ownership cannot prove whether a file field is a document or active program" - ); + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "immutable application ownership should support {field_code:?}" + ); + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&actual), + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "the ordered {field_code:?} contract should accept matching document arguments" + ); + } } #[test] @@ -514,7 +585,6 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() system_origin: true, system_association: true, association_eligible: true, - dbus_activatable: false, launch_spec: Some(spec), names: HashSet::new(), }; @@ -540,13 +610,13 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() } #[test] -fn dedicated_authority_accepts_url_fields_but_rejects_ambiguous_file_fields_and_payloads() { +fn dedicated_authority_accepts_document_fields_but_rejects_unprotected_fixed_payloads() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); for (arguments, expected) in [ (Vec::new(), true), (vec![LaunchArgument::FieldCode(FieldCode::Url)], true), - (vec![LaunchArgument::FieldCode(FieldCode::File)], false), + (vec![LaunchArgument::FieldCode(FieldCode::File)], true), ( vec![LaunchArgument::Literal(LiteralArgument { value: b"runtime-selected-payload".to_vec(), @@ -704,7 +774,6 @@ fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { system_origin: true, system_association: true, association_eligible: true, - dbus_activatable: false, launch_spec: Some(spec.clone()), names: HashSet::new(), } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs index fa8115aa2..6a3eaaedd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs @@ -77,15 +77,20 @@ fn executable_contract_is_dedicated( .desktop_provenance .same_application_source(&record.executable_provenance) && index.records_form_one_application_family(spec.executable, record.system_origin) - // A file in argv[1] can be either a document or an interpreter's active program - // Static desktop metadata cannot distinguish those roles without a protected payload - && !spec.arguments.iter().any(is_dynamic_file_field) && !spec.arguments.iter().any(is_unprotected_fixed_payload) } fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { match command_line.quality { - // The live executable remains authoritative when argv memory is absent or rewritten + // An empty contract cannot distinguish an ordinary switch from an active payload + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.arguments.is_empty() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine) + } + // A nonempty package-backed contract still contributes identity when argv was rewritten CommandLineQuality::RewrittenProcessTitle | CommandLineQuality::Truncated | CommandLineQuality::Unavailable => { @@ -94,7 +99,7 @@ fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> La CommandLineQuality::Structured => { let actual = command_line.argv.get(1..).unwrap_or_default(); if actual.len() <= MAX_PROCESS_ARGUMENTS - && (spec.arguments.is_empty() || match_ordered_dedicated_contract(spec, actual)) + && match_ordered_dedicated_contract(spec, actual) { LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) } else { @@ -428,13 +433,6 @@ const fn is_dynamic_document_field(argument: &LaunchArgument) -> bool { matches!(argument, LaunchArgument::FieldCode(_)) } -const fn is_dynamic_file_field(argument: &LaunchArgument) -> bool { - matches!( - argument, - LaunchArgument::FieldCode(FieldCode::File | FieldCode::Files) - ) -} - fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { matches!( argument, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs deleted file mode 100644 index 347415bff..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Ordered application attribution from process, portal, and desktop evidence - -use std::collections::HashSet; - -use unixnotis_core::{ - AttributionDiagnostics, AttributionReason, AttributionStatus, InlineReplyPolicy, - NotificationAttribution, RecordTrust, -}; -use zbus::fdo::DBusProxy; -use zbus::Connection; - -use super::desktop_index::{ - normalize_desktop_id, normalize_name, verify_record_launch, DesktopIdentityIndex, - DesktopRecord, InstallProvenance, LaunchFailure, LaunchVerification, VerifiedLaunch, -}; -use super::executable::{executable_evidence_for_path, FileIdentity}; -use super::policy::inline_reply_policy; -use super::sender::{refresh_sender_security_evidence, CommandLineEvidence, SenderMetadata}; - -mod candidates; -mod diagnostics; -mod evidence; -mod resolution; - -use candidates::{ - extend_unique_records, preferred_record, resolve_unverified_candidates, - strongest_verified_result, trusted_relay_resolution, -}; -use diagnostics::with_diagnostics; -use evidence::{current_system_identity_matches_sender, verify_record_sender}; -use resolution::{ - policy_resolution, resolution_for_portal_record, resolution_for_record, sender_claim_group_key, - trusted_portal_path, -}; - -const MAX_DESKTOP_ID_BYTES: usize = 256; - -#[derive(Clone, Copy)] -pub(in crate::daemon) struct AppClaim<'a> { - pub(in crate::daemon) reported_name: &'a str, - pub(in crate::daemon) desktop_entry: Option<&'a str>, -} - -pub(in crate::daemon) struct AttributionResolution { - pub(in crate::daemon) attribution: NotificationAttribution, - pub(in crate::daemon) diagnostics: AttributionDiagnostics, - pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, -} - -#[derive(Clone, Copy)] -struct VerifiedDesktopRecord<'record>(&'record DesktopRecord, VerifiedLaunch); - -#[derive(Clone, Copy)] -struct CandidateVerification<'record> { - record: &'record DesktopRecord, - verification: LaunchVerification, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum SenderClaimRelation { - ClaimedApplication, - DifferentVerifiedApplication, - SamePackageHelper, - UnknownExecutable, - TrustedRelay, -} - -impl CandidateVerification<'_> { - const fn is_definitive_mismatch(&self) -> bool { - matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) - } - - const fn failure(&self) -> LaunchFailure { - match self.verification { - LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, - LaunchVerification::InsufficientEvidence(reason) - | LaunchVerification::DefinitiveMismatch(reason) => reason, - } - } -} - -pub(in crate::daemon) fn unknown_reply_denied( - claim: AppClaim<'_>, - sender: &SenderMetadata, - reason: &str, -) -> AttributionResolution { - let detail = sender.sender_executable.as_deref().map_or_else( - || reason.to_string(), - |path| format!("{reason}; source {path}"), - ); - let resolution = policy_resolution(NotificationAttribution::unresolved( - claim.reported_name, - AttributionReason::MissingSenderEvidence, - &detail, - sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), - )); - with_diagnostics( - resolution, - claim, - sender, - None, - LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence), - ) -} - -pub(in crate::daemon) async fn resolve_attribution( - claim: AppClaim<'_>, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, - connection: &Connection, -) -> AttributionResolution { - let mut owned_desktop_ids = HashSet::new(); - // Well-known ownership remains supporting context rather than application authority - if let (Some(sender_name), Some(desktop_id)) = ( - sender.sender_name.as_deref(), - claim.desktop_entry.and_then(validate_desktop_id), - ) { - let records = index.records_for_id(&desktop_id); - if records.iter().any(|record| record.dbus_activatable) - && sender_owns_name(connection, sender_name, &desktop_id).await - { - owned_desktop_ids.insert(normalize_desktop_id(&desktop_id)); - } - } - - // Cached process data is refreshed before it affects attribution - let mut sender = refresh_sender_security_evidence(sender); - let initial = resolve_with_evidence(claim, &sender, index, &owned_desktop_ids); - if sender.install_provenance.is_known() - || !matches!( - initial.attribution.status, - unixnotis_core::AttributionStatus::Recognized - ) - { - return initial; - } - - // Ownership is needed only to distinguish a probable helper from a different installed app - enrich_sender_install_provenance(&mut sender, index).await; - resolve_with_evidence(claim, &sender, index, &owned_desktop_ids) -} - -async fn enrich_sender_install_provenance( - sender: &mut SenderMetadata, - index: &DesktopIdentityIndex, -) { - if sender.install_provenance.is_known() { - return; - } - let (Some(path), Some(sender_identity)) = ( - sender.sender_executable.as_deref(), - sender.sender_executable_identity, - ) else { - return; - }; - if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { - return; - } - let Some(current) = executable_evidence_for_path(std::path::Path::new(path)) else { - return; - }; - if !current_system_identity_matches_sender(current.identity, sender_identity) { - return; - } - sender.install_provenance = index - .install_provenance_for_path_async(current.canonical_path) - .await; -} - -fn resolve_with_evidence( - claim: AppClaim<'_>, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, - _owned_desktop_ids: &HashSet, -) -> AttributionResolution { - let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); - let hint_records = desktop_entry - .as_deref() - .map_or_else(Vec::new, |desktop_id| index.records_for_id(desktop_id)); - if desktop_entry.is_some() - && !hint_records.is_empty() - && claim.reported_name.trim().is_empty() - && trusted_portal_path(sender, index).is_some() - { - // A trusted portal executable may forward its broker-owned application id - let record = preferred_record(&hint_records); - let mut resolution = with_diagnostics( - resolution_for_portal_record(record, claim.reported_name, sender, index), - claim, - sender, - Some(record), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - ); - resolution.diagnostics.record_trust = RecordTrust::Portal; - resolution.diagnostics.reason = "verified portal application identity".to_string(); - return resolution; - } - - // Hints, executable identity, and claimed names contribute candidates without granting trust - let mut candidates = hint_records.clone(); - if let Some(identity) = sender.sender_executable_identity { - extend_unique_records(&mut candidates, index.records_for_executable(identity)); - } - if !claim.reported_name.trim().is_empty() { - extend_unique_records( - &mut candidates, - index.records_for_claim(claim.reported_name), - ); - } - let results = candidates - .iter() - .map(|record| CandidateVerification { - record, - verification: verify_record_sender(record, sender, index), - }) - .collect::>(); - - if let Some(record) = strongest_verified_result(&results, claim.reported_name, index) { - return with_diagnostics( - resolution_for_record(record, claim.reported_name, sender, index), - claim, - sender, - Some(record.0), - LaunchVerification::Verified(record.1), - ); - } - - // A verified relay identifies itself but never authenticates the forwarded label - if let Some(resolution) = trusted_relay_resolution(claim, sender, index) { - return resolution; - } - - resolve_unverified_candidates(claim, sender, index, &hint_records, &results) -} - -async fn sender_owns_name(connection: &Connection, sender_name: &str, desktop_id: &str) -> bool { - let Ok(bus_name) = zbus::names::BusName::try_from(desktop_id) else { - return false; - }; - let Ok(proxy) = DBusProxy::new(connection).await else { - return false; - }; - proxy - .get_name_owner(bus_name) - .await - .is_ok_and(|owner| owner.as_str() == sender_name) -} - -pub(super) fn validate_desktop_id(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() - || value.len() > MAX_DESKTOP_ID_BYTES - || value.contains(['/', '\\', '\0']) - || value.chars().any(char::is_control) - { - return None; - } - let value = value.strip_suffix(".desktop").unwrap_or(value); - if value == "." || value == ".." || value.is_empty() { - return None; - } - Some(value.to_string()) -} - -#[cfg(test)] -#[path = "tests/resolver.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs index fd2de486e..90554b30d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs @@ -4,16 +4,18 @@ use std::collections::HashSet; use unixnotis_core::{AttributionReason, AttributionStatus, NotificationAttribution}; +use super::super::desktop_index::{ + normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, LaunchFailure, + LaunchVerification, VerifiedLaunch, +}; +use super::super::sender::SenderMetadata; use super::diagnostics::{launch_failure_label, with_diagnostics}; -use super::evidence::{candidate_proves_conflict, lineage_association}; +use super::evidence::{candidate_proves_conflict, lineage_association, sender_claim_relation}; +use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; use super::resolution::{ conflict_from_candidate, policy_resolution, recognized_resolution, sender_claim_group_key, }; -use super::{ - normalize_desktop_id, normalize_name, AppClaim, AttributionResolution, CandidateVerification, - DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, SenderMetadata, - VerifiedDesktopRecord, VerifiedLaunch, -}; +use super::{AppClaim, AttributionResolution}; pub(super) fn resolve_unverified_candidates( claim: AppClaim<'_>, @@ -107,15 +109,9 @@ pub(super) fn resolve_unverified_candidates( .max_by_key(|result| record_trust_rank(result.record)) { let failure = candidate.failure(); + let detail = recognized_candidate_detail(sender, index, candidate.record, failure); return with_diagnostics( - recognized_resolution( - claim, - sender, - candidate.record, - index, - failure, - launch_failure_label(failure), - ), + recognized_resolution(claim, sender, candidate.record, index, failure, &detail), claim, sender, Some(candidate.record), @@ -126,6 +122,30 @@ pub(super) fn resolve_unverified_candidates( unresolved_candidate_resolution(claim, sender, index) } +fn recognized_candidate_detail( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + record: &DesktopRecord, + failure: LaunchFailure, +) -> String { + match sender_claim_relation(sender, index, record) { + SenderClaimRelation::SamePackageHelper => { + "Sender belongs to the same installed application package but was not strongly bound" + .to_string() + } + SenderClaimRelation::DifferentInstalledPackage => { + "Sender belongs to a separate installed package without a conflicting application identity" + .to_string() + } + SenderClaimRelation::ClaimedApplication + | SenderClaimRelation::DifferentVerifiedApplication + | SenderClaimRelation::UnknownExecutable + | SenderClaimRelation::TrustedRelay => { + launch_failure_label(failure).to_string() + } + } +} + fn ambiguous_protected_family_resolution( claim: AppClaim<'_>, sender: &SenderMetadata, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs index 10d45864f..fcd37e6e9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs @@ -5,11 +5,11 @@ use unixnotis_core::{ LaunchVerificationView, RecordTrust, }; -use super::{ - AppClaim, AttributionResolution, DesktopRecord, LaunchFailure, LaunchVerification, - SenderMetadata, VerifiedLaunch, +use super::super::desktop_index::{ + DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, }; -use crate::daemon::notifications::identity::sender::CommandLineQuality; +use super::super::sender::{CommandLineQuality, SenderMetadata}; +use super::{AppClaim, AttributionResolution}; pub(super) fn with_diagnostics( mut resolution: AttributionResolution, @@ -70,6 +70,9 @@ pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str LaunchFailure::MissingSenderEvidence => "missing sender process evidence", LaunchFailure::MissingCommandLine => "missing command-line evidence", LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", + LaunchFailure::EmptyContractNeedsCommandLine => { + "empty launch contract requires structured command-line evidence" + } LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", @@ -85,6 +88,7 @@ const fn launch_authority_for_failure(failure: LaunchFailure) -> LaunchAuthority match failure { LaunchFailure::DynamicOnlyContract => LaunchAuthorityView::DynamicOnly, LaunchFailure::AmbiguousDesktopAssociation => LaunchAuthorityView::Ambiguous, + LaunchFailure::EmptyContractNeedsCommandLine => LaunchAuthorityView::DedicatedExecutable, LaunchFailure::ProtectedPayloadMismatch | LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine => LaunchAuthorityView::ProtectedPayload, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs index d9c55ad8c..329f845ae 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs @@ -1,10 +1,12 @@ //! Sender, lineage, and contradiction evidence evaluation -use super::{ - executable_evidence_for_path, verify_record_launch, CandidateVerification, CommandLineEvidence, - DesktopIdentityIndex, DesktopRecord, FileIdentity, InstallProvenance, LaunchFailure, - LaunchVerification, SenderClaimRelation, SenderMetadata, VerifiedLaunch, +use super::super::desktop_index::{ + verify_record_launch, DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, + VerifiedLaunch, }; +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::sender::{CommandLineEvidence, SenderMetadata}; +use super::model::{CandidateVerification, SenderClaimRelation}; pub(super) fn lineage_association<'record>( sender: &SenderMetadata, @@ -110,6 +112,7 @@ pub(super) fn candidate_proves_conflict( LaunchFailure::MissingSenderEvidence | LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine + | LaunchFailure::EmptyContractNeedsCommandLine | LaunchFailure::UnsupportedWrapper | LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::DynamicOnlyContract @@ -144,20 +147,19 @@ pub(super) fn sender_claim_relation( return SenderClaimRelation::DifferentVerifiedApplication; } - let sender_provenance = sender_install_provenance(sender); - if sender_provenance.same_application_source(&claimed_record.executable_provenance) { + if sender + .install_provenance + .same_application_source(&claimed_record.executable_provenance) + { return SenderClaimRelation::SamePackageHelper; } - if sender_provenance.is_known() && claimed_record.executable_provenance.is_known() { - return SenderClaimRelation::DifferentVerifiedApplication; + if sender.install_provenance.is_known() && claimed_record.executable_provenance.is_known() { + // Package inequality rules out a same-package helper but does not identify another app + return SenderClaimRelation::DifferentInstalledPackage; } SenderClaimRelation::UnknownExecutable } -fn sender_install_provenance(sender: &SenderMetadata) -> InstallProvenance { - sender.install_provenance.clone() -} - pub(super) const fn current_system_identity_matches_sender( current: FileIdentity, sender_identity: FileIdentity, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs new file mode 100644 index 000000000..5d3edff91 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs @@ -0,0 +1,17 @@ +//! Ordered application attribution from process, portal, and desktop evidence + +mod candidates; +mod diagnostics; +mod evidence; +mod model; +mod pipeline; +mod resolution; +mod sender_context; +mod validation; + +pub(in crate::daemon) use model::{AppClaim, AttributionResolution}; +pub(in crate::daemon) use pipeline::resolve_attribution; +pub(in crate::daemon) use resolution::unknown_reply_denied; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs new file mode 100644 index 000000000..1dc97a35a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs @@ -0,0 +1,55 @@ +//! Internal value types shared by resolver stages + +use unixnotis_core::{AttributionDiagnostics, InlineReplyPolicy, NotificationAttribution}; + +use super::super::desktop_index::{ + DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, +}; + +#[derive(Clone, Copy)] +pub(in crate::daemon) struct AppClaim<'claim> { + pub(in crate::daemon) reported_name: &'claim str, + pub(in crate::daemon) desktop_entry: Option<&'claim str>, +} + +pub(in crate::daemon) struct AttributionResolution { + pub(in crate::daemon) attribution: NotificationAttribution, + pub(in crate::daemon) diagnostics: AttributionDiagnostics, + pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, +} + +#[derive(Clone, Copy)] +pub(super) struct VerifiedDesktopRecord<'record>( + pub(super) &'record DesktopRecord, + pub(super) VerifiedLaunch, +); + +#[derive(Clone, Copy)] +pub(super) struct CandidateVerification<'record> { + pub(super) record: &'record DesktopRecord, + pub(super) verification: LaunchVerification, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum SenderClaimRelation { + ClaimedApplication, + DifferentVerifiedApplication, + DifferentInstalledPackage, + SamePackageHelper, + UnknownExecutable, + TrustedRelay, +} + +impl CandidateVerification<'_> { + pub(super) const fn is_definitive_mismatch(&self) -> bool { + matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) + } + + pub(super) const fn failure(&self) -> LaunchFailure { + match self.verification { + LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, + LaunchVerification::InsufficientEvidence(reason) + | LaunchVerification::DefinitiveMismatch(reason) => reason, + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs new file mode 100644 index 000000000..698c38b07 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -0,0 +1,118 @@ +//! Ordered attribution pipeline and candidate orchestration + +use unixnotis_core::{AttributionStatus, RecordTrust}; + +use super::super::desktop_index::{ + DesktopIdentityIndex, LaunchFailure, LaunchVerification, VerifiedLaunch, +}; +use super::super::sender::{refresh_sender_security_evidence, SenderMetadata}; +use super::candidates::{ + extend_unique_records, preferred_record, resolve_unverified_candidates, + strongest_verified_result, trusted_relay_resolution, +}; +use super::diagnostics::with_diagnostics; +use super::evidence::verify_record_sender; +use super::model::{AppClaim, AttributionResolution, CandidateVerification}; +use super::resolution::{ + conflict_from_candidate, resolution_for_portal_record, resolution_for_record, + trusted_portal_path, +}; +use super::sender_context::enrich_sender_install_provenance; +use super::validation::validate_desktop_id; + +pub(in crate::daemon) async fn resolve_attribution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + // Cached process data is refreshed before it affects attribution + let mut sender = refresh_sender_security_evidence(sender); + let initial = resolve_with_evidence(claim, &sender, index); + if initial.attribution.status != AttributionStatus::Recognized { + return initial; + } + + // Ownership is needed only to distinguish a probable helper from a different installed app + enrich_sender_install_provenance(&mut sender, index).await; + resolve_with_evidence(claim, &sender, index) +} + +pub(super) fn resolve_with_evidence( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); + let hint_records = desktop_entry + .as_deref() + .map_or_else(Vec::new, |desktop_id| index.records_for_id(desktop_id)); + if desktop_entry.is_some() + && !hint_records.is_empty() + && trusted_portal_path(sender, index).is_some() + { + // A trusted portal executable may forward its broker-owned application id + let record = preferred_record(&hint_records); + if !claim.reported_name.trim().is_empty() + && !index.record_matches_claim(record, claim.reported_name) + { + // A protected portal id and a different caller label are affirmative contradiction + let mut resolution = conflict_from_candidate( + claim, + sender, + index, + record, + LaunchFailure::DesktopClaimMismatch, + ); + resolution.diagnostics.record_trust = RecordTrust::Portal; + resolution.diagnostics.reason = + "verified portal application id contradicted the reported name".to_string(); + return resolution; + } + let mut resolution = with_diagnostics( + resolution_for_portal_record(record, claim.reported_name, sender, index), + claim, + sender, + Some(record), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.record_trust = RecordTrust::Portal; + resolution.diagnostics.reason = "verified portal application identity".to_string(); + return resolution; + } + + // Hints, executable identity, and claimed names contribute candidates without granting trust + let mut candidates = hint_records.clone(); + if let Some(identity) = sender.sender_executable_identity { + extend_unique_records(&mut candidates, index.records_for_executable(identity)); + } + if !claim.reported_name.trim().is_empty() { + extend_unique_records( + &mut candidates, + index.records_for_claim(claim.reported_name), + ); + } + let results = candidates + .iter() + .map(|record| CandidateVerification { + record, + verification: verify_record_sender(record, sender, index), + }) + .collect::>(); + + if let Some(record) = strongest_verified_result(&results, claim.reported_name, index) { + return with_diagnostics( + resolution_for_record(record, claim.reported_name, sender, index), + claim, + sender, + Some(record.0), + LaunchVerification::Verified(record.1), + ); + } + + // A verified relay identifies itself but never authenticates the forwarded label + if let Some(resolution) = trusted_relay_resolution(claim, sender, index) { + return resolution; + } + + resolve_unverified_candidates(claim, sender, index, &hint_records, &results) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index 605359a26..f7ce88c90 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -4,12 +4,39 @@ use unixnotis_core::{ AttributionDiagnostics, AttributionReason, AttributionStatus, NotificationAttribution, }; -use super::diagnostics::{launch_failure_label, with_diagnostics}; -use super::{ - inline_reply_policy, normalize_name, AppClaim, AttributionResolution, DesktopIdentityIndex, - DesktopRecord, LaunchFailure, LaunchVerification, SenderMetadata, VerifiedDesktopRecord, +use super::super::desktop_index::{ + normalize_name, DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, }; +use super::super::policy::inline_reply_policy; +use super::super::sender::SenderMetadata; +use super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::model::VerifiedDesktopRecord; +use super::{AppClaim, AttributionResolution}; + +pub(in crate::daemon) fn unknown_reply_denied( + claim: AppClaim<'_>, + sender: &SenderMetadata, + reason: &str, +) -> AttributionResolution { + let detail = sender.sender_executable.as_deref().map_or_else( + || reason.to_string(), + |path| format!("{reason}; source {path}"), + ); + let resolution = policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::MissingSenderEvidence, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )); + with_diagnostics( + resolution, + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence), + ) +} pub(super) fn resolution_for_portal_record( record: &DesktopRecord, @@ -190,9 +217,9 @@ pub(super) fn policy_resolution(attribution: NotificationAttribution) -> Attribu const fn attribution_reason_for_failure(failure: LaunchFailure) -> AttributionReason { match failure { LaunchFailure::MissingSenderEvidence => AttributionReason::MissingSenderEvidence, - LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine => { - AttributionReason::MissingCommandLine - } + LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine + | LaunchFailure::EmptyContractNeedsCommandLine => AttributionReason::MissingCommandLine, LaunchFailure::UnsupportedWrapper => AttributionReason::UnsupportedWrapper, LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::RequiredArgumentMismatch => { AttributionReason::AmbiguousDesktopRecords diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs new file mode 100644 index 000000000..60ae514e9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs @@ -0,0 +1,35 @@ +//! Live sender metadata used by the attribution pipeline + +use super::super::desktop_index::DesktopIdentityIndex; +use super::super::executable::executable_evidence_for_path; +use super::super::sender::SenderMetadata; +use super::evidence::current_system_identity_matches_sender; + +pub(super) async fn enrich_sender_install_provenance( + sender: &mut SenderMetadata, + index: &DesktopIdentityIndex, +) { + if sender.install_provenance.is_known() { + return; + } + let (Some(path), Some(sender_identity)) = ( + sender.sender_executable.as_deref(), + sender.sender_executable_identity, + ) else { + return; + }; + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return; + } + + // Reopen the executable before package ownership can affect attribution + let Some(current) = executable_evidence_for_path(std::path::Path::new(path)) else { + return; + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return; + } + sender.install_provenance = index + .install_provenance_for_path_async(current.canonical_path) + .await; +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs new file mode 100644 index 000000000..06efb9e67 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs @@ -0,0 +1,4 @@ +//! Candidate selection and family-ranking tests + +mod claims; +mod families; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs similarity index 79% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs index 1c1ac9029..41a4e8e2b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/claims.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs @@ -1,3 +1,5 @@ +//! Application-name and desktop-hint association cases + use super::super::*; #[test] @@ -58,7 +60,15 @@ fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { record.desktop_provenance = package("example-app"); record.executable_provenance = package("example-app"); } - let index = DesktopIdentityIndex::from_records(vec![alias, canonical], Vec::new()); + let different_identity = identity(103, 1_030, 0); + let different_record = system_record( + "org.example.Different", + "Different App", + "/usr/bin/different", + different_identity, + ); + let index = + DesktopIdentityIndex::from_records(vec![alias, canonical, different_record], Vec::new()); let records = index.records_for_executable(executable); let results = records .iter() @@ -71,8 +81,7 @@ fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { }, }) .collect::>(); - let mut different = sender("/usr/bin/different", identity(103, 1_030, 0)); - different.install_provenance = package("different-app"); + let different = sender("/usr/bin/different", different_identity); let resolution = resolve_unverified_candidates( AppClaim { @@ -91,16 +100,3 @@ fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { unixnotis_core::AttributionReason::ExecutableMismatch ); } - -#[test] -fn sender_claim_group_key_is_nonempty_and_bound_to_sender_identity() { - let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); - - let unresolved = - sender_claim_group_key(AttributionStatus::Unresolved, "Example App", &metadata); - let conflict = sender_claim_group_key(AttributionStatus::Conflict, "Example App", &metadata); - - assert_eq!(unresolved, "unresolved:106:1060:exampleapp"); - assert_eq!(conflict, "conflict:106:1060:exampleapp"); - assert_ne!(unresolved, conflict); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs similarity index 99% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs index f64ec7ba4..569d31e3b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/families.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs @@ -1,3 +1,5 @@ +//! Canonical desktop application-family cases + use super::super::*; #[test] @@ -26,7 +28,6 @@ fn equivalent_desktop_aliases_use_one_canonical_application_identity() { }, &sender(&app_path, app_identity), &index, - &HashSet::new(), ) }; let canonical_first = resolve_alias(vec![canonical.clone(), alias.clone()]); @@ -110,7 +111,6 @@ fn stronger_verified_family_wins_after_weaker_families_are_ambiguous() { "/home/user/one", identity(96, 960, 1_000), false, - false, ); let second = DesktopRecord::fixture( "org.example.UserTwo", @@ -118,7 +118,6 @@ fn stronger_verified_family_wins_after_weaker_families_are_ambiguous() { "/home/user/two", identity(97, 970, 1_000), false, - false, ); let system = system_record( "org.example.System", diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs new file mode 100644 index 000000000..d8c4b0b02 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs @@ -0,0 +1,56 @@ +//! Structured resolver diagnostic regressions + +use super::super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::super::resolution::recognized_resolution; +use super::*; + +#[test] +fn empty_contract_diagnostic_explains_why_command_line_evidence_is_required() { + assert_eq!( + launch_failure_label(LaunchFailure::EmptyContractNeedsCommandLine), + "empty launch contract requires structured command-line evidence" + ); +} + +#[test] +fn nonconflicting_mismatch_is_reported_as_insufficient_evidence() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(202, 2_020, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let metadata = sender("/usr/libexec/example-helper", identity(203, 2_030, 0)); + let claim = AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }; + let resolution = recognized_resolution( + claim, + &metadata, + record, + &index, + LaunchFailure::ExecutableMismatch, + "helper could not be strongly bound", + ); + + let resolution = with_diagnostics( + resolution, + claim, + &metadata, + Some(record), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs new file mode 100644 index 000000000..5e718334c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs @@ -0,0 +1,5 @@ +//! Sender and process-evidence tests + +mod helpers; +mod identity; +mod runtime; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs similarity index 53% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs index 4e0bb5683..0c7c5636c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/helpers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs @@ -1,16 +1,16 @@ -use super::super::super::evidence::sender_claim_relation; +//! Helper-process and process-lineage association cases + +use super::super::super::evidence::{lineage_association, sender_claim_relation}; use super::super::*; #[test] fn helper_process_lineage_is_recognized_without_becoming_suspicious() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( - vec![system_record( - "org.example.True", - "Example App", - &app_path, - app_identity, - )], + vec![ + system_record("org.example.True", "Example App", &app_path, app_identity) + .with_launch_literals(&["--application-mode"]), + ], Vec::new(), ); let helper_identity = identity(88, 880, 0); @@ -30,13 +30,78 @@ fn helper_process_lineage_is_recognized_without_becoming_suspicious() { }, &helper, &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.attribution.display_name, "Example App"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert!(resolution + .attribution + .diagnostic_detail + .contains("Same-user ancestor")); +} + +#[test] +fn stale_ancestor_identity_does_not_create_a_lineage_association() { + let (app_path, live_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }; + let record = system_record("org.example.True", "Example App", &app_path, stale_identity) + .with_launch_literals(&["--application-mode"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut helper = sender("/usr/libexec/example-helper", identity(204, 2_040, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_081, + start_time: 7_071, + uid: 0, + executable: app_path, + executable_identity: stale_identity, + }); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("Same-user ancestor")); +} + +#[test] +fn lineage_rejects_a_candidate_with_a_different_indexed_executable() { + let (app_path, live_identity) = installed_system_executable(); + let indexed = system_record("org.example.True", "Example App", &app_path, live_identity) + .with_launch_literals(&["--application-mode"]); + let index = DesktopIdentityIndex::from_records(vec![indexed.clone()], Vec::new()); + let mut mismatched = indexed; + mismatched.executable_identity = Some(FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }); + let result = CandidateVerification { + record: &mismatched, + verification: LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + }; + let mut helper = sender("/usr/libexec/example-helper", identity(206, 2_060, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_082, + start_time: 7_072, + uid: 0, + executable: app_path, + executable_identity: live_identity, + }); + + assert!(lineage_association(&helper, &index, &[&result]).is_none()); } #[test] @@ -60,7 +125,6 @@ fn helper_without_lineage_is_recognized_when_no_contradictory_owner_is_known() { }, &helper, &index, - &HashSet::new(), ); assert_eq!( @@ -91,7 +155,6 @@ fn verified_and_recognized_senders_never_share_an_application_group() { }, &sender(&app_path, app_identity), &index, - &HashSet::new(), ); let recognized = resolve_with_evidence( AppClaim { @@ -100,7 +163,6 @@ fn verified_and_recognized_senders_never_share_an_application_group() { }, &sender("/opt/example/helper", identity(90, 900, 1_000)), &index, - &HashSet::new(), ); assert_eq!(verified.attribution.status, AttributionStatus::Verified); @@ -133,15 +195,27 @@ fn package_owned_helper_for_the_claimed_application_is_recognized() { }, &helper, &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + let claimed_record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("claimed record should be indexed"); + assert_eq!( + sender_claim_relation(&helper, &index, claimed_record), + SenderClaimRelation::SamePackageHelper + ); + assert!(resolution + .attribution + .diagnostic_detail + .contains("same installed application package")); } #[test] -fn different_verified_package_is_concrete_conflict_evidence() { +fn separately_packaged_helper_is_recognized_without_becoming_suspicious() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -152,25 +226,71 @@ fn different_verified_package_is_concrete_conflict_evidence() { )], Vec::new(), ); - let mut different = sender("/usr/bin/different-app", identity(92, 920, 0)); - different.install_provenance = package("org.example.Different"); + let mut different_package = sender("/usr/libexec/example-helper", identity(92, 920, 0)); + different_package.install_provenance = package("org.example.Integration"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &different_package, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence, + "the launch mismatch remains diagnostic evidence without proving impersonation" + ); + let claimed_record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("claimed record should be indexed"); + assert_eq!( + sender_claim_relation(&different_package, &index, claimed_record), + SenderClaimRelation::DifferentInstalledPackage + ); + assert!(resolution + .attribution + .diagnostic_detail + .contains("separate installed package")); +} + +#[test] +fn sender_owned_by_another_indexed_system_application_is_a_concrete_conflict() { + let (app_path, app_identity) = installed_system_executable(); + let other_identity = identity(93, 930, 0); + let index = DesktopIdentityIndex::from_records( + vec![ + system_record("org.example.True", "Example App", &app_path, app_identity), + system_record( + "org.example.Other", + "Other App", + "/usr/bin/other-app", + other_identity, + ), + ], + Vec::new(), + ); let resolution = resolve_with_evidence( AppClaim { reported_name: "Example App", desktop_entry: Some("org.example.True"), }, - &different, + &sender("/usr/bin/other-app", other_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_eq!( resolution.diagnostics.verification, - LaunchVerificationView::DefinitiveMismatch, - "only the concrete different-package relation should remain a definitive mismatch" + LaunchVerificationView::DefinitiveMismatch ); } @@ -190,7 +310,6 @@ fn user_record_owning_sender_executable_cannot_prove_a_conflict() { "/home/user/bin/local", user_identity, false, - false, ); let index = DesktopIdentityIndex::from_records(vec![claimed, user], Vec::new()); let claimed_record = index diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs new file mode 100644 index 000000000..cdd6a49ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs @@ -0,0 +1,27 @@ +//! Immutable sender-executable identity checks + +use super::super::super::evidence::current_system_identity_matches_sender; +use super::super::*; + +#[test] +fn reopened_system_identity_must_remain_protected_and_executable() { + let (_, trusted) = installed_system_executable(); + let unprotected = FileIdentity { + uid: 1_000, + ..trusted + }; + let non_executable = FileIdentity { + mode: 0o100_644, + ..trusted + }; + + assert!(current_system_identity_matches_sender(trusted, trusted)); + assert!(!current_system_identity_matches_sender( + unprotected, + trusted + )); + assert!(!current_system_identity_matches_sender( + non_executable, + trusted + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs similarity index 91% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs index 47da7ffbf..3d90fbd41 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs @@ -1,4 +1,6 @@ -use super::*; +//! Shared runtime and protected-payload regressions + +use super::super::*; #[test] fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { @@ -20,7 +22,6 @@ fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { }, &sender("/usr/bin/python3", python_identity), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -61,7 +62,6 @@ fn unlisted_runtimes_cannot_associate_a_different_application_payload() { }, &sender_with_arguments(executable, runtime_identity, &[actual]), &index, - &HashSet::new(), ); assert_ne!( @@ -92,7 +92,6 @@ fn java_cannot_associate_a_different_jar() { }, &sender_with_arguments("/usr/bin/java", java_identity, &["-jar", "/tmp/fake.jar"]), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -120,7 +119,6 @@ fn matching_fixed_system_application_argument_allows_association() { }, &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -150,7 +148,6 @@ fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { &["/tmp/attacker-controlled.bin"], ), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -158,7 +155,7 @@ fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { } #[test] -fn dedicated_executable_remains_verified_when_command_line_is_unavailable() { +fn empty_dedicated_contract_is_recognized_when_command_line_is_unavailable() { let (launcher_path, launcher_identity) = installed_system_executable(); let record = system_record( "org.example.True", @@ -177,7 +174,33 @@ fn dedicated_executable_remains_verified_when_command_line_is_unavailable() { }, &missing_command_line, &index, - &HashSet::new(), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn nonempty_dedicated_contract_can_rely_on_exact_executable_evidence() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.True", + "Command Line App", + &launcher_path, + launcher_identity, + ) + .with_launch_literals(&["--background"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut missing_command_line = sender(&launcher_path, launcher_identity); + missing_command_line.command_line = CommandLineEvidence::default(); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Command Line App", + desktop_entry: Some("org.example.True"), + }, + &missing_command_line, + &index, ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -245,7 +268,6 @@ fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { }, &sender_with_arguments(executable, runtime_identity, &sender_arguments), &index, - &HashSet::new(), ); assert_ne!( @@ -278,7 +300,6 @@ fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { }, &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -304,7 +325,6 @@ fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { }, &sender_with_arguments("/usr/bin/python3", runtime_identity, &["/tmp/local.py"]), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); @@ -340,7 +360,6 @@ fn dynamic_only_contract_is_unverified_instead_of_suspicious() { }, &sender_with_arguments(&runtime_path, runtime_identity, &["/tmp/payload"]), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs new file mode 100644 index 000000000..241c0e166 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -0,0 +1,41 @@ +//! Resolver behavior tests grouped by evidence path + +use std::collections::HashSet; +use std::path::PathBuf; + +use unixnotis_core::{ + AttributionStatus, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, + LaunchVerificationView, RecordTrust, +}; + +use super::candidates::{resolve_unverified_candidates, strongest_verified_result}; +use super::evidence::verify_record_sender; +use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; +use super::pipeline::{resolve_attribution, resolve_with_evidence}; +use super::AppClaim; +use crate::daemon::notifications::identity::desktop_index::model::{ + ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, +}; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::{ + normalize_name, DesktopIdentityIndex, DesktopRecord, InstallProvenance, LaunchFailure, + LaunchVerification, VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{ + CommandLineEvidence, CommandLineQuality, ProcessLineageEvidence, SenderMetadata, +}; +use crate::daemon::notifications::identity::FileIdentity; + +mod support; + +use support::*; + +mod candidates; +mod diagnostics; +mod evidence; +mod model; +mod pipeline; +mod resolution; +mod sender_context; +mod validation; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs new file mode 100644 index 000000000..d3ec61c52 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs @@ -0,0 +1,29 @@ +//! Resolver value-model tests + +use super::super::model::CandidateVerification; +use super::*; + +#[test] +fn candidate_verification_preserves_mismatch_kind_and_verified_fallback() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(201, 2_010, 0), + ); + let mismatch = CandidateVerification { + record: &record, + verification: LaunchVerification::DefinitiveMismatch( + LaunchFailure::ProtectedPayloadMismatch, + ), + }; + let verified = CandidateVerification { + record: &record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }; + + assert!(mismatch.is_definitive_mismatch()); + assert_eq!(mismatch.failure(), LaunchFailure::ProtectedPayloadMismatch); + assert!(!verified.is_definitive_mismatch()); + assert_eq!(verified.failure(), LaunchFailure::DesktopClaimMismatch); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs new file mode 100644 index 000000000..0f2dd8588 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs @@ -0,0 +1,6 @@ +//! End-to-end resolver-pipeline tests + +mod dedicated; +mod portal; +mod provenance; +mod spoof; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs similarity index 90% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs index a7ba26c5b..b1bbf8198 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association/dedicated.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs @@ -1,3 +1,5 @@ +//! Dedicated executable contract cases + use super::super::*; #[test] @@ -20,7 +22,6 @@ fn dedicated_system_identity_allows_legitimate_reply() { }, &sender(&app_path, app_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -55,7 +56,6 @@ fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract &["--password-store=kwallet6", "--ozone-platform=x11", "--"], ), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -79,14 +79,13 @@ fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { &["--display-backend=x11", "--tray"], ), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); } #[test] -fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { +fn empty_dedicated_contract_with_rewritten_argv_is_recognized_not_conflicting() { let (app_path, app_identity) = installed_system_executable(); let record = system_record("org.example.True", "Example App", &app_path, app_identity); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); @@ -103,10 +102,9 @@ fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { }, &rewritten, &index, - &HashSet::new(), ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); assert_eq!( resolution.diagnostics.command_line_quality, @@ -114,7 +112,7 @@ fn dedicated_executable_with_rewritten_argv_is_not_a_conflict() { ); assert_eq!( resolution.diagnostics.verification, - LaunchVerificationView::Verified + LaunchVerificationView::InsufficientEvidence ); assert_eq!( resolution.diagnostics.launch_authority, @@ -131,7 +129,6 @@ fn verified_executable_recovers_from_stale_desktop_hint() { "/usr/bin/env", identity(90, 900, 0), false, - false, ); // An env wrapper cannot associate the user entry with the dedicated Signal process stale_user_entry.association_eligible = false; @@ -148,7 +145,6 @@ fn verified_executable_recovers_from_stale_desktop_hint() { }, &sender(&signal_path, signal_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -182,7 +178,6 @@ fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { }, &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); @@ -192,8 +187,7 @@ fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { #[test] fn duplicate_desktop_id_prefers_the_protected_record() { let (app_path, app_identity) = installed_system_executable(); - let user_record = - DesktopRecord::fixture("true", "Example App", &app_path, app_identity, false, false); + let user_record = DesktopRecord::fixture("true", "Example App", &app_path, app_identity, false); let mut system_record = system_record("true", "Example App", &app_path, app_identity); system_record.badge_icon = "protected-example".to_string(); let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); @@ -224,29 +218,6 @@ fn duplicate_protected_desktop_id_keeps_stable_index_order() { assert_eq!(verified.0.badge_icon, "first-example"); } -#[test] -fn reopened_system_identity_must_remain_protected_and_executable() { - let (_, trusted) = installed_system_executable(); - let unprotected = FileIdentity { - uid: 1_000, - ..trusted - }; - let non_executable = FileIdentity { - mode: 0o100_644, - ..trusted - }; - - assert!(current_system_identity_matches_sender(trusted, trusted)); - assert!(!current_system_identity_matches_sender( - unprotected, - trusted - )); - assert!(!current_system_identity_matches_sender( - non_executable, - trusted - )); -} - #[test] fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { let (system_path, cached_identity) = installed_system_executable(); @@ -279,7 +250,6 @@ fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { }, &sender(&system_path, sender_identity), &index, - &HashSet::new(), ); assert_ne!( @@ -302,7 +272,6 @@ fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { "/home/user/bin/local-app", app_identity, false, - false, )], Vec::new(), ); @@ -314,7 +283,6 @@ fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { }, &sender("/home/user/bin/local-app", app_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs similarity index 63% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs index f13e8c37a..372103853 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/portal.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs @@ -1,4 +1,6 @@ -use super::*; +//! Trusted portal attribution regressions + +use super::super::*; #[test] fn unmediated_flatpak_process_cannot_become_portal_associated() { @@ -20,7 +22,6 @@ fn unmediated_flatpak_process_cannot_become_portal_associated() { }, &sender("/usr/bin/flatpak", flatpak_identity), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -48,7 +49,6 @@ fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { }, &sender("/usr/lib/untrusted-relay", relay_identity), &index, - &HashSet::new(), ); assert_ne!(resolution.attribution.status, AttributionStatus::Verified); @@ -78,7 +78,6 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { }, &sender(&portal_path, portal_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Verified); @@ -86,6 +85,64 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); } +#[test] +fn trusted_portal_accepts_a_matching_nonempty_application_name() { + let flatpak_identity = identity(26, 260, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Flatpak App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.display_name, "Flatpak App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); +} + +#[test] +fn trusted_portal_reports_a_name_that_contradicts_its_verified_application_id() { + let flatpak_identity = identity(27, 270, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Different App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!(resolution.diagnostics.record_trust, RecordTrust::Portal); +} + #[test] fn trusted_portal_rejects_a_stale_indexed_inode() { let (portal_path, live_identity) = installed_system_executable(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs new file mode 100644 index 000000000..4a44dbf0b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -0,0 +1,49 @@ +//! Async provenance enrichment in the resolver pipeline + +use super::super::*; + +#[tokio::test] +async fn recognized_helper_is_reresolved_with_live_package_provenance() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read the helper executable identity"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read the application executable identity"); + let ownership_index = DesktopIdentityIndex::default(); + let helper_provenance = ownership_index + .install_provenance_for_path_async(helper_path.clone()) + .await; + let app_provenance = ownership_index + .install_provenance_for_path_async(app_path.clone()) + .await; + assert!(helper_provenance.is_known()); + assert!(helper_provenance.same_application_source(&app_provenance)); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.executable_provenance = app_provenance; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let resolution = resolve_attribution( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender(&helper_path.display().to_string(), helper_evidence.identity), + &index, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert!(resolution + .attribution + .diagnostic_detail + .contains("same installed application package")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs similarity index 90% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs index 49f962fbc..050fd1eb8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs @@ -1,4 +1,6 @@ -use super::*; +//! Spoofing and conflicting-identity regressions + +use super::super::*; #[test] fn sender_metadata_timeout_is_recognized_not_conflict() { @@ -20,7 +22,6 @@ fn sender_metadata_timeout_is_recognized_not_conflict() { }, &SenderMetadata::default(), &index, - &HashSet::new(), ); assert_eq!( @@ -46,7 +47,6 @@ fn user_shadow_cannot_join_the_system_desktop_group() { "/home/user/bin/signal", user_identity, false, - false, ); user.desktop_identity = Some(identity(32, 320, 1000)); let index = DesktopIdentityIndex::from_records(vec![user, system], Vec::new()); @@ -58,7 +58,6 @@ fn user_shadow_cannot_join_the_system_desktop_group() { }, &sender("/home/user/bin/signal", user_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -83,7 +82,6 @@ fn user_desktop_mismatch_cannot_manufacture_a_conflict() { "/home/user/bin/local-app", user_identity, false, - false, ); user.desktop_identity = Some(identity(36, 360, 1_000)); let index = DesktopIdentityIndex::from_records(vec![user], Vec::new()); @@ -95,7 +93,6 @@ fn user_desktop_mismatch_cannot_manufacture_a_conflict() { }, &sender("/tmp/unrelated", hostile_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -120,12 +117,16 @@ fn protected_conflict_evidence_outranks_a_user_desktop_shadow() { "/home/user/bin/protected-handler", user_identity, false, - false, ); user.desktop_identity = Some(identity(40, 400, 1_000)); - let index = DesktopIdentityIndex::from_records(vec![user, protected], Vec::new()); - let mut different = sender("/usr/bin/unrelated", hostile_identity); - different.install_provenance = package("org.example.Unrelated"); + let unrelated = system_record( + "org.example.Unrelated", + "Unrelated App", + "/usr/bin/unrelated", + hostile_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![user, protected, unrelated], Vec::new()); + let different = sender("/usr/bin/unrelated", hostile_identity); let resolution = resolve_with_evidence( AppClaim { @@ -134,7 +135,6 @@ fn protected_conflict_evidence_outranks_a_user_desktop_shadow() { }, &different, &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); @@ -165,7 +165,6 @@ fn ambiguous_protected_records_are_unresolved_not_conflicting() { }, &sender("/usr/bin/unrelated", identity(43, 430, 0)), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); @@ -198,7 +197,6 @@ fn visually_confusable_system_brand_without_contradictory_owner_is_recognized() }, &sender("/tmp/fake", hostile_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -227,7 +225,6 @@ fn basename_spoof_without_immutable_owner_is_recognized_without_actions() { }, &sender("/tmp/signal-desktop", hostile_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -263,7 +260,6 @@ fn exact_protected_name_without_contradictory_owner_stays_recognized() { }, &sender("/tmp/keepassxc", hostile_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -285,7 +281,6 @@ fn exact_system_notify_send_identity_is_a_non_replying_relay() { }, &sender("/usr/bin/notify-send", relay_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Relay); @@ -321,7 +316,6 @@ fn trusted_relay_claiming_a_system_app_stays_relay_without_conflict() { }, &sender("/usr/bin/notify-send", relay_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Relay); @@ -349,7 +343,6 @@ fn malicious_notify_send_basename_is_not_a_trusted_relay() { }, &sender("/tmp/notify-send", hostile_identity), &index, - &HashSet::new(), ); assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); @@ -365,11 +358,9 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() "/usr/bin/example-app", app_identity, true, - true, ); record.executable_identity = None; let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); - let owned = HashSet::from(["org.example.app".to_string()]); let resolution = resolve_with_evidence( AppClaim { @@ -378,7 +369,6 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() }, &sender("/usr/lib/example-launcher", identity(5, 50, 0)), &index, - &owned, ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); @@ -388,20 +378,3 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() .diagnostic_detail .contains("/usr/lib/example-launcher")); } - -#[test] -fn desktop_id_validation_never_accepts_a_path_or_control_character() { - assert_eq!( - validate_desktop_id("org.signal.Signal.desktop").as_deref(), - Some("org.signal.Signal") - ); - assert_eq!(validate_desktop_id("../signal"), None); - assert_eq!(validate_desktop_id("org.example.\nApp"), None); - assert_eq!(validate_desktop_id("."), None); - assert_eq!(validate_desktop_id(".desktop"), None); - assert_eq!( - validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), - Some(256) - ); - assert_eq!(validate_desktop_id(&"a".repeat(257)), None); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs new file mode 100644 index 000000000..b2b58b9a3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -0,0 +1,64 @@ +//! Attribution construction and grouping tests + +use super::super::resolution::{ + resolution_for_record, sender_claim_group_key, unknown_reply_denied, +}; +use super::*; + +#[test] +fn sender_claim_group_key_is_nonempty_and_bound_to_sender_identity() { + let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + + let unresolved = + sender_claim_group_key(AttributionStatus::Unresolved, "Example App", &metadata); + let conflict = sender_claim_group_key(AttributionStatus::Conflict, "Example App", &metadata); + + assert_eq!(unresolved, "unresolved:106:1060:exampleapp"); + assert_eq!(conflict, "conflict:106:1060:exampleapp"); + assert_ne!(unresolved, conflict); +} + +#[test] +fn verified_record_with_a_contradictory_name_becomes_conflict() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(205, 2_050, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let resolution = resolution_for_record( + VerifiedDesktopRecord(record, VerifiedLaunch::DedicatedExecutable), + "Different App", + &sender("/usr/bin/example-app", identity(205, 2_050, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn missing_sender_reply_resolution_is_unresolved_and_noninteractive() { + let metadata = SenderMetadata::default(); + let resolution = unknown_reply_denied( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &metadata, + "sender metadata unavailable", + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution + .attribution + .diagnostic_detail + .contains("sender metadata unavailable")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs new file mode 100644 index 000000000..79d8b4fd2 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs @@ -0,0 +1,71 @@ +//! Live sender-context enrichment tests + +use super::super::sender_context::enrich_sender_install_provenance; +use super::*; + +#[tokio::test] +async fn provenance_enrichment_preserves_known_ownership_without_lookup() { + let expected = package("example-app"); + let mut metadata = SenderMetadata { + install_provenance: expected.clone(), + ..SenderMetadata::default() + }; + + enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + + assert_eq!(metadata.install_provenance, expected); +} + +#[tokio::test] +async fn provenance_enrichment_keeps_unknown_when_process_identity_is_missing() { + let mut metadata = SenderMetadata::default(); + + enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); +} + +#[tokio::test] +async fn provenance_enrichment_resolves_a_reopened_system_executable() { + let (path, executable_identity) = installed_system_executable(); + let mut metadata = sender(&path, executable_identity); + + enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + + assert!(metadata.install_provenance.is_known()); +} + +#[tokio::test] +async fn provenance_enrichment_rejects_untrusted_or_nonexecutable_sender_metadata() { + let (path, executable_identity) = installed_system_executable(); + let invalid_identities = [ + FileIdentity { + uid: 1_000, + ..executable_identity + }, + FileIdentity { + mode: 0o100_644, + ..executable_identity + }, + ]; + + for invalid_identity in invalid_identities { + let mut metadata = sender(&path, invalid_identity); + enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); + } +} + +#[tokio::test] +async fn provenance_enrichment_rejects_a_stale_executable_identity() { + let (path, executable_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: executable_identity.inode.saturating_add(1), + ..executable_identity + }; + let mut metadata = sender(&path, stale_identity); + + enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs similarity index 78% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs index 88a78980b..d5581b2ce 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs @@ -1,32 +1,14 @@ -use std::collections::HashSet; -use std::path::PathBuf; - -use unixnotis_core::{ - AttributionStatus, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, - LaunchVerificationView, RecordTrust, -}; +//! Shared resolver fixtures and synthetic process-evidence builders use super::*; -use crate::daemon::notifications::identity::desktop_index::model::{ - ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, -}; -use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; -use crate::daemon::notifications::identity::desktop_index::{ - DesktopIdentityIndex, DesktopRecord, InstallProvenance, -}; -use crate::daemon::notifications::identity::sender::{ - CommandLineEvidence, CommandLineQuality, ProcessLineageEvidence, -}; -use crate::daemon::notifications::identity::FileIdentity; - -trait DesktopRecordFixture { + +pub(super) trait DesktopRecordFixture { fn fixture( id: &str, display_name: &str, executable_path: &str, identity: FileIdentity, system_entry: bool, - dbus_activatable: bool, ) -> Self; fn with_launch_literals(self, arguments: &[&str]) -> Self; @@ -41,7 +23,6 @@ impl DesktopRecordFixture for DesktopRecord { executable_path: &str, identity: FileIdentity, system_entry: bool, - dbus_activatable: bool, ) -> Self { Self { id: id.to_string(), @@ -72,7 +53,6 @@ impl DesktopRecordFixture for DesktopRecord { system_origin: system_entry, system_association: system_entry, association_eligible: true, - dbus_activatable, launch_spec: Some(LaunchSpec { executable: identity, arguments: Vec::new(), @@ -126,7 +106,7 @@ impl DesktopRecordFixture for DesktopRecord { } } -trait DesktopIdentityIndexFixture { +pub(super) trait DesktopIdentityIndexFixture { fn from_records( records: Vec, trusted_relays: Vec<(PathBuf, FileIdentity)>, @@ -163,7 +143,7 @@ fn index_trusted_portal(index: &mut DesktopIdentityIndex, path: PathBuf, identit .push(ExecutableIdentity { path, identity }); } -fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { +pub(super) fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { FileIdentity { device, inode, @@ -172,14 +152,14 @@ fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { } } -fn package(package_id: &str) -> InstallProvenance { +pub(super) fn package(package_id: &str) -> InstallProvenance { InstallProvenance::Package { provider: PackageProvider::Pacman, package_id: package_id.to_string(), } } -fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { +pub(super) fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { SenderMetadata { sender_name: Some(":1.42".to_string()), sender_executable: Some(path.to_string()), @@ -192,7 +172,11 @@ fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { } } -fn sender_with_arguments(path: &str, identity: FileIdentity, arguments: &[&str]) -> SenderMetadata { +pub(super) fn sender_with_arguments( + path: &str, + identity: FileIdentity, + arguments: &[&str], +) -> SenderMetadata { let mut metadata = sender(path, identity); metadata.command_line = CommandLineEvidence { argv: std::iter::once(path) @@ -204,20 +188,31 @@ fn sender_with_arguments(path: &str, identity: FileIdentity, arguments: &[&str]) metadata } -fn system_record(id: &str, name: &str, path: &str, identity: FileIdentity) -> DesktopRecord { - DesktopRecord::fixture(id, name, path, identity, true, false) +pub(super) fn system_record( + id: &str, + name: &str, + path: &str, + identity: FileIdentity, +) -> DesktopRecord { + DesktopRecord::fixture(id, name, path, identity, true) } -fn installed_system_executable() -> (String, FileIdentity) { +pub(super) fn installed_system_executable() -> (String, FileIdentity) { let path = unixnotis_core::util::trusted_system_program_path("true") .expect("find a protected system executable"); let evidence = executable_evidence_for_path(&path).expect("read system executable evidence"); - assert!(evidence.identity.is_system_managed()); - assert!(evidence.identity.is_executable_regular()); + assert!( + evidence.identity.is_system_managed(), + "fixture executable should be system managed" + ); + assert!( + evidence.identity.is_executable_regular(), + "fixture executable should be a regular executable" + ); (path.display().to_string(), evidence.identity) } -fn verified_executable_record<'record>( +pub(super) fn verified_executable_record<'record>( records: &[&'record DesktopRecord], reported_name: &str, sender: &SenderMetadata, @@ -232,12 +227,3 @@ fn verified_executable_record<'record>( .collect::>(); strongest_verified_result(&results, reported_name, index) } - -#[path = "resolver/association.rs"] -mod association; -#[path = "resolver/portal.rs"] -mod portal; -#[path = "resolver/runtime.rs"] -mod runtime; -#[path = "resolver/spoof.rs"] -mod spoof; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs new file mode 100644 index 000000000..474d78bc4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs @@ -0,0 +1,20 @@ +//! Desktop-identifier validation tests + +use super::super::validation::validate_desktop_id; + +#[test] +fn desktop_id_validation_accepts_ids_and_rejects_paths_or_control_characters() { + assert_eq!( + validate_desktop_id("org.example.App.desktop").as_deref(), + Some("org.example.App") + ); + assert_eq!(validate_desktop_id("../example"), None); + assert_eq!(validate_desktop_id("org.example.\nApp"), None); + assert_eq!(validate_desktop_id("."), None); + assert_eq!(validate_desktop_id(".desktop"), None); + assert_eq!( + validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), + Some(256) + ); + assert_eq!(validate_desktop_id(&"a".repeat(257)), None); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs new file mode 100644 index 000000000..04f86fb88 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs @@ -0,0 +1,20 @@ +//! Validation for caller-provided desktop identifiers + +const MAX_DESKTOP_ID_BYTES: usize = 256; + +pub(super) fn validate_desktop_id(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() + || value.len() > MAX_DESKTOP_ID_BYTES + || value.contains(['/', '\\', '\0']) + || value.chars().any(char::is_control) + { + return None; + } + + let value = value.strip_suffix(".desktop").unwrap_or(value); + if value == "." || value == ".." || value.is_empty() { + return None; + } + Some(value.to_string()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs deleted file mode 100644 index 2c764e20e..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/resolver/association.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[path = "association/claims.rs"] -mod claims; -#[path = "association/dedicated.rs"] -mod dedicated; -#[path = "association/families.rs"] -mod families; -#[path = "association/helpers.rs"] -mod helpers; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 1b99baa91..851331dbe 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -138,12 +138,7 @@ impl NotificationServer { }; let resolution = if let Ok(resolution) = tokio::time::timeout( ATTRIBUTION_TIMEOUT, - resolve_attribution( - claim, - &sender, - &desktop_identity_index, - self.state.connection(), - ), + resolve_attribution(claim, &sender, &desktop_identity_index), ) .await { diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index 719a5ff0b..07abad53b 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -10,7 +10,6 @@ clippy::option_if_let_else, clippy::ref_option, clippy::significant_drop_tightening, - clippy::struct_excessive_bools, clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps, clippy::unused_async, From efbddd7008ebe6f13fa46afd2068356572c79718 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 12:41:46 -0500 Subject: [PATCH 158/275] fix(panel): use connected notification groups Summary: use connected notification groups. Scope: panel. --- .../src/ui/notifications/model/item.rs | 6 --- .../notifications/row/notification/build.rs | 19 +------ .../ui/notifications/row/notification/mod.rs | 1 - .../notifications/row/notification/stack.rs | 19 ------- .../notifications/row/notification/state.rs | 3 -- .../row/notification/tests/stack.rs | 36 ------------- .../row/notification/tests/support.rs | 7 +-- .../row/notification/update/tests/state.rs | 54 ++++++++++++++++--- .../row/notification/update/visual.rs | 44 +++++++++------ .../src/ui/notifications/store/blocks.rs | 17 ------ .../src/ui/notifications/store/mutation.rs | 5 +- .../ui/notifications/store/tests/blocks.rs | 3 -- .../ui/notifications/store/tests/mutation.rs | 8 --- crates/unixnotis-core/assets/panel.css | 50 ++++++----------- .../unixnotis-core/src/css/hooks/classes.rs | 2 - .../src/css/hooks/tests/hooks.rs | 30 +++++------ 16 files changed, 107 insertions(+), 197 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs delete mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 80dd6975c..3bc42443b 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -68,8 +68,6 @@ pub struct RowData { pub group_last: bool, // True when this notification previews a collapsed multi-item group pub collapsed_group_preview: bool, - // Collapsed groups render at most two shallow rear cards - pub stack_depth: u8, pub is_active: bool, pub presentation: RowPresentation, pub notification: Option>, @@ -87,7 +85,6 @@ impl Default for RowData { group_first: false, group_last: false, collapsed_group_preview: false, - stack_depth: 0, is_active: false, presentation: RowPresentation::default(), notification: None, @@ -112,7 +109,6 @@ impl RowData { group_first: false, group_last: false, collapsed_group_preview: false, - stack_depth: 0, is_active: false, presentation: RowPresentation::default(), notification: Some(sample), @@ -137,7 +133,6 @@ impl RowData { group_first: false, group_last: false, collapsed_group_preview, - stack_depth: 0, is_active, presentation, notification: Some(notification), @@ -154,7 +149,6 @@ impl RowData { && self.group_first == other.group_first && self.group_last == other.group_last && self.collapsed_group_preview == other.collapsed_group_preview - && self.stack_depth == other.stack_depth && self.is_active == other.is_active && self.presentation == other.presentation && Self::same_notification(&self.notification, &other.notification) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index e2b8a222e..bf3ae0c9a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -192,11 +192,7 @@ pub(in crate::ui::notifications) fn build_notification_row( let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); - // Rear layers follow master's paint order so the readable card always stays on top - let stack_ghost_back = build_stack_ghost(2); - let stack_ghost_middle = build_stack_ghost(1); - root.append(&stack_ghost_back); - root.append(&stack_ghost_middle); + // One readable surface carries the preview while the group count conveys hidden entries root.append(&card_plate); let notify_key = Rc::new(Cell::new(NotificationKey { @@ -213,8 +209,6 @@ pub(in crate::ui::notifications) fn build_notification_row( NotificationRowWidgets { card, card_plate, - stack_ghost_middle, - stack_ghost_back, icon, header, app_label, @@ -250,17 +244,6 @@ pub(in crate::ui::notifications) fn build_notification_row( ) } -fn build_stack_ghost(depth: u8) -> gtk::Box { - let ghost = gtk::Box::new(gtk::Orientation::Vertical, 0); - // Rear cards deliberately contain no content or controls - ghost.add_css_class("unixnotis-panel-card"); - ghost.add_css_class("unixnotis-stack-ghost"); - ghost.add_css_class(&format!("unixnotis-stack-ghost-{depth}")); - ghost.set_hexpand(true); - ghost.set_visible(false); - ghost -} - fn connect_dismiss_button( button: >k::Button, command_tx: mpsc::Sender, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 3ec06b1d9..b6d349be1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -5,7 +5,6 @@ mod build; mod reply; -mod stack; mod state; #[cfg(test)] #[path = "tests/support.rs"] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs deleted file mode 100644 index db5a45b51..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Collapsed notification stack state - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct StackLayerVisibility { - pub(super) middle: bool, - pub(super) back: bool, -} - -pub(super) const fn layer_visibility(stack_depth: u8) -> StackLayerVisibility { - // Depth one uses the back slot because its card starts without overlap - StackLayerVisibility { - middle: stack_depth >= 2, - back: stack_depth >= 1, - } -} - -#[cfg(test)] -#[path = "tests/stack.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 0a2649b90..705109c85 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -16,9 +16,6 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing pub(super) card_plate: unixnotis_ui::CutCorner, - // Shallow rear cards reproduce the stable collapsed-group depth from master - pub(super) stack_ghost_middle: gtk::Box, - pub(super) stack_ghost_back: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, // Identity header collapses completely for rows owned by a group header diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs deleted file mode 100644 index 61d318589..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Collapsed notification stack tests - -use super::{layer_visibility, StackLayerVisibility}; - -#[test] -fn one_hidden_notification_uses_only_the_back_layer() { - assert_eq!( - layer_visibility(1), - StackLayerVisibility { - middle: false, - back: true, - } - ); -} - -#[test] -fn two_or_more_hidden_notifications_use_both_rear_layers() { - let expected = StackLayerVisibility { - middle: true, - back: true, - }; - - assert_eq!(layer_visibility(2), expected); - assert_eq!(layer_visibility(u8::MAX), expected); -} - -#[test] -fn expanded_or_single_notification_rows_hide_rear_layers() { - assert_eq!( - layer_visibility(0), - StackLayerVisibility { - middle: false, - back: false, - } - ); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index fd9af6854..8cdadbcb9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -61,7 +61,6 @@ pub(super) fn notification_row_with_receiver() -> ( pub(super) struct RowFlags { pub(super) is_active: bool, pub(super) collapsed_group_preview: bool, - pub(super) stack_depth: u8, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, pub(super) reduced_motion: bool, @@ -70,7 +69,7 @@ pub(super) struct RowFlags { } pub(super) fn row_data(notification: Rc, flags: RowFlags) -> RowData { - let mut row = RowData::notification( + RowData::notification( Rc::from(notification.app_name.to_ascii_lowercase()), notification, flags.collapsed_group_preview, @@ -84,9 +83,7 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R metadata: Rc::new(flags.metadata.unwrap_or_default()), card_corners: flags.card_corners, }, - ); - row.stack_depth = flags.stack_depth; - row + ) } pub(super) fn current_millis() -> i64 { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 3ca5e20fb..253a96e94 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -9,7 +9,8 @@ use crate::ui::icons::IconResolver; use super::super::super::state::IconSignature; use super::super::super::test_support::{ - notification_row, notification_row_with_receiver, row_data, sample_notification, RowFlags, + child_count, notification_row, notification_row_with_receiver, row_data, sample_notification, + RowFlags, }; use super::update_notification_row; @@ -73,8 +74,7 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row .card .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW)); - assert!(row.card.has_css_class(hooks::panel_card::GROUP_COLLAPSED)); - assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); + assert!(row.card.has_css_class(hooks::panel_card::GROUPED)); assert!(!row.app_label.get_visible()); assert!(!row.icon.get_visible()); assert!(row.header.get_visible()); @@ -181,13 +181,12 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { } #[gtk::test] -fn collapsed_group_preview_shows_master_style_rear_layers() { - let (_root, row) = notification_row(); +fn collapsed_group_preview_uses_one_content_surface() { + let (root, row) = notification_row(); let data = row_data( Rc::new(sample_notification()), RowFlags { collapsed_group_preview: true, - stack_depth: 2, ..Default::default() }, ); @@ -195,8 +194,12 @@ fn collapsed_group_preview_shows_master_style_rear_layers() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert!(row.stack_ghost_middle.get_visible()); - assert!(row.stack_ghost_back.get_visible()); + assert_eq!(child_count(&root), 1); + assert!( + row.card + .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW), + "the single readable surface should retain collapsed preview state" + ); } #[gtk::test] @@ -361,6 +364,41 @@ fn update_notification_row_applies_custom_metadata_and_corner_geometry() { assert_eq!(row.card_plate.corners(), corners); } +#[gtk::test] +fn grouped_rows_keep_cut_corners_only_on_the_outer_bottom_edge() { + let (_root, row) = notification_row(); + let corners = CutCorners { + top_left: 8, + top_right: 9, + bottom_right: 10, + bottom_left: 11, + }; + let mut middle = row_data( + Rc::new(sample_notification()), + RowFlags { + card_corners: corners, + ..Default::default() + }, + ); + middle.expanded = true; + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &middle, &IconResolver::new(), &command_tx); + assert_eq!(row.card_plate.corners(), CutCorners::default()); + + let mut last = middle; + last.group_last = true; + update_notification_row(&row, &last, &IconResolver::new(), &command_tx); + assert_eq!( + row.card_plate.corners(), + CutCorners { + bottom_right: 10, + bottom_left: 11, + ..CutCorners::default() + } + ); +} + #[gtk::test] fn update_notification_row_marks_an_empty_action_set_as_unavailable() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 1151b5f1b..7595f859f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -5,7 +5,6 @@ use unixnotis_core::{hooks, NotificationView, Urgency}; use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use super::super::super::super::item::RowData; -use super::super::stack::layer_visibility; use super::super::state::NotificationRowWidgets; use super::labels::has_visible_text; @@ -20,7 +19,7 @@ pub(super) fn apply_visual_state( let presentation = NotificationPresentation::from_view(notification); let is_critical = notification.urgency == Urgency::Critical as u8; // Theme changes update recycled rows without rebuilding the GTK child tree - row.card_plate.set_corners(data.presentation.card_corners); + row.card_plate.set_corners(card_corners_for_row(data)); // Explicit state updates prevent recycled rows from retaining stale classes set_class_state(card, hooks::shared_state::CRITICAL, is_critical); for (level, class_name) in [ @@ -41,29 +40,19 @@ pub(super) fn apply_visual_state( ); let grouped = data.collapsed_group_preview || data.expanded; set_class_state(card, hooks::panel_card::GROUPED, grouped); - set_class_state( - card, - hooks::panel_card::GROUP_COLLAPSED, - data.collapsed_group_preview, - ); - set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); set_class_state(card, hooks::panel_card::GROUP_FIRST, data.group_first); set_class_state(card, hooks::panel_card::GROUP_LAST, data.group_last); set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); set_class_state( &row.card_plate, - hooks::panel_card::GROUP_COLLAPSED, - data.collapsed_group_preview, + hooks::panel_card::GROUP_FIRST, + data.group_first, ); set_class_state( &row.card_plate, - hooks::panel_card::GROUP_EXPANDED, - data.expanded, + hooks::panel_card::GROUP_LAST, + data.group_last, ); - let layers = layer_visibility(data.stack_depth); - set_widget_visible_if_changed(&row.stack_ghost_middle, layers.middle); - set_widget_visible_if_changed(&row.stack_ghost_back, layers.back); - set_class_state( card, hooks::panel_card::HAS_SUMMARY, @@ -80,6 +69,29 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); } +const fn card_corners_for_row(data: &RowData) -> unixnotis_core::CutCorners { + let grouped = data.collapsed_group_preview || data.expanded; + if !grouped { + return data.presentation.card_corners; + } + + // The group header owns the top edge while only the final child owns bottom corners + unixnotis_core::CutCorners { + top_left: 0, + top_right: 0, + bottom_right: if data.group_last { + data.presentation.card_corners.bottom_right + } else { + 0 + }, + bottom_left: if data.group_last { + data.presentation.card_corners.bottom_left + } else { + 0 + }, + } +} + fn set_class_state>(root: &W, class_name: &str, enabled: bool) { // Guard CSS churn so GTK does not reprocess matching classes if enabled { diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 5a81123d4..6ed23e36f 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -44,7 +44,6 @@ impl NotificationList { // Collapsed groups render the newest content row under their shared header let collapsed_group_preview = !expanded && ids.len() > 1; - let stack_depth = collapsed_stack_depth(ids.len(), expanded); for (index, id) in ids.iter().enumerate() { if !expanded && index > 0 { break; @@ -72,7 +71,6 @@ impl NotificationList { row.group_first = index == 0; row.group_last = !expanded || index + 1 == ids.len(); } - row.stack_depth = stack_depth; entry.item.update(row); items.push(entry.item.clone()); keys.push(RowKey::Notification { id: *id }); @@ -155,21 +153,6 @@ impl NotificationList { } } -pub(in crate::ui::notifications) const fn collapsed_stack_depth( - count: usize, - expanded: bool, -) -> u8 { - if expanded { - return 0; - } - // One rear card represents the second item while larger groups cap at two - if count >= 3 { - 2 - } else { - count.saturating_sub(1) as u8 - } -} - pub(in crate::ui::notifications) fn common_prefix_suffix( current: &[RowKey], next: &[RowKey], diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index b4fece55d..4292772dc 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -76,7 +76,7 @@ impl NotificationList { if let Some(entry) = self.entries.get(&id) { if !self.group_span_matches_visible_shape(&entry.app_key) { // Span changes still need the rebuild path - // Header count and card depth must move as one visible update + // Header count and collapsed preview state must move as one visible update self.dirty_groups.insert(entry.app_key.clone()); self.request_rebuild(); debug!(id, active = is_active, "notification group shape changed"); @@ -100,7 +100,7 @@ impl NotificationList { card_corners: self.notification_corners, }; // Update the row object in-place when the visible span stays identical - let mut row = super::item::RowData::notification( + let row = super::item::RowData::notification( entry.app_key.clone(), entry.view.clone(), collapsed_group_preview, @@ -108,7 +108,6 @@ impl NotificationList { entry.is_active, presentation, ); - row.stack_depth = super::blocks::collapsed_stack_depth(group_len, expanded); entry.item.update(row); if let Some(ids) = self.grouped_cache.get(&entry.app_key) { if ids.first().copied() == Some(id) { diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index 1093b680f..917c1fbbb 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -76,7 +76,6 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert!(!header.expanded); let visible = items[1].data(); assert!(visible.collapsed_group_preview); - assert_eq!(visible.stack_depth, 2); assert!(!visible.expanded); assert!(visible.group_first); assert!(visible.group_last); @@ -94,7 +93,6 @@ fn build_group_block_keeps_single_notification_outside_collapsed_group_preview() assert_eq!(items.len(), 1); let visible = items[0].data(); assert!(!visible.collapsed_group_preview); - assert_eq!(visible.stack_depth, 0); assert!(!visible.group_first); assert!(!visible.group_last); } @@ -130,7 +128,6 @@ fn build_group_block_expands_group_to_all_notifications() { for item in items.iter().skip(1) { let data = item.data(); assert!(!data.collapsed_group_preview); - assert_eq!(data.stack_depth, 0); assert!(data.expanded); } let first = items[1].data(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index f7cab344b..3a3a29d11 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -78,14 +78,6 @@ fn collapsed_group_preview_requires_a_collapsed_group_with_multiple_rows() { assert!(!is_collapsed_group_preview(true, 2)); } -#[test] -fn collapsed_group_depth_matches_master_and_caps_at_two_layers() { - assert_eq!(super::super::blocks::collapsed_stack_depth(1, false), 0); - assert_eq!(super::super::blocks::collapsed_stack_depth(2, false), 1); - assert_eq!(super::super::blocks::collapsed_stack_depth(4, false), 2); - assert_eq!(super::super::blocks::collapsed_stack_depth(4, true), 0); -} - #[test] fn transient_rows_follow_config_when_closed() { assert!(!should_archive_entry( diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 99530c798..7f623b5cb 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -392,13 +392,13 @@ entry selection { .unixnotis-group { background: transparent; margin-top: 14px; - margin-bottom: 8px; + margin-bottom: 0; } .unixnotis-group-header { background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); color: @unixnotis-text; - border-radius: 999px; + border-radius: var(--unixnotis-notification-card-radius) var(--unixnotis-notification-card-radius) 0 0; padding: 6px 12px; border: 1px solid @unixnotis-card-border; box-shadow: none; @@ -536,44 +536,21 @@ entry selection { } .unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { - margin-left: 8px; -} - -.unixnotis-panel-card-foreground.unixnotis-panel-card-group-collapsed { - margin-top: -58px; -} - -.unixnotis-panel-card-foreground.unixnotis-panel-card-group-expanded { - margin-bottom: var(--unixnotis-panel-card-gap); + margin-bottom: 0; } -.unixnotis-stack-ghost { - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.10); - border-radius: 18px; - padding: 0; - min-height: 68px; - margin-right: 10px; - margin-bottom: 0; - margin-left: 10px; - border: 1px solid alpha(@unixnotis-card-border, 0.62); - box-shadow: - 0 -2px 10px -8px alpha(@unixnotis-accent, 0.22), - 0 8px 14px -14px @unixnotis-shadow-soft; +.unixnotis-panel-card.unixnotis-panel-card-grouped { + border-top-width: 0; + border-radius: 0; + box-shadow: inset 0 0 0 1px alpha(#ffffff, 0.025); } -.unixnotis-stack-ghost-1 { - margin-top: -58px; +.unixnotis-panel-card.unixnotis-panel-card-group-last { + border-radius: 0 0 var(--unixnotis-notification-card-radius) var(--unixnotis-notification-card-radius); } -.unixnotis-stack-ghost-2 { - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.06); - margin-top: 0; - margin-right: 20px; - margin-left: 20px; - border-color: alpha(@unixnotis-card-border, 0.42); - box-shadow: - 0 -2px 10px -9px alpha(@unixnotis-accent, 0.14), - 0 10px 16px -15px @unixnotis-shadow-soft; +.unixnotis-panel-card-foreground.unixnotis-panel-card-group-last { + margin-bottom: var(--unixnotis-panel-card-gap); } .unixnotis-panel-card.active { @@ -793,6 +770,11 @@ entry selection { inset 0 0 0 1px alpha(#ffffff, 0.05); } +.unixnotis-panel-card.unixnotis-panel-card-grouped:hover, +.unixnotis-panel-card.active.unixnotis-panel-card-grouped:hover { + box-shadow: inset 0 0 0 1px alpha(#ffffff, 0.05); +} + /* * Premium glowing scrollbars */ diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index ca1ca9056..04d994656 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -112,8 +112,6 @@ pub mod panel_card { pub const FOOTER_LEFT: &str = "unixnotis-panel-card-footer-left"; pub const FOOTER_RIGHT: &str = "unixnotis-panel-card-footer-right"; pub const THUMBNAIL: &str = "unixnotis-panel-card-thumbnail"; - pub const GROUP_COLLAPSED: &str = "unixnotis-panel-card-group-collapsed"; - pub const GROUP_EXPANDED: &str = "unixnotis-panel-card-group-expanded"; pub const GROUPED: &str = "unixnotis-panel-card-grouped"; pub const GROUP_FIRST: &str = "unixnotis-panel-card-group-first"; pub const GROUP_LAST: &str = "unixnotis-panel-card-group-last"; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index ef40957f7..378c8db5b 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -102,8 +102,6 @@ fn hook_names_stay_unique() { panel_card::FOOTER_LEFT, panel_card::FOOTER_RIGHT, panel_card::THUMBNAIL, - panel_card::GROUP_COLLAPSED, - panel_card::GROUP_EXPANDED, panel_card::GROUPED, panel_card::GROUP_FIRST, panel_card::GROUP_LAST, @@ -245,16 +243,15 @@ fn stock_panel_css_targets_real_group_card_hooks() { let css = crate::theme::DEFAULT_PANEL_CSS; // Group headers and notification cards are sibling ListView rows, not nested widgets - // Stock CSS targets explicit collapsed and expanded content states - assert!(css.contains(&format!(".{}", panel_card::GROUP_COLLAPSED))); - assert!(css.contains(&format!(".{}", panel_card::GROUP_EXPANDED))); + // One grouped-card hook joins both collapsed previews and expanded children to the header + assert!(css.contains(&format!(".unixnotis-panel-card.{}", panel_card::GROUPED))); assert!(css.contains(&format!( ".unixnotis-panel-card-foreground.{}", - panel_card::GROUP_COLLAPSED + panel_card::GROUPED ))); assert!(css.contains(&format!( - ".unixnotis-panel-card-foreground.{}", - panel_card::GROUP_EXPANDED + ".unixnotis-panel-card.{}", + shared_state::COLLAPSED_GROUP_PREVIEW ))); // These selectors belonged to an older nested-card idea and do not match the real tree @@ -276,18 +273,15 @@ fn stock_group_count_stays_neutral_during_header_hover() { } #[test] -fn stock_panel_css_preserves_master_style_collapsed_stack_layers() { +fn stock_panel_css_uses_one_continuous_group_surface_without_ghost_layers() { let css = crate::theme::DEFAULT_PANEL_CSS; - // Rear silhouettes make a collapsed group visibly different from one card - assert!(css.contains(".unixnotis-stack-ghost {")); - assert!(css.contains(".unixnotis-stack-ghost-1 {")); - assert!(css.contains(".unixnotis-stack-ghost-2 {")); - - // Only the foreground overlaps the rear layers - assert!(css.contains( - ".unixnotis-panel-card-foreground.unixnotis-panel-card-group-collapsed {\n margin-top: -58px;" - )); + assert!(!css.contains("unixnotis-stack-ghost")); + assert!(!css.contains("margin-top: -58px")); + assert!(css + .contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-top-width: 0;")); + assert!(css + .contains(".unixnotis-panel-card.unixnotis-panel-card-group-last {\n border-radius: 0 0")); } #[test] From 2fa6e96ada4900d822582af51750be68938c8da4 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 12:41:58 -0500 Subject: [PATCH 159/275] fix(theme): publish stock exports atomically Summary: publish stock exports atomically. Scope: theme. --- crates/noticenterctl/src/theme/export.rs | 146 +++++++++++++++--- .../noticenterctl/src/theme/tests/export.rs | 63 +++++++- crates/unixnotis-core/src/filesystem/mod.rs | 5 +- .../unixnotis-core/src/filesystem/rename.rs | 61 +++++++- .../src/filesystem/tests/rename.rs | 91 ++++++++++- 5 files changed, 340 insertions(+), 26 deletions(-) diff --git a/crates/noticenterctl/src/theme/export.rs b/crates/noticenterctl/src/theme/export.rs index 79111198f..31b383ac6 100644 --- a/crates/noticenterctl/src/theme/export.rs +++ b/crates/noticenterctl/src/theme/export.rs @@ -1,10 +1,14 @@ //! Safe export of editable embedded stock theme files +use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; use unixnotis_core::filesystem::{ - create_directory_all, write_file_if_missing, CreateDirectoryOutcome, + create_directory_all, remove_directory_tree, rename_directory_no_replace, + write_file_if_missing, CreateDirectoryOutcome, RenameDirectoryOutcome, }; use unixnotis_core::{ Config, ThemeManifest, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, @@ -12,6 +16,8 @@ use unixnotis_core::{ }; const DEFAULT_EXPORT_DIRECTORY: &str = "stock-theme-v2"; +const MAX_STAGING_ATTEMPTS: u8 = 16; +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); pub(super) fn run(output: Option) -> Result<()> { let destination = match output { @@ -39,35 +45,74 @@ pub(super) fn default_export_directory_for_config(config_path: &Path) -> Result< } pub(super) fn export_stock_theme(destination: &Path) -> Result<()> { - match create_directory_all(destination, 0o700).with_context(|| { + let manifest = toml::to_string_pretty(&ThemeManifest { + api_version: THEME_API_VERSION, + name: "UnixNotis stock export".to_string(), + }) + .context("serialize stock theme manifest")?; + export_stock_theme_files( + destination, + &[ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", DEFAULT_POPUP_CSS), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ("theme.toml", manifest.as_str()), + ], + ) +} + +pub(super) fn export_stock_theme_files(destination: &Path, files: &[(&str, &str)]) -> Result<()> { + match std::fs::symlink_metadata(destination) { + Ok(_) => { + return Err(anyhow!( + "stock theme export directory already exists: {}", + destination.display() + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "inspect stock theme export destination {}", + destination.display() + ) + }); + } + } + + let staging = reserve_staging_directory(destination)?; + let write_result = write_staged_theme_files(&staging, files); + if let Err(error) = write_result { + return Err(clean_up_failed_staging(&staging, error)); + } + + match rename_directory_no_replace(&staging, destination).with_context(|| { format!( - "create stock theme export directory {}", + "publish complete stock theme export {}", destination.display() ) - })? { - CreateDirectoryOutcome::TargetCreated => {} - CreateDirectoryOutcome::TargetAlreadyExisted => { - return Err(anyhow!( + }) { + Ok(RenameDirectoryOutcome::Renamed) => Ok(()), + Ok(RenameDirectoryOutcome::DestinationExists) => { + let error = anyhow!( "stock theme export directory already exists: {}", destination.display() - )); + ); + Err(clean_up_failed_staging(&staging, error)) } + Ok(RenameDirectoryOutcome::SourceMissing) => Err(anyhow!( + "stock theme staging directory disappeared before publication: {}", + staging.display() + )), + Err(error) => Err(clean_up_failed_staging(&staging, error)), } +} - let manifest = toml::to_string_pretty(&ThemeManifest { - api_version: THEME_API_VERSION, - name: "UnixNotis stock export".to_string(), - }) - .context("serialize stock theme manifest")?; - for (name, contents) in [ - ("base.css", DEFAULT_BASE_CSS), - ("panel.css", DEFAULT_PANEL_CSS), - ("popup.css", DEFAULT_POPUP_CSS), - ("widgets.css", DEFAULT_WIDGETS_CSS), - ("media.css", DEFAULT_MEDIA_CSS), - ("theme.toml", manifest.as_str()), - ] { - let path = destination.join(name); +fn write_staged_theme_files(staging: &Path, files: &[(&str, &str)]) -> Result<()> { + for (name, contents) in files { + let path = staging.join(name); let created = write_file_if_missing(&path, contents.as_bytes(), 0o600) .with_context(|| format!("write exported stock theme file {name}"))?; if !created { @@ -79,3 +124,60 @@ pub(super) fn export_stock_theme(destination: &Path) -> Result<()> { } Ok(()) } + +fn reserve_staging_directory(destination: &Path) -> Result { + let parent = export_parent(destination); + create_directory_all(parent, 0o700) + .with_context(|| format!("create stock theme export parent {}", parent.display()))?; + let file_name = destination + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("stock theme export path needs a directory name"))?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is earlier than the Unix epoch")? + .as_nanos(); + + for attempt in 0..MAX_STAGING_ATTEMPTS { + let serial = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + let mut staging_name = OsString::from("."); + staging_name.push(file_name); + staging_name.push(format!( + ".{}.{}.{serial}.{attempt}.staging", + std::process::id(), + nanos + )); + let staging = parent.join(staging_name); + match create_directory_all(&staging, 0o700).with_context(|| { + format!( + "create private stock theme staging area {}", + staging.display() + ) + })? { + CreateDirectoryOutcome::TargetCreated => return Ok(staging), + CreateDirectoryOutcome::TargetAlreadyExisted => {} + } + } + + Err(anyhow!( + "unable to reserve private staging beside {}", + destination.display() + )) +} + +pub(super) fn export_parent(destination: &Path) -> &Path { + destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn clean_up_failed_staging(staging: &Path, error: anyhow::Error) -> anyhow::Error { + match remove_directory_tree(staging) { + Ok(_) => error, + Err(cleanup_error) => error.context(format!( + "also failed to remove stock theme staging area {}: {cleanup_error}", + staging.display() + )), + } +} diff --git a/crates/noticenterctl/src/theme/tests/export.rs b/crates/noticenterctl/src/theme/tests/export.rs index 73a6793be..dc33f5c3a 100644 --- a/crates/noticenterctl/src/theme/tests/export.rs +++ b/crates/noticenterctl/src/theme/tests/export.rs @@ -5,7 +5,10 @@ use std::os::unix::fs::symlink; use unixnotis_core::{ThemeManifest, DEFAULT_BASE_CSS, THEME_API_VERSION}; -use super::super::export::{default_export_directory_for_config, export_stock_theme}; +use super::super::export::{ + default_export_directory_for_config, export_parent, export_stock_theme, + export_stock_theme_files, +}; fn test_root(name: &str) -> std::path::PathBuf { let serial = std::time::SystemTime::now() @@ -86,6 +89,64 @@ fn stock_export_rejects_a_symlinked_destination_parent() { fs::remove_dir_all(root).expect("test root should be removable"); } +#[test] +fn failed_stock_export_removes_staging_without_publishing_a_partial_directory() { + let root = test_root("theme-export-partial-failure"); + fs::create_dir_all(&root).expect("test root should be created"); + let destination = root.join("stock"); + + export_stock_theme_files( + &destination, + &[("base.css", "first"), ("base.css", "collision")], + ) + .expect_err("a staged file collision should abort publication"); + + assert!( + !destination.exists(), + "a failed export must not publish an incomplete destination" + ); + assert_eq!( + fs::read_dir(&root) + .expect("test root should remain readable") + .count(), + 0, + "failed export staging should be removed" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_stages_beside_the_selected_destination() { + let destination = std::path::Path::new("example-parent/stock"); + + assert_eq!( + export_parent(destination), + std::path::Path::new("example-parent") + ); + assert_eq!( + export_parent(std::path::Path::new("stock")), + std::path::Path::new(".") + ); +} + +#[test] +fn stock_export_reports_destination_inspection_failure_before_staging() { + let root = test_root("theme-export-invalid-parent"); + fs::create_dir_all(&root).expect("test root should be created"); + let parent_file = root.join("not-a-directory"); + fs::write(&parent_file, "content").expect("parent fixture should be written"); + let destination = parent_file.join("stock"); + + let error = export_stock_theme(&destination) + .expect_err("an unreadable destination path should fail before staging"); + + assert!( + format!("{error:#}").contains("inspect stock theme export destination"), + "destination inspection errors should retain their precise context" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + #[test] fn default_stock_export_directory_is_sibling_of_active_config() { let config = std::path::Path::new("profile/unixnotis/config.toml"); diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 58444e632..b9a1465a1 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -28,7 +28,10 @@ pub use remove::{ remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, }; -pub use rename::{rename_regular_file_no_replace, RenameRegularFileOutcome}; +pub use rename::{ + rename_directory_no_replace, rename_regular_file_no_replace, RenameDirectoryOutcome, + RenameRegularFileOutcome, +}; pub use symlink::{ create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, }; diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs index 9872af619..6b4728a28 100644 --- a/crates/unixnotis-core/src/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -5,8 +5,9 @@ use std::path::Path; use rustix::fs::{renameat_with, RenameFlags}; -use super::descriptor::{open_parent_existing, sync_directory}; +use super::descriptor::{open_parent_existing, open_target_directory, sync_directory}; use super::regular::validate_existing_target; +use super::tree::revalidate_directory_identity; /// Result of moving a regular file without replacing another filesystem entry #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -19,6 +20,17 @@ pub enum RenameRegularFileOutcome { DestinationExists, } +/// Result of moving a directory without replacing another filesystem entry +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenameDirectoryOutcome { + /// The source did not exist when the move reached the filesystem boundary + SourceMissing, + /// The source was moved to the previously unused destination + Renamed, + /// A destination entry already existed and was preserved + DestinationExists, +} + /// Move a regular file without following links or replacing the destination /// /// # Errors @@ -66,6 +78,42 @@ pub fn rename_regular_file_no_replace( Ok(RenameRegularFileOutcome::Renamed) } +/// Move a directory without following links or replacing the destination +/// +/// # Errors +/// +/// Returns an error when either parent crosses a link, the source is not a directory, or the +/// rename and directory synchronization cannot complete +pub fn rename_directory_no_replace( + source: &Path, + destination: &Path, +) -> io::Result { + let Some((source_parent, source_name, source_directory)) = open_target_directory(source)? + else { + return Ok(RenameDirectoryOutcome::SourceMissing); + }; + let (destination_parent, destination_name) = open_parent_existing(destination)?; + // The retained descriptor ensures the visible source name still identifies the staged tree + revalidate_directory_identity(&source_parent, &source_name, &source_directory)?; + + let rename_result = renameat_with( + &source_parent, + &source_name, + &destination_parent, + &destination_name, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into); + let outcome = classify_directory_rename_attempt(rename_result)?; + if outcome != RenameDirectoryOutcome::Renamed { + return Ok(outcome); + } + + sync_directory(&destination_parent)?; + sync_directory(&source_parent)?; + Ok(RenameDirectoryOutcome::Renamed) +} + fn classify_rename_attempt(result: io::Result<()>) -> io::Result { match result { Ok(()) => Ok(RenameRegularFileOutcome::Renamed), @@ -77,6 +125,17 @@ fn classify_rename_attempt(result: io::Result<()>) -> io::Result) -> io::Result { + match result { + Ok(()) => Ok(RenameDirectoryOutcome::Renamed), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(RenameDirectoryOutcome::DestinationExists), + io::ErrorKind::NotFound => Ok(RenameDirectoryOutcome::SourceMissing), + _ => Err(error), + }, + } +} + #[cfg(test)] #[path = "tests/rename.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/tests/rename.rs b/crates/unixnotis-core/src/filesystem/tests/rename.rs index 75d0ff26d..53306763e 100644 --- a/crates/unixnotis-core/src/filesystem/tests/rename.rs +++ b/crates/unixnotis-core/src/filesystem/tests/rename.rs @@ -3,7 +3,10 @@ use std::fs; use std::os::unix::fs::symlink; -use super::{classify_rename_attempt, rename_regular_file_no_replace, RenameRegularFileOutcome}; +use super::{ + classify_directory_rename_attempt, classify_rename_attempt, rename_directory_no_replace, + rename_regular_file_no_replace, RenameDirectoryOutcome, RenameRegularFileOutcome, +}; use crate::test_support::unique_temp_path; #[test] @@ -179,3 +182,89 @@ fn regular_file_rename_rejects_a_directory_source() { assert!(!destination.exists()); let _ = fs::remove_dir_all(root); } + +#[test] +fn directory_rename_publishes_a_complete_tree_without_replacing_a_destination() { + let root = unique_temp_path("rename-directory"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&source).expect("create staged directory"); + fs::write(source.join("theme.toml"), "api_version = 2").expect("write staged manifest"); + + let outcome = + rename_directory_no_replace(&source, &destination).expect("publish staged directory"); + + assert_eq!(outcome, RenameDirectoryOutcome::Renamed); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(destination.join("theme.toml")).expect("read published manifest"), + "api_version = 2" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_preserves_an_existing_destination_and_staged_source() { + let root = unique_temp_path("rename-directory-collision"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&source).expect("create staged directory"); + fs::create_dir_all(&destination).expect("create destination directory"); + fs::write(source.join("staged.css"), "staged").expect("write staged file"); + fs::write(destination.join("personal.css"), "personal").expect("write personal file"); + + let outcome = + rename_directory_no_replace(&source, &destination).expect("classify destination collision"); + + assert_eq!(outcome, RenameDirectoryOutcome::DestinationExists); + assert_eq!( + fs::read_to_string(source.join("staged.css")).expect("read retained staged file"), + "staged" + ); + assert_eq!( + fs::read_to_string(destination.join("personal.css")).expect("read personal file"), + "personal" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_rejects_a_symlink_source() { + let root = unique_temp_path("rename-directory-symlink"); + let actual = root.join("actual"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&actual).expect("create actual directory"); + symlink(&actual, &source).expect("create staged directory link"); + + rename_directory_no_replace(&source, &destination) + .expect_err("a staged directory link must be rejected"); + + assert!(actual.is_dir()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_reports_a_missing_staged_source_without_creating_a_destination() { + let root = unique_temp_path("rename-directory-missing-source"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&root).expect("create rename test root"); + + let outcome = rename_directory_no_replace(&source, &destination) + .expect("a missing staged directory should be a normal classified outcome"); + + assert_eq!(outcome, RenameDirectoryOutcome::SourceMissing); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_classifies_source_disappearance_at_the_rename_boundary() { + let outcome = + classify_directory_rename_attempt(Err(std::io::Error::from(std::io::ErrorKind::NotFound))) + .expect("rename-time source disappearance should be classified"); + + assert_eq!(outcome, RenameDirectoryOutcome::SourceMissing); +} From d67b9152159bb0f4f47e1477fb423bbdef510621 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 12:42:09 -0500 Subject: [PATCH 160/275] refactor(css): split theme token responsibilities Summary: split theme token responsibilities. Scope: css. --- crates/unixnotis-core/src/css/tests/tokens.rs | 91 ------ crates/unixnotis-core/src/css/tokens.rs | 299 ------------------ .../unixnotis-core/src/css/tokens/layout.rs | 86 +++++ .../unixnotis-core/src/css/tokens/legacy.rs | 25 ++ crates/unixnotis-core/src/css/tokens/mod.rs | 14 + crates/unixnotis-core/src/css/tokens/model.rs | 24 ++ .../unixnotis-core/src/css/tokens/modern.rs | 82 +++++ .../unixnotis-core/src/css/tokens/palette.rs | 85 +++++ .../src/css/tokens/tests/legacy.rs | 15 + .../src/css/tokens/tests/mod.rs | 5 + .../src/css/tokens/tests/model.rs | 21 ++ .../src/css/tokens/tests/modern.rs | 74 +++++ 12 files changed, 431 insertions(+), 390 deletions(-) delete mode 100644 crates/unixnotis-core/src/css/tests/tokens.rs delete mode 100644 crates/unixnotis-core/src/css/tokens.rs create mode 100644 crates/unixnotis-core/src/css/tokens/layout.rs create mode 100644 crates/unixnotis-core/src/css/tokens/legacy.rs create mode 100644 crates/unixnotis-core/src/css/tokens/mod.rs create mode 100644 crates/unixnotis-core/src/css/tokens/model.rs create mode 100644 crates/unixnotis-core/src/css/tokens/modern.rs create mode 100644 crates/unixnotis-core/src/css/tokens/palette.rs create mode 100644 crates/unixnotis-core/src/css/tokens/tests/legacy.rs create mode 100644 crates/unixnotis-core/src/css/tokens/tests/mod.rs create mode 100644 crates/unixnotis-core/src/css/tokens/tests/model.rs create mode 100644 crates/unixnotis-core/src/css/tokens/tests/modern.rs diff --git a/crates/unixnotis-core/src/css/tests/tokens.rs b/crates/unixnotis-core/src/css/tests/tokens.rs deleted file mode 100644 index c4c85bf00..000000000 --- a/crates/unixnotis-core/src/css/tests/tokens.rs +++ /dev/null @@ -1,91 +0,0 @@ -#![expect( - clippy::float_cmp, - reason = "theme-token resolution returns exact configured and clamped constants" -)] - -use super::{ - build_legacy_theme_color_overrides, build_modern_theme_custom_properties, - theme_card_style_values, -}; -use crate::{gtk_css_features_for_version, ThemeConfig}; - -#[test] -fn theme_card_style_values_clamp_alpha_and_keep_lengths() { - let values = theme_card_style_values(&ThemeConfig { - border_width: 3, - card_radius: 18, - card_alpha: 1.5, - ..ThemeConfig::default() - }); - - assert_eq!(values.border_width_px, 3.0); - assert_eq!(values.card_radius_px, 18.0); - assert_eq!(values.card_alpha, 1.0); -} - -#[test] -fn legacy_theme_color_overrides_include_card_alpha() { - let overrides = build_legacy_theme_color_overrides(&ThemeConfig { - card_alpha: 0.42, - ..ThemeConfig::default() - }); - - assert!(overrides.contains("@define-color unixnotis-card alpha(@unixnotis-card-base, 0.42);")); -} - -#[test] -fn modern_theme_custom_properties_stay_additive() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 2, - card_radius: 12, - surface_alpha: 0.88, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); - - assert!(overrides.contains(":root {")); - assert!(overrides.contains("--unixnotis-border-width: 2px;")); - assert!(overrides.contains("--unixnotis-card-radius: 12px;")); - assert!(overrides.contains("--unixnotis-panel-card-padding-y: 9px;")); - assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); - assert!(overrides.contains("--unixnotis-media-card-radius: 18px;")); - assert!(overrides.contains("--unixnotis-media-title-font-size: 13px;")); - assert!(overrides.contains("--unixnotis-ui-font-family: \"Inter\", \"SF Pro Text\",")); - assert!( - overrides.contains("--unixnotis-monospace-font-family: \"CaskaydiaCove Nerd Font Mono\",") - ); - assert!(overrides.contains("--unixnotis-accent-color: @unixnotis-accent;")); - assert!(overrides.contains("--unixnotis-surface-alpha: 0.88;")); - assert!(overrides.contains("--unixnotis-card-alpha: 0.94;")); -} - -#[test] -fn modern_theme_custom_properties_stay_off_on_older_gtk() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig::default(), - gtk_css_features_for_version(4, 15), - ); - assert!(overrides.is_empty()); -} - -#[test] -fn modern_theme_tokens_trim_float_values_without_losing_fraction() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 3, - card_radius: 10, - surface_alpha: 0.5, - surface_strong_alpha: 1.0, - card_alpha: 0.125, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); - - assert!(overrides.contains("--unixnotis-border-width: 3px;")); - assert!(overrides.contains("--unixnotis-surface-alpha: 0.5;")); - assert!(overrides.contains("--unixnotis-surface-strong-alpha: 1;")); - assert!(overrides.contains("--unixnotis-card-alpha: 0.125;")); -} diff --git a/crates/unixnotis-core/src/css/tokens.rs b/crates/unixnotis-core/src/css/tokens.rs deleted file mode 100644 index 524b5e502..000000000 --- a/crates/unixnotis-core/src/css/tokens.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Shared theme token contract for legacy and modern GTK CSS paths - -use crate::config::ThemeConfig; - -use super::features::GtkCssFeatures; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ThemeCardStyleValues { - // These are reused by several override builders, so they stay grouped here - pub border_width_px: f32, - pub card_radius_px: f32, - pub card_alpha: f32, -} - -#[must_use] -pub fn theme_card_style_values(theme: &ThemeConfig) -> ThemeCardStyleValues { - ThemeCardStyleValues { - border_width_px: f32::from(theme.border_width), - card_radius_px: f32::from(theme.card_radius), - card_alpha: clamp_alpha(theme.card_alpha), - } -} - -#[must_use] -pub fn build_legacy_theme_color_overrides(theme: &ThemeConfig) -> String { - // Legacy alpha colors stay first so old themes keep working as-is - let surface_alpha = clamp_alpha(theme.surface_alpha); - let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); - let card_alpha = clamp_alpha(theme.card_alpha); - let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); - let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); - - format!( - r" -@define-color unixnotis-surface alpha(@unixnotis-surface-base, {surface_alpha}); -@define-color unixnotis-surface-strong alpha(@unixnotis-surface-strong-base, {surface_strong_alpha}); -@define-color unixnotis-card alpha(@unixnotis-card-base, {card_alpha}); -@define-color unixnotis-shadow-soft alpha(#000000, {shadow_soft}); -@define-color unixnotis-shadow-strong alpha(#000000, {shadow_strong}); -" - ) -} - -#[must_use] -pub fn build_modern_theme_custom_properties( - theme: &ThemeConfig, - features: GtkCssFeatures, -) -> String { - // Older GTK builds should see no modern token output at all - if !features.supports_modern_theme_tokens() { - return String::new(); - } - - let surface_alpha = clamp_alpha(theme.surface_alpha); - let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); - let card_alpha = clamp_alpha(theme.card_alpha); - let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); - let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); - let card_style = theme_card_style_values(theme); - - // Keep the selector text plain in the final output while avoiding lint confusion here - let mut block = String::from(":\u{72}oot {\n"); - - // Config-driven tokens stay aligned with live theme knobs - push_px_token( - &mut block, - "--unixnotis-border-width", - card_style.border_width_px, - ); - push_px_token( - &mut block, - "--unixnotis-card-radius", - card_style.card_radius_px, - ); - push_alpha_token(&mut block, "--unixnotis-surface-alpha", surface_alpha); - push_alpha_token( - &mut block, - "--unixnotis-surface-strong-alpha", - surface_strong_alpha, - ); - push_alpha_token(&mut block, "--unixnotis-card-alpha", card_alpha); - push_alpha_token(&mut block, "--unixnotis-shadow-soft-alpha", shadow_soft); - push_alpha_token(&mut block, "--unixnotis-shadow-strong-alpha", shadow_strong); - - // Shared color aliases let modern themes keep using the same palette names - for (name, value) in color_alias_tokens() { - push_raw_token(&mut block, name, value); - } - - // Layout tokens give custom themes stable numbers without scraping the stock css - for (name, value) in layout_tokens() { - push_raw_token(&mut block, name, value); - } - - block.push_str("}\n"); - block -} - -const fn clamp_alpha(value: f32) -> f32 { - value.clamp(0.0, 1.0) -} - -fn push_px_token(block: &mut String, name: &str, value: f32) { - // Trimmed floats keep the generated CSS readable in bug reports - block.push_str(&format!(" {name}: {}px;\n", trim_float(value))); -} - -fn push_alpha_token(block: &mut String, name: &str, value: f32) { - block.push_str(&format!(" {name}: {};\n", trim_float(value))); -} - -fn push_raw_token(block: &mut String, name: &str, value: &str) { - block.push_str(&format!(" {name}: {value};\n")); -} - -fn trim_float(value: f32) -> String { - let mut text = format!("{value:.4}"); - while text.contains('.') && text.ends_with('0') { - text.pop(); - } - if text.ends_with('.') { - text.pop(); - } - text -} - -const fn color_alias_tokens() -> &'static [(&'static str, &'static str)] { - // Color aliases mirror the stock palette so modern themes can stay readable - &[ - ("--unixnotis-surface-base-color", "@unixnotis-surface-base"), - ("--unixnotis-surface-color", "@unixnotis-surface"), - ( - "--unixnotis-surface-strong-color", - "@unixnotis-surface-strong", - ), - ("--unixnotis-surface-soft-color", "@unixnotis-surface-soft"), - ("--unixnotis-card-color", "@unixnotis-card"), - ("--unixnotis-text-color", "@unixnotis-text"), - ("--unixnotis-muted-color", "@unixnotis-muted"), - ("--unixnotis-accent-color", "@unixnotis-accent"), - ("--unixnotis-accent-2-color", "@unixnotis-accent-2"), - ("--unixnotis-urgent-color", "@unixnotis-urgent"), - ("--unixnotis-accent-wifi-color", "@unixnotis-accent-wifi"), - ( - "--unixnotis-accent-bluetooth-color", - "@unixnotis-accent-bluetooth", - ), - ( - "--unixnotis-accent-airplane-color", - "@unixnotis-accent-airplane", - ), - ("--unixnotis-accent-night-color", "@unixnotis-accent-night"), - ("--unixnotis-card-border-color", "@unixnotis-card-border"), - ("--unixnotis-outline-color", "@unixnotis-outline"), - ("--unixnotis-shadow-soft-color", "@unixnotis-shadow-soft"), - ( - "--unixnotis-shadow-strong-color", - "@unixnotis-shadow-strong", - ), - ("--unixnotis-glow-cyan-color", "@unixnotis-glow-cyan"), - ("--unixnotis-glow-pink-color", "@unixnotis-glow-pink"), - ("--unixnotis-glow-wifi-color", "@unixnotis-glow-wifi"), - ( - "--unixnotis-glow-bluetooth-color", - "@unixnotis-glow-bluetooth", - ), - ( - "--unixnotis-glow-airplane-color", - "@unixnotis-glow-airplane", - ), - ("--unixnotis-glow-night-color", "@unixnotis-glow-night"), - ("--unixnotis-panel-grad-1-color", "@unixnotis-panel-grad-1"), - ("--unixnotis-panel-grad-2-color", "@unixnotis-panel-grad-2"), - ("--unixnotis-panel-grad-3-color", "@unixnotis-panel-grad-3"), - ( - "--unixnotis-notification-bg-1-color", - "@unixnotis-notification-bg-1", - ), - ( - "--unixnotis-notification-bg-2-color", - "@unixnotis-notification-bg-2", - ), - ("--unixnotis-popup-bg-1-color", "@unixnotis-popup-bg-1"), - ("--unixnotis-popup-bg-2-color", "@unixnotis-popup-bg-2"), - ("--unixnotis-pill-bg-color", "@unixnotis-pill-bg"), - ("--unixnotis-pill-border-color", "@unixnotis-pill-border"), - ("--unixnotis-pill-hover-color", "@unixnotis-pill-hover"), - ("--unixnotis-action-bg-color", "@unixnotis-action-bg"), - ( - "--unixnotis-action-bg-hover-color", - "@unixnotis-action-bg-hover", - ), - ( - "--unixnotis-action-bg-active-color", - "@unixnotis-action-bg-active", - ), - ( - "--unixnotis-popup-action-bg-color", - "@unixnotis-popup-action-bg", - ), - ( - "--unixnotis-popup-action-hover-color", - "@unixnotis-popup-action-hover", - ), - ( - "--unixnotis-popup-action-active-color", - "@unixnotis-popup-action-active", - ), - ] -} - -const fn layout_tokens() -> &'static [(&'static str, &'static str)] { - // These numbers match the shipped layout so custom themes can override safely - &[ - ( - "--unixnotis-ui-font-family", - r#""Inter", "SF Pro Text", "Noto Sans", sans-serif"#, - ), - ( - "--unixnotis-monospace-font-family", - r#""CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace"#, - ), - ("--unixnotis-panel-radius", "30px"), - ("--unixnotis-panel-padding", "16px"), - ("--unixnotis-panel-header-radius", "18px"), - ("--unixnotis-panel-header-padding", "12px"), - ("--unixnotis-panel-card-padding-y", "9px"), - ("--unixnotis-panel-card-padding-x", "11px"), - ("--unixnotis-panel-card-gap", "8px"), - ("--unixnotis-panel-action-gap", "6px"), - ("--unixnotis-panel-close-size", "28px"), - ("--unixnotis-panel-search-min-height", "34px"), - ("--unixnotis-panel-search-padding-x", "10px"), - ("--unixnotis-notification-card-radius", "14px"), - ("--unixnotis-notification-action-padding-y", "4px"), - ("--unixnotis-notification-action-padding-x", "10px"), - ("--unixnotis-popup-stack-padding", "8px"), - ("--unixnotis-popup-card-radius", "18px"), - ("--unixnotis-popup-card-padding-y", "12px"), - ("--unixnotis-popup-card-padding-x", "14px"), - ("--unixnotis-popup-actions-gap", "6px"), - ("--unixnotis-popup-close-size", "24px"), - ("--unixnotis-popup-reveal-duration", "200ms"), - ("--unixnotis-quick-slider-radius", "18px"), - ("--unixnotis-quick-slider-padding-y", "8px"), - ("--unixnotis-quick-slider-padding-x", "12px"), - ("--unixnotis-quick-slider-icon-size", "32px"), - ("--unixnotis-quick-slider-knob-size", "16px"), - ("--unixnotis-toggle-min-width", "104px"), - ("--unixnotis-toggle-min-height", "56px"), - ("--unixnotis-toggle-padding-y", "10px"), - ("--unixnotis-toggle-padding-x", "12px"), - ("--unixnotis-stat-card-radius", "18px"), - ("--unixnotis-stat-card-min-height", "56px"), - ("--unixnotis-stat-card-padding-y", "10px"), - ("--unixnotis-stat-card-padding-x", "12px"), - ("--unixnotis-info-card-min-height", "56px"), - ("--unixnotis-info-card-padding", "12px"), - ("--unixnotis-info-card-radius", "22px"), - ("--unixnotis-calendar-radius", "18px"), - ("--unixnotis-media-container-gap", "10px"), - ("--unixnotis-media-row-gap", "6px"), - ("--unixnotis-media-control-gap", "6px"), - ("--unixnotis-media-action-rail-gap", "8px"), - ("--unixnotis-media-card-padding-y", "8px"), - ("--unixnotis-media-card-padding-x", "10px"), - ("--unixnotis-media-card-padding-inline-y", "10px"), - ("--unixnotis-media-card-padding-inline-x", "12px"), - ("--unixnotis-media-card-padding-stacked", "12px"), - ("--unixnotis-media-card-padding-showcase-y", "10px"), - ("--unixnotis-media-card-padding-showcase-x", "12px"), - ("--unixnotis-media-art-size", "50px"), - ("--unixnotis-media-art-frame-size", "54px"), - ("--unixnotis-media-button-padding-y", "4px"), - ("--unixnotis-media-button-padding-x", "6px"), - ("--unixnotis-media-nav-size", "22px"), - ("--unixnotis-media-nav-radius", "12px"), - ("--unixnotis-media-nav-font-size", "12px"), - ("--unixnotis-media-card-radius", "18px"), - ("--unixnotis-media-card-min-height", "68px"), - ("--unixnotis-media-card-inline-min-height", "88px"), - ("--unixnotis-media-card-stacked-min-height", "108px"), - ("--unixnotis-media-card-showcase-min-height", "92px"), - ("--unixnotis-media-art-radius", "12px"), - ("--unixnotis-media-art-frame-radius", "14px"), - ("--unixnotis-media-source-font-size", "11px"), - ("--unixnotis-media-source-letter-spacing", "0.1em"), - ("--unixnotis-media-position-font-size", "11px"), - ("--unixnotis-media-position-letter-spacing", "0.08em"), - ("--unixnotis-media-title-font-size", "13px"), - ("--unixnotis-media-title-font-weight", "700"), - ("--unixnotis-media-artist-font-size", "12px"), - ("--unixnotis-media-button-radius", "10px"), - ] -} - -#[cfg(test)] -#[path = "tests/tokens.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/css/tokens/layout.rs b/crates/unixnotis-core/src/css/tokens/layout.rs new file mode 100644 index 000000000..dc9e45ce1 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/layout.rs @@ -0,0 +1,86 @@ +//! Stable layout values exposed to compatible custom themes + +pub(super) const fn layout_tokens() -> &'static [(&'static str, &'static str)] { + // These numbers match shipped layout defaults without requiring themes to scrape stock CSS + &[ + ( + "--unixnotis-ui-font-family", + r#""Inter", "SF Pro Text", "Noto Sans", sans-serif"#, + ), + ( + "--unixnotis-monospace-font-family", + r#""CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace"#, + ), + ("--unixnotis-panel-radius", "30px"), + ("--unixnotis-panel-padding", "16px"), + ("--unixnotis-panel-header-radius", "18px"), + ("--unixnotis-panel-header-padding", "12px"), + ("--unixnotis-panel-card-padding-y", "9px"), + ("--unixnotis-panel-card-padding-x", "11px"), + ("--unixnotis-panel-card-gap", "8px"), + ("--unixnotis-panel-action-gap", "6px"), + ("--unixnotis-panel-close-size", "28px"), + ("--unixnotis-panel-search-min-height", "34px"), + ("--unixnotis-panel-search-padding-x", "10px"), + ("--unixnotis-notification-card-radius", "14px"), + ("--unixnotis-notification-action-padding-y", "4px"), + ("--unixnotis-notification-action-padding-x", "10px"), + ("--unixnotis-popup-stack-padding", "8px"), + ("--unixnotis-popup-card-radius", "18px"), + ("--unixnotis-popup-card-padding-y", "12px"), + ("--unixnotis-popup-card-padding-x", "14px"), + ("--unixnotis-popup-actions-gap", "6px"), + ("--unixnotis-popup-close-size", "24px"), + ("--unixnotis-popup-reveal-duration", "200ms"), + ("--unixnotis-quick-slider-radius", "18px"), + ("--unixnotis-quick-slider-padding-y", "8px"), + ("--unixnotis-quick-slider-padding-x", "12px"), + ("--unixnotis-quick-slider-icon-size", "32px"), + ("--unixnotis-quick-slider-knob-size", "16px"), + ("--unixnotis-toggle-min-width", "104px"), + ("--unixnotis-toggle-min-height", "56px"), + ("--unixnotis-toggle-padding-y", "10px"), + ("--unixnotis-toggle-padding-x", "12px"), + ("--unixnotis-stat-card-radius", "18px"), + ("--unixnotis-stat-card-min-height", "56px"), + ("--unixnotis-stat-card-padding-y", "10px"), + ("--unixnotis-stat-card-padding-x", "12px"), + ("--unixnotis-info-card-min-height", "56px"), + ("--unixnotis-info-card-padding", "12px"), + ("--unixnotis-info-card-radius", "22px"), + ("--unixnotis-calendar-radius", "18px"), + ("--unixnotis-media-container-gap", "10px"), + ("--unixnotis-media-row-gap", "6px"), + ("--unixnotis-media-control-gap", "6px"), + ("--unixnotis-media-action-rail-gap", "8px"), + ("--unixnotis-media-card-padding-y", "8px"), + ("--unixnotis-media-card-padding-x", "10px"), + ("--unixnotis-media-card-padding-inline-y", "10px"), + ("--unixnotis-media-card-padding-inline-x", "12px"), + ("--unixnotis-media-card-padding-stacked", "12px"), + ("--unixnotis-media-card-padding-showcase-y", "10px"), + ("--unixnotis-media-card-padding-showcase-x", "12px"), + ("--unixnotis-media-art-size", "50px"), + ("--unixnotis-media-art-frame-size", "54px"), + ("--unixnotis-media-button-padding-y", "4px"), + ("--unixnotis-media-button-padding-x", "6px"), + ("--unixnotis-media-nav-size", "22px"), + ("--unixnotis-media-nav-radius", "12px"), + ("--unixnotis-media-nav-font-size", "12px"), + ("--unixnotis-media-card-radius", "18px"), + ("--unixnotis-media-card-min-height", "68px"), + ("--unixnotis-media-card-inline-min-height", "88px"), + ("--unixnotis-media-card-stacked-min-height", "108px"), + ("--unixnotis-media-card-showcase-min-height", "92px"), + ("--unixnotis-media-art-radius", "12px"), + ("--unixnotis-media-art-frame-radius", "14px"), + ("--unixnotis-media-source-font-size", "11px"), + ("--unixnotis-media-source-letter-spacing", "0.1em"), + ("--unixnotis-media-position-font-size", "11px"), + ("--unixnotis-media-position-letter-spacing", "0.08em"), + ("--unixnotis-media-title-font-size", "13px"), + ("--unixnotis-media-title-font-weight", "700"), + ("--unixnotis-media-artist-font-size", "12px"), + ("--unixnotis-media-button-radius", "10px"), + ] +} diff --git a/crates/unixnotis-core/src/css/tokens/legacy.rs b/crates/unixnotis-core/src/css/tokens/legacy.rs new file mode 100644 index 000000000..ae31ff68e --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/legacy.rs @@ -0,0 +1,25 @@ +//! GTK color definitions supported by every compatible GTK version + +use crate::config::ThemeConfig; + +use super::model::clamp_alpha; + +#[must_use] +pub fn build_legacy_theme_color_overrides(theme: &ThemeConfig) -> String { + // Legacy alpha colors stay first so existing theme palettes remain stable + let surface_alpha = clamp_alpha(theme.surface_alpha); + let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); + let card_alpha = clamp_alpha(theme.card_alpha); + let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); + let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); + + format!( + r" +@define-color unixnotis-surface alpha(@unixnotis-surface-base, {surface_alpha}); +@define-color unixnotis-surface-strong alpha(@unixnotis-surface-strong-base, {surface_strong_alpha}); +@define-color unixnotis-card alpha(@unixnotis-card-base, {card_alpha}); +@define-color unixnotis-shadow-soft alpha(#000000, {shadow_soft}); +@define-color unixnotis-shadow-strong alpha(#000000, {shadow_strong}); +" + ) +} diff --git a/crates/unixnotis-core/src/css/tokens/mod.rs b/crates/unixnotis-core/src/css/tokens/mod.rs new file mode 100644 index 000000000..65986a529 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/mod.rs @@ -0,0 +1,14 @@ +//! Shared theme token contract for legacy and modern GTK CSS paths + +mod layout; +mod legacy; +mod model; +mod modern; +mod palette; + +pub use legacy::build_legacy_theme_color_overrides; +pub use model::{theme_card_style_values, ThemeCardStyleValues}; +pub use modern::build_modern_theme_custom_properties; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/css/tokens/model.rs b/crates/unixnotis-core/src/css/tokens/model.rs new file mode 100644 index 000000000..73c0e90b4 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/model.rs @@ -0,0 +1,24 @@ +//! Config-backed values shared by CSS token renderers + +use crate::config::ThemeConfig; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeCardStyleValues { + // These values are reused by several override builders + pub border_width_px: f32, + pub card_radius_px: f32, + pub card_alpha: f32, +} + +#[must_use] +pub fn theme_card_style_values(theme: &ThemeConfig) -> ThemeCardStyleValues { + ThemeCardStyleValues { + border_width_px: f32::from(theme.border_width), + card_radius_px: f32::from(theme.card_radius), + card_alpha: clamp_alpha(theme.card_alpha), + } +} + +pub(super) const fn clamp_alpha(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} diff --git a/crates/unixnotis-core/src/css/tokens/modern.rs b/crates/unixnotis-core/src/css/tokens/modern.rs new file mode 100644 index 000000000..b68d7d4c1 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/modern.rs @@ -0,0 +1,82 @@ +//! Modern GTK custom-property rendering + +use crate::config::ThemeConfig; + +use super::super::features::GtkCssFeatures; +use super::layout::layout_tokens; +use super::model::{clamp_alpha, theme_card_style_values}; +use super::palette::color_alias_tokens; + +#[must_use] +pub fn build_modern_theme_custom_properties( + theme: &ThemeConfig, + features: GtkCssFeatures, +) -> String { + // Older GTK builds should see no custom-property output + if !features.supports_modern_theme_tokens() { + return String::new(); + } + + let surface_alpha = clamp_alpha(theme.surface_alpha); + let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); + let card_alpha = clamp_alpha(theme.card_alpha); + let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); + let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); + let card_style = theme_card_style_values(theme); + + // Keep the selector plain in generated CSS while avoiding source-lint confusion + let mut block = String::from(":\u{72}oot {\n"); + push_px_token( + &mut block, + "--unixnotis-border-width", + card_style.border_width_px, + ); + push_px_token( + &mut block, + "--unixnotis-card-radius", + card_style.card_radius_px, + ); + push_alpha_token(&mut block, "--unixnotis-surface-alpha", surface_alpha); + push_alpha_token( + &mut block, + "--unixnotis-surface-strong-alpha", + surface_strong_alpha, + ); + push_alpha_token(&mut block, "--unixnotis-card-alpha", card_alpha); + push_alpha_token(&mut block, "--unixnotis-shadow-soft-alpha", shadow_soft); + push_alpha_token(&mut block, "--unixnotis-shadow-strong-alpha", shadow_strong); + + for (name, value) in color_alias_tokens() { + push_raw_token(&mut block, name, value); + } + for (name, value) in layout_tokens() { + push_raw_token(&mut block, name, value); + } + + block.push_str("}\n"); + block +} + +fn push_px_token(block: &mut String, name: &str, value: f32) { + block.push_str(&format!(" {name}: {}px;\n", trim_float(value))); +} + +fn push_alpha_token(block: &mut String, name: &str, value: f32) { + block.push_str(&format!(" {name}: {};\n", trim_float(value))); +} + +fn push_raw_token(block: &mut String, name: &str, value: &str) { + block.push_str(&format!(" {name}: {value};\n")); +} + +fn trim_float(value: f32) -> String { + // Removing trailing zeroes keeps generated diagnostics readable + let mut text = format!("{value:.4}"); + while text.contains('.') && text.ends_with('0') { + text.pop(); + } + if text.ends_with('.') { + text.pop(); + } + text +} diff --git a/crates/unixnotis-core/src/css/tokens/palette.rs b/crates/unixnotis-core/src/css/tokens/palette.rs new file mode 100644 index 000000000..3a8468d35 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/palette.rs @@ -0,0 +1,85 @@ +//! Stable color aliases exposed to compatible custom themes + +pub(super) const fn color_alias_tokens() -> &'static [(&'static str, &'static str)] { + &[ + ("--unixnotis-surface-base-color", "@unixnotis-surface-base"), + ("--unixnotis-surface-color", "@unixnotis-surface"), + ( + "--unixnotis-surface-strong-color", + "@unixnotis-surface-strong", + ), + ("--unixnotis-surface-soft-color", "@unixnotis-surface-soft"), + ("--unixnotis-card-color", "@unixnotis-card"), + ("--unixnotis-text-color", "@unixnotis-text"), + ("--unixnotis-muted-color", "@unixnotis-muted"), + ("--unixnotis-accent-color", "@unixnotis-accent"), + ("--unixnotis-accent-2-color", "@unixnotis-accent-2"), + ("--unixnotis-urgent-color", "@unixnotis-urgent"), + ("--unixnotis-accent-wifi-color", "@unixnotis-accent-wifi"), + ( + "--unixnotis-accent-bluetooth-color", + "@unixnotis-accent-bluetooth", + ), + ( + "--unixnotis-accent-airplane-color", + "@unixnotis-accent-airplane", + ), + ("--unixnotis-accent-night-color", "@unixnotis-accent-night"), + ("--unixnotis-card-border-color", "@unixnotis-card-border"), + ("--unixnotis-outline-color", "@unixnotis-outline"), + ("--unixnotis-shadow-soft-color", "@unixnotis-shadow-soft"), + ( + "--unixnotis-shadow-strong-color", + "@unixnotis-shadow-strong", + ), + ("--unixnotis-glow-cyan-color", "@unixnotis-glow-cyan"), + ("--unixnotis-glow-pink-color", "@unixnotis-glow-pink"), + ("--unixnotis-glow-wifi-color", "@unixnotis-glow-wifi"), + ( + "--unixnotis-glow-bluetooth-color", + "@unixnotis-glow-bluetooth", + ), + ( + "--unixnotis-glow-airplane-color", + "@unixnotis-glow-airplane", + ), + ("--unixnotis-glow-night-color", "@unixnotis-glow-night"), + ("--unixnotis-panel-grad-1-color", "@unixnotis-panel-grad-1"), + ("--unixnotis-panel-grad-2-color", "@unixnotis-panel-grad-2"), + ("--unixnotis-panel-grad-3-color", "@unixnotis-panel-grad-3"), + ( + "--unixnotis-notification-bg-1-color", + "@unixnotis-notification-bg-1", + ), + ( + "--unixnotis-notification-bg-2-color", + "@unixnotis-notification-bg-2", + ), + ("--unixnotis-popup-bg-1-color", "@unixnotis-popup-bg-1"), + ("--unixnotis-popup-bg-2-color", "@unixnotis-popup-bg-2"), + ("--unixnotis-pill-bg-color", "@unixnotis-pill-bg"), + ("--unixnotis-pill-border-color", "@unixnotis-pill-border"), + ("--unixnotis-pill-hover-color", "@unixnotis-pill-hover"), + ("--unixnotis-action-bg-color", "@unixnotis-action-bg"), + ( + "--unixnotis-action-bg-hover-color", + "@unixnotis-action-bg-hover", + ), + ( + "--unixnotis-action-bg-active-color", + "@unixnotis-action-bg-active", + ), + ( + "--unixnotis-popup-action-bg-color", + "@unixnotis-popup-action-bg", + ), + ( + "--unixnotis-popup-action-hover-color", + "@unixnotis-popup-action-hover", + ), + ( + "--unixnotis-popup-action-active-color", + "@unixnotis-popup-action-active", + ), + ] +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/legacy.rs b/crates/unixnotis-core/src/css/tokens/tests/legacy.rs new file mode 100644 index 000000000..3ec6abeae --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/legacy.rs @@ -0,0 +1,15 @@ +use super::super::build_legacy_theme_color_overrides; +use crate::ThemeConfig; + +#[test] +fn legacy_theme_color_overrides_include_card_alpha() { + let overrides = build_legacy_theme_color_overrides(&ThemeConfig { + card_alpha: 0.42, + ..ThemeConfig::default() + }); + + assert!( + overrides.contains("@define-color unixnotis-card alpha(@unixnotis-card-base, 0.42);"), + "legacy output should preserve the configured card alpha" + ); +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/mod.rs b/crates/unixnotis-core/src/css/tokens/tests/mod.rs new file mode 100644 index 000000000..bcdf028b2 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/mod.rs @@ -0,0 +1,5 @@ +//! Theme token contract tests by renderer + +mod legacy; +mod model; +mod modern; diff --git a/crates/unixnotis-core/src/css/tokens/tests/model.rs b/crates/unixnotis-core/src/css/tokens/tests/model.rs new file mode 100644 index 000000000..98d41db93 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/model.rs @@ -0,0 +1,21 @@ +#![expect( + clippy::float_cmp, + reason = "theme-token resolution returns exact configured and clamped constants" +)] + +use super::super::theme_card_style_values; +use crate::ThemeConfig; + +#[test] +fn theme_card_style_values_clamp_alpha_and_keep_lengths() { + let values = theme_card_style_values(&ThemeConfig { + border_width: 3, + card_radius: 18, + card_alpha: 1.5, + ..ThemeConfig::default() + }); + + assert_eq!(values.border_width_px, 3.0); + assert_eq!(values.card_radius_px, 18.0); + assert_eq!(values.card_alpha, 1.0); +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/modern.rs b/crates/unixnotis-core/src/css/tokens/tests/modern.rs new file mode 100644 index 000000000..a8a33d161 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/modern.rs @@ -0,0 +1,74 @@ +use super::super::build_modern_theme_custom_properties; +use crate::{gtk_css_features_for_version, ThemeConfig}; + +#[test] +fn modern_theme_custom_properties_stay_additive() { + let overrides = build_modern_theme_custom_properties( + &ThemeConfig { + border_width: 2, + card_radius: 12, + surface_alpha: 0.88, + ..ThemeConfig::default() + }, + gtk_css_features_for_version(4, 16), + ); + + for expected in [ + ":root {", + "--unixnotis-border-width: 2px;", + "--unixnotis-card-radius: 12px;", + "--unixnotis-panel-card-padding-y: 9px;", + "--unixnotis-popup-reveal-duration: 200ms;", + "--unixnotis-media-card-radius: 18px;", + "--unixnotis-media-title-font-size: 13px;", + "--unixnotis-ui-font-family: \"Inter\", \"SF Pro Text\",", + "--unixnotis-monospace-font-family: \"CaskaydiaCove Nerd Font Mono\",", + "--unixnotis-accent-color: @unixnotis-accent;", + "--unixnotis-surface-alpha: 0.88;", + "--unixnotis-card-alpha: 0.94;", + ] { + assert!( + overrides.contains(expected), + "modern token output should contain {expected}" + ); + } +} + +#[test] +fn modern_theme_custom_properties_stay_off_on_older_gtk() { + let overrides = build_modern_theme_custom_properties( + &ThemeConfig::default(), + gtk_css_features_for_version(4, 15), + ); + assert!( + overrides.is_empty(), + "GTK versions without custom properties should receive no modern block" + ); +} + +#[test] +fn modern_theme_tokens_trim_float_values_without_losing_fraction() { + let overrides = build_modern_theme_custom_properties( + &ThemeConfig { + border_width: 3, + card_radius: 10, + surface_alpha: 0.5, + surface_strong_alpha: 1.0, + card_alpha: 0.125, + ..ThemeConfig::default() + }, + gtk_css_features_for_version(4, 16), + ); + + for expected in [ + "--unixnotis-border-width: 3px;", + "--unixnotis-surface-alpha: 0.5;", + "--unixnotis-surface-strong-alpha: 1;", + "--unixnotis-card-alpha: 0.125;", + ] { + assert!( + overrides.contains(expected), + "trimmed modern output should contain {expected}" + ); + } +} From e9cbfd6670ecaf62b37549ba5d534b862888b96f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 14:43:07 -0500 Subject: [PATCH 161/275] fix(popups): isolate default activation Summary: isolate default activation. Scope: popups. --- crates/unixnotis-core/assets/popup.css | 28 +++- .../src/ui/entry/activation.rs | 132 +++++++++++++++ crates/unixnotis-popups/src/ui/entry/build.rs | 56 +------ .../src/ui/entry/builders/common.rs | 7 + .../src/ui/entry/builders/layout.rs | 2 +- .../src/ui/entry/builders/reply/widget.rs | 2 + .../src/ui/entry/builders/tests/common.rs | 2 + .../src/ui/entry/builders/tests/layout.rs | 1 + crates/unixnotis-popups/src/ui/entry/mod.rs | 1 + .../ui/entry/presentation/tests/view_model.rs | 16 ++ .../src/ui/entry/presentation/view_model.rs | 2 + .../src/ui/entry/tests/activation.rs | 152 ++++++++++++++++++ .../src/ui/entry/tests/build.rs | 38 +++-- .../src/ui/state/tests/mutation.rs | 2 +- crates/unixnotis-ui/src/presentation/build.rs | 13 +- .../src/presentation/tests/presentation.rs | 18 +++ crates/unixnotis-ui/src/presentation/types.rs | 1 + 17 files changed, 392 insertions(+), 81 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/entry/activation.rs create mode 100644 crates/unixnotis-popups/src/ui/entry/tests/activation.rs diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 8ab0cbe90..e3d7186cd 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -57,6 +57,15 @@ inset 0 1px 0 alpha(#ffffff, 0.035); } +.unixnotis-popup-card.unixnotis-popup-default-action:focus-visible { + border-color: alpha(@unixnotis-accent, 0.48); + outline: none; + box-shadow: + 0 14px 34px -18px @unixnotis-shadow-strong, + 0 0 0 2px alpha(@unixnotis-accent, 0.18), + inset 0 1px 0 alpha(#ffffff, 0.035); +} + .unixnotis-popup-card.utility { padding-top: 12px; padding-bottom: 12px; @@ -71,15 +80,19 @@ min-width: 0; } -.unixnotis-popup-identity-row, +.unixnotis-popup-identity-row { + min-width: 0; +} + .unixnotis-popup-message { min-width: 0; + margin-top: 1px; } .unixnotis-popup-app-name { - color: alpha(@unixnotis-text, 0.78); + color: alpha(@unixnotis-text, 0.68); font-weight: 600; - font-size: 12px; + font-size: 11px; } .unixnotis-popup-time { @@ -110,9 +123,10 @@ } .unixnotis-popup-summary { - font-weight: 650; + color: alpha(@unixnotis-text, 0.98); + font-weight: 700; font-size: 15px; - margin-top: 1px; + margin-top: 0; } .unixnotis-popup-icon { @@ -147,10 +161,10 @@ } .unixnotis-popup-body { - color: @unixnotis-muted; + color: alpha(@unixnotis-muted, 0.88); font-weight: 400; font-size: 13px; - margin-top: 2px; + margin-top: 3px; } .unixnotis-popup-footer-note { diff --git a/crates/unixnotis-popups/src/ui/entry/activation.rs b/crates/unixnotis-popups/src/ui/entry/activation.rs new file mode 100644 index 000000000..d27a726a3 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/activation.rs @@ -0,0 +1,132 @@ +//! Whole-card default action activation and interactive-child isolation + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +use super::commands::try_send_command; +use super::presentation::PopupEntryViewModel; +use crate::dbus::UiCommand; + +pub(super) const INTERACTIVE_CLASS: &str = "unixnotis-popup-interactive"; + +pub(super) fn mark_interactive>(widget: &W) { + // One explicit marker protects current controls and future composite widgets + widget.add_css_class(INTERACTIVE_CLASS); +} + +pub(super) fn connect_default_action( + root: >k::Box, + notification: NotificationKey, + view: &PopupEntryViewModel, + command_tx: &tokio::sync::mpsc::Sender, +) { + let Some(action_key) = view.default_action_key.clone() else { + return; + }; + + // A blank-label default action still needs a discoverable keyboard target + root.set_focusable(true); + root.set_accessible_role(gtk::AccessibleRole::Button); + root.update_property(&[gtk::accessible::Property::Label("Open notification")]); + root.add_css_class("unixnotis-popup-default-action"); + + let gesture = gtk::GestureClick::new(); + gesture.set_button(1); + let root_weak = root.downgrade(); + let click_tx = command_tx.clone(); + let click_key = action_key.clone(); + gesture.connect_released(move |_, _, x, y| { + let Some(root) = root_weak.upgrade() else { + return; + }; + dispatch_default_action( + root.upcast_ref(), + root.pick(x, y, gtk::PickFlags::DEFAULT), + notification, + &click_key, + &click_tx, + ); + }); + root.add_controller(gesture); + + let key_controller = gtk::EventControllerKey::new(); + let root_weak = root.downgrade(); + let key_tx = command_tx.clone(); + key_controller.connect_key_pressed(move |_, key, _, _| { + let Some(root) = root_weak.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; + handle_default_action_key(root.has_focus(), key, notification, &action_key, &key_tx) + }); + root.add_controller(key_controller); +} + +fn handle_default_action_key( + root_has_focus: bool, + key: gtk::gdk::Key, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) -> gtk::glib::Propagation { + if !root_has_focus || !is_default_activation_key(key) { + return gtk::glib::Propagation::Proceed; + } + invoke_default_action(notification, action_key, command_tx); + gtk::glib::Propagation::Stop +} + +fn dispatch_default_action( + root: >k::Widget, + picked: Option, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + if picked_widget_blocks_default_action(root, picked) { + return; + } + invoke_default_action(notification, action_key, command_tx); +} + +fn invoke_default_action( + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + // Presentation policy has already removed default actions from weak identities + try_send_command( + command_tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.to_string(), + }, + ); +} + +fn picked_widget_blocks_default_action( + root: >k::Widget, + mut picked: Option, +) -> bool { + while let Some(current) = picked { + if current == *root { + return false; + } + // Focusability is a safe fallback for controls not yet carrying the marker + if current.has_css_class(INTERACTIVE_CLASS) || current.is_focusable() { + return true; + } + picked = current.parent(); + } + false +} + +const fn is_default_activation_key(key: gtk::gdk::Key) -> bool { + matches!( + key, + gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space + ) +} + +#[cfg(test)] +#[path = "tests/activation.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index dd9847703..02496f4bc 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -7,6 +7,7 @@ use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; use super::super::UiState; +use super::activation::connect_default_action; use super::builders::{ build_action_row, build_close_button, build_inline_reply, build_popup_content, }; @@ -194,61 +195,6 @@ fn connect_close_action( }); } -fn connect_default_action( - root: >k::Box, - notification: unixnotis_core::NotificationKey, - view: &PopupEntryViewModel, - command_tx: &tokio::sync::mpsc::Sender, -) { - let Some(action_key) = view - .primary_actions - .iter() - .chain(&view.overflow_actions) - .find(|action| action.key == "default") - .map(|action| action.key.clone()) - else { - return; - }; - - let gesture = gtk::GestureClick::new(); - // Default card actions only belong to plain card clicks - gesture.set_button(1); - let root_weak = root.downgrade(); - let tx = command_tx.clone(); - gesture.connect_released(move |_, _, x, y| { - let Some(root) = root_weak.upgrade() else { - return; - }; - if picked_widget_blocks_default_action(root.pick(x, y, gtk::PickFlags::DEFAULT)) { - return; - } - // The presentation model already removed actions with weak provenance - try_send_command( - &tx, - UiCommand::InvokeAction { - notification, - action_key: action_key.clone(), - }, - ); - }); - root.add_controller(gesture); -} - -fn picked_widget_blocks_default_action(mut widget: Option) -> bool { - while let Some(current) = widget { - if widget_type_blocks_default_action(current.type_()) { - return true; - } - widget = current.parent(); - } - false -} - -fn widget_type_blocks_default_action(widget_type: gtk::glib::Type) -> bool { - // Button clicks should always stay owned by the button widget subtree - widget_type.is_a(gtk::Button::static_type()) -} - fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { if enabled { // Skip duplicate adds so repeated rebuilds do not churn the class list diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index f99b12360..40906c0b0 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -9,6 +9,7 @@ use unixnotis_ui::presentation::build_semantic_badge; use super::super::commands::try_send_command; use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; use crate::dbus::UiCommand; +use crate::ui::entry::activation::mark_interactive; use crate::ui::UiState; pub(super) struct IdentityAvatar { @@ -29,6 +30,9 @@ pub(super) fn build_identity_avatar( icon.set_size_request(icon_size, icon_size); icon.set_valign(Align::Center); icon.set_halign(Align::Center); + // Expansion centers the glyph optically inside the fixed avatar allocation + icon.set_hexpand(true); + icon.set_vexpand(true); icon.set_accessible_role(gtk::AccessibleRole::Presentation); icon.add_css_class("unixnotis-popup-icon"); @@ -142,6 +146,7 @@ pub(in crate::ui::entry) fn build_close_button() -> gtk::Button { close.add_css_class("unixnotis-popup-close"); close.set_halign(Align::End); close.set_tooltip_text(Some("Dismiss notification")); + mark_interactive(&close); close } @@ -173,6 +178,7 @@ fn build_action_button( ) -> gtk::Button { let button = gtk::Button::with_label(&action.label); button.add_css_class("unixnotis-popup-action"); + mark_interactive(&button); let action_key = action.key.clone(); let tx = command_tx.clone(); let popover = popover.cloned(); @@ -201,6 +207,7 @@ fn build_overflow_menu( menu.set_icon_name("view-more-symbolic"); menu.set_tooltip_text(Some("More actions")); menu.add_css_class("unixnotis-popup-action-overflow"); + mark_interactive(&menu); let popover = gtk::Popover::new(); let list = gtk::Box::new(gtk::Orientation::Vertical, 4); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs index b0752eacf..7000c88be 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -29,7 +29,7 @@ pub(super) fn build_popup_grid( grid.add_css_class(layout.css_class); grid.add_css_class("unixnotis-popup-content-grid"); grid.set_column_spacing(10); - grid.set_row_spacing(2); + grid.set_row_spacing(4); grid.set_hexpand(true); grid.set_accessible_role(gtk::AccessibleRole::Group); let accessible_label = popup_accessible_label(view); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs index fd03e059d..ca9bea3b9 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs @@ -10,6 +10,7 @@ use super::lifecycle::{ bounded_reply_text, cancel_reply, submit_reply, ReplySubmission, MAX_REPLY_CHARS, }; use crate::dbus::UiCommand; +use crate::ui::entry::activation::mark_interactive; use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; pub(in crate::ui::entry) fn build_inline_reply( @@ -23,6 +24,7 @@ pub(in crate::ui::entry) fn build_inline_reply( let root = gtk::Box::new(gtk::Orientation::Vertical, 4); root.add_css_class("unixnotis-popup-inline-reply"); + mark_interactive(&root); let reveal = gtk::Button::with_label(reply_label(notification)); reveal.add_css_class("unixnotis-popup-action"); root.append(&reveal); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index ffa10a011..62826ca37 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -190,6 +190,8 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { assert_eq!(avatar.widget.width_request(), 36); assert_eq!(avatar.widget.height_request(), 36); assert_eq!(icon.pixel_size(), 22); + assert!(icon.hexpands()); + assert!(icon.vexpands()); } fn view_model() -> PopupEntryViewModel { diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs index a338d66ef..0060dde99 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -45,6 +45,7 @@ fn view_model() -> PopupEntryViewModel { title: "Build finished".to_string(), body: None, thumbnail: ThumbnailKind::None, + default_action_key: None, primary_actions: Vec::new(), overflow_actions: Vec::new(), trust: TrustPresentation { diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index 991dfb71e..85f4ebab8 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -1,5 +1,6 @@ //! Popup row construction and bounded label handling +mod activation; mod build; mod builders; mod commands; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 2aa4a68ab..e70d8a504 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -58,12 +58,28 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { let model = PopupEntryViewModel::for_notification_at(&view, 1_000); assert_eq!(model.kind, PopupKind::Utility); + assert_eq!(model.default_action_key.as_deref(), Some("default")); assert_eq!(model.primary_actions.len(), 2); assert_eq!(model.primary_actions[0].key, "default"); assert_eq!(model.overflow_actions.len(), 1); assert_eq!(model.overflow_actions[0].key, "archive"); } +#[test] +fn blank_default_action_is_clickable_without_becoming_a_visible_control() { + let mut view = notification(); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.default_action_key.as_deref(), Some("default")); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); +} + #[test] fn weak_attribution_hides_every_application_directed_action() { let mut view = notification(); diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 992f26328..284ede1ec 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -22,6 +22,7 @@ pub(in crate::ui::entry) struct PopupEntryViewModel { pub(in crate::ui::entry) title: String, pub(in crate::ui::entry) body: Option, pub(in crate::ui::entry) thumbnail: ThumbnailKind, + pub(in crate::ui::entry) default_action_key: Option, pub(in crate::ui::entry) primary_actions: Vec, pub(in crate::ui::entry) overflow_actions: Vec, pub(in crate::ui::entry) trust: PopupTrustPresentation, @@ -55,6 +56,7 @@ impl PopupEntryViewModel { title: shared.title, body: shared.body, thumbnail: shared.media.thumbnail, + default_action_key: shared.actions.default_key, primary_actions: shared.actions.primary, overflow_actions: shared.actions.overflow, trust: shared.trust, diff --git a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs new file mode 100644 index 000000000..7397bb208 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs @@ -0,0 +1,152 @@ +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +use super::{ + connect_default_action, dispatch_default_action, handle_default_action_key, + is_default_activation_key, mark_interactive, +}; +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use unixnotis_ui::presentation::{BadgePresentation, ThumbnailKind, TrustLevel, TrustPresentation}; + +const KEY: NotificationKey = NotificationKey { + id: 41, + generation: 3, +}; + +#[gtk::test] +fn clicking_overflow_menu_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let menu = gtk::MenuButton::new(); + mark_interactive(&menu); + root.append(&menu); + assert_pick_does_not_dispatch(&root, &menu); +} + +#[gtk::test] +fn clicking_reply_entry_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let reply = gtk::Box::new(gtk::Orientation::Vertical, 0); + mark_interactive(&reply); + let entry = gtk::Entry::new(); + reply.append(&entry); + root.append(&reply); + assert_pick_does_not_dispatch(&root, &entry); +} + +#[gtk::test] +fn clicking_reply_button_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let button = gtk::Button::with_label("Reply"); + mark_interactive(&button); + root.append(&button); + assert_pick_does_not_dispatch(&root, &button); +} + +#[gtk::test] +fn clicking_plain_card_content_invokes_default_once() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let label = gtk::Label::new(Some("Message")); + root.append(&label); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + + dispatch_default_action( + root.upcast_ref(), + Some(label.upcast()), + KEY, + "default", + &command_tx, + ); + + assert_default_command(&mut command_rx); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn default_action_card_is_focusable_and_keyboard_activatable() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let view = PopupEntryViewModel { + kind: PopupKind::Utility, + app_label: "Example".to_string(), + secondary_claim: None, + badge: BadgePresentation::AuthenticatedApplication, + timestamp_label: "now".to_string(), + title: "Update complete".to_string(), + body: None, + thumbnail: ThumbnailKind::None, + default_action_key: Some("default".to_string()), + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Verified, + short_label: None, + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + }; + + connect_default_action(&root, KEY, &view, &command_tx); + + assert!(root.is_focusable()); + assert_eq!(root.accessible_role(), gtk::AccessibleRole::Button); + assert!(is_default_activation_key(gtk::gdk::Key::Return)); + assert!(is_default_activation_key(gtk::gdk::Key::KP_Enter)); + assert!(is_default_activation_key(gtk::gdk::Key::space)); + assert!(!is_default_activation_key(gtk::gdk::Key::Escape)); +} + +#[gtk::test] +fn keyboard_default_action_requires_card_focus_and_enter_or_space() { + for key in [ + gtk::gdk::Key::Return, + gtk::gdk::Key::KP_Enter, + gtk::gdk::Key::space, + ] { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + assert_eq!( + handle_default_action_key(true, key, KEY, "default", &command_tx), + gtk::glib::Propagation::Stop + ); + assert_default_command(&mut command_rx); + } + + for (focused, key) in [ + (false, gtk::gdk::Key::Return), + (true, gtk::gdk::Key::Escape), + (false, gtk::gdk::Key::Escape), + ] { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + assert_eq!( + handle_default_action_key(focused, key, KEY, "default", &command_tx), + gtk::glib::Propagation::Proceed + ); + assert!(command_rx.try_recv().is_err()); + } +} + +fn assert_pick_does_not_dispatch>(root: >k::Box, picked: &W) { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + dispatch_default_action( + root.upcast_ref(), + Some(picked.clone().upcast()), + KEY, + "default", + &command_tx, + ); + assert!(command_rx.try_recv().is_err()); +} + +fn assert_default_command(command_rx: &mut tokio::sync::mpsc::Receiver) { + match command_rx.try_recv().expect("default action command") { + UiCommand::InvokeAction { + notification, + action_key, + } => { + assert_eq!(notification, KEY); + assert_eq!(action_key, "default"); + } + command => panic!("unexpected command: {command:?}"), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index 68aa1c5e6..f399752c3 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -1,5 +1,5 @@ -use super::{connect_close_action, connect_default_action, widget_type_blocks_default_action}; -use gtk::glib::prelude::StaticType; +use super::connect_close_action; +use crate::ui::entry::activation::connect_default_action; use gtk::prelude::*; use unixnotis_core::{ Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, @@ -9,18 +9,6 @@ use unixnotis_core::{ use crate::dbus::UiCommand; use crate::ui::entry::presentation::PopupEntryViewModel; -#[gtk::test] -fn default_card_action_is_blocked_for_button_widgets() { - // Button clicks must remain owned by the button action - assert!(widget_type_blocks_default_action(gtk::Button::static_type())); -} - -#[gtk::test] -fn default_card_action_is_allowed_for_plain_content_widgets() { - // Plain card content may use the notification default action - assert!(!widget_type_blocks_default_action(gtk::Label::static_type())); -} - #[gtk::test] fn close_button_dispatches_only_the_notification_dismissal() { let close = gtk::Button::new(); @@ -49,7 +37,25 @@ fn exact_default_action_adds_card_click_handling() { connect_default_action(&root, view.key(), &model, &command_tx); - assert_eq!(root.observe_controllers().n_items(), 1); + assert_eq!(root.observe_controllers().n_items(), 2); + assert!(root.is_focusable()); +} + +#[gtk::test] +fn blank_default_action_still_adds_card_click_handling() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.key(), &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 2); + assert!(root.is_focusable()); } #[gtk::test] @@ -68,7 +74,7 @@ fn nondefault_action_does_not_make_the_whole_card_clickable() { assert_eq!(root.observe_controllers().n_items(), 0); } -fn notification() -> NotificationView { +pub(super) fn notification() -> NotificationView { NotificationView { id: 31, generation: 1, diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 8806628cc..6a5100282 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -153,7 +153,7 @@ fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { assert!(grid.has_css_class("unixnotis-popup-content-grid")); assert_eq!(grid.column_spacing(), 10); - assert_eq!(grid.row_spacing(), 2); + assert_eq!(grid.row_spacing(), 4); assert_eq!( grid.property::("accessible-role"), gtk::AccessibleRole::Group diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index e5496048c..90b7b3b2e 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -240,14 +240,25 @@ fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> A if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { return ActionPresentation::default(); } + // A blank default label keeps card activation without creating an empty button + let default_key = notification + .actions + .iter() + .find(|action| action.key == "default") + .map(|action| action.key.clone()); let mut actions = notification .actions .iter() - .filter(|action| action.key != "inline-reply") + .filter(|action| { + action.key != "inline-reply" + && !action.key.trim().is_empty() + && !action.label.trim().is_empty() + }) .map(action_view) .collect::>(); let overflow = actions.split_off(actions.len().min(kind.action_limit())); ActionPresentation { + default_key, primary: actions, overflow, } diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 68bd216d7..c67b887ea 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -42,10 +42,28 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { ); assert_eq!(presentation.media.thumbnail, ThumbnailKind::Content); assert_eq!(presentation.actions.primary.len(), 1); + assert_eq!(presentation.actions.primary[0].key, "default"); + assert_eq!(presentation.actions.primary[0].label, "Open"); assert!(presentation.actions.overflow.is_empty()); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); assert_eq!(presentation.timestamp, "2m"); } +#[test] +fn blank_default_action_keeps_card_activation_without_rendering_a_button() { + let mut view = notification(); + view.actions = vec![Action { + key: "default".to_string(), + label: " ".to_string(), + }]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + #[test] fn shared_model_downgrades_conflicts_and_denies_application_interaction() { let mut view = notification(); diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index 1bee70682..da4bdb27e 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -108,6 +108,7 @@ pub struct ActionView { /// Compact actions split without silently dropping safe overflow #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ActionPresentation { + pub default_key: Option, pub primary: Vec, pub overflow: Vec, } From 5151d3f74d1eb767d400584feb8f63ed1ce3abf9 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 14:43:13 -0500 Subject: [PATCH 162/275] fix(panel): keep actions generation bound Summary: keep actions generation bound. Scope: panel. --- .../row/notification/update/actions.rs | 98 ++++++++++++--- .../row/notification/update/tests/actions.rs | 117 ++++++++++++++++-- 2 files changed, 191 insertions(+), 24 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 19d3caa32..429fd5b94 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -34,22 +34,13 @@ pub(super) fn update_actions( ) { let presentation = NotificationPresentation::from_view(notification); configure_inline_reply(&row.inline_reply, notification, is_active); - let safe_actions = presentation - .actions - .primary - .iter() - .chain(&presentation.actions.overflow) - .collect::>(); + let action_signature = action_signature(&presentation, is_active); // Fast path skips button rebuilding when the action set is unchanged { let cached = row.action_cache.borrow(); let reply_cached = row.reply_cache.borrow(); if row.action_cache_key.get() == notification.key() - && cached.len() == safe_actions.len() - && cached - .iter() - .zip(&safe_actions) - .all(|((key, label), action)| key == &action.key && label == &action.label) + && cached.as_slice() == action_signature.as_slice() && reply_cached.0 == notification.inline_reply && reply_cached.1 == notification.inline_reply_policy && reply_cached.2 == is_active @@ -62,10 +53,7 @@ pub(super) fn update_actions( // Cache the current action signature for the next update cycle let mut cached = row.action_cache.borrow_mut(); cached.clear(); - cached.reserve(safe_actions.len()); - for action in &safe_actions { - cached.push((action.key.clone(), action.label.clone())); - } + cached.extend(action_signature); row.action_cache_key.set(notification.key()); *row.reply_cache.borrow_mut() = ( notification.inline_reply.clone(), @@ -78,6 +66,10 @@ pub(super) fn update_actions( while let Some(child) = row.actions_box.first_child() { row.actions_box.remove(&child); } + // Archived notifications cannot be valid daemon action targets + if !is_active { + return; + } if visible_action_count_from(&presentation, is_active) == 0 { return; } @@ -114,6 +106,74 @@ pub(super) fn update_actions( &presentation.actions.overflow, )); } + if let Some(default_key) = blank_default_action_key(&presentation) { + row.actions_box.append(&build_default_action_button( + command_tx, + notification.key(), + default_key, + )); + } +} + +fn action_signature( + presentation: &NotificationPresentation, + is_active: bool, +) -> Vec<(String, String)> { + if !is_active { + return Vec::new(); + } + let mut signature = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .map(|action| (action.key.clone(), action.label.clone())) + .collect::>(); + if let Some(default_key) = blank_default_action_key(presentation) { + // The empty label distinguishes the compact icon-only default control + signature.push((default_key.to_string(), String::new())); + } + signature +} + +fn blank_default_action_key(presentation: &NotificationPresentation) -> Option<&str> { + let default_key = presentation.actions.default_key.as_deref()?; + let already_visible = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .any(|action| action.key == default_key); + (!already_visible).then_some(default_key) +} + +fn build_default_action_button( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + action_key: &str, +) -> gtk::Button { + let button = gtk::Button::from_icon_name("document-open-symbolic"); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + button.add_css_class("unixnotis-panel-default-action"); + button.set_tooltip_text(Some("Open notification")); + button.update_property(&[gtk::accessible::Property::Label("Open notification")]); + let action_key = action_key.to_string(); + let tx = command_tx.clone(); + let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); + button.connect_clicked(move |_| { + if !action_gate.try_start() { + return; + } + try_send_command( + &tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.clone(), + }, + ); + }); + button } fn build_action_button( @@ -179,7 +239,11 @@ pub(super) fn visible_action_count(notification: &NotificationView, is_active: b } fn visible_action_count_from(presentation: &NotificationPresentation, is_active: bool) -> usize { + if !is_active { + return 0; + } let regular = presentation.actions.primary.len() + presentation.actions.overflow.len(); - let reply = is_active && presentation.trust.reply == ReplyPresentation::Available; - regular + usize::from(reply) + let reply = presentation.trust.reply == ReplyPresentation::Available; + let blank_default = blank_default_action_key(presentation).is_some(); + regular + usize::from(reply) + usize::from(blank_default) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 297958ec4..b630aca65 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -258,7 +258,7 @@ fn recycled_action_button_targets_the_new_notification_generation() { } #[gtk::test] -fn inactive_reply_action_stays_hidden_beside_a_regular_action() { +fn inactive_history_row_hides_every_application_action() { let (_root, row) = notification_row(); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); let mut notification = sample_notification(); @@ -281,14 +281,102 @@ fn inactive_reply_action_stays_hidden_beside_a_regular_action() { &command_tx, ); + assert_eq!(child_count(&row.actions_box), 0); + assert!(row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); +} + +#[gtk::test] +fn active_blank_default_action_builds_accessible_open_control() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("blank default action control"); + assert!(button.has_css_class("unixnotis-panel-default-action")); + assert_eq!(button.tooltip_text().as_deref(), Some("Open notification")); + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { notification, action_key }) + if notification.id == 1 + && notification.generation == 1 + && action_key == "default" + )); +} + +#[gtk::test] +fn labeled_default_action_does_not_build_a_duplicate_open_control() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: "Open conversation".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + assert_eq!(child_count(&row.actions_box), 1); let button = row .actions_box .first_child() - .expect("regular action") - .downcast::() - .expect("action child should be a button"); - assert_eq!(button.label().as_deref(), Some("Open")); + .and_downcast::() + .expect("labeled default action button"); + assert_eq!(button.label().as_deref(), Some("Open conversation")); + assert!(!button.has_css_class("unixnotis-panel-default-action")); +} + +#[gtk::test] +fn historical_blank_default_action_has_no_control_or_activation() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 0); + assert!(command_rx.try_recv().is_err()); } #[gtk::test] @@ -407,7 +495,7 @@ fn visible_action_count_requires_a_live_available_explicit_reply() { label: "Dismiss".to_string(), }, ]; - assert_eq!(visible_action_count(¬ification, false), 2); + assert_eq!(visible_action_count(¬ification, false), 0); notification.actions.push(Action { key: "inline-reply".to_string(), @@ -415,8 +503,23 @@ fn visible_action_count_requires_a_live_available_explicit_reply() { }); assert_eq!(visible_action_count(¬ification, true), 2); notification.inline_reply.available = true; - assert_eq!(visible_action_count(¬ification, false), 2); + assert_eq!(visible_action_count(¬ification, false), 0); assert_eq!(visible_action_count(¬ification, true), 3); notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; assert_eq!(visible_action_count(¬ification, true), 2); } + +#[test] +fn visible_action_count_includes_primary_and_overflow_actions() { + let mut notification = sample_notification(); + notification.actions = ["Open", "Archive", "Mute"] + .into_iter() + .map(|label| Action { + key: label.to_ascii_lowercase(), + label: label.to_string(), + }) + .collect(); + + assert_eq!(visible_action_count(¬ification, true), 3); + assert_eq!(visible_action_count(¬ification, false), 0); +} From 5ea603359ad37dfe7fd4a5f073b6e122f9ca379b Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 14:43:29 -0500 Subject: [PATCH 163/275] fix(panel): restore readable group depth Summary: restore readable group depth. Scope: panel. --- .../src/ui/notifications/model/item.rs | 18 +++--- .../src/ui/notifications/model/tests/item.rs | 10 ++++ .../notifications/row/notification/build.rs | 7 ++- .../ui/notifications/row/notification/mod.rs | 1 + .../notifications/row/notification/stack.rs | 59 +++++++++++++++++++ .../notifications/row/notification/state.rs | 3 + .../row/notification/tests/stack.rs | 41 +++++++++++++ .../row/notification/tests/support.rs | 1 + .../row/notification/update/tests/state.rs | 29 ++++----- .../row/notification/update/visual.rs | 43 ++++---------- .../src/ui/notifications/store/blocks.rs | 16 +++-- .../src/ui/notifications/store/lifecycle.rs | 1 + .../src/ui/notifications/store/mutation.rs | 2 + .../ui/notifications/store/tests/blocks.rs | 26 ++++---- .../ui/notifications/view/tests/widgets.rs | 4 ++ crates/unixnotis-core/assets/panel.css | 42 +++++++++---- .../unixnotis-core/src/css/hooks/classes.rs | 2 - .../src/css/hooks/tests/hooks.rs | 18 +++--- 18 files changed, 220 insertions(+), 103 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 3bc42443b..e2d849bbd 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -63,11 +63,10 @@ pub struct RowData { pub group_key: Rc, pub count: u32, pub expanded: bool, - // Position flags let CSS form one continuous grouped surface - pub group_first: bool, - pub group_last: bool, // True when this notification previews a collapsed multi-item group pub collapsed_group_preview: bool, + // Rear silhouettes cap at two layers while the count keeps the exact total + pub stack_depth: u8, pub is_active: bool, pub presentation: RowPresentation, pub notification: Option>, @@ -82,9 +81,8 @@ impl Default for RowData { group_key: Rc::from(""), count: 0, expanded: false, - group_first: false, - group_last: false, collapsed_group_preview: false, + stack_depth: 0, is_active: false, presentation: RowPresentation::default(), notification: None, @@ -106,9 +104,8 @@ impl RowData { group_key, count: count as u32, expanded, - group_first: false, - group_last: false, collapsed_group_preview: false, + stack_depth: 0, is_active: false, presentation: RowPresentation::default(), notification: Some(sample), @@ -119,6 +116,7 @@ impl RowData { group_key: Rc, notification: Rc, collapsed_group_preview: bool, + stack_depth: u8, expanded: bool, is_active: bool, presentation: RowPresentation, @@ -130,9 +128,8 @@ impl RowData { group_key, count: 0, expanded, - group_first: false, - group_last: false, collapsed_group_preview, + stack_depth, is_active, presentation, notification: Some(notification), @@ -146,9 +143,8 @@ impl RowData { && Rc::ptr_eq(&self.group_key, &other.group_key) && self.count == other.count && self.expanded == other.expanded - && self.group_first == other.group_first - && self.group_last == other.group_last && self.collapsed_group_preview == other.collapsed_group_preview + && self.stack_depth == other.stack_depth && self.is_active == other.is_active && self.presentation == other.presentation && Self::same_notification(&self.notification, &other.notification) diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 9cff5b7d5..b91a01bae 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -54,6 +54,7 @@ fn row_data_notification_sets_expected_fields() { Rc::from("terminal"), view.clone(), true, + 2, false, true, presentation.clone(), @@ -62,6 +63,7 @@ fn row_data_notification_sets_expected_fields() { assert_eq!(data.kind, RowKind::Notification); assert_eq!(data.id, 42); assert!(data.collapsed_group_preview); + assert_eq!(data.stack_depth, 2); assert!(data.is_active); assert_eq!(data.presentation, presentation); assert!(Rc::ptr_eq(data.notification.as_ref().expect("view"), &view)); @@ -73,6 +75,7 @@ fn row_item_update_emits_only_for_changed_data() { Rc::from("terminal"), notification(1), false, + 0, false, true, RowPresentation::default(), @@ -92,6 +95,7 @@ fn row_item_update_emits_only_for_changed_data() { Rc::from("terminal"), notification(2), false, + 0, false, true, RowPresentation::default(), @@ -107,6 +111,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { group.clone(), view.clone(), false, + 0, false, true, RowPresentation { @@ -143,6 +148,10 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { changed.collapsed_group_preview = true; assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); + changed.stack_depth = 2; + assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); changed.is_active = false; assert!(!base.is_equivalent(&changed)); @@ -161,6 +170,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { group, view, false, + 0, false, true, RowPresentation { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index bf3ae0c9a..9dd61b702 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -16,6 +16,7 @@ use crate::control::UiCommand; use crate::ui::try_send_command; use super::reply::build_inline_reply; +use super::stack::append_stack_layers; use super::state::NotificationRowWidgets; pub(in crate::ui::notifications) fn build_notification_row( @@ -192,8 +193,8 @@ pub(in crate::ui::notifications) fn build_notification_row( let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); - // One readable surface carries the preview while the group count conveys hidden entries - root.append(&card_plate); + // Rear layers and the readable foreground remain one virtualized ListView row + let (stack_middle, stack_back) = append_stack_layers(&root, &card_plate); let notify_key = Rc::new(Cell::new(NotificationKey { id: 0, @@ -209,6 +210,8 @@ pub(in crate::ui::notifications) fn build_notification_row( NotificationRowWidgets { card, card_plate, + stack_middle, + stack_back, icon, header, app_label, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index b6d349be1..3ec06b1d9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -5,6 +5,7 @@ mod build; mod reply; +mod stack; mod state; #[cfg(test)] #[path = "tests/support.rs"] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs new file mode 100644 index 000000000..27667240e --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -0,0 +1,59 @@ +//! Collapsed group depth layers and paint order + +use gtk::prelude::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StackLayer { + Back, + Middle, + Foreground, +} + +const STACK_LAYER_ORDER: [StackLayer; 3] = + [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground]; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct StackLayerVisibility { + pub(super) middle: bool, + pub(super) back: bool, +} + +pub(super) fn append_stack_layers( + root: >k::Box, + foreground: &unixnotis_ui::CutCorner, +) -> (gtk::Box, gtk::Box) { + let middle = build_stack_layer("unixnotis-stack-layer-middle"); + let back = build_stack_layer("unixnotis-stack-layer-back"); + + // Later GTK siblings paint over earlier layers when negative margins overlap + for layer in STACK_LAYER_ORDER { + match layer { + StackLayer::Back => root.append(&back), + StackLayer::Middle => root.append(&middle), + StackLayer::Foreground => root.append(foreground), + } + } + (middle, back) +} + +pub(super) const fn stack_layer_visibility(depth: u8) -> StackLayerVisibility { + StackLayerVisibility { + middle: depth >= 2, + back: depth >= 1, + } +} + +fn build_stack_layer(position_class: &str) -> gtk::Box { + let layer = gtk::Box::new(gtk::Orientation::Vertical, 0); + layer.add_css_class("unixnotis-stack-layer"); + layer.add_css_class(position_class); + layer.set_hexpand(true); + layer.set_can_target(false); + layer.set_accessible_role(gtk::AccessibleRole::Presentation); + layer.set_visible(false); + layer +} + +#[cfg(test)] +#[path = "tests/stack.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 705109c85..25a121561 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -16,6 +16,9 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing pub(super) card_plate: unixnotis_ui::CutCorner, + // Collapsed groups use at most two non-interactive rear silhouettes + pub(super) stack_middle: gtk::Box, + pub(super) stack_back: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, // Identity header collapses completely for rows owned by a group header diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs new file mode 100644 index 000000000..19fde444c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -0,0 +1,41 @@ +use gtk::prelude::*; + +use super::{append_stack_layers, stack_layer_visibility, StackLayerVisibility}; + +#[test] +fn collapsed_stack_depth_maps_to_at_most_two_rear_layers() { + assert_eq!(stack_layer_visibility(0), StackLayerVisibility::default()); + assert_eq!( + stack_layer_visibility(1), + StackLayerVisibility { + middle: false, + back: true, + } + ); + assert_eq!( + stack_layer_visibility(2), + StackLayerVisibility { + middle: true, + back: true, + } + ); + assert_eq!(stack_layer_visibility(u8::MAX), stack_layer_visibility(2)); +} + +#[gtk::test] +fn stack_layers_paint_behind_foreground_and_never_accept_input() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + + assert_eq!(root.first_child().as_ref(), Some(back.upcast_ref())); + assert_eq!(back.next_sibling().as_ref(), Some(middle.upcast_ref())); + assert_eq!( + middle.next_sibling().as_ref(), + Some(foreground.upcast_ref()) + ); + assert!(!middle.can_target()); + assert!(!back.can_target()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 8cdadbcb9..1b0f03e3e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -73,6 +73,7 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R Rc::from(notification.app_name.to_ascii_lowercase()), notification, flags.collapsed_group_preview, + u8::from(flags.collapsed_group_preview), false, flags.is_active, RowPresentation { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 253a96e94..8963ad6f4 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -181,20 +181,23 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { } #[gtk::test] -fn collapsed_group_preview_uses_one_content_surface() { +fn collapsed_group_preview_uses_one_readable_card_above_depth_layers() { let (root, row) = notification_row(); - let data = row_data( + let mut data = row_data( Rc::new(sample_notification()), RowFlags { collapsed_group_preview: true, ..Default::default() }, ); + data.stack_depth = 2; let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert_eq!(child_count(&root), 1); + assert_eq!(child_count(&root), 3); + assert!(row.stack_middle.get_visible()); + assert!(row.stack_back.get_visible()); assert!( row.card .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW), @@ -301,6 +304,7 @@ fn update_notification_row_shows_metadata_lanes_and_footer_state() { let data = row_data( Rc::new(notification), RowFlags { + is_active: true, show_metadata: true, show_thumbnail: true, ..Default::default() @@ -360,12 +364,13 @@ fn update_notification_row_applies_custom_metadata_and_corner_geometry() { assert_eq!(row.meta_label.text().as_str(), "INFO"); assert_eq!(row.footer_left.text().as_str(), "ARCHIVE"); - assert_eq!(row.footer_right.text().as_str(), "2 OPTIONS"); + assert!(!row.footer_right.get_visible()); + assert!(row.footer_right.text().is_empty()); assert_eq!(row.card_plate.corners(), corners); } #[gtk::test] -fn grouped_rows_keep_cut_corners_only_on_the_outer_bottom_edge() { +fn separated_group_rows_keep_complete_configured_cut_corners() { let (_root, row) = notification_row(); let corners = CutCorners { top_left: 8, @@ -384,19 +389,7 @@ fn grouped_rows_keep_cut_corners_only_on_the_outer_bottom_edge() { let (command_tx, _rx) = tokio::sync::mpsc::channel(1); update_notification_row(&row, &middle, &IconResolver::new(), &command_tx); - assert_eq!(row.card_plate.corners(), CutCorners::default()); - - let mut last = middle; - last.group_last = true; - update_notification_row(&row, &last, &IconResolver::new(), &command_tx); - assert_eq!( - row.card_plate.corners(), - CutCorners { - bottom_right: 10, - bottom_left: 11, - ..CutCorners::default() - } - ); + assert_eq!(row.card_plate.corners(), corners); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 7595f859f..ead4d378d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -5,6 +5,7 @@ use unixnotis_core::{hooks, NotificationView, Urgency}; use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use super::super::super::super::item::RowData; +use super::super::stack::stack_layer_visibility; use super::super::state::NotificationRowWidgets; use super::labels::has_visible_text; @@ -38,21 +39,17 @@ pub(super) fn apply_visual_state( hooks::shared_state::COLLAPSED_GROUP_PREVIEW, data.collapsed_group_preview, ); - let grouped = data.collapsed_group_preview || data.expanded; - set_class_state(card, hooks::panel_card::GROUPED, grouped); - set_class_state(card, hooks::panel_card::GROUP_FIRST, data.group_first); - set_class_state(card, hooks::panel_card::GROUP_LAST, data.group_last); - set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); - set_class_state( - &row.card_plate, - hooks::panel_card::GROUP_FIRST, - data.group_first, - ); set_class_state( &row.card_plate, - hooks::panel_card::GROUP_LAST, - data.group_last, + hooks::shared_state::COLLAPSED_GROUP_PREVIEW, + data.collapsed_group_preview, ); + let layers = stack_layer_visibility(data.stack_depth); + set_widget_visible_if_changed(&row.stack_middle, layers.middle); + set_widget_visible_if_changed(&row.stack_back, layers.back); + let grouped = data.collapsed_group_preview || data.expanded; + set_class_state(card, hooks::panel_card::GROUPED, grouped); + set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); set_class_state( card, hooks::panel_card::HAS_SUMMARY, @@ -70,26 +67,8 @@ pub(super) fn apply_visual_state( } const fn card_corners_for_row(data: &RowData) -> unixnotis_core::CutCorners { - let grouped = data.collapsed_group_preview || data.expanded; - if !grouped { - return data.presentation.card_corners; - } - - // The group header owns the top edge while only the final child owns bottom corners - unixnotis_core::CutCorners { - top_left: 0, - top_right: 0, - bottom_right: if data.group_last { - data.presentation.card_corners.bottom_right - } else { - 0 - }, - bottom_left: if data.group_last { - data.presentation.card_corners.bottom_left - } else { - 0 - }, - } + // Every separated foreground card keeps the configured complete silhouette + data.presentation.card_corners } fn set_class_state>(root: &W, class_name: &str, enabled: bool) { diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 6ed23e36f..7aa954bec 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -44,6 +44,7 @@ impl NotificationList { // Collapsed groups render the newest content row under their shared header let collapsed_group_preview = !expanded && ids.len() > 1; + let stack_depth = collapsed_stack_depth(ids.len(), expanded); for (index, id) in ids.iter().enumerate() { if !expanded && index > 0 { break; @@ -59,18 +60,15 @@ impl NotificationList { metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, }; - let mut row = RowData::notification( + let row = RowData::notification( entry.app_key.clone(), entry.view.clone(), collapsed_group_preview, + stack_depth, expanded, entry.is_active, presentation, ); - if ids.len() > 1 { - row.group_first = index == 0; - row.group_last = !expanded || index + 1 == ids.len(); - } entry.item.update(row); items.push(entry.item.clone()); keys.push(RowKey::Notification { id: *id }); @@ -153,6 +151,14 @@ impl NotificationList { } } +pub(in crate::ui::notifications) fn collapsed_stack_depth(count: usize, expanded: bool) -> u8 { + if expanded { + return 0; + } + // One hidden item adds one layer and larger groups cap at two quiet silhouettes + count.saturating_sub(1).min(2) as u8 +} + pub(in crate::ui::notifications) fn common_prefix_suffix( current: &[RowKey], next: &[RowKey], diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index cfec80226..102d3b806 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -106,6 +106,7 @@ impl NotificationList { app_key.clone(), view.clone(), false, + 0, false, is_active, presentation, diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 4292772dc..7ba8e97ee 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -91,6 +91,7 @@ impl NotificationList { .unwrap_or(false); let group_len = self.grouped_cache.get(&entry.app_key).map_or(0, Vec::len); let collapsed_group_preview = is_collapsed_group_preview(expanded, group_len); + let stack_depth = super::blocks::collapsed_stack_depth(group_len, expanded); let presentation = super::item::RowPresentation { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, @@ -104,6 +105,7 @@ impl NotificationList { entry.app_key.clone(), entry.view.clone(), collapsed_group_preview, + stack_depth, expanded, entry.is_active, presentation, diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index 917c1fbbb..acf701baf 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gio::prelude::ListModelExt; -use super::common_prefix_suffix; +use super::{collapsed_stack_depth, common_prefix_suffix}; use crate::ui::notifications::item::{RowData, RowItem}; use crate::ui::notifications::model::types::{GroupRange, RowKey}; use crate::ui::notifications::test_support as support; @@ -76,9 +76,8 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert!(!header.expanded); let visible = items[1].data(); assert!(visible.collapsed_group_preview); + assert_eq!(visible.stack_depth, 2); assert!(!visible.expanded); - assert!(visible.group_first); - assert!(visible.group_last); } #[gtk::test] @@ -93,8 +92,7 @@ fn build_group_block_keeps_single_notification_outside_collapsed_group_preview() assert_eq!(items.len(), 1); let visible = items[0].data(); assert!(!visible.collapsed_group_preview); - assert!(!visible.group_first); - assert!(!visible.group_last); + assert_eq!(visible.stack_depth, 0); } #[gtk::test] @@ -129,16 +127,16 @@ fn build_group_block_expands_group_to_all_notifications() { let data = item.data(); assert!(!data.collapsed_group_preview); assert!(data.expanded); + assert_eq!(data.stack_depth, 0); } - let first = items[1].data(); - let middle = items[2].data(); - let last = items[3].data(); - assert!(first.group_first); - assert!(!first.group_last); - assert!(!middle.group_first); - assert!(!middle.group_last); - assert!(!last.group_first); - assert!(last.group_last); +} + +#[test] +fn collapsed_stack_depth_caps_at_two_and_clears_when_expanded() { + assert_eq!(collapsed_stack_depth(1, false), 0); + assert_eq!(collapsed_stack_depth(2, false), 1); + assert_eq!(collapsed_stack_depth(4, false), 2); + assert_eq!(collapsed_stack_depth(4, true), 0); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index 0a5b8b3a3..274ec4894 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -88,6 +88,7 @@ fn bind_row_refreshes_notification_widget_and_tracks_item_updates() { Rc::from("terminal"), notification, false, + 0, false, true, RowPresentation::default(), @@ -110,6 +111,7 @@ fn bind_row_refreshes_notification_widget_and_tracks_item_updates() { Rc::from("terminal"), changed, false, + 0, false, true, RowPresentation::default(), @@ -131,6 +133,7 @@ fn unbind_disconnects_row_item_update_handler() { Rc::from("terminal"), notification, false, + 0, false, true, RowPresentation::default(), @@ -152,6 +155,7 @@ fn unbind_disconnects_row_item_update_handler() { Rc::from("terminal"), changed, false, + 0, false, true, RowPresentation::default(), diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 7f623b5cb..5dc96cd93 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -392,13 +392,13 @@ entry selection { .unixnotis-group { background: transparent; margin-top: 14px; - margin-bottom: 0; + margin-bottom: 8px; } .unixnotis-group-header { background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); color: @unixnotis-text; - border-radius: var(--unixnotis-notification-card-radius) var(--unixnotis-notification-card-radius) 0 0; + border-radius: var(--unixnotis-notification-card-radius); padding: 6px 12px; border: 1px solid @unixnotis-card-border; box-shadow: none; @@ -536,21 +536,37 @@ entry selection { } .unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { - margin-bottom: 0; + margin-left: 8px; + margin-right: 8px; + margin-bottom: var(--unixnotis-panel-card-gap); } .unixnotis-panel-card.unixnotis-panel-card-grouped { - border-top-width: 0; - border-radius: 0; - box-shadow: inset 0 0 0 1px alpha(#ffffff, 0.025); + border-radius: var(--unixnotis-notification-card-radius); } -.unixnotis-panel-card.unixnotis-panel-card-group-last { - border-radius: 0 0 var(--unixnotis-notification-card-radius) var(--unixnotis-notification-card-radius); +.unixnotis-panel-card-foreground.collapsed-group-preview { + margin-top: -58px; + margin-bottom: 10px; } -.unixnotis-panel-card-foreground.unixnotis-panel-card-group-last { - margin-bottom: var(--unixnotis-panel-card-gap); +.unixnotis-stack-layer { + min-height: 68px; + padding: 0; + border: 1px solid alpha(@unixnotis-card-border, 0.58); + border-radius: var(--unixnotis-notification-card-radius); + background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.09); + box-shadow: 0 8px 16px -15px @unixnotis-shadow-soft; +} + +.unixnotis-stack-layer-back { + margin: 0 20px; + opacity: 0.72; +} + +.unixnotis-stack-layer-middle { + margin: -58px 14px 0; + opacity: 0.86; } .unixnotis-panel-card.active { @@ -711,6 +727,12 @@ entry selection { padding-right: 7px; } +.unixnotis-panel-default-action { + min-width: 32px; + min-height: 28px; + padding: 3px 8px; +} + .unixnotis-panel-action-overflow-list { padding: 6px; } diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 04d994656..dd646e40b 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -113,8 +113,6 @@ pub mod panel_card { pub const FOOTER_RIGHT: &str = "unixnotis-panel-card-footer-right"; pub const THUMBNAIL: &str = "unixnotis-panel-card-thumbnail"; pub const GROUPED: &str = "unixnotis-panel-card-grouped"; - pub const GROUP_FIRST: &str = "unixnotis-panel-card-group-first"; - pub const GROUP_LAST: &str = "unixnotis-panel-card-group-last"; pub const HAS_ACTIONS: &str = "unixnotis-panel-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-panel-card-has-body"; pub const HAS_SUMMARY: &str = "unixnotis-panel-card-has-summary"; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 378c8db5b..227a13d8d 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -103,8 +103,6 @@ fn hook_names_stay_unique() { panel_card::FOOTER_RIGHT, panel_card::THUMBNAIL, panel_card::GROUPED, - panel_card::GROUP_FIRST, - panel_card::GROUP_LAST, panel_card::HAS_ACTIONS, panel_card::HAS_BODY, panel_card::HAS_SUMMARY, @@ -243,7 +241,7 @@ fn stock_panel_css_targets_real_group_card_hooks() { let css = crate::theme::DEFAULT_PANEL_CSS; // Group headers and notification cards are sibling ListView rows, not nested widgets - // One grouped-card hook joins both collapsed previews and expanded children to the header + // Grouped cards stay separate while collapsed previews own their internal depth layers assert!(css.contains(&format!(".unixnotis-panel-card.{}", panel_card::GROUPED))); assert!(css.contains(&format!( ".unixnotis-panel-card-foreground.{}", @@ -253,6 +251,8 @@ fn stock_panel_css_targets_real_group_card_hooks() { ".unixnotis-panel-card.{}", shared_state::COLLAPSED_GROUP_PREVIEW ))); + assert!(css.contains(".unixnotis-stack-layer-back")); + assert!(css.contains(".unixnotis-stack-layer-middle")); // These selectors belonged to an older nested-card idea and do not match the real tree assert!(!css.contains("unixnotis-group-cards")); @@ -273,15 +273,15 @@ fn stock_group_count_stays_neutral_during_header_hover() { } #[test] -fn stock_panel_css_uses_one_continuous_group_surface_without_ghost_layers() { +fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { let css = crate::theme::DEFAULT_PANEL_CSS; + // The discarded legacy names stay absent while the new layers remain explicit assert!(!css.contains("unixnotis-stack-ghost")); - assert!(!css.contains("margin-top: -58px")); - assert!(css - .contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-top-width: 0;")); - assert!(css - .contains(".unixnotis-panel-card.unixnotis-panel-card-group-last {\n border-radius: 0 0")); + assert!(css.contains(".unixnotis-stack-layer-back")); + assert!(css.contains(".unixnotis-stack-layer-middle")); + assert!(css.contains("margin: -58px 14px 0")); + assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); } #[test] From be3c324a8c06646a654bc35c775ec128237680cd Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 15:13:47 -0500 Subject: [PATCH 164/275] fix(attribution): require positive branding evidence Summary: require positive branding evidence. Scope: attribution. --- .../identity/resolver/candidates.rs | 114 +++++++++++++----- .../identity/resolver/pipeline.rs | 18 ++- .../resolver/tests/evidence/helpers.rs | 44 ++++--- .../identity/resolver/tests/pipeline/spoof.rs | 43 +++++-- .../src/presentation/tests/presentation.rs | 30 +++++ 5 files changed, 189 insertions(+), 60 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs index 90554b30d..b168eacc6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs @@ -103,49 +103,107 @@ pub(super) fn resolve_unverified_candidates( return resolution; } - // A known application with incomplete evidence remains useful but non-authoritative + // Application branding requires evidence that connects the sender to the candidate if let Some(candidate) = matching_results .iter() .max_by_key(|result| record_trust_rank(result.record)) { - let failure = candidate.failure(); - let detail = recognized_candidate_detail(sender, index, candidate.record, failure); - return with_diagnostics( - recognized_resolution(claim, sender, candidate.record, index, failure, &detail), - claim, - sender, - Some(candidate.record), - candidate.verification, - ); + return resolve_matching_candidate(claim, sender, index, candidate); } unresolved_candidate_resolution(claim, sender, index) } -fn recognized_candidate_detail( +fn resolve_matching_candidate( + claim: AppClaim<'_>, sender: &SenderMetadata, index: &DesktopIdentityIndex, - record: &DesktopRecord, - failure: LaunchFailure, -) -> String { - match sender_claim_relation(sender, index, record) { - SenderClaimRelation::SamePackageHelper => { - "Sender belongs to the same installed application package but was not strongly bound" - .to_string() - } - SenderClaimRelation::DifferentInstalledPackage => { - "Sender belongs to a separate installed package without a conflicting application identity" - .to_string() - } - SenderClaimRelation::ClaimedApplication - | SenderClaimRelation::DifferentVerifiedApplication - | SenderClaimRelation::UnknownExecutable - | SenderClaimRelation::TrustedRelay => { - launch_failure_label(failure).to_string() + candidate: &CandidateVerification<'_>, +) -> AttributionResolution { + let failure = candidate.failure(); + match sender_claim_relation(sender, index, candidate.record) { + SenderClaimRelation::ClaimedApplication => recognized_candidate_resolution( + claim, + sender, + index, + candidate, + launch_failure_label(failure), + ), + SenderClaimRelation::SamePackageHelper => recognized_candidate_resolution( + claim, + sender, + index, + candidate, + "Sender belongs to the same installed application package but was not strongly bound", + ), + SenderClaimRelation::DifferentInstalledPackage => unresolved_claim_resolution( + claim, + sender, + candidate, + "Sender belongs to a separate installed package without a positive application association", + ), + SenderClaimRelation::UnknownExecutable => unresolved_claim_resolution( + claim, + sender, + candidate, + "No positive sender association with the claimed application was established", + ), + SenderClaimRelation::DifferentVerifiedApplication => { + conflict_from_candidate(claim, sender, index, candidate.record, failure) } + SenderClaimRelation::TrustedRelay => trusted_relay_resolution(claim, sender, index) + .unwrap_or_else(|| { + unresolved_claim_resolution( + claim, + sender, + candidate, + "The relay executable could not be revalidated", + ) + }), } } +fn recognized_candidate_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + candidate: &CandidateVerification<'_>, + detail: &str, +) -> AttributionResolution { + let failure = candidate.failure(); + with_diagnostics( + recognized_resolution(claim, sender, candidate.record, index, failure, detail), + claim, + sender, + Some(candidate.record), + candidate.verification, + ) +} + +fn unresolved_claim_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + candidate: &CandidateVerification<'_>, + detail: &str, +) -> AttributionResolution { + let detail = sender.sender_executable.as_deref().map_or_else( + || detail.to_string(), + |path| format!("{detail}; source {path}"), + ); + with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::NoDesktopCandidate, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )), + claim, + sender, + Some(candidate.record), + candidate.verification, + ) +} + fn ambiguous_protected_family_resolution( claim: AppClaim<'_>, sender: &SenderMetadata, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index 698c38b07..7c09eca65 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -28,7 +28,10 @@ pub(in crate::daemon) async fn resolve_attribution( // Cached process data is refreshed before it affects attribution let mut sender = refresh_sender_security_evidence(sender); let initial = resolve_with_evidence(claim, &sender, index); - if initial.attribution.status != AttributionStatus::Recognized { + if initial.attribution.status != AttributionStatus::Recognized + && !(initial.attribution.status == AttributionStatus::Unresolved + && claim_has_index_candidate(claim, index)) + { return initial; } @@ -37,6 +40,19 @@ pub(in crate::daemon) async fn resolve_attribution( resolve_with_evidence(claim, &sender, index) } +fn claim_has_index_candidate(claim: AppClaim<'_>, index: &DesktopIdentityIndex) -> bool { + if !claim.reported_name.trim().is_empty() + && !index.records_for_claim(claim.reported_name).is_empty() + { + return true; + } + + claim + .desktop_entry + .and_then(validate_desktop_id) + .is_some_and(|desktop_id| !index.records_for_id(&desktop_id).is_empty()) +} + pub(super) fn resolve_with_evidence( claim: AppClaim<'_>, sender: &SenderMetadata, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs index 0c7c5636c..896fb973e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs @@ -70,7 +70,8 @@ fn stale_ancestor_identity_does_not_create_a_lineage_association() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); assert!(!resolution .attribution .diagnostic_detail @@ -105,7 +106,7 @@ fn lineage_rejects_a_candidate_with_a_different_indexed_executable() { } #[test] -fn helper_without_lineage_is_recognized_when_no_contradictory_owner_is_known() { +fn unknown_executable_cannot_borrow_installed_app_identity() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -116,7 +117,7 @@ fn helper_without_lineage_is_recognized_when_no_contradictory_owner_is_known() { )], Vec::new(), ); - let helper = sender("/opt/example/helper", identity(89, 890, 1_000)); + let helper = sender("/tmp/random-script", identity(89, 890, 1_000)); let resolution = resolve_with_evidence( AppClaim { @@ -127,17 +128,19 @@ fn helper_without_lineage_is_recognized_when_no_contradictory_owner_is_known() { &index, ); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.attribution.claimed_name, "Example App"); + assert!(resolution.attribution.desktop_id.is_empty()); assert_eq!( - resolution.attribution.status, - AttributionStatus::Recognized, - "missing lineage cannot prove that a helper belongs to another application" + resolution.attribution.badge_icon, + "application-x-executable-symbolic" ); - assert_eq!(resolution.attribution.display_name, "Example App"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] -fn verified_and_recognized_senders_never_share_an_application_group() { +fn verified_and_unresolved_senders_never_share_an_application_group() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -156,7 +159,7 @@ fn verified_and_recognized_senders_never_share_an_application_group() { &sender(&app_path, app_identity), &index, ); - let recognized = resolve_with_evidence( + let unresolved = resolve_with_evidence( AppClaim { reported_name: "Example App", desktop_entry: Some("org.example.True"), @@ -166,15 +169,15 @@ fn verified_and_recognized_senders_never_share_an_application_group() { ); assert_eq!(verified.attribution.status, AttributionStatus::Verified); - assert_eq!(recognized.attribution.status, AttributionStatus::Recognized); + assert_eq!(unresolved.attribution.status, AttributionStatus::Unresolved); assert_ne!( - verified.attribution.group_key, recognized.attribution.group_key, + verified.attribution.group_key, unresolved.attribution.group_key, "different trust domains must remain separate even for one canonical application" ); } #[test] -fn package_owned_helper_for_the_claimed_application_is_recognized() { +fn same_package_helper_is_recognized() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -215,7 +218,7 @@ fn package_owned_helper_for_the_claimed_application_is_recognized() { } #[test] -fn separately_packaged_helper_is_recognized_without_becoming_suspicious() { +fn different_package_cannot_borrow_installed_app_identity() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -238,7 +241,14 @@ fn separately_packaged_helper_is_recognized_without_becoming_suspicious() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.attribution.claimed_name, "Example App"); + assert!(resolution.attribution.desktop_id.is_empty()); + assert_eq!( + resolution.attribution.badge_icon, + "application-x-executable-symbolic" + ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_eq!( resolution.diagnostics.verification, @@ -254,14 +264,10 @@ fn separately_packaged_helper_is_recognized_without_becoming_suspicious() { sender_claim_relation(&different_package, &index, claimed_record), SenderClaimRelation::DifferentInstalledPackage ); - assert!(resolution - .attribution - .diagnostic_detail - .contains("separate installed package")); } #[test] -fn sender_owned_by_another_indexed_system_application_is_a_concrete_conflict() { +fn verified_different_application_is_conflict() { let (app_path, app_identity) = installed_system_executable(); let other_identity = identity(93, 930, 0); let index = DesktopIdentityIndex::from_records( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs index 050fd1eb8..61850ec5c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs @@ -3,7 +3,7 @@ use super::super::*; #[test] -fn sender_metadata_timeout_is_recognized_not_conflict() { +fn sender_metadata_timeout_is_unresolved_not_conflict() { let protected_identity = identity(39, 390, 0); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -26,9 +26,10 @@ fn sender_metadata_timeout_is_recognized_not_conflict() { assert_eq!( resolution.attribution.status, - AttributionStatus::Recognized, - "a timed-out sender lookup cannot prove impersonation" + AttributionStatus::Unresolved, + "a timed-out sender lookup cannot prove application association" ); + assert_eq!(resolution.attribution.display_name, "Unknown application"); } #[test] @@ -95,7 +96,8 @@ fn user_desktop_mismatch_cannot_manufacture_a_conflict() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -176,7 +178,7 @@ fn ambiguous_protected_records_are_unresolved_not_conflicting() { } #[test] -fn visually_confusable_system_brand_without_contradictory_owner_is_recognized() { +fn visually_confusable_system_brand_without_association_is_unresolved() { let signal_identity = identity(40, 400, 0); let hostile_identity = identity(41, 410, 1000); let index = DesktopIdentityIndex::from_records( @@ -199,13 +201,14 @@ fn visually_confusable_system_brand_without_contradictory_owner_is_recognized() &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } } #[test] -fn basename_spoof_without_immutable_owner_is_recognized_without_actions() { +fn basename_spoof_without_immutable_owner_is_unresolved_without_actions() { let signal_identity = identity(1, 10, 0); let hostile_identity = identity(7, 70, 1000); let index = DesktopIdentityIndex::from_records( @@ -227,7 +230,12 @@ fn basename_spoof_without_immutable_owner_is_recognized_without_actions() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!( + resolution.attribution.badge_icon, + "application-x-executable-symbolic" + ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_eq!( resolution.diagnostics.verification, @@ -240,7 +248,7 @@ fn basename_spoof_without_immutable_owner_is_recognized_without_actions() { } #[test] -fn exact_protected_name_without_contradictory_owner_stays_recognized() { +fn exact_protected_name_without_positive_association_stays_unresolved() { let keepass_identity = identity(2, 20, 0); let hostile_identity = identity(8, 80, 1000); let index = DesktopIdentityIndex::from_records( @@ -262,7 +270,8 @@ fn exact_protected_name_without_contradictory_owner_stays_recognized() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } @@ -296,7 +305,7 @@ fn exact_system_notify_send_identity_is_a_non_replying_relay() { } #[test] -fn trusted_relay_claiming_a_system_app_stays_relay_without_conflict() { +fn trusted_relay_uses_command_line_identity() { let signal_identity = identity(1, 10, 0); let relay_identity = identity(3, 30, 0); let index = DesktopIdentityIndex::from_records( @@ -319,6 +328,15 @@ fn trusted_relay_claiming_a_system_app_stays_relay_without_conflict() { ); assert_eq!(resolution.attribution.status, AttributionStatus::Relay); + assert_eq!( + resolution.attribution.display_name, + "Command-line notification" + ); + assert_eq!(resolution.attribution.claimed_name, "Signal"); + assert_eq!( + resolution.attribution.badge_icon, + "utilities-terminal-symbolic" + ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); assert_ne!( @@ -371,7 +389,8 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert!(resolution .attribution diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index c67b887ea..cb7f2037e 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -143,6 +143,36 @@ fn unknown_claim_stays_secondary_and_unverified() { ); } +#[test] +fn unresolved_claim_has_no_application_actions_or_reply() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Signal", + AttributionReason::NoDesktopCandidate, + "sender has no positive application association", + "unresolved:random-script:signal".to_string(), + ); + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); + assert!(presentation.actions.default_key.is_none()); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + #[test] fn communication_layout_is_preserved_for_unverified_sender() { let mut view = notification(); From 09f367606fd49fe7aeebf95ad73c4e072c998c85 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 16:56:08 -0500 Subject: [PATCH 165/275] feat(attribution): bind protected launchers to runtimes Summary: bind protected launchers to runtimes. Scope: attribution. --- Cargo.lock | 40 +++ Cargo.toml | 2 + crates/unixnotis-daemon/Cargo.toml | 3 + .../identity/desktop_index/index.rs | 27 +- .../identity/desktop_index/launch.rs | 35 ++- .../identity/desktop_index/launcher.rs | 52 ++++ .../identity/desktop_index/launcher/read.rs | 107 +++++++ .../identity/desktop_index/launcher/syntax.rs | 134 +++++++++ .../desktop_index/launcher/validation.rs | 14 + .../identity/desktop_index/mod.rs | 1 + .../identity/desktop_index/model.rs | 30 +- .../identity/desktop_index/record.rs | 119 +++++++- .../identity/desktop_index/tests/index.rs | 65 ++++ .../identity/desktop_index/tests/launch.rs | 23 +- .../desktop_index/tests/launcher/binding.rs | 27 ++ .../desktop_index/tests/launcher/mod.rs | 4 + .../desktop_index/tests/launcher/read.rs | 130 ++++++++ .../desktop_index/tests/launcher/syntax.rs | 137 +++++++++ .../tests/launcher/validation.rs | 47 +++ .../identity/desktop_index/tests/parsing.rs | 6 +- .../identity/desktop_index/tests/record.rs | 133 +++++++++ .../identity/desktop_index/tests/scan.rs | 66 +++- .../desktop_index/tests/verification.rs | 282 +++++++++++++++--- .../identity/desktop_index/verification.rs | 58 +++- .../identity/resolver/diagnostics.rs | 10 +- .../identity/resolver/evidence.rs | 31 +- .../identity/resolver/resolution.rs | 10 +- .../resolver/tests/candidates/claims.rs | 3 +- .../resolver/tests/candidates/families.rs | 12 +- .../resolver/tests/evidence/helpers.rs | 34 ++- .../resolver/tests/pipeline/provenance.rs | 3 +- .../identity/resolver/tests/pipeline/spoof.rs | 2 +- .../identity/resolver/tests/resolution.rs | 31 ++ .../identity/resolver/tests/support.rs | 26 +- 34 files changed, 1590 insertions(+), 114 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs diff --git a/Cargo.lock b/Cargo.lock index f1cd4358f..221e77a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2802,6 +2802,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2967,6 +2968,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -3481,6 +3488,36 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + [[package]] name = "typenum" version = "1.19.0" @@ -3634,6 +3671,7 @@ version = "1.2.0" dependencies = [ "anyhow", "arc-swap", + "blake3", "chrono", "clap", "futures-util", @@ -3647,6 +3685,8 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "tree-sitter", + "tree-sitter-bash", "unicode-security", "unixnotis-core", "url", diff --git a/Cargo.toml b/Cargo.toml index 095678656..2acdf0189 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,8 @@ rustix = { version = "1.1", features = ["event", "fs", "process"] } resvg = { version = "0.47.0", default-features = false } semver = "1.0.28" shell-words = "1.1.1" +tree-sitter = "0.25" +tree-sitter-bash = "0.25.1" [workspace.metadata.unixnotis.installer] # Installer-managed binaries live in workspace metadata to avoid duplication between build and install logic. diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index 8545aab7b..f321ced6c 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] anyhow.workspace = true arc-swap.workspace = true +blake3.workspace = true clap.workspace = true chrono.workspace = true futures-util.workspace = true @@ -23,5 +24,7 @@ indexmap.workspace = true notify.workspace = true rustix.workspace = true shell-words.workspace = true +tree-sitter.workspace = true +tree-sitter-bash.workspace = true url.workspace = true wait-timeout.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index f9bbcb646..e1d6a7e70 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -283,7 +283,7 @@ impl DesktopIdentityIndex { } // Only records with a reproducible launch contract become executable evidence if record.association_eligible { - if let Some(identity) = record.executable_identity { + if let Some(identity) = record.runtime_executable_identity { self.by_identity .entry((identity.device, identity.inode)) .or_default() @@ -294,6 +294,21 @@ impl DesktopIdentityIndex { self.index_application_family(record_index); } + pub(super) fn rebuild_executable_index(&mut self) { + self.by_identity.clear(); + for (record_index, record) in self.records.iter().enumerate() { + if !record.association_eligible { + continue; + } + if let Some(identity) = record.runtime_executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + } + pub(super) fn rebuild_application_families(&mut self) { self.families.clear(); self.family_by_record.clear(); @@ -307,7 +322,7 @@ impl DesktopIdentityIndex { self.family_by_record.push(None); return; }; - let Some(executable_identity) = record.executable_identity else { + let Some(executable_identity) = record.runtime_executable_identity else { self.family_by_record.push(None); return; }; @@ -318,7 +333,7 @@ impl DesktopIdentityIndex { && family.system_association == record.system_association && family .install_provenance - .same_application_source(&record.executable_provenance) + .same_application_source(&record.runtime_executable_provenance) && family.protected_payloads == protected_payloads && family_names_are_compatible(family, record) }); @@ -342,7 +357,7 @@ impl DesktopIdentityIndex { names: record.names.clone(), system_origin: record.system_origin, system_association: record.system_association, - install_provenance: record.executable_provenance.clone(), + install_provenance: record.runtime_executable_provenance.clone(), protected_payloads, }); self.family_by_record.push(Some(family_index)); @@ -407,3 +422,7 @@ fn trusted_system_executable_path(path: &Path) -> bool { path.is_absolute() && ROOTS.iter().any(|root| path.starts_with(root)) } + +#[cfg(test)] +#[path = "tests/index.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs index bcda9228d..4a7ef2074 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use gio::prelude::AppInfoExt; use super::super::executable::executable_evidence_for_path; +use super::launcher::inspect_package_shell_launcher; use super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; use super::program::resolve_program; use super::wrappers::normalize_launch_command; @@ -12,10 +13,16 @@ use super::wrappers::normalize_launch_command; const MAX_EXEC_TEMPLATE_BYTES: usize = 16 * 1024; const MAX_EXEC_TEMPLATE_ARGUMENTS: usize = 128; +pub(super) struct BuiltLaunchSpec { + pub(super) declared_path: PathBuf, + pub(super) runtime_path: PathBuf, + pub(super) spec: LaunchSpec, +} + pub(super) fn build_launch_spec( desktop: &gio::DesktopAppInfo, desktop_path: &Path, -) -> Option<(PathBuf, LaunchSpec)> { +) -> Option { let template = desktop.string("Exec")?; if template.len() > MAX_EXEC_TEMPLATE_BYTES { return None; @@ -25,8 +32,17 @@ pub(super) fn build_launch_spec( return None; } let normalized = normalize_launch_command(words).ok()?; - let executable_path = resolve_program(Path::new(&normalized.executable))?; - let executable = executable_evidence_for_path(&executable_path)?.identity; + let declared_path = resolve_program(Path::new(&normalized.executable))?; + let declared_executable = executable_evidence_for_path(&declared_path)?.identity; + // Inspection never runs a launcher and accepts only one protected literal final target + let package_launcher = inspect_package_shell_launcher(&declared_path, declared_executable); + let runtime_path = package_launcher.as_ref().map_or_else( + || declared_path.clone(), + |binding| binding.target_path.clone(), + ); + let runtime_executable = package_launcher + .as_ref() + .map_or(declared_executable, |binding| binding.target_identity); let mut arguments = Vec::with_capacity(normalized.arguments.len()); let mut literal_files_are_system_managed = true; @@ -62,16 +78,19 @@ pub(super) fn build_launch_spec( arguments.push(argument); } - Some(( - executable_path, - LaunchSpec { - executable, + Some(BuiltLaunchSpec { + declared_path, + runtime_path, + spec: LaunchSpec { + declared_executable, + runtime_executable, arguments, environment: normalized.environment, wrappers: normalized.wrappers, + package_launcher, literal_files_are_system_managed, }, - )) + }) } fn literal_argument(value: Vec) -> LaunchArgument { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs new file mode 100644 index 000000000..18a338d49 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs @@ -0,0 +1,52 @@ +//! Protected shell-launcher inspection and runtime binding + +mod read; +mod syntax; +mod validation; + +use std::path::Path; + +use super::super::executable::FileIdentity; +use super::model::PackageLauncherBinding; + +/// Extracts one literal runtime target without running or emulating the launcher +pub(super) fn inspect_package_shell_launcher( + path: &Path, + expected_identity: FileIdentity, +) -> Option { + // Reading through one no-follow descriptor binds syntax to the indexed file + let launcher = read::read_launcher(path, expected_identity)?; + let target_path = syntax::literal_final_exec_target(&launcher.contents)?; + + // The literal target must already be protected before package ownership is queried + let target_identity = validation::protected_runtime_target(&target_path)?; + Some(PackageLauncherBinding { + launcher_path: path.to_path_buf(), + launcher_identity: launcher.identity, + launcher_digest: launcher.digest, + target_path, + target_identity, + }) +} + +/// Reopens both files and repeats the literal-target proof before granting authority +pub(super) fn launcher_binding_is_current(binding: &PackageLauncherBinding) -> bool { + let Some(launcher) = read::read_launcher(&binding.launcher_path, binding.launcher_identity) + else { + return false; + }; + if launcher.digest != binding.launcher_digest { + return false; + } + if syntax::literal_final_exec_target(&launcher.contents).as_ref() != Some(&binding.target_path) + { + return false; + } + + validation::protected_runtime_target(&binding.target_path) + .is_some_and(|current| current.same_file(binding.target_identity)) +} + +#[cfg(test)] +#[path = "tests/launcher/mod.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs new file mode 100644 index 000000000..e3ff58446 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs @@ -0,0 +1,107 @@ +//! Bounded descriptor-backed launcher reads + +use std::fs::File; +use std::io::Read; +use std::os::fd::OwnedFd; +use std::path::Path; +use std::time::SystemTime; + +use rustix::fs::{open, Mode, OFlags}; + +use super::super::super::executable::FileIdentity; + +pub(super) const MAX_LAUNCHER_BYTES: u64 = 64 * 1024; +pub(super) const MAX_LAUNCHER_LINES: usize = 1_024; + +pub(super) struct LauncherContents { + pub(super) contents: Vec, + pub(super) identity: FileIdentity, + pub(super) digest: [u8; 32], +} + +pub(super) fn read_launcher( + path: &Path, + expected_identity: FileIdentity, +) -> Option { + // No-follow prevents a launcher path from redirecting inspection through a symlink + let descriptor = open_launcher_descriptor(path)?; + let mut file = File::from(descriptor); + let before = file.metadata().ok()?; + let identity = FileIdentity::from_metadata(&before); + if !identity.same_file(expected_identity) { + return None; + } + if !identity.is_system_managed() { + return None; + } + if !identity.is_executable_regular() { + return None; + } + if !launcher_size_is_supported(before.len()) { + return None; + } + + // One extra byte distinguishes the exact limit from a truncated oversized script + let mut contents = Vec::with_capacity(usize::try_from(before.len()).ok()?); + file.by_ref() + .take(MAX_LAUNCHER_BYTES.saturating_add(1)) + .read_to_end(&mut contents) + .ok()?; + if !launcher_contents_are_supported(&contents) { + return None; + } + + // A second descriptor snapshot rejects replacement or mutation during the read + let after = file.metadata().ok()?; + let after_identity = FileIdentity::from_metadata(&after); + if !snapshot_is_unchanged( + identity, + after_identity, + before.len(), + after.len(), + before.modified().ok()?, + after.modified().ok()?, + ) { + return None; + } + + Some(LauncherContents { + digest: *blake3::hash(&contents).as_bytes(), + contents, + identity, + }) +} + +pub(super) const fn launcher_size_is_supported(size: u64) -> bool { + size <= MAX_LAUNCHER_BYTES +} + +pub(super) fn launcher_contents_are_supported(contents: &[u8]) -> bool { + u64::try_from(contents.len()) + .ok() + .is_some_and(launcher_size_is_supported) + && contents.split(|byte| *byte == b'\n').count() <= MAX_LAUNCHER_LINES +} + +pub(super) fn open_launcher_descriptor(path: &Path) -> Option { + open(path, protected_open_flags(), Mode::empty()).ok() +} + +pub(super) const fn protected_open_flags() -> OFlags { + OFlags::RDONLY + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW) +} + +pub(super) fn snapshot_is_unchanged( + before_identity: FileIdentity, + after_identity: FileIdentity, + before_size: u64, + after_size: u64, + before_modified: SystemTime, + after_modified: SystemTime, +) -> bool { + before_identity.same_file(after_identity) + && before_size == after_size + && before_modified == after_modified +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs new file mode 100644 index 000000000..c7fdf9100 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs @@ -0,0 +1,134 @@ +//! Fail-closed Bash syntax analysis for literal final `exec` targets + +use std::path::{Component, Path, PathBuf}; + +use tree_sitter::{Node, Parser}; + +const MAX_SYNTAX_NODES: usize = 16_384; +const MAX_EXEC_ARGUMENTS: usize = 128; +const FORBIDDEN_COMMANDS: [&str; 7] = [ + ".", "alias", "builtin", "command", "enable", "eval", "source", +]; + +pub(super) fn literal_final_exec_target(source: &[u8]) -> Option { + validate_shell_shebang(source)?; + let source_text = std::str::from_utf8(source).ok()?; + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_bash::LANGUAGE.into()) + .ok()?; + let tree = parser.parse(source_text, None)?; + let root = tree.root_node(); + if root.has_error() { + return None; + } + if root.kind() != "program" { + return None; + } + + // Iterative traversal applies one resource bound to nested substitutions and blocks + let nodes = syntax_nodes(root)?; + if nodes + .iter() + .any(|node| matches!(node.kind(), "function_definition" | "heredoc_redirect")) + { + return None; + } + + let commands = nodes + .iter() + .filter(|node| node.kind() == "command") + .copied() + .collect::>(); + for command in &commands { + let name = command_name(*command, source)?; + if FORBIDDEN_COMMANDS.contains(&name) { + return None; + } + } + + let mut exec_commands = commands + .into_iter() + .filter(|command| command_name(*command, source) == Some("exec")); + let exec = exec_commands.next()?; + if exec_commands.next().is_some() { + return None; + } + if exec.parent().map(|node| node.kind()) != Some("program") { + return None; + } + if last_top_level_statement(root)? != exec || exec.child_by_field_name("redirect").is_some() { + return None; + } + + let mut cursor = exec.walk(); + let arguments = exec + .children_by_field_name("argument", &mut cursor) + .collect::>(); + if arguments.is_empty() { + return None; + } + if arguments.len() > MAX_EXEC_ARGUMENTS { + return None; + } + literal_absolute_path(arguments[0], source) +} + +fn validate_shell_shebang(source: &[u8]) -> Option<()> { + let first_line = source.split(|byte| *byte == b'\n').next()?; + let first_line = std::str::from_utf8(first_line).ok()?.trim_end_matches('\r'); + let command = first_line.strip_prefix("#!")?.trim(); + let words = command.split_ascii_whitespace().collect::>(); + match words.as_slice() { + ["/bin/sh" | "/usr/bin/sh" | "/bin/bash" | "/usr/bin/bash"] + | ["/usr/bin/env", "sh" | "bash"] => Some(()), + _ => None, + } +} + +fn syntax_nodes(root: Node<'_>) -> Option>> { + let mut pending = vec![root]; + let mut nodes = Vec::new(); + while let Some(node) = pending.pop() { + if nodes.len() >= MAX_SYNTAX_NODES { + return None; + } + let mut cursor = node.walk(); + pending.extend(node.children(&mut cursor)); + nodes.push(node); + } + Some(nodes) +} + +fn command_name<'source>(command: Node<'_>, source: &'source [u8]) -> Option<&'source str> { + command.child_by_field_name("name")?.utf8_text(source).ok() +} + +fn last_top_level_statement(root: Node<'_>) -> Option> { + let mut cursor = root.walk(); + root.named_children(&mut cursor) + .filter(|node| node.kind() != "comment") + .last() +} + +fn literal_absolute_path(node: Node<'_>, source: &[u8]) -> Option { + // Only an unquoted word without expansions can select the authenticated target + if node.kind() != "word" { + return None; + } + if node.named_child_count() != 0 { + return None; + } + let value = node.utf8_text(source).ok()?; + if value.contains(['*', '?', '[', ']', '{', '}', '~', '$', '`']) { + return None; + } + let path = Path::new(value); + let mut components = path.components(); + if components.next() != Some(Component::RootDir) + || !components.all(|component| matches!(component, Component::Normal(_))) + { + return None; + } + Some(path.to_path_buf()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs new file mode 100644 index 000000000..55dcbaaa1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs @@ -0,0 +1,14 @@ +//! Protected runtime-target validation + +use std::path::Path; + +use super::super::super::executable::FileIdentity; +use super::read::open_launcher_descriptor; + +pub(super) fn protected_runtime_target(path: &Path) -> Option { + // Runtime targets are opened directly so the literal path cannot terminate in a symlink + let descriptor = open_launcher_descriptor(path)?; + let metadata = std::fs::File::from(descriptor).metadata().ok()?; + let identity = FileIdentity::from_metadata(&metadata); + (identity.is_system_managed() && identity.is_executable_regular()).then_some(identity) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index 1b6a56f4e..cd6eb1c6c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -2,6 +2,7 @@ mod index; mod launch; +mod launcher; pub(in crate::daemon::notifications::identity) mod model; mod names; mod program; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index 1bed1220a..5c8589471 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -10,13 +10,27 @@ use super::provenance::{InstallProvenance, PackageOwnershipCache}; #[derive(Debug, Clone)] pub(in crate::daemon::notifications::identity) struct LaunchSpec { - pub(in crate::daemon::notifications::identity) executable: FileIdentity, + /// Program named directly by the desktop entry after wrapper normalization + pub(in crate::daemon::notifications::identity) declared_executable: FileIdentity, + /// Program expected to remain after a validated package launcher exits through `exec` + pub(in crate::daemon::notifications::identity) runtime_executable: FileIdentity, pub(in crate::daemon::notifications::identity) arguments: Vec, pub(in crate::daemon::notifications::identity) environment: Vec<(Vec, Vec)>, pub(in crate::daemon::notifications::identity) wrappers: Vec, + pub(in crate::daemon::notifications::identity) package_launcher: Option, pub(in crate::daemon::notifications::identity) literal_files_are_system_managed: bool, } +/// Immutable relationship between a protected launcher and its literal runtime target +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) struct PackageLauncherBinding { + pub(in crate::daemon::notifications::identity) launcher_path: PathBuf, + pub(in crate::daemon::notifications::identity) launcher_identity: FileIdentity, + pub(in crate::daemon::notifications::identity) launcher_digest: [u8; 32], + pub(in crate::daemon::notifications::identity) target_path: PathBuf, + pub(in crate::daemon::notifications::identity) target_identity: FileIdentity, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications::identity) enum LaunchArgument { Literal(LiteralArgument), @@ -57,6 +71,7 @@ pub(in crate::daemon::notifications::identity) enum LaunchAuthority { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications::identity) enum VerifiedLaunch { DedicatedExecutable, + PackageLauncherTarget, ProtectedPayload, } @@ -68,6 +83,7 @@ pub(in crate::daemon::notifications::identity) enum LaunchFailure { UnstructuredCommandLine, EmptyContractNeedsCommandLine, UnsupportedWrapper, + LauncherBindingChanged, AmbiguousDesktopAssociation, DynamicOnlyContract, ExecutableMismatch, @@ -91,11 +107,17 @@ pub(in crate::daemon::notifications::identity) struct DesktopRecord { pub(in crate::daemon::notifications::identity) display_name: String, pub(in crate::daemon::notifications::identity) badge_icon: String, pub(in crate::daemon::notifications::identity) desktop_path: Option, - pub(in crate::daemon::notifications::identity) executable_path: Option, - pub(in crate::daemon::notifications::identity) executable_identity: Option, + pub(in crate::daemon::notifications::identity) declared_executable_path: Option, + pub(in crate::daemon::notifications::identity) declared_executable_identity: + Option, + pub(in crate::daemon::notifications::identity) runtime_executable_path: Option, + pub(in crate::daemon::notifications::identity) runtime_executable_identity: + Option, pub(in crate::daemon::notifications::identity) desktop_identity: Option, pub(in crate::daemon::notifications::identity) desktop_provenance: InstallProvenance, - pub(in crate::daemon::notifications::identity) executable_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) declared_executable_provenance: + InstallProvenance, + pub(in crate::daemon::notifications::identity) runtime_executable_provenance: InstallProvenance, pub(in crate::daemon::notifications::identity) system_origin: bool, pub(in crate::daemon::notifications::identity) system_association: bool, pub(in crate::daemon::notifications::identity) association_eligible: bool, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index affd559ed..0dd4bdf67 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -29,14 +29,20 @@ impl DesktopIdentityIndex { let display_name = desktop.display_name().to_string(); // Wrapper normalization finds the application executable instead of indexing env itself let parsed_launch = build_launch_spec(&desktop, path); - let executable_path = parsed_launch + let declared_executable_path = parsed_launch .as_ref() - .map(|(executable_path, _spec)| executable_path.clone()); - let executable_identity = parsed_launch + .map(|launch| launch.declared_path.clone()); + let declared_executable_identity = parsed_launch .as_ref() - .map(|(_executable_path, spec)| spec.executable); + .map(|launch| launch.spec.declared_executable); + let runtime_executable_path = parsed_launch + .as_ref() + .map(|launch| launch.runtime_path.clone()); + let runtime_executable_identity = parsed_launch + .as_ref() + .map(|launch| launch.spec.runtime_executable); let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); - let launch_spec = parsed_launch.map(|(_executable_path, spec)| spec); + let launch_spec = parsed_launch.map(|launch| launch.spec); // Every association needs a complete Exec contract instead of a runtime-name exception let association_eligible = launch_spec.is_some(); // System association requires protected metadata and a reproducible launch specification @@ -52,11 +58,14 @@ impl DesktopIdentityIndex { display_name, badge_icon, desktop_path: Some(path.to_path_buf()), - executable_path, - executable_identity, + declared_executable_path, + declared_executable_identity, + runtime_executable_path, + runtime_executable_identity, desktop_identity, desktop_provenance: InstallProvenance::Unknown, - executable_provenance: InstallProvenance::Unknown, + declared_executable_provenance: InstallProvenance::Unknown, + runtime_executable_provenance: InstallProvenance::Unknown, system_origin, system_association, association_eligible, @@ -74,7 +83,8 @@ impl DesktopIdentityIndex { record .desktop_path .iter() - .chain(record.executable_path.iter()) + .chain(record.declared_executable_path.iter()) + .chain(record.runtime_executable_path.iter()) .cloned() }) .collect::>(); @@ -90,18 +100,33 @@ impl DesktopIdentityIndex { .and_then(|path| ownership.get(path)) .cloned() .unwrap_or(InstallProvenance::Unknown); - record.executable_provenance = record - .executable_path + record.declared_executable_provenance = record + .declared_executable_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + record.runtime_executable_provenance = record + .runtime_executable_path .as_ref() .and_then(|path| ownership.get(path)) .cloned() .unwrap_or(InstallProvenance::Unknown); + + // A parsed target is promoted only when all protected files share one source + if !runtime_binding_is_valid(record) { + discard_untrusted_launcher_binding(record); + } + record.system_association = record.association_eligible && record .desktop_identity .is_some_and(super::super::executable::FileIdentity::is_system_managed) && record - .executable_identity + .declared_executable_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .runtime_executable_identity .is_some_and(super::super::executable::FileIdentity::is_system_managed) && record .launch_spec @@ -109,12 +134,80 @@ impl DesktopIdentityIndex { .is_some_and(|spec| spec.literal_files_are_system_managed) && record .desktop_provenance - .same_application_source(&record.executable_provenance); + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) + && installed_identity_is_current( + record.declared_executable_path.as_deref(), + record.declared_executable_identity, + ) + && installed_identity_is_current( + record.runtime_executable_path.as_deref(), + record.runtime_executable_identity, + ); } + self.rebuild_executable_index(); self.rebuild_application_families(); } } +fn discard_untrusted_launcher_binding(record: &mut DesktopRecord) { + let Some(spec) = record.launch_spec.as_mut() else { + return; + }; + spec.package_launcher = None; + + // Falling back to the declared file preserves ordinary direct-executable behavior + spec.runtime_executable = spec.declared_executable; + record.runtime_executable_path = record.declared_executable_path.clone(); + record.runtime_executable_identity = record.declared_executable_identity; + record.runtime_executable_provenance = record.declared_executable_provenance.clone(); +} + +fn runtime_binding_is_valid(record: &DesktopRecord) -> bool { + let Some(spec) = record.launch_spec.as_ref() else { + return false; + }; + let direct_identity_matches = spec.declared_executable.same_file(spec.runtime_executable); + let direct_path_matches = record.declared_executable_path == record.runtime_executable_path; + let Some(binding) = spec.package_launcher.as_ref() else { + return direct_identity_matches && direct_path_matches; + }; + + // Package equality is supporting evidence only after the literal file relationship exists + binding + .launcher_identity + .same_file(spec.declared_executable) + && binding.target_identity.same_file(spec.runtime_executable) + && record.declared_executable_path.as_deref() == Some(&binding.launcher_path) + && record.runtime_executable_path.as_deref() == Some(&binding.target_path) + && record + .desktop_provenance + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) +} + +fn installed_identity_is_current( + path: Option<&Path>, + expected: Option, +) -> bool { + let (Some(path), Some(expected)) = (path, expected) else { + return false; + }; + executable_evidence_for_path(path).is_some_and(|current| { + current.identity.same_file(expected) + && current.identity.is_system_managed() + && current.identity.is_executable_regular() + }) +} + +#[cfg(test)] +#[path = "tests/record.rs"] +mod tests; + fn association_aliases( desktop: &gio::DesktopAppInfo, id: &str, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs new file mode 100644 index 000000000..49314f8d6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs @@ -0,0 +1,65 @@ +//! Executable lookup-index rebuild cases + +use std::collections::HashSet; + +use super::super::model::{DesktopIdentityIndex, DesktopRecord, LaunchSpec}; +use crate::daemon::notifications::identity::desktop_index::provenance::InstallProvenance; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn executable_index_rebuild_replaces_stale_runtime_identity() { + let old = identity(70); + let new = identity(71); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record(old)); + index.records[0].runtime_executable_identity = Some(new); + index.records[0] + .launch_spec + .as_mut() + .expect("runtime launch specification") + .runtime_executable = new; + + index.rebuild_executable_index(); + + assert!(index.records_for_executable(old).is_empty()); + assert_eq!(index.records_for_executable(new).len(), 1); +} + +fn record(runtime: FileIdentity) -> DesktopRecord { + DesktopRecord { + id: "org.example.App".to_string(), + display_name: "Example App".to_string(), + badge_icon: "example-app".to_string(), + desktop_path: None, + declared_executable_path: Some("/usr/bin/example-app".into()), + declared_executable_identity: Some(runtime), + runtime_executable_path: Some("/usr/bin/example-app".into()), + runtime_executable_identity: Some(runtime), + desktop_identity: None, + desktop_provenance: InstallProvenance::Unknown, + declared_executable_provenance: InstallProvenance::Unknown, + runtime_executable_provenance: InstallProvenance::Unknown, + system_origin: false, + system_association: false, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: runtime, + runtime_executable: runtime, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: false, + }), + names: HashSet::new(), + } +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 1_000, + mode: 0o100_755, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs index ac89eb596..bb5fcbd68 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -19,7 +19,7 @@ fn launch_spec_matches_sender( sender_identity: FileIdentity, cmdline: &[Vec], ) -> bool { - if !spec.executable.same_file(sender_identity) + if !spec.runtime_executable.same_file(sender_identity) || cmdline.is_empty() || cmdline.len() > MAX_PROCESS_ARGUMENTS { @@ -157,7 +157,9 @@ fn fixed_immutable_application_argument_is_matched_exactly() { ) .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; assert!(launch_spec_matches_sender( &spec, @@ -198,8 +200,9 @@ fn user_writable_literal_payload_cannot_support_a_system_association() { .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&desktop_path).expect("parse desktop entry"); - let (_executable_path, spec) = - build_launch_spec(&desktop, &desktop_path).expect("build launch spec"); + let spec = build_launch_spec(&desktop, &desktop_path) + .expect("build launch spec") + .spec; assert!(!spec.literal_files_are_system_managed); } @@ -216,7 +219,9 @@ fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { ) .expect("write desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; assert!(launch_spec_matches_sender( &spec, @@ -313,7 +318,9 @@ fn launch_spec_parses_every_supported_desktop_field_code() { ) .expect("write field-code desktop entry"); let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); - let (_executable_path, spec) = build_launch_spec(&desktop, &path).expect("build launch spec"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; assert!(matches!( spec.arguments[0], @@ -352,10 +359,12 @@ fn process_matcher_checks_identity_emptiness_and_argument_limits_independently() executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); let other = executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other fixture"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let exact_limit = diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs new file mode 100644 index 000000000..a6e853b03 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs @@ -0,0 +1,27 @@ +//! Launcher-binding revalidation failure cases + +use super::super::launcher_binding_is_current; +use crate::daemon::notifications::identity::desktop_index::model::PackageLauncherBinding; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn unprotected_launcher_binding_is_never_current() { + let binding = PackageLauncherBinding { + launcher_path: "/tmp/unixnotis-missing-launcher".into(), + launcher_identity: identity(80), + launcher_digest: [0; 32], + target_path: "/tmp/unixnotis-missing-runtime".into(), + target_identity: identity(81), + }; + + assert!(!launcher_binding_is_current(&binding)); +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 0, + mode: 0o100_755, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs new file mode 100644 index 000000000..0afc9ebe7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs @@ -0,0 +1,4 @@ +mod binding; +mod read; +mod syntax; +mod validation; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs new file mode 100644 index 000000000..c3ba3a5b0 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs @@ -0,0 +1,130 @@ +//! Launcher file-read boundary cases + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::time::{Duration, UNIX_EPOCH}; + +use rustix::io::{fcntl_getfd, FdFlags}; + +use super::super::read::{ + launcher_contents_are_supported, launcher_size_is_supported, open_launcher_descriptor, + read_launcher, snapshot_is_unchanged, MAX_LAUNCHER_BYTES, MAX_LAUNCHER_LINES, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::test_support::TempRoot; + +#[test] +fn user_writable_launcher_is_not_inspected() { + let root = TempRoot::new("user-writable-launcher"); + let path = root.join("launcher"); + fs::write(&path, "#!/bin/sh\nexec /usr/bin/true \"$@\"\n").expect("write launcher fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("make launcher fixture executable"); + let identity = executable_evidence_for_path(&path) + .expect("read launcher fixture identity") + .identity; + + assert!(read_launcher(&path, identity).is_none()); +} + +#[test] +fn launcher_symlink_is_not_followed() { + let root = TempRoot::new("launcher-symlink"); + let target = std::path::Path::new("/usr/bin/true"); + let identity = executable_evidence_for_path(target) + .expect("read target identity") + .identity; + let link = root.join("launcher"); + symlink(target, &link).expect("create launcher symlink fixture"); + + assert!(read_launcher(&link, identity).is_none()); + assert!(open_launcher_descriptor(&link).is_none()); +} + +#[test] +fn oversized_launcher_is_rejected() { + let root = TempRoot::new("oversized-launcher"); + let path = root.join("launcher"); + let contents = format!( + "#!/bin/sh\n# {}\nexec /usr/bin/true\n", + "x".repeat(65 * 1024) + ); + fs::write(&path, contents).expect("write oversized launcher fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("make oversized launcher executable"); + let identity = executable_evidence_for_path(&path) + .expect("read oversized launcher identity") + .identity; + + assert!(read_launcher(&path, identity).is_none()); + assert!(launcher_size_is_supported(MAX_LAUNCHER_BYTES)); + assert!(!launcher_size_is_supported( + MAX_LAUNCHER_BYTES.saturating_add(1) + )); + let exact_bytes = + vec![b'x'; usize::try_from(MAX_LAUNCHER_BYTES).expect("byte limit fits usize")]; + let oversized_bytes = vec![ + b'x'; + usize::try_from(MAX_LAUNCHER_BYTES.saturating_add(1)) + .expect("oversized byte limit fits usize") + ]; + assert!(launcher_contents_are_supported(&exact_bytes)); + assert!(!launcher_contents_are_supported(&oversized_bytes)); + assert!(launcher_contents_are_supported( + &"\n" + .repeat(MAX_LAUNCHER_LINES.saturating_sub(1)) + .into_bytes() + )); + assert!(!launcher_contents_are_supported( + &"\n".repeat(MAX_LAUNCHER_LINES).into_bytes() + )); +} + +#[test] +fn changed_launcher_identity_is_rejected() { + let current = executable_evidence_for_path(std::path::Path::new("/usr/bin/true")) + .expect("current system executable"); + let stale = executable_evidence_for_path(std::path::Path::new("/usr/bin/false")) + .expect("different system executable"); + + assert!(read_launcher(¤t.canonical_path, stale.identity).is_none()); +} + +#[test] +fn launcher_descriptor_is_close_on_exec() { + let descriptor = open_launcher_descriptor(std::path::Path::new("/usr/bin/true")) + .expect("open protected launcher candidate"); + let flags = fcntl_getfd(&descriptor).expect("read launcher descriptor flags"); + + assert!(flags.contains(FdFlags::CLOEXEC)); +} + +#[test] +fn launcher_snapshot_requires_identity_size_and_time_to_remain_equal() { + let current = executable_evidence_for_path(std::path::Path::new("/usr/bin/true")) + .expect("current executable identity") + .identity; + let other = executable_evidence_for_path(std::path::Path::new("/usr/bin/false")) + .expect("other executable identity") + .identity; + let first_time = UNIX_EPOCH + Duration::from_secs(10); + let second_time = UNIX_EPOCH + Duration::from_secs(11); + + assert!(snapshot_is_unchanged( + current, current, 20, 20, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, other, 20, 20, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, current, 20, 21, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, + current, + 20, + 20, + first_time, + second_time + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs new file mode 100644 index 000000000..4ca92c575 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs @@ -0,0 +1,137 @@ +//! Shell-launcher syntax acceptance and rejection cases + +use std::path::Path; + +use super::super::syntax::literal_final_exec_target; + +#[test] +fn package_shell_launcher_extracts_literal_final_target() { + let source = b"#!/bin/sh\nexec /usr/lib/example/example \"$@\"\n"; + + assert_eq!( + literal_final_exec_target(source).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); +} + +#[test] +fn package_shell_launcher_allows_dynamic_arguments_after_literal_target() { + let source = br#"#!/usr/bin/env bash + +FLAGS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/example-flags.conf" +if [[ -f "${FLAGS_FILE}" ]]; then + FLAGS="$(sed 's/#.*//' "${FLAGS_FILE}" | tr '\n' ' ')" +fi +exec /usr/lib/example/example $FLAGS "$@" +"#; + + assert_eq!( + literal_final_exec_target(source).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); +} + +#[test] +fn dynamic_exec_targets_are_rejected() { + for source in [ + b"#!/bin/sh\nexec \"$TARGET\" \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec ${TARGET} \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec \"$(find-runtime)\" \"$@\"\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "dynamic executable target must fail closed" + ); + } +} + +#[test] +fn relative_exec_targets_are_rejected() { + for source in [ + b"#!/bin/sh\nexec ./example \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec example \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec /usr/lib/../bin/example \"$@\"\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "relative or normalized executable target must fail closed" + ); + } +} + +#[test] +fn multiple_exec_targets_are_rejected() { + let source = b"#!/bin/sh\nexec /usr/lib/example/first || exec /usr/lib/example/second\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn exec_with_control_operator_is_rejected() { + for source in [ + b"#!/bin/sh\nexec /usr/lib/example/example; fallback\n".as_slice(), + b"#!/bin/sh\nexec /usr/lib/example/example | other\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "control operators around the authoritative exec must fail closed" + ); + } +} + +#[test] +fn sourced_launchers_are_rejected() { + for command in ["source helper-script", ". helper-script"] { + let source = format!("#!/bin/sh\n{command}\nexec /usr/lib/example/example \"$@\"\n"); + assert!( + literal_final_exec_target(source.as_bytes()).is_none(), + "sourced code can change final command meaning" + ); + } +} + +#[test] +fn commands_after_exec_are_rejected() { + let source = b"#!/bin/sh\nexec /usr/lib/example/example \"$@\"\necho unreachable\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn unsupported_shell_shebang_is_rejected() { + let source = b"#!/usr/bin/fish\nexec /usr/lib/example/example $argv\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn malformed_shell_syntax_is_rejected_even_with_a_literal_final_exec() { + let source = b"#!/bin/sh\nif then\nexec /usr/lib/example/example \"$@\"\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn exec_argument_limit_accepts_the_boundary_and_rejects_one_more() { + let command = |extra_arguments: usize| { + format!( + "#!/bin/sh\nexec /usr/lib/example/example {}\n", + std::iter::repeat_n("$ARG", extra_arguments) + .collect::>() + .join(" ") + ) + }; + let exact = command(127); + let over = command(128); + + assert_eq!( + literal_final_exec_target(exact.as_bytes()).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); + assert!(literal_final_exec_target(over.as_bytes()).is_none()); +} + +#[test] +fn exec_without_a_target_is_rejected() { + assert!(literal_final_exec_target(b"#!/bin/sh\nexec\n").is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs new file mode 100644 index 000000000..96ecb949f --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs @@ -0,0 +1,47 @@ +//! Runtime-target file validation cases + +use std::os::unix::fs::symlink; +use std::path::Path; +use std::{fs, os::unix::fs::PermissionsExt}; + +use super::super::validation::protected_runtime_target; +use crate::test_support::TempRoot; + +#[test] +fn protected_runtime_target_accepts_installed_regular_executable() { + let identity = protected_runtime_target(Path::new("/usr/bin/true")) + .expect("installed protected executable"); + + assert!(identity.is_system_managed()); + assert!(identity.is_executable_regular()); +} + +#[test] +fn runtime_target_symlink_is_not_followed() { + let root = TempRoot::new("runtime-target-symlink"); + let path = root.join("runtime"); + symlink("/usr/bin/true", &path).expect("create runtime target symlink fixture"); + + assert!(protected_runtime_target(&path).is_none()); +} + +#[test] +fn changed_runtime_target_identity_is_detected() { + let current = + protected_runtime_target(Path::new("/usr/bin/true")).expect("current runtime target"); + let stale = + protected_runtime_target(Path::new("/usr/bin/false")).expect("different runtime target"); + + assert!(!current.same_file(stale)); +} + +#[test] +fn user_owned_runtime_target_is_rejected() { + let root = TempRoot::new("user-runtime-target"); + let path = root.join("runtime"); + fs::write(&path, "fixture").expect("write runtime target fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("make runtime target executable"); + + assert!(protected_runtime_target(&path).is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index 9125058b6..b920fbf55 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -16,7 +16,7 @@ fn dbus_activated_desktop_entry_without_exec_has_no_executable() { let mut index = DesktopIdentityIndex::default(); index.add_desktop_file(&path, true); assert_eq!(index.records.len(), 1); - assert!(index.records[0].executable_path.is_none()); + assert!(index.records[0].runtime_executable_path.is_none()); assert!(!index.records[0].system_association); } @@ -34,7 +34,7 @@ fn desktop_entry_exec_is_resolved_to_its_application_program() { assert_eq!( index.records[0] - .executable_path + .runtime_executable_path .as_deref() .and_then(std::path::Path::file_name), Some(std::ffi::OsStr::new("true")) @@ -57,7 +57,7 @@ fn env_wrapped_desktop_entry_indexes_the_wrapped_application() { let record = &index.records[0]; assert_eq!( record - .executable_path + .runtime_executable_path .as_deref() .and_then(std::path::Path::file_name), Some(std::ffi::OsStr::new("true")) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs new file mode 100644 index 000000000..4cb8cfe94 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs @@ -0,0 +1,133 @@ +//! Runtime-binding provenance and normalization cases + +use std::collections::HashSet; + +use super::{discard_untrusted_launcher_binding, runtime_binding_is_valid}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopRecord, LaunchSpec, PackageLauncherBinding, +}; +use crate::daemon::notifications::identity::desktop_index::provenance::{ + InstallProvenance, PackageProvider, +}; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn desktop_launcher_and_target_must_share_package() { + let record = launcher_record("example-app", "example-app", "example-app"); + + assert!(runtime_binding_is_valid(&record)); +} + +#[test] +fn launcher_target_from_different_package_is_rejected() { + let record = launcher_record("example-app", "example-app", "other-runtime"); + + assert!(!runtime_binding_is_valid(&record)); +} + +#[test] +fn same_package_without_literal_launcher_relation_is_not_retained() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + record + .launch_spec + .as_mut() + .expect("launcher launch specification") + .package_launcher = None; + + assert!(!runtime_binding_is_valid(&record)); + discard_untrusted_launcher_binding(&mut record); + let spec = record + .launch_spec + .as_ref() + .expect("normalized direct launch specification"); + assert!(spec.declared_executable.same_file(spec.runtime_executable)); + assert_eq!( + record.declared_executable_path, + record.runtime_executable_path + ); +} + +#[test] +fn direct_runtime_path_must_match_declared_path() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + let spec = record + .launch_spec + .as_mut() + .expect("launcher launch specification"); + spec.package_launcher = None; + spec.runtime_executable = spec.declared_executable; + record.runtime_executable_identity = record.declared_executable_identity; + + assert!(!runtime_binding_is_valid(&record)); +} + +#[test] +fn direct_runtime_identity_must_match_declared_identity() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + record + .launch_spec + .as_mut() + .expect("launcher launch specification") + .package_launcher = None; + record.runtime_executable_path = record.declared_executable_path.clone(); + + assert!(!runtime_binding_is_valid(&record)); +} + +fn launcher_record( + desktop_package: &str, + launcher_package: &str, + runtime_package: &str, +) -> DesktopRecord { + let launcher = identity(41); + let runtime = identity(42); + DesktopRecord { + id: "org.example.App".to_string(), + display_name: "Example App".to_string(), + badge_icon: "example-app".to_string(), + desktop_path: Some("/usr/share/applications/org.example.App.desktop".into()), + declared_executable_path: Some("/usr/bin/example-app".into()), + declared_executable_identity: Some(launcher), + runtime_executable_path: Some("/usr/lib/example-app/runtime".into()), + runtime_executable_identity: Some(runtime), + desktop_identity: Some(identity(40)), + desktop_provenance: package(desktop_package), + declared_executable_provenance: package(launcher_package), + runtime_executable_provenance: package(runtime_package), + system_origin: true, + system_association: false, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: launcher, + runtime_executable: runtime, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/example-app".into(), + launcher_identity: launcher, + launcher_digest: [5; 32], + target_path: "/usr/lib/example-app/runtime".into(), + target_identity: runtime, + }), + literal_files_are_system_managed: true, + }), + names: HashSet::new(), + } +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 0, + mode: 0o100_755, + } +} + +fn package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs index 14437b6ea..994ffc201 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -1,8 +1,11 @@ use std::fs; use std::os::unix::fs::symlink; +use super::super::launcher::launcher_binding_is_current; +use super::super::model::{LaunchVerification, VerifiedLaunch}; use super::super::scan::{ScanBudget, ScanLimits}; -use super::super::DesktopIdentityIndex; +use super::super::{verify_record_launch, DesktopIdentityIndex}; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; use crate::test_support::TempRoot; #[test] @@ -176,3 +179,64 @@ fn exhausted_user_budget_does_not_block_system_desktop_records() { .any(|record| record.system_origin && record.display_name == "System App")); assert_eq!(snapshot.watched_directories.len(), 2); } + +#[test] +fn local_arch_package_launcher_reaches_its_runtime_target() { + let desktop = std::path::Path::new("/usr/share/applications/signal.desktop"); + if !desktop.exists() { + return; + } + + let snapshot = DesktopIdentityIndex::build_snapshot(); + let record = snapshot + .index + .records_for_id("signal") + .into_iter() + .find(|record| record.system_origin) + .expect("installed package desktop record"); + + assert!(record.system_association); + assert_eq!( + record.declared_executable_path.as_deref(), + Some(std::path::Path::new("/usr/bin/signal-desktop")) + ); + assert_eq!( + record.runtime_executable_path.as_deref(), + Some(std::path::Path::new( + "/usr/lib/signal-desktop/signal-desktop" + )) + ); + let binding = record + .launch_spec + .as_ref() + .and_then(|spec| spec.package_launcher.as_ref()) + .expect("installed package launcher binding"); + assert!(launcher_binding_is_current(binding)); + let mut stale_digest = binding.clone(); + stale_digest.launcher_digest[0] ^= 1; + assert!(!launcher_binding_is_current(&stale_digest)); + let mut changed_target = binding.clone(); + changed_target.target_path = "/usr/bin/true".into(); + assert!(!launcher_binding_is_current(&changed_target)); + let runtime_identity = record + .runtime_executable_identity + .expect("installed runtime identity"); + let command_line = CommandLineEvidence { + argv: [ + "/usr/lib/signal-desktop/signal-desktop", + "--password-store=kwallet6", + "--ozone-platform=x11", + "--use-tray-icon", + "--", + ] + .into_iter() + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + quality: CommandLineQuality::Structured, + }; + + assert_eq!( + verify_record_launch(record, &snapshot.index, runtime_identity, &command_line), + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs index dfdd4f594..c7683daf2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs @@ -5,11 +5,13 @@ use super::{ classify_launch_authority, executable_contract_is_dedicated, field_value_matches, is_protected_payload, literal_file_identities_are_current, literal_file_matches, match_ordered_dedicated_contract, match_ordered_exec_contract, verify_dedicated, - verify_protected_payload, verify_record_launch, MAX_PROCESS_ARGUMENTS, + verify_protected_payload, verify_record_launch, verify_record_launch_with, + MAX_PROCESS_ARGUMENTS, }; use crate::daemon::notifications::identity::desktop_index::model::{ DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, - LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, VerifiedLaunch, + LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, PackageLauncherBinding, + VerifiedLaunch, }; use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; use crate::daemon::notifications::identity::desktop_index::InstallProvenance; @@ -61,7 +63,8 @@ fn protected_payload_verification_requires_current_file_identity_and_fixed_argum file: Some(("/usr/bin/true".into(), payload.identity)), }; let spec = LaunchSpec { - executable: shell.identity, + declared_executable: shell.identity, + runtime_executable: shell.identity, arguments: vec![ LaunchArgument::Literal(payload_argument.clone()), LaunchArgument::Literal(LiteralArgument { @@ -71,6 +74,7 @@ fn protected_payload_verification_requires_current_file_identity_and_fixed_argum ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -110,13 +114,15 @@ fn trusted_payload_cannot_be_used_as_a_decoy_argument() { let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); let spec = LaunchSpec { - executable: runtime.identity, + declared_executable: runtime.identity, + runtime_executable: runtime.identity, arguments: vec![LaunchArgument::Literal(LiteralArgument { value: b"/usr/bin/true".to_vec(), file: Some(("/usr/bin/true".into(), payload.identity)), })], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let sender = structured_command(&["/usr/bin/sh", "/usr/bin/false", "/usr/bin/true"]); @@ -133,7 +139,8 @@ fn variable_width_field_before_protected_payload_does_not_create_false_conflict( let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("protected payload"); let spec = LaunchSpec { - executable: payload.identity, + declared_executable: payload.identity, + runtime_executable: payload.identity, arguments: vec![ LaunchArgument::FieldCode(FieldCode::Files), LaunchArgument::Literal(LiteralArgument { @@ -147,6 +154,7 @@ fn variable_width_field_before_protected_payload_does_not_create_false_conflict( ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let sender = structured_command(&[ @@ -166,10 +174,12 @@ fn variable_width_field_before_protected_payload_does_not_create_false_conflict( #[test] fn ordered_contract_preserves_repeated_literals_and_field_positions() { + let identity = executable_evidence_for_path(Path::new("/usr/bin/true")) + .expect("system executable") + .identity; let spec = LaunchSpec { - executable: executable_evidence_for_path(Path::new("/usr/bin/true")) - .expect("system executable") - .identity, + declared_executable: identity, + runtime_executable: identity, arguments: vec![ LaunchArgument::Literal(LiteralArgument { value: b"--mode".to_vec(), @@ -187,6 +197,7 @@ fn ordered_contract_preserves_repeated_literals_and_field_positions() { ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -215,7 +226,8 @@ fn dedicated_contract_does_not_accept_reordered_fixed_options() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![ LaunchArgument::Literal(LiteralArgument { value: b"--first".to_vec(), @@ -228,6 +240,7 @@ fn dedicated_contract_does_not_accept_reordered_fixed_options() { ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -278,10 +291,12 @@ fn empty_dedicated_contract_rejects_positional_payload() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: Vec::new(), environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -299,10 +314,12 @@ fn empty_contract_with_unstructured_argv_is_not_verified() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: Vec::new(), environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -329,10 +346,12 @@ fn empty_contract_accepts_only_non_positional_switches() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: Vec::new(), environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -363,10 +382,12 @@ fn dynamic_contract_without_shared_provenance_is_not_dedicated() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(field_code)], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let record = DesktopRecord { @@ -374,11 +395,14 @@ fn dynamic_contract_without_shared_provenance_is_not_dedicated() { display_name: "Runtime application".to_string(), badge_icon: "runtime".to_string(), desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), - executable_path: Some("/usr/bin/true".into()), - executable_identity: Some(executable.identity), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), desktop_identity: None, desktop_provenance: test_package("runtime-desktop"), - executable_provenance: test_package("runtime"), + declared_executable_provenance: test_package("runtime"), + runtime_executable_provenance: test_package("runtime"), system_origin: true, system_association: true, association_eligible: true, @@ -406,7 +430,8 @@ fn dedicated_system_application_accepts_dynamic_url_field() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![ LaunchArgument::Literal(LiteralArgument { value: b"--".to_vec(), @@ -416,6 +441,7 @@ fn dedicated_system_application_accepts_dynamic_url_field() { ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let record = DesktopRecord { @@ -423,11 +449,14 @@ fn dedicated_system_application_accepts_dynamic_url_field() { display_name: "True".to_string(), badge_icon: "true".to_string(), desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), - executable_path: Some("/usr/bin/true".into()), - executable_identity: Some(executable.identity), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), desktop_identity: Some(executable.identity), desktop_provenance: test_package("true"), - executable_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), system_origin: true, system_association: true, association_eligible: true, @@ -454,10 +483,12 @@ fn dynamic_runtime_requires_matching_immutable_installation_provenance() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let record = DesktopRecord { @@ -465,11 +496,14 @@ fn dynamic_runtime_requires_matching_immutable_installation_provenance() { display_name: "True".to_string(), badge_icon: "runtime".to_string(), desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), - executable_path: Some("/usr/bin/true".into()), - executable_identity: Some(executable.identity), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), desktop_identity: Some(executable.identity), desktop_provenance: test_package("runtime-frontend"), - executable_provenance: test_package("shared-runtime"), + declared_executable_provenance: test_package("shared-runtime"), + runtime_executable_provenance: test_package("shared-runtime"), system_origin: true, system_association: true, association_eligible: true, @@ -503,15 +537,18 @@ fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { ), ] { let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![LaunchArgument::FieldCode(field_code)], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let mut record = record_for_spec("org.example.Viewer", &spec); record.desktop_provenance = test_package("example-viewer"); - record.executable_provenance = test_package("example-viewer"); + record.declared_executable_provenance = test_package("example-viewer"); + record.runtime_executable_provenance = test_package("example-viewer"); let mut index = DesktopIdentityIndex::default(); index.index_record(record); let indexed = index @@ -538,6 +575,165 @@ fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { } } +#[test] +fn package_launcher_target_verifies_with_matching_runtime_and_ordered_contract() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let binding = PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [7; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }; + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(binding), + literal_files_are_system_managed: true, + }; + let package = test_package("example-chat"); + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_path = Some("/usr/bin/false".into()); + record.declared_executable_identity = Some(launcher.identity); + record.runtime_executable_path = Some("/usr/bin/true".into()); + record.runtime_executable_identity = Some(runtime.identity); + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + let verification = verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&[ + "/usr/bin/true", + "--password-store=desktop", + "--display=x11", + "--", + ]), + |_| true, + ); + + assert_eq!( + verification, + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) + ); +} + +#[test] +fn package_launcher_target_requires_current_binding_and_structured_arguments() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [3; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_identity = Some(launcher.identity); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&["/usr/bin/true"]), + |_| false, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged) + ); + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &CommandLineEvidence::default(), + |_| true, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + ); +} + +#[test] +fn shared_launcher_target_does_not_merge_incompatible_application_families() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [9; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let package = test_package("example-suite"); + let mut first = record_for_spec("org.example.First", &spec); + let mut second = record_for_spec("org.example.Second", &spec); + for record in [&mut first, &mut second] { + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package.clone(); + } + let mut index = DesktopIdentityIndex::default(); + index.index_record(first); + index.index_record(second); + let indexed = index + .records_for_id("org.example.First") + .into_iter() + .next() + .expect("first application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "one package-owned runtime shared by unrelated families must remain non-authoritative" + ); +} + #[test] fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { for (wrapper_count, environment_count, expected) in [ @@ -565,11 +761,13 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: Vec::new(), environment: std::iter::repeat_n((b"A".to_vec(), b"1".to_vec()), environment_count) .collect(), wrappers: std::iter::repeat_n(LaunchWrapper::Env, wrapper_count).collect(), + package_launcher: None, literal_files_are_system_managed: true, }; let record = DesktopRecord { @@ -577,11 +775,14 @@ fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() display_name: "Boundary".to_string(), badge_icon: "boundary".to_string(), desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), - executable_path: Some("/usr/bin/true".into()), - executable_identity: Some(executable.identity), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), desktop_identity: None, desktop_provenance: test_package("true"), - executable_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), system_origin: true, system_association: true, association_eligible: true, @@ -626,10 +827,12 @@ fn dedicated_authority_accepts_document_fields_but_rejects_unprotected_fixed_pay ), ] { let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments, environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let mut index = DesktopIdentityIndex::default(); @@ -664,10 +867,12 @@ fn protected_payload_accepts_exactly_the_bounded_argument_limit() { }) })); let spec = LaunchSpec { - executable: runtime.identity, + declared_executable: runtime.identity, + runtime_executable: runtime.identity, arguments, environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; let mut argv = vec![b"/usr/bin/sh".to_vec(), b"/usr/bin/true".to_vec()]; @@ -689,7 +894,8 @@ fn optional_icon_contract_preserves_its_flag_and_value_relationship() { let executable = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); let spec = LaunchSpec { - executable: executable.identity, + declared_executable: executable.identity, + runtime_executable: executable.identity, arguments: vec![ LaunchArgument::OptionalIcon { name: "example-icon".to_string(), @@ -701,6 +907,7 @@ fn optional_icon_contract_preserves_its_flag_and_value_relationship() { ], environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }; @@ -766,11 +973,14 @@ fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { display_name: "Contract application".to_string(), badge_icon: "contract".to_string(), desktop_path: Some(format!("/usr/share/applications/{id}.desktop").into()), - executable_path: Some("/usr/bin/true".into()), - executable_identity: Some(spec.executable), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(spec.declared_executable), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(spec.runtime_executable), desktop_identity: None, desktop_provenance: test_package(id), - executable_provenance: test_package(id), + declared_executable_provenance: test_package(id), + runtime_executable_provenance: test_package(id), system_origin: true, system_association: true, association_eligible: true, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs index 6a3eaaedd..f8d5b4138 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs @@ -5,6 +5,7 @@ use std::path::Path; use super::super::executable::{executable_evidence_for_path, FileIdentity}; use super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::launcher::launcher_binding_is_current; use super::model::{ DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, @@ -17,6 +18,22 @@ pub(super) fn verify_record_launch( index: &DesktopIdentityIndex, sender_identity: FileIdentity, command_line: &CommandLineEvidence, +) -> LaunchVerification { + verify_record_launch_with( + record, + index, + sender_identity, + command_line, + launcher_binding_is_current, + ) +} + +fn verify_record_launch_with( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + sender_identity: FileIdentity, + command_line: &CommandLineEvidence, + binding_is_current: impl FnOnce(&super::model::PackageLauncherBinding) -> bool, ) -> LaunchVerification { let Some(spec) = record.launch_spec.as_ref() else { return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); @@ -24,9 +41,16 @@ pub(super) fn verify_record_launch( if spec.wrappers.len() > 16 || spec.environment.len() > 128 { return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); } - if !spec.executable.same_file(sender_identity) { + if !spec.runtime_executable.same_file(sender_identity) { return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); } + if spec + .package_launcher + .as_ref() + .is_some_and(|binding| !binding_is_current(binding)) + { + return LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged); + } if !literal_file_identities_are_current(spec) { return LaunchVerification::InsufficientEvidence(LaunchFailure::ProtectedPayloadMismatch); } @@ -71,17 +95,35 @@ fn executable_contract_is_dedicated( ) -> bool { record.system_origin && record.system_association - && spec.executable.is_system_managed() - && spec.executable.is_executable_regular() + && spec.declared_executable.is_system_managed() + && spec.declared_executable.is_executable_regular() + && spec.runtime_executable.is_system_managed() + && spec.runtime_executable.is_executable_regular() && record .desktop_provenance - .same_application_source(&record.executable_provenance) - && index.records_form_one_application_family(spec.executable, record.system_origin) + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) + && index.records_form_one_application_family(spec.runtime_executable, record.system_origin) && !spec.arguments.iter().any(is_unprotected_fixed_payload) } fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { + let verified_launch = if spec.package_launcher.is_some() { + VerifiedLaunch::PackageLauncherTarget + } else { + VerifiedLaunch::DedicatedExecutable + }; match command_line.quality { + // Launcher targets require the original desktop contract to match observed runtime argv + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.package_launcher.is_some() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + } // An empty contract cannot distinguish an ordinary switch from an active payload CommandLineQuality::RewrittenProcessTitle | CommandLineQuality::Truncated @@ -93,15 +135,13 @@ fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> La // A nonempty package-backed contract still contributes identity when argv was rewritten CommandLineQuality::RewrittenProcessTitle | CommandLineQuality::Truncated - | CommandLineQuality::Unavailable => { - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) - } + | CommandLineQuality::Unavailable => LaunchVerification::Verified(verified_launch), CommandLineQuality::Structured => { let actual = command_line.argv.get(1..).unwrap_or_default(); if actual.len() <= MAX_PROCESS_ARGUMENTS && match_ordered_dedicated_contract(spec, actual) { - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + LaunchVerification::Verified(verified_launch) } else { LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs index fcd37e6e9..a1818e691 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs @@ -24,6 +24,11 @@ pub(super) fn with_diagnostics( LaunchAuthorityView::DedicatedExecutable, "verified by dedicated executable identity", ), + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::DedicatedExecutable, + "verified by protected package launcher and runtime identity", + ), LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) => ( LaunchVerificationView::Verified, LaunchAuthorityView::ProtectedPayload, @@ -74,6 +79,7 @@ pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str "empty launch contract requires structured command-line evidence" } LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", + LaunchFailure::LauncherBindingChanged => "package launcher binding changed", LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", LaunchFailure::ExecutableMismatch => "executable identity mismatch", @@ -88,7 +94,9 @@ const fn launch_authority_for_failure(failure: LaunchFailure) -> LaunchAuthority match failure { LaunchFailure::DynamicOnlyContract => LaunchAuthorityView::DynamicOnly, LaunchFailure::AmbiguousDesktopAssociation => LaunchAuthorityView::Ambiguous, - LaunchFailure::EmptyContractNeedsCommandLine => LaunchAuthorityView::DedicatedExecutable, + LaunchFailure::EmptyContractNeedsCommandLine | LaunchFailure::LauncherBindingChanged => { + LaunchAuthorityView::DedicatedExecutable + } LaunchFailure::ProtectedPayloadMismatch | LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine => LaunchAuthorityView::ProtectedPayload, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs index 329f845ae..2ece310d0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs @@ -18,16 +18,22 @@ pub(super) fn lineage_association<'record>( let record = result.record; if !record.system_association || !record - .executable_identity + .runtime_executable_identity .is_some_and(|identity| identity.same_file(ancestor.executable_identity)) { continue; } let verification = verify_ancestor_record(record, index, ancestor.executable_identity); - if matches!( - verification, - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) - ) { + if matches!(verification, LaunchVerification::Verified(_)) + || (record + .launch_spec + .as_ref() + .is_some_and(|spec| spec.package_launcher.is_some()) + && matches!( + verification, + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + )) + { return Some(( record, format!( @@ -46,7 +52,7 @@ fn verify_ancestor_record( index: &DesktopIdentityIndex, identity: FileIdentity, ) -> LaunchVerification { - let Some(path) = record.executable_path.as_deref() else { + let Some(path) = record.runtime_executable_path.as_deref() else { return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); }; let Some(current) = executable_evidence_for_path(path) else { @@ -66,7 +72,7 @@ pub(super) fn verify_record_sender( if !record.association_eligible { return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); } - let Some(record_identity) = record.executable_identity else { + let Some(record_identity) = record.runtime_executable_identity else { return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); }; let Some(sender_identity) = sender.sender_executable_identity else { @@ -80,7 +86,7 @@ pub(super) fn verify_record_sender( if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); } - let Some(path) = record.executable_path.as_deref() else { + let Some(path) = record.runtime_executable_path.as_deref() else { return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); }; let Some(current) = executable_evidence_for_path(path) else { @@ -114,6 +120,7 @@ pub(super) fn candidate_proves_conflict( | LaunchFailure::UnstructuredCommandLine | LaunchFailure::EmptyContractNeedsCommandLine | LaunchFailure::UnsupportedWrapper + | LaunchFailure::LauncherBindingChanged | LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::DynamicOnlyContract | LaunchFailure::RequiredArgumentMismatch @@ -133,7 +140,7 @@ pub(super) fn sender_claim_relation( return SenderClaimRelation::TrustedRelay; } if claimed_record - .executable_identity + .runtime_executable_identity .is_some_and(|identity| identity.same_file(sender_identity)) { return SenderClaimRelation::ClaimedApplication; @@ -149,11 +156,13 @@ pub(super) fn sender_claim_relation( if sender .install_provenance - .same_application_source(&claimed_record.executable_provenance) + .same_application_source(&claimed_record.runtime_executable_provenance) { return SenderClaimRelation::SamePackageHelper; } - if sender.install_provenance.is_known() && claimed_record.executable_provenance.is_known() { + if sender.install_provenance.is_known() + && claimed_record.runtime_executable_provenance.is_known() + { // Package inequality rules out a same-package helper but does not identify another app return SenderClaimRelation::DifferentInstalledPackage; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index f7ce88c90..dc1e6fe71 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -90,13 +90,15 @@ pub(super) fn resolution_for_record( let canonical = index.canonical_record_for_record(record); let canonical_id = index.canonical_id_for_record(record); let source = record - .executable_path + .runtime_executable_path .as_deref() .map(|path| path.display().to_string()) .unwrap_or_default(); if record.system_association { let reason = match verified.1 { - VerifiedLaunch::DedicatedExecutable => AttributionReason::ExactSystemExecutable, + VerifiedLaunch::DedicatedExecutable | VerifiedLaunch::PackageLauncherTarget => { + AttributionReason::ExactSystemExecutable + } VerifiedLaunch::ProtectedPayload => AttributionReason::VerifiedProtectedPayload, }; return policy_resolution(NotificationAttribution::verified( @@ -220,7 +222,9 @@ const fn attribution_reason_for_failure(failure: LaunchFailure) -> AttributionRe LaunchFailure::MissingCommandLine | LaunchFailure::UnstructuredCommandLine | LaunchFailure::EmptyContractNeedsCommandLine => AttributionReason::MissingCommandLine, - LaunchFailure::UnsupportedWrapper => AttributionReason::UnsupportedWrapper, + LaunchFailure::UnsupportedWrapper | LaunchFailure::LauncherBindingChanged => { + AttributionReason::UnsupportedWrapper + } LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::RequiredArgumentMismatch => { AttributionReason::AmbiguousDesktopRecords } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs index 41a4e8e2b..bd49519d3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs @@ -58,7 +58,8 @@ fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { ); for record in [&mut canonical, &mut alias] { record.desktop_provenance = package("example-app"); - record.executable_provenance = package("example-app"); + record.declared_executable_provenance = package("example-app"); + record.runtime_executable_provenance = package("example-app"); } let different_identity = identity(103, 1_030, 0); let different_record = system_record( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs index 569d31e3b..7f584b7dd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs @@ -15,9 +15,11 @@ fn equivalent_desktop_aliases_use_one_canonical_application_identity() { ); alias.badge_icon = "example-app-new-window".to_string(); canonical.desktop_provenance = package("example-app"); - canonical.executable_provenance = package("example-app"); + canonical.declared_executable_provenance = package("example-app"); + canonical.runtime_executable_provenance = package("example-app"); alias.desktop_provenance = package("example-app"); - alias.executable_provenance = package("example-app"); + alias.declared_executable_provenance = package("example-app"); + alias.runtime_executable_provenance = package("example-app"); let resolve_alias = |records| { let index = DesktopIdentityIndex::from_records(records, Vec::new()); @@ -65,7 +67,8 @@ fn fuzzy_name_substrings_do_not_merge_distinct_application_families() { ); for record in [&mut first, &mut second] { record.desktop_provenance = package("example-suite"); - record.executable_provenance = package("example-suite"); + record.declared_executable_provenance = package("example-suite"); + record.runtime_executable_provenance = package("example-suite"); } let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); let records = index.records_for_executable(executable); @@ -158,7 +161,8 @@ fn strongest_verified_family_selects_its_canonical_record() { ); for record in [&mut canonical, &mut alias] { record.desktop_provenance = package("example-app"); - record.executable_provenance = package("example-app"); + record.declared_executable_provenance = package("example-app"); + record.runtime_executable_provenance = package("example-app"); } let index = DesktopIdentityIndex::from_records(vec![alias, canonical], Vec::new()); let records = index.records_for_executable(executable); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs index 896fb973e..a4a429fdc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs @@ -85,7 +85,7 @@ fn lineage_rejects_a_candidate_with_a_different_indexed_executable() { .with_launch_literals(&["--application-mode"]); let index = DesktopIdentityIndex::from_records(vec![indexed.clone()], Vec::new()); let mut mismatched = indexed; - mismatched.executable_identity = Some(FileIdentity { + mismatched.runtime_executable_identity = Some(FileIdentity { inode: live_identity.inode.saturating_add(1), ..live_identity }); @@ -105,6 +105,38 @@ fn lineage_rejects_a_candidate_with_a_different_indexed_executable() { assert!(lineage_association(&helper, &index, &[&result]).is_none()); } +#[test] +fn direct_protected_payload_without_command_line_is_not_lineage_evidence() { + let (app_path, app_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); + let indexed = system_record("org.example.True", "Example App", &app_path, app_identity) + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); + let index = DesktopIdentityIndex::from_records(vec![indexed], Vec::new()); + let record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("protected-payload record should be indexed"); + let result = CandidateVerification { + record, + verification: LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine), + }; + let mut helper = sender("/usr/libexec/example-helper", identity(207, 2_070, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_083, + start_time: 7_073, + uid: 0, + executable: app_path, + executable_identity: app_identity, + }); + + assert!( + lineage_association(&helper, &index, &[&result]).is_none(), + "missing command-line evidence is accepted only for a validated package launcher" + ); +} + #[test] fn unknown_executable_cannot_borrow_installed_app_identity() { let (app_path, app_identity) = installed_system_executable(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index 4a44dbf0b..e3d0ee9f3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -29,7 +29,8 @@ async fn recognized_helper_is_reresolved_with_live_package_provenance() { app_evidence.identity, ); record.desktop_provenance = app_provenance.clone(); - record.executable_provenance = app_provenance; + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance; let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_attribution( AppClaim { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs index 61850ec5c..512464cf9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs @@ -377,7 +377,7 @@ fn owned_dbus_application_name_without_executable_evidence_remains_unverified() app_identity, true, ); - record.executable_identity = None; + record.runtime_executable_identity = None; let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_with_evidence( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs index b2b58b9a3..eb0df83ac 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -4,6 +4,7 @@ use super::super::resolution::{ resolution_for_record, sender_claim_group_key, unknown_reply_denied, }; use super::*; +use unixnotis_core::ApplicationActionPolicy; #[test] fn sender_claim_group_key_is_nonempty_and_bound_to_sender_identity() { @@ -43,6 +44,36 @@ fn verified_record_with_a_contradictory_name_becomes_conflict() { assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } +#[test] +fn verified_package_launcher_target_receives_application_authority() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/lib/example-app/runtime", + identity(207, 2_070, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + + let resolution = resolution_for_record( + VerifiedDesktopRecord(record, VerifiedLaunch::PackageLauncherTarget), + "Example App", + &sender("/usr/lib/example-app/runtime", identity(207, 2_070, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!( + resolution.attribution.application_action_policy(), + ApplicationActionPolicy::Allow + ); +} + #[test] fn missing_sender_reply_resolution_is_unresolved_and_noninteractive() { let metadata = SenderMetadata::default(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs index d5581b2ce..901d58270 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs @@ -31,8 +31,10 @@ impl DesktopRecordFixture for DesktopRecord { desktop_path: Some(PathBuf::from(format!( "/usr/share/applications/{id}.desktop" ))), - executable_path: Some(PathBuf::from(executable_path)), - executable_identity: Some(identity), + declared_executable_path: Some(PathBuf::from(executable_path)), + declared_executable_identity: Some(identity), + runtime_executable_path: Some(PathBuf::from(executable_path)), + runtime_executable_identity: Some(identity), desktop_identity: Some(identity), desktop_provenance: if system_entry { InstallProvenance::Package { @@ -42,7 +44,15 @@ impl DesktopRecordFixture for DesktopRecord { } else { InstallProvenance::Unknown }, - executable_provenance: if system_entry { + declared_executable_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, + runtime_executable_provenance: if system_entry { InstallProvenance::Package { provider: PackageProvider::Pacman, package_id: id.to_string(), @@ -54,10 +64,12 @@ impl DesktopRecordFixture for DesktopRecord { system_association: system_entry, association_eligible: true, launch_spec: Some(LaunchSpec { - executable: identity, + declared_executable: identity, + runtime_executable: identity, arguments: Vec::new(), environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }), names: HashSet::from([normalize_name(display_name)]), @@ -66,10 +78,11 @@ impl DesktopRecordFixture for DesktopRecord { fn with_launch_literals(mut self, arguments: &[&str]) -> Self { let executable = self - .executable_identity + .runtime_executable_identity .expect("launch fixture needs executable identity"); self.launch_spec = Some(LaunchSpec { - executable, + declared_executable: executable, + runtime_executable: executable, arguments: arguments .iter() .map(|value| { @@ -81,6 +94,7 @@ impl DesktopRecordFixture for DesktopRecord { .collect(), environment: Vec::new(), wrappers: Vec::new(), + package_launcher: None, literal_files_are_system_managed: true, }); self From e6b9cb931c77bde8d0e6a04933b7bacab8b079bd Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 19:32:47 -0500 Subject: [PATCH 166/275] fix(attribution): separate association from authentication Summary: separate association from authentication. Scope: attribution. --- .../unixnotis-core/src/model/attribution.rs | 123 ++++++++++++++---- .../unixnotis-core/src/model/interaction.rs | 67 ++++++++++ crates/unixnotis-core/src/model/mod.rs | 5 +- .../unixnotis-core/src/model/notification.rs | 3 +- .../src/model/tests/attribution.rs | 94 +++++++++++-- .../src/model/tests/interaction.rs | 58 +++++++++ .../daemon/notifications/identity/policy.rs | 14 +- .../identity/resolver/pipeline.rs | 33 +++-- .../identity/resolver/resolution.rs | 34 +++-- .../resolver/tests/candidates/families.rs | 8 +- .../resolver/tests/evidence/helpers.rs | 6 +- .../resolver/tests/evidence/runtime.rs | 12 +- .../identity/resolver/tests/mod.rs | 8 +- .../resolver/tests/pipeline/dedicated.rs | 24 +++- .../resolver/tests/pipeline/portal.rs | 24 +++- .../resolver/tests/pipeline/provenance.rs | 75 +++++++++++ .../identity/resolver/tests/resolution.rs | 16 ++- .../notifications/identity/tests/policy.rs | 29 +++-- 18 files changed, 525 insertions(+), 108 deletions(-) create mode 100644 crates/unixnotis-core/src/model/interaction.rs create mode 100644 crates/unixnotis-core/src/model/tests/interaction.rs diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index aa998529a..d3a844803 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; +use super::interaction::{ApplicationActionPolicy, InteractionPolicies}; use crate::util; const MAX_ATTRIBUTION_TEXT_BYTES: usize = 256; @@ -22,15 +23,29 @@ pub enum AttributionStatus { Relay = 4, } +/// Security boundary supporting the visible application association +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum IdentityAssurance { + Authenticated = 0, + SystemAssociated = 1, + PortalAssociated = 2, + UserAssociated = 3, + #[default] + Unresolved = 4, + Conflict = 5, + Relay = 6, +} + /// Stable reason for one attribution result // Numeric ranges keep positive, uncertain, and contradictory evidence easy to inspect #[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum AttributionReason { ExactSystemExecutable = 0, - VerifiedPortalAppId = 1, + PortalAppIdAssociation = 1, ExactUserExecutable = 2, - VerifiedProtectedPayload = 3, + ProtectedPayloadMatch = 3, TrustedRelayExecutable = 4, #[default] @@ -46,24 +61,6 @@ pub enum AttributionReason { ApplicationClaimMismatch = 22, } -/// Independent policy for credential-like inline text controls -// Value one stays unused until confirmation is enforced by the daemon -#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] -#[repr(u8)] -pub enum InlineReplyPolicy { - Allow = 0, - #[default] - Deny = 2, -} - -/// Backend policy for application-owned action signals -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum ApplicationActionPolicy { - Allow, - Confirm, - Deny, -} - /// Application identity selected from sender and desktop evidence #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationAttribution { @@ -76,6 +73,10 @@ pub struct NotificationAttribution { pub badge_icon: String, // Status and reason carry state without parsing diagnostic text pub status: AttributionStatus, + // Assurance names the boundary independently from evidence completeness + pub assurance: IdentityAssurance, + // Interaction authority is explicit so UI code never infers it from branding + pub interactions: InteractionPolicies, pub reason: AttributionReason, // Human-readable detail is display-only and never interpreted by clients pub diagnostic_detail: String, @@ -91,6 +92,8 @@ impl Default for NotificationAttribution { desktop_id: String::new(), badge_icon: "application-x-executable-symbolic".to_string(), status: AttributionStatus::Unresolved, + assurance: IdentityAssurance::Unresolved, + interactions: InteractionPolicies::DENY, reason: AttributionReason::MissingSenderEvidence, diagnostic_detail: String::new(), group_key: "unknown".to_string(), @@ -116,6 +119,8 @@ impl NotificationAttribution { desktop_id, badge_icon, AttributionStatus::Verified, + IdentityAssurance::Authenticated, + InteractionPolicies::AUTHENTICATED, reason, diagnostic_detail, group_key, @@ -139,6 +144,48 @@ impl NotificationAttribution { desktop_id, badge_icon, AttributionStatus::Recognized, + IdentityAssurance::UserAssociated, + InteractionPolicies::DENY, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build a canonical identity with an explicit non-authenticating boundary + #[must_use] + #[expect( + clippy::too_many_arguments, + reason = "association and interaction fields stay explicit at the trust boundary" + )] + pub fn associated( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + assurance: IdentityAssurance, + interactions: InteractionPolicies, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + debug_assert!( + matches!( + assurance, + IdentityAssurance::SystemAssociated + | IdentityAssurance::PortalAssociated + | IdentityAssurance::UserAssociated + ), + "associated attribution requires an application association boundary" + ); + Self::resolved( + display_name, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Recognized, + assurance, + interactions, reason, diagnostic_detail, group_key, @@ -159,6 +206,8 @@ impl NotificationAttribution { "", "application-x-executable-symbolic", AttributionStatus::Unresolved, + IdentityAssurance::Unresolved, + InteractionPolicies::DENY, reason, diagnostic_detail, group_key, @@ -189,6 +238,8 @@ impl NotificationAttribution { desktop_id, "dialog-warning-symbolic", AttributionStatus::Conflict, + IdentityAssurance::Conflict, + InteractionPolicies::DENY, reason, diagnostic_detail, group_key, @@ -204,6 +255,8 @@ impl NotificationAttribution { "", "utilities-terminal-symbolic", AttributionStatus::Relay, + IdentityAssurance::Relay, + InteractionPolicies::DENY, AttributionReason::TrustedRelayExecutable, diagnostic_detail, group_key, @@ -221,6 +274,8 @@ impl NotificationAttribution { desktop_id: &str, badge_icon: &str, status: AttributionStatus, + assurance: IdentityAssurance, + interactions: InteractionPolicies, reason: AttributionReason, diagnostic_detail: &str, group_key: String, @@ -231,19 +286,39 @@ impl NotificationAttribution { desktop_id: bounded_text(desktop_id), badge_icon: bounded_text(badge_icon), status, + assurance, + interactions, reason, diagnostic_detail: bounded_text(diagnostic_detail), group_key: bounded_group_key(&group_key), } } - /// Policy for signals that belong to the authenticated application + /// Policy for whole-card or advertised default activation + #[must_use] + pub const fn default_activation_policy(&self) -> ApplicationActionPolicy { + self.interactions.default_activation + } + + /// Policy for non-default application action buttons + #[must_use] + pub const fn action_button_policy(&self) -> ApplicationActionPolicy { + self.interactions.action_buttons + } + + /// Compatibility policy for clients that have not split action surfaces yet #[must_use] pub const fn application_action_policy(&self) -> ApplicationActionPolicy { - if matches!(self.status, AttributionStatus::Verified) { - ApplicationActionPolicy::Allow + self.interactions.default_activation + } + + /// Policy for one exact advertised action key + #[must_use] + pub fn action_policy(&self, action_key: &str) -> ApplicationActionPolicy { + if action_key == "default" { + self.default_activation_policy() } else { - ApplicationActionPolicy::Deny + self.action_button_policy() } } } diff --git a/crates/unixnotis-core/src/model/interaction.rs b/crates/unixnotis-core/src/model/interaction.rs new file mode 100644 index 000000000..272d63f62 --- /dev/null +++ b/crates/unixnotis-core/src/model/interaction.rs @@ -0,0 +1,67 @@ +//! Independent authority for application-owned notification controls + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use zbus::zvariant::Type; + +/// Policy for credential-like inline text controls +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum InlineReplyPolicy { + Allow = 0, + Confirm = 1, + #[default] + Deny = 2, +} + +/// Policy for one application-owned action signal +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum ApplicationActionPolicy { + Allow = 0, + Confirm = 1, + #[default] + Deny = 2, +} + +/// Independent authority for each interaction surface +#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct InteractionPolicies { + pub default_activation: ApplicationActionPolicy, + pub action_buttons: ApplicationActionPolicy, + pub inline_reply: InlineReplyPolicy, +} + +impl InteractionPolicies { + /// Future strong boundaries may grant every advertised interaction + pub const AUTHENTICATED: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Allow, + inline_reply: InlineReplyPolicy::Allow, + }; + + /// Native association keeps compatible card activation but gates richer controls + pub const NATIVE_COMPATIBILITY: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Confirm, + inline_reply: InlineReplyPolicy::Deny, + }; + + /// Brokered and user-local associations require confirmation for every action + pub const CONFIRM_ACTIONS: Self = Self { + default_activation: ApplicationActionPolicy::Confirm, + action_buttons: ApplicationActionPolicy::Confirm, + inline_reply: InlineReplyPolicy::Deny, + }; + + /// Uncertain or contradictory senders cannot emit application-owned signals + pub const DENY: Self = Self { + default_activation: ApplicationActionPolicy::Deny, + action_buttons: ApplicationActionPolicy::Deny, + inline_reply: InlineReplyPolicy::Deny, + }; +} + +#[cfg(test)] +#[path = "tests/interaction.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index 9a37610dd..cec489ecc 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -4,20 +4,21 @@ mod attribution; mod diagnostics; mod image; +mod interaction; mod notification; mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. pub use attribution::{ - ApplicationActionPolicy, AttributionReason, AttributionStatus, InlineReplyPolicy, - NotificationAttribution, + AttributionReason, AttributionStatus, IdentityAssurance, NotificationAttribution, }; pub use diagnostics::{ AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, RecordTrust, }; pub use image::{ImageData, NotificationImage}; +pub use interaction::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; pub use notification::{Notification, NotificationKey, NotificationView}; pub use reply::InlineReply; pub use types::{Action, Urgency}; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 0925f41eb..81599db55 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -6,9 +6,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; -use super::attribution::{InlineReplyPolicy, NotificationAttribution}; +use super::attribution::NotificationAttribution; use super::diagnostics::AttributionDiagnostics; use super::image::NotificationImage; +use super::interaction::InlineReplyPolicy; use super::reply::InlineReply; use super::types::{Action, Urgency}; use crate::util::{fold_text_for_layout, MAX_DISPLAY_TOKEN_WIDTH}; diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 9a1481798..5ed967416 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -1,7 +1,5 @@ -use super::{ - ApplicationActionPolicy, AttributionReason, AttributionStatus, InlineReplyPolicy, - NotificationAttribution, -}; +use super::{AttributionReason, AttributionStatus, IdentityAssurance, NotificationAttribution}; +use crate::model::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; #[test] @@ -27,9 +25,9 @@ fn attribution_wire_enums_use_declared_one_byte_values() { for (reason, discriminant) in [ (AttributionReason::ExactSystemExecutable, 0_u8), - (AttributionReason::VerifiedPortalAppId, 1), + (AttributionReason::PortalAppIdAssociation, 1), (AttributionReason::ExactUserExecutable, 2), - (AttributionReason::VerifiedProtectedPayload, 3), + (AttributionReason::ProtectedPayloadMatch, 3), (AttributionReason::TrustedRelayExecutable, 4), (AttributionReason::MissingSenderEvidence, 10), (AttributionReason::MissingCommandLine, 11), @@ -53,12 +51,37 @@ fn attribution_wire_enums_use_declared_one_byte_values() { for (policy, discriminant) in [ (InlineReplyPolicy::Allow, 0_u8), + (InlineReplyPolicy::Confirm, 1), (InlineReplyPolicy::Deny, 2), ] { let encoded = to_bytes(context, &policy).expect("serialize inline reply policy"); assert_eq!(InlineReplyPolicy::signature(), u8::signature()); assert_eq!(encoded.bytes(), &[discriminant]); } + + for (assurance, discriminant) in [ + (IdentityAssurance::Authenticated, 0_u8), + (IdentityAssurance::SystemAssociated, 1), + (IdentityAssurance::PortalAssociated, 2), + (IdentityAssurance::UserAssociated, 3), + (IdentityAssurance::Unresolved, 4), + (IdentityAssurance::Conflict, 5), + (IdentityAssurance::Relay, 6), + ] { + let encoded = to_bytes(context, &assurance).expect("serialize identity assurance"); + assert_eq!(IdentityAssurance::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } + + for (policy, discriminant) in [ + (ApplicationActionPolicy::Allow, 0_u8), + (ApplicationActionPolicy::Confirm, 1), + (ApplicationActionPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize application action policy"); + assert_eq!(ApplicationActionPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } } #[test] @@ -69,8 +92,9 @@ fn attribution_wire_enums_reject_unknown_discriminants() { assert!(unknown.deserialize::().is_err()); assert!(unknown.deserialize::().is_err()); - let unused_policy = to_bytes(context, &1_u8).expect("serialize unused policy byte"); - assert!(unused_policy.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); } #[test] @@ -182,7 +206,7 @@ fn relay_never_promotes_the_caller_label_to_primary_identity() { } #[test] -fn only_verified_identity_allows_application_actions() { +fn interaction_policies_keep_identity_and_action_authority_separate() { let verified = NotificationAttribution::verified( "Verified", "Verified", @@ -193,9 +217,57 @@ fn only_verified_identity_allows_application_actions() { "system-app:verified".to_string(), ); assert_eq!( - verified.application_action_policy(), + verified.action_policy("default"), ApplicationActionPolicy::Allow ); + assert_eq!( + verified.action_policy("open"), + ApplicationActionPolicy::Allow + ); + + let native = NotificationAttribution::associated( + "System app", + "System app", + "org.example.System", + "system", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "", + "associated:system-app:system".to_string(), + ); + assert_eq!( + native.default_activation_policy(), + ApplicationActionPolicy::Allow, + "native association should preserve compatible card activation" + ); + assert_eq!( + native.action_button_policy(), + ApplicationActionPolicy::Confirm, + "native association should require confirmation for richer actions" + ); + assert_eq!( + native.interactions.inline_reply, + InlineReplyPolicy::Deny, + "same-user native association cannot protect credential-like reply text" + ); + + let portal = NotificationAttribution::associated( + "Portal app", + "Portal app", + "org.example.Portal", + "portal", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "", + "associated:portal-app:portal".to_string(), + ); + assert_eq!( + portal.default_activation_policy(), + ApplicationActionPolicy::Confirm, + "an app id without unforgeable provenance must not activate silently" + ); for attribution in [ NotificationAttribution::recognized( @@ -223,7 +295,7 @@ fn only_verified_identity_allows_application_actions() { NotificationAttribution::relay("Relay", "", "relay:relay".to_string()), ] { assert_eq!( - attribution.application_action_policy(), + attribution.action_policy("default"), ApplicationActionPolicy::Deny, "status {:?} must not emit application-owned signals", attribution.status diff --git a/crates/unixnotis-core/src/model/tests/interaction.rs b/crates/unixnotis-core/src/model/tests/interaction.rs new file mode 100644 index 000000000..feaa24b92 --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/interaction.rs @@ -0,0 +1,58 @@ +//! Interaction policy wire and matrix regressions + +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +use super::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; + +#[test] +fn interaction_policy_enums_keep_stable_one_byte_wire_values() { + let context = Context::new_dbus(LE, 0); + for (policy, discriminant) in [ + (ApplicationActionPolicy::Allow, 0_u8), + (ApplicationActionPolicy::Confirm, 1), + (ApplicationActionPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize action policy"); + assert_eq!(ApplicationActionPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } + for (policy, discriminant) in [ + (InlineReplyPolicy::Allow, 0_u8), + (InlineReplyPolicy::Confirm, 1), + (InlineReplyPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize reply policy"); + assert_eq!(InlineReplyPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } +} + +#[test] +fn native_compatibility_keeps_default_activation_without_richer_authority() { + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.default_activation, + ApplicationActionPolicy::Allow + ); + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.action_buttons, + ApplicationActionPolicy::Confirm + ); + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.inline_reply, + InlineReplyPolicy::Deny + ); +} + +#[test] +fn confirmation_and_denial_matrices_never_allow_inline_text() { + for policies in [ + InteractionPolicies::CONFIRM_ACTIONS, + InteractionPolicies::DENY, + ] { + assert_ne!( + policies.inline_reply, + InlineReplyPolicy::Allow, + "weaker associations must not expose credential-like reply text" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs index 11992a3dd..bd3954c13 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs @@ -1,16 +1,10 @@ //! Interaction decisions kept independent from presentation association -use unixnotis_core::{AttributionStatus, InlineReplyPolicy}; +use unixnotis_core::{InlineReplyPolicy, InteractionPolicies}; -pub(super) const fn inline_reply_policy(status: AttributionStatus) -> InlineReplyPolicy { - // Text entry stays disabled unless strong evidence identifies the application - match status { - AttributionStatus::Verified => InlineReplyPolicy::Allow, - AttributionStatus::Recognized - | AttributionStatus::Unresolved - | AttributionStatus::Conflict - | AttributionStatus::Relay => InlineReplyPolicy::Deny, - } +pub(super) const fn inline_reply_policy(interactions: InteractionPolicies) -> InlineReplyPolicy { + // The resolver owns this policy instead of deriving text authority from branding + interactions.inline_reply } #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index 7c09eca65..da58d542c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -1,6 +1,6 @@ //! Ordered attribution pipeline and candidate orchestration -use unixnotis_core::{AttributionStatus, RecordTrust}; +use unixnotis_core::{AttributionStatus, InteractionPolicies, RecordTrust}; use super::super::desktop_index::{ DesktopIdentityIndex, LaunchFailure, LaunchVerification, VerifiedLaunch, @@ -28,10 +28,12 @@ pub(in crate::daemon) async fn resolve_attribution( // Cached process data is refreshed before it affects attribution let mut sender = refresh_sender_security_evidence(sender); let initial = resolve_with_evidence(claim, &sender, index); - if initial.attribution.status != AttributionStatus::Recognized - && !(initial.attribution.status == AttributionStatus::Unresolved - && claim_has_index_candidate(claim, index)) - { + let needs_provenance = needs_sender_provenance( + initial.attribution.status, + initial.attribution.interactions, + claim_has_index_candidate(claim, index), + ); + if !needs_provenance { return initial; } @@ -40,7 +42,18 @@ pub(in crate::daemon) async fn resolve_attribution( resolve_with_evidence(claim, &sender, index) } -fn claim_has_index_candidate(claim: AppClaim<'_>, index: &DesktopIdentityIndex) -> bool { +pub(super) fn needs_sender_provenance( + status: AttributionStatus, + interactions: InteractionPolicies, + claim_has_candidate: bool, +) -> bool { + // Package lookup is useful only while it can positively bind a denied helper + interactions == InteractionPolicies::DENY + && (status == AttributionStatus::Recognized + || (status == AttributionStatus::Unresolved && claim_has_candidate)) +} + +pub(super) fn claim_has_index_candidate(claim: AppClaim<'_>, index: &DesktopIdentityIndex) -> bool { if !claim.reported_name.trim().is_empty() && !index.records_for_claim(claim.reported_name).is_empty() { @@ -66,12 +79,12 @@ pub(super) fn resolve_with_evidence( && !hint_records.is_empty() && trusted_portal_path(sender, index).is_some() { - // A trusted portal executable may forward its broker-owned application id + // A trusted portal executable associates branding but cannot authenticate the origin let record = preferred_record(&hint_records); if !claim.reported_name.trim().is_empty() && !index.record_matches_claim(record, claim.reported_name) { - // A protected portal id and a different caller label are affirmative contradiction + // A portal desktop id and a different caller label remain contradictory evidence let mut resolution = conflict_from_candidate( claim, sender, @@ -81,7 +94,7 @@ pub(super) fn resolve_with_evidence( ); resolution.diagnostics.record_trust = RecordTrust::Portal; resolution.diagnostics.reason = - "verified portal application id contradicted the reported name".to_string(); + "portal application id contradicted the reported name".to_string(); return resolution; } let mut resolution = with_diagnostics( @@ -92,7 +105,7 @@ pub(super) fn resolve_with_evidence( LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), ); resolution.diagnostics.record_trust = RecordTrust::Portal; - resolution.diagnostics.reason = "verified portal application identity".to_string(); + resolution.diagnostics.reason = "portal-mediated application association".to_string(); return resolution; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index dc1e6fe71..15b8b9687 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -1,7 +1,8 @@ //! Structured attribution construction and trust-domain grouping use unixnotis_core::{ - AttributionDiagnostics, AttributionReason, AttributionStatus, NotificationAttribution, + AttributionDiagnostics, AttributionReason, AttributionStatus, IdentityAssurance, + InteractionPolicies, NotificationAttribution, }; use super::super::desktop_index::{ @@ -50,14 +51,16 @@ pub(super) fn resolution_for_portal_record( ); let canonical = index.canonical_record_for_record(record); let canonical_id = index.canonical_id_for_record(record); - let attribution = NotificationAttribution::verified( + let attribution = NotificationAttribution::associated( &canonical.display_name, reported_name, canonical_id, &canonical.badge_icon, - AttributionReason::VerifiedPortalAppId, + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, &format!("Mediated by {portal}"), - format!("verified:portal-app:{canonical_id}"), + format!("associated:portal-app:{canonical_id}"), ); policy_resolution(attribution) } @@ -99,16 +102,18 @@ pub(super) fn resolution_for_record( VerifiedLaunch::DedicatedExecutable | VerifiedLaunch::PackageLauncherTarget => { AttributionReason::ExactSystemExecutable } - VerifiedLaunch::ProtectedPayload => AttributionReason::VerifiedProtectedPayload, + VerifiedLaunch::ProtectedPayload => AttributionReason::ProtectedPayloadMatch, }; - return policy_resolution(NotificationAttribution::verified( + return policy_resolution(NotificationAttribution::associated( &canonical.display_name, reported_name, canonical_id, &canonical.badge_icon, + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, reason, &source, - format!("verified:system-app:{canonical_id}"), + format!("associated:system-app:{canonical_id}"), )); } @@ -116,11 +121,13 @@ pub(super) fn resolution_for_record( || "unknown".to_string(), super::super::executable::FileIdentity::group_fragment, ); - policy_resolution(NotificationAttribution::recognized( + policy_resolution(NotificationAttribution::associated( &canonical.display_name, reported_name, canonical_id, &canonical.badge_icon, + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, AttributionReason::ExactUserExecutable, &source, format!( @@ -159,11 +166,18 @@ pub(super) fn recognized_resolution( sender_identity_fragment(sender) ) }; - policy_resolution(NotificationAttribution::recognized( + let assurance = if record.system_origin { + IdentityAssurance::SystemAssociated + } else { + IdentityAssurance::UserAssociated + }; + policy_resolution(NotificationAttribution::associated( &canonical.display_name, claim.reported_name, canonical_id, &canonical.badge_icon, + assurance, + InteractionPolicies::DENY, attribution_reason_for_failure(failure), &source, group_key, @@ -210,7 +224,7 @@ fn conflict_resolution( pub(super) fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { AttributionResolution { - inline_reply_policy: inline_reply_policy(attribution.status), + inline_reply_policy: inline_reply_policy(attribution.interactions), attribution, diagnostics: AttributionDiagnostics::default(), } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs index 7f584b7dd..bcbd2b9e6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs @@ -36,12 +36,16 @@ fn equivalent_desktop_aliases_use_one_canonical_application_identity() { let alias_first = resolve_alias(vec![alias, canonical]); for resolution in [&canonical_first, &alias_first] { - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); assert_eq!(resolution.attribution.display_name, "Example App"); assert_eq!(resolution.attribution.badge_icon, "example-app"); assert_eq!( resolution.attribution.group_key, - "verified:system-app:org.example.True" + "associated:system-app:org.example.True" ); } assert_eq!( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs index a4a429fdc..bc04daac3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs @@ -200,7 +200,11 @@ fn verified_and_unresolved_senders_never_share_an_application_group() { &index, ); - assert_eq!(verified.attribution.status, AttributionStatus::Verified); + assert_eq!(verified.attribution.status, AttributionStatus::Recognized); + assert_eq!( + verified.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); assert_eq!(unresolved.attribution.status, AttributionStatus::Unresolved); assert_ne!( verified.attribution.group_key, unresolved.attribution.group_key, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs index 3d90fbd41..f4fab6faf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs @@ -121,8 +121,8 @@ fn matching_fixed_system_application_argument_allows_association() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] @@ -203,8 +203,8 @@ fn nonempty_dedicated_contract_can_rely_on_exact_executable_evidence() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] @@ -302,8 +302,8 @@ fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs index 241c0e166..5ae0af498 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -4,14 +4,16 @@ use std::collections::HashSet; use std::path::PathBuf; use unixnotis_core::{ - AttributionStatus, CommandLineQualityView, InlineReplyPolicy, LaunchAuthorityView, - LaunchVerificationView, RecordTrust, + AttributionStatus, CommandLineQualityView, InlineReplyPolicy, InteractionPolicies, + LaunchAuthorityView, LaunchVerificationView, RecordTrust, }; use super::candidates::{resolve_unverified_candidates, strongest_verified_result}; use super::evidence::verify_record_sender; use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; -use super::pipeline::{resolve_attribution, resolve_with_evidence}; +use super::pipeline::{ + claim_has_index_candidate, needs_sender_provenance, resolve_attribution, resolve_with_evidence, +}; use super::AppClaim; use crate::daemon::notifications::identity::desktop_index::model::{ ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs index b1bbf8198..a5b2e2d59 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs @@ -3,7 +3,7 @@ use super::super::*; #[test] -fn dedicated_system_identity_allows_legitimate_reply() { +fn dedicated_system_identity_is_associated_without_inline_reply_authority() { let (app_path, app_identity) = installed_system_executable(); let index = DesktopIdentityIndex::from_records( vec![system_record( @@ -24,13 +24,25 @@ fn dedicated_system_identity_allows_legitimate_reply() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); assert_eq!(resolution.attribution.display_name, "True Chat"); assert_eq!( resolution.attribution.group_key, - "verified:system-app:org.example.True" + "associated:system-app:org.example.True" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), + unixnotis_core::ApplicationActionPolicy::Allow + ); + assert_eq!( + resolution.attribution.action_button_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm ); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); assert!(!resolution .attribution .diagnostic_detail @@ -81,7 +93,7 @@ fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); } #[test] @@ -147,7 +159,7 @@ fn verified_executable_recovers_from_stale_desktop_hint() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.attribution.display_name, "Signal"); assert_eq!(resolution.attribution.desktop_id, "signal-true"); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs index 372103853..c795f97df 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs @@ -56,7 +56,7 @@ fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { } #[test] -fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { +fn portal_mediated_flatpak_uses_broker_associated_desktop_identity() { let flatpak_identity = identity(22, 220, 0); let (portal_path, portal_identity) = installed_system_executable(); let mut record = system_record( @@ -72,7 +72,7 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { let resolution = resolve_with_evidence( AppClaim { - // The GTK portal backend forwards an empty app name and verified desktop-entry hint + // The GTK portal backend forwards an empty app name and desktop-entry hint reported_name: "", desktop_entry: Some("org.example.FlatpakApp"), }, @@ -80,9 +80,21 @@ fn portal_mediated_flatpak_uses_broker_verified_desktop_identity() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::PortalAssociated + ); assert_eq!(resolution.attribution.display_name, "Flatpak App"); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm + ); + assert_eq!( + resolution.attribution.action_button_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm + ); } #[test] @@ -109,9 +121,9 @@ fn trusted_portal_accepts_a_matching_nonempty_application_name() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!(resolution.attribution.display_name, "Flatpak App"); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index e3d0ee9f3..aee58057d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -2,6 +2,81 @@ use super::super::*; +#[test] +fn provenance_enrichment_is_limited_to_denied_association_candidates() { + for (status, policies, has_candidate, expected) in [ + ( + AttributionStatus::Recognized, + InteractionPolicies::DENY, + false, + true, + ), + ( + AttributionStatus::Unresolved, + InteractionPolicies::DENY, + true, + true, + ), + ( + AttributionStatus::Unresolved, + InteractionPolicies::DENY, + false, + false, + ), + ( + AttributionStatus::Recognized, + InteractionPolicies::NATIVE_COMPATIBILITY, + true, + false, + ), + ( + AttributionStatus::Conflict, + InteractionPolicies::DENY, + true, + false, + ), + ] { + assert_eq!( + needs_sender_provenance(status, policies, has_candidate), + expected, + "status={status:?}, policies={policies:?}, has_candidate={has_candidate}" + ); + } +} + +#[test] +fn provenance_candidate_lookup_accepts_only_indexed_name_or_desktop_id() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(41, 42, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + assert!(claim_has_index_candidate( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &index, + )); + assert!(claim_has_index_candidate( + AppClaim { + reported_name: "", + desktop_entry: Some("org.example.App"), + }, + &index, + )); + assert!(!claim_has_index_candidate( + AppClaim { + reported_name: "Unknown App", + desktop_entry: Some("org.example.Missing"), + }, + &index, + )); +} + #[tokio::test] async fn recognized_helper_is_reresolved_with_live_package_provenance() { let helper_path = unixnotis_core::util::trusted_system_program_path("true") diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs index eb0df83ac..3348b42d4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -45,7 +45,7 @@ fn verified_record_with_a_contradictory_name_becomes_conflict() { } #[test] -fn verified_package_launcher_target_receives_application_authority() { +fn package_launcher_target_preserves_only_compatible_default_activation() { let record = system_record( "org.example.App", "Example App", @@ -66,12 +66,20 @@ fn verified_package_launcher_target_receives_application_authority() { &index, ); - assert_eq!(resolution.attribution.status, AttributionStatus::Verified); - assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Allow); + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); assert_eq!( - resolution.attribution.application_action_policy(), + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), ApplicationActionPolicy::Allow ); + assert_eq!( + resolution.attribution.action_button_policy(), + ApplicationActionPolicy::Confirm + ); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs index d4d7dd3c4..0052ca180 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs @@ -1,22 +1,27 @@ -use unixnotis_core::{AttributionStatus, InlineReplyPolicy}; +use unixnotis_core::{InlineReplyPolicy, InteractionPolicies}; use super::inline_reply_policy; #[test] -fn only_system_and_portal_associations_allow_inline_replies() { - for class in [AttributionStatus::Verified, AttributionStatus::Verified] { - assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Allow); - } +fn only_authenticated_policy_allows_inline_replies() { + assert_eq!( + inline_reply_policy(InteractionPolicies::AUTHENTICATED), + InlineReplyPolicy::Allow, + "authenticated interaction policy should retain reply authority" + ); } #[test] -fn every_unconfirmed_attribution_class_denies_inline_replies() { - for class in [ - AttributionStatus::Recognized, - AttributionStatus::Relay, - AttributionStatus::Unresolved, - AttributionStatus::Conflict, +fn every_same_user_association_policy_denies_inline_replies() { + for policies in [ + InteractionPolicies::NATIVE_COMPATIBILITY, + InteractionPolicies::CONFIRM_ACTIONS, + InteractionPolicies::DENY, ] { - assert_eq!(inline_reply_policy(class), InlineReplyPolicy::Deny); + assert_eq!( + inline_reply_policy(policies), + InlineReplyPolicy::Deny, + "same-user execution cannot authenticate credential-like reply text" + ); } } From 8d0571a599c2d147e7caca65fd34e0a81b82b9be Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 19:36:23 -0500 Subject: [PATCH 167/275] feat(presentation): expose associated trust and authority Summary: expose associated trust and authority. Scope: presentation. --- .../noticenterctl/src/output/diagnostics.rs | 53 +++- .../src/output/tests/diagnostics.rs | 10 +- .../unixnotis-center/src/control/commands.rs | 2 + crates/unixnotis-center/src/control/model.rs | 3 + .../src/control/tests/commands.rs | 1 + .../src/ui/notifications/row/group.rs | 11 +- .../notifications/row/notification/state.rs | 3 +- .../row/notification/tests/support.rs | 18 +- .../row/notification/update/actions.rs | 44 +++- .../row/notification/update/tests/actions.rs | 86 +++++- .../row/notification/update/tests/state.rs | 2 + .../row/notification/update/visual.rs | 11 +- .../unixnotis-core/src/control/constants.rs | 2 +- .../unixnotis-core/src/control/diagnostics.rs | 5 +- crates/unixnotis-core/src/control/proxy.rs | 1 + .../unixnotis-core/src/model/attribution.rs | 13 +- .../src/model/tests/attribution.rs | 15 +- .../src/daemon/auth/authorization.rs | 13 +- .../unixnotis-daemon/src/daemon/auth/mod.rs | 3 +- .../src/daemon/auth/policy.rs | 4 + .../src/daemon/auth/tests/authorization.rs | 46 +++- .../src/daemon/control/action.rs | 13 +- .../src/daemon/control/server.rs | 22 +- .../src/daemon/control/tests/action.rs | 11 +- .../src/daemon/control/tests/reply.rs | 10 +- .../src/daemon/control/tests/server.rs | 2 +- crates/unixnotis-daemon/src/store/runtime.rs | 15 +- .../src/store/test_support.rs | 10 +- .../src/store/tests/runtime.rs | 135 +++++++++- crates/unixnotis-popups/src/dbus/commands.rs | 2 + .../src/dbus/tests/commands.rs | 1 + crates/unixnotis-popups/src/dbus/types.rs | 3 + .../src/ui/entry/activation.rs | 1 + .../src/ui/entry/builders/common.rs | 24 +- .../src/ui/entry/builders/tests/common.rs | 56 +++- .../src/ui/entry/presentation/tests/trust.rs | 4 +- .../ui/entry/presentation/tests/view_model.rs | 8 +- .../src/ui/entry/tests/activation.rs | 2 + .../src/ui/entry/tests/commands.rs | 1 + crates/unixnotis-ui/src/presentation/build.rs | 76 ++++-- .../src/presentation/interaction.rs | 25 ++ crates/unixnotis-ui/src/presentation/mod.rs | 2 + .../src/presentation/tests/interaction.rs | 39 +++ .../src/presentation/tests/mod.rs | 1 + .../src/presentation/tests/presentation.rs | 248 +++++++++++++++++- crates/unixnotis-ui/src/presentation/types.rs | 7 +- 46 files changed, 948 insertions(+), 116 deletions(-) create mode 100644 crates/unixnotis-ui/src/presentation/interaction.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/interaction.rs diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs index abef1e7fa..d33767d8f 100644 --- a/crates/noticenterctl/src/output/diagnostics.rs +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -4,8 +4,9 @@ use std::fmt::Write; use anyhow::Result; use unixnotis_core::{ - CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, - NotificationDiagnosticsView, PopupAdmissionView, PopupDeliveryStage, RecordTrust, + ApplicationActionPolicy, CommandLineQualityView, IdentityAssurance, InlineReplyPolicy, + LaunchAuthorityView, LaunchVerificationView, NotificationDiagnosticsView, PopupAdmissionView, + PopupDeliveryStage, RecordTrust, }; use super::write_stdout; @@ -63,6 +64,26 @@ fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result "Launch detail: {}", value_or_none(&diagnostics.reason) )?; + writeln!( + output, + "Identity assurance: {}", + identity_assurance(view.identity_assurance) + )?; + writeln!( + output, + "Default activation: {}", + action_policy(view.interaction_policies.default_activation) + )?; + writeln!( + output, + "Action buttons: {}", + action_policy(view.interaction_policies.action_buttons) + )?; + writeln!( + output, + "Inline reply: {}", + reply_policy(view.interaction_policies.inline_reply) + )?; writeln!(output, "Stored: {}", yes_no(view.stored))?; writeln!( output, @@ -106,6 +127,34 @@ fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result Ok(output) } +const fn identity_assurance(value: IdentityAssurance) -> &'static str { + match value { + IdentityAssurance::Authenticated => "authenticated", + IdentityAssurance::SystemAssociated => "system associated", + IdentityAssurance::PortalAssociated => "portal associated", + IdentityAssurance::UserAssociated => "user associated", + IdentityAssurance::Unresolved => "unresolved", + IdentityAssurance::Conflict => "conflict", + IdentityAssurance::Relay => "relay", + } +} + +const fn action_policy(value: ApplicationActionPolicy) -> &'static str { + match value { + ApplicationActionPolicy::Allow => "allowed", + ApplicationActionPolicy::Confirm => "confirmation required", + ApplicationActionPolicy::Deny => "denied", + } +} + +const fn reply_policy(value: InlineReplyPolicy) -> &'static str { + match value { + InlineReplyPolicy::Allow => "allowed", + InlineReplyPolicy::Confirm => "confirmation required", + InlineReplyPolicy::Deny => "denied", + } +} + const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { match value { PopupDeliveryStage::Suppressed => "suppressed", diff --git a/crates/noticenterctl/src/output/tests/diagnostics.rs b/crates/noticenterctl/src/output/tests/diagnostics.rs index 569bd0dfd..7b5248394 100644 --- a/crates/noticenterctl/src/output/tests/diagnostics.rs +++ b/crates/noticenterctl/src/output/tests/diagnostics.rs @@ -15,7 +15,13 @@ fn diagnostics_keep_launch_verification_distinct_from_attribution_status() { "diagnostics should label the launch evidence detail" ); assert!( - !output.contains("Identity result:"), - "launch evidence must not masquerade as the final attribution state" + output.contains("Identity assurance: unresolved"), + "final identity authority must remain distinct from the launch match" + ); + assert!( + output.contains("Default activation: denied") + && output.contains("Action buttons: denied") + && output.contains("Inline reply: denied"), + "diagnostics must expose every independent interaction policy" ); } diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index 6847df498..f605e7819 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -27,11 +27,13 @@ pub async fn handle_command( UiCommand::InvokeAction { notification, action_key, + confirmed, } => { timed_dbus_call(proxy.invoke_action_generation( notification.id, notification.generation, &action_key, + confirmed, )) .await } diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index e04824f2c..f1bfc25c6 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -48,6 +48,7 @@ pub enum UiCommand { InvokeAction { notification: NotificationKey, action_key: String, + confirmed: bool, }, Reply { id: u32, @@ -71,10 +72,12 @@ impl fmt::Debug for UiCommand { Self::InvokeAction { notification, action_key, + confirmed, } => formatter .debug_struct("InvokeAction") .field("notification", notification) .field("action_key", action_key) + .field("confirmed", confirmed) .finish(), Self::Reply { id, generation, .. } => formatter .debug_struct("Reply") diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index e4c6594e7..bae568810 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -15,6 +15,7 @@ fn drop_stale_offline_commands_retains_safe_actions() { generation: 13, }, action_key: "open".to_string(), + confirmed: false, }); offline.push_back(UiCommand::SetDnd(true)); offline.push_back(UiCommand::ClearAll); diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 0407fc24f..61871f04f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -213,13 +213,22 @@ pub(in crate::ui::notifications) fn update_group_row( ); for (level, class_name) in [ (TrustLevel::Verified, "verified"), - (TrustLevel::Recognized, "recognized"), (TrustLevel::Unresolved, "unresolved"), (TrustLevel::Conflict, "conflict"), (TrustLevel::Relay, "relay"), ] { set_class_state(root, class_name, presentation.trust.level == level); } + set_class_state( + root, + "recognized", + matches!( + presentation.trust.level, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated + ), + ); if apply_semantic_badge(&group.icon, presentation.identity.badge, GROUP_ICON_SIZE) { group.icon.set_visible(true); } else { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 25a121561..b1a13484a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -59,7 +59,8 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { // Recycled rows must rebuild action closures when the notification generation changes pub(super) action_cache_key: Cell, // Last rendered action signature for cheap no-op detection - pub(super) action_cache: RefCell>, + pub(super) action_cache: + RefCell>, // Reply metadata and live state are cached separately from ordinary actions pub(super) reply_cache: RefCell<( unixnotis_core::InlineReply, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 1b0f03e3e..9aa07fbc9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -17,15 +17,15 @@ pub(super) fn sample_notification() -> NotificationView { id: 1, generation: 1, app_name: "demo".to_string(), - attribution: unixnotis_core::NotificationAttribution { - display_name: "demo".to_string(), - claimed_name: "demo".to_string(), - badge_icon: "demo".to_string(), - status: unixnotis_core::AttributionStatus::Verified, - reason: unixnotis_core::AttributionReason::ExactSystemExecutable, - group_key: "test:demo".to_string(), - ..unixnotis_core::NotificationAttribution::default() - }, + attribution: unixnotis_core::NotificationAttribution::verified( + "demo", + "demo", + "org.example.Demo", + "demo", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "test:demo".to_string(), + ), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 429fd5b94..5d3850368 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -1,6 +1,7 @@ //! Notification action button rebuilding and dispatch use std::borrow::Cow; +use std::cell::Cell; use std::rc::Rc; use std::time::Duration; @@ -8,7 +9,9 @@ use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; use unixnotis_core::NotificationView; -use unixnotis_ui::presentation::{NotificationPresentation, ReplyPresentation}; +use unixnotis_ui::presentation::{ + action_activation, ActionActivation, NotificationPresentation, ReplyPresentation, +}; use crate::control::UiCommand; use crate::ui::panel::behavior::input::ClickCooldown; @@ -118,7 +121,7 @@ pub(super) fn update_actions( fn action_signature( presentation: &NotificationPresentation, is_active: bool, -) -> Vec<(String, String)> { +) -> Vec<(String, String, unixnotis_core::ApplicationActionPolicy)> { if !is_active { return Vec::new(); } @@ -127,24 +130,22 @@ fn action_signature( .primary .iter() .chain(&presentation.actions.overflow) - .map(|action| (action.key.clone(), action.label.clone())) + .map(|action| (action.key.clone(), action.label.clone(), action.policy)) .collect::>(); if let Some(default_key) = blank_default_action_key(presentation) { // The empty label distinguishes the compact icon-only default control - signature.push((default_key.to_string(), String::new())); + signature.push(( + default_key.to_string(), + String::new(), + unixnotis_core::ApplicationActionPolicy::Allow, + )); } signature } fn blank_default_action_key(presentation: &NotificationPresentation) -> Option<&str> { - let default_key = presentation.actions.default_key.as_deref()?; - let already_visible = presentation - .actions - .primary - .iter() - .chain(&presentation.actions.overflow) - .any(|action| action.key == default_key); - (!already_visible).then_some(default_key) + // Shared presentation keeps allowed defaults out of the visible button lists + presentation.actions.default_key.as_deref() } fn build_default_action_button( @@ -170,6 +171,7 @@ fn build_default_action_button( UiCommand::InvokeAction { notification, action_key: action_key.clone(), + confirmed: false, }, ); }); @@ -186,12 +188,27 @@ fn build_action_button( button.add_css_class("unixnotis-panel-action"); button.add_css_class("unixnotis-notification-action"); let action_key = action.key.clone(); + let original_label = clamp_action_label_text(&action.label).into_owned(); + let policy = action.policy; let tx = command_tx.clone(); + let confirmation_armed = Cell::new(false); let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); - button.connect_clicked(move |_| { + button.connect_clicked(move |button| { if !action_gate.try_start() { return; } + let confirmed = match action_activation(policy, confirmation_armed.get()) { + ActionActivation::Denied => return, + ActionActivation::ArmConfirmation => { + confirmation_armed.set(true); + let confirmation_label = format!("Confirm {original_label}"); + button.set_label(&confirmation_label); + button.set_tooltip_text(Some("Activate again to confirm")); + button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + return; + } + ActionActivation::Invoke { confirmed } => confirmed, + }; debug!( id = notification.id, generation = notification.generation, @@ -204,6 +221,7 @@ fn build_action_button( UiCommand::InvokeAction { notification, action_key: action_key.clone(), + confirmed, }, ); }); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index b630aca65..b495b007c 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::{hooks, Action, InlineReply}; +use unixnotis_core::{hooks, Action, ApplicationActionPolicy, InlineReply}; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -37,7 +37,11 @@ fn update_notification_row_rebuilds_actions_only_when_signature_changes() { assert!(!row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); assert_eq!( row.action_cache.borrow().as_slice(), - &[("open".to_string(), "Open".to_string())] + &[( + "open".to_string(), + "Open".to_string(), + ApplicationActionPolicy::Allow, + )] ); update_notification_row(&row, &data, &IconResolver::new(), &command_tx); @@ -190,10 +194,12 @@ fn update_notification_row_action_button_sends_command_once_per_click_window() { UiCommand::InvokeAction { notification, action_key, + confirmed, } => { assert_eq!(notification.id, 1); assert_eq!(notification.generation, 1); assert_eq!(action_key, "open"); + assert!(!confirmed, "allowed action should not claim confirmation"); } command => panic!("expected action command, got {command:?}"), } @@ -250,7 +256,7 @@ fn recycled_action_button_targets_the_new_notification_generation() { assert!(matches!( command_rx.try_recv(), - Ok(UiCommand::InvokeAction { notification, action_key }) + Ok(UiCommand::InvokeAction { notification, action_key, confirmed: false }) if notification.id == 2 && notification.generation == 7 && action_key == "open" @@ -318,7 +324,7 @@ fn active_blank_default_action_builds_accessible_open_control() { button.emit_clicked(); assert!(matches!( command_rx.try_recv(), - Ok(UiCommand::InvokeAction { notification, action_key }) + Ok(UiCommand::InvokeAction { notification, action_key, confirmed: false }) if notification.id == 1 && notification.generation == 1 && action_key == "default" @@ -326,7 +332,7 @@ fn active_blank_default_action_builds_accessible_open_control() { } #[gtk::test] -fn labeled_default_action_does_not_build_a_duplicate_open_control() { +fn labeled_default_action_uses_one_compact_accessible_open_control() { let (_root, row) = notification_row(); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); let mut notification = sample_notification(); @@ -353,9 +359,73 @@ fn labeled_default_action_does_not_build_a_duplicate_open_control() { .actions_box .first_child() .and_downcast::() - .expect("labeled default action button"); - assert_eq!(button.label().as_deref(), Some("Open conversation")); - assert!(!button.has_css_class("unixnotis-panel-default-action")); + .expect("compact default action button"); + assert!(button.has_css_class("unixnotis-panel-default-action")); + assert_eq!(button.tooltip_text().as_deref(), Some("Open notification")); +} + +#[gtk::test] +fn confirmable_panel_action_requires_two_clicks_before_dispatch() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("confirmable action button"); + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "first click must not invoke a confirmable action" + ); + + std::thread::sleep(std::time::Duration::from_millis(200)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 1 + && notification.generation == 1 + && action_key == "archive" + )); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 8963ad6f4..b7a9c9fa0 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -20,6 +20,8 @@ fn icon_signature_changes_when_trust_presentation_changes() { let mut suspicious = verified.clone(); // Keep resolver inputs unchanged to isolate the trust-state regression suspicious.attribution.status = unixnotis_core::AttributionStatus::Conflict; + suspicious.attribution.assurance = unixnotis_core::IdentityAssurance::Conflict; + suspicious.attribution.interactions = unixnotis_core::InteractionPolicies::DENY; assert_ne!( IconSignature::from(&verified), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index ead4d378d..55cd0e949 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -25,13 +25,22 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::shared_state::CRITICAL, is_critical); for (level, class_name) in [ (TrustLevel::Verified, "verified"), - (TrustLevel::Recognized, "recognized"), (TrustLevel::Unresolved, "unresolved"), (TrustLevel::Conflict, "conflict"), (TrustLevel::Relay, "relay"), ] { set_class_state(card, class_name, presentation.trust.level == level); } + set_class_state( + card, + "recognized", + matches!( + presentation.trust.level, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated + ), + ); set_widget_visible_if_changed(&row.urgency_badge, is_critical); set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); set_class_state( diff --git a/crates/unixnotis-core/src/control/constants.rs b/crates/unixnotis-core/src/control/constants.rs index 20ed23d51..b6de8e201 100644 --- a/crates/unixnotis-core/src/control/constants.rs +++ b/crates/unixnotis-core/src/control/constants.rs @@ -7,7 +7,7 @@ pub const CONTROL_OBJECT_PATH: &str = "/com/unixnotis/Control"; /// D-Bus interface name for control calls pub const CONTROL_INTERFACE: &str = "com.unixnotis.Control"; /// Coordinated private interface version shared by daemon and UI binaries -pub const CONTROL_API_VERSION: u32 = 2; +pub const CONTROL_API_VERSION: u32 = 3; /// Freedesktop notification service name owned by the active notification daemon pub const NOTIFICATIONS_BUS_NAME: &str = "org.freedesktop.Notifications"; /// Inhibit scope meaning all notification output diff --git a/crates/unixnotis-core/src/control/diagnostics.rs b/crates/unixnotis-core/src/control/diagnostics.rs index d016bc8e8..e83e767e6 100644 --- a/crates/unixnotis-core/src/control/diagnostics.rs +++ b/crates/unixnotis-core/src/control/diagnostics.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::Type; -use crate::AttributionDiagnostics; +use crate::{AttributionDiagnostics, IdentityAssurance, InteractionPolicies}; use super::{PopupAdmissionView, PopupDeliveryStage}; @@ -14,6 +14,9 @@ pub struct NotificationDiagnosticsView { pub generation: u64, pub stored: bool, pub attribution: AttributionDiagnostics, + // Final authority stays separate from the lower-level launch evidence above + pub identity_assurance: IdentityAssurance, + pub interaction_policies: InteractionPolicies, pub popup_admission: PopupAdmissionView, pub renderer_process_running: bool, pub renderer_ready: bool, diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 6d1dc0a9f..641e47afb 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -70,6 +70,7 @@ trait Control { id: u32, generation: u64, action_key: &str, + confirmed: bool, ) -> zbus::Result<()>; /// Submit text for an explicitly advertised inline-reply action fn reply_notification(&self, id: u32, generation: u64, reply_text: &str) -> zbus::Result<()>; diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index d3a844803..f1bed92e0 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -27,13 +27,20 @@ pub enum AttributionStatus { #[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum IdentityAssurance { + /// Kernel, confinement, or broker evidence binds both identity and execution origin Authenticated = 0, + /// Protected installation evidence binds an app but cannot prove same-UID code integrity SystemAssociated = 1, + /// A trusted portal process supplied an app ID without unforgeable caller provenance PortalAssociated = 2, + /// A user-local desktop record associates branding without a protected boundary UserAssociated = 3, #[default] + /// No positive application association was established Unresolved = 4, + /// Concrete protected evidence contradicts the application claim Conflict = 5, + /// A known forwarding executable supplied an unauthenticated application label Relay = 6, } @@ -306,12 +313,6 @@ impl NotificationAttribution { self.interactions.action_buttons } - /// Compatibility policy for clients that have not split action surfaces yet - #[must_use] - pub const fn application_action_policy(&self) -> ApplicationActionPolicy { - self.interactions.default_activation - } - /// Policy for one exact advertised action key #[must_use] pub fn action_policy(&self, action_key: &str) -> ApplicationActionPolicy { diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 5ed967416..4d5875247 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -206,7 +206,7 @@ fn relay_never_promotes_the_caller_label_to_primary_identity() { } #[test] -fn interaction_policies_keep_identity_and_action_authority_separate() { +fn authenticated_and_native_policies_keep_action_surfaces_separate() { let verified = NotificationAttribution::verified( "Verified", "Verified", @@ -246,12 +246,25 @@ fn interaction_policies_keep_identity_and_action_authority_separate() { ApplicationActionPolicy::Confirm, "native association should require confirmation for richer actions" ); + assert_eq!( + native.action_policy("default"), + ApplicationActionPolicy::Allow, + "the protocol default key should use default activation policy" + ); + assert_eq!( + native.action_policy("archive"), + ApplicationActionPolicy::Confirm, + "non-default keys should use button policy" + ); assert_eq!( native.interactions.inline_reply, InlineReplyPolicy::Deny, "same-user native association cannot protect credential-like reply text" ); +} +#[test] +fn portal_and_unassociated_policies_never_allow_silent_actions() { let portal = NotificationAttribution::associated( "Portal app", "Portal app", diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index a5b8adc70..9a9d76870 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -12,8 +12,8 @@ use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; use super::executable_trust::is_trusted_control_executable_path; use super::policy::{ - TRUSTED_CONTROL_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES, - TRUSTED_POPUP_READINESS_EXECUTABLES, + TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, + TRUSTED_PANEL_READINESS_EXECUTABLES, TRUSTED_POPUP_READINESS_EXECUTABLES, }; #[cfg(not(target_os = "linux"))] use super::process_identity::read_process_executable_path; @@ -43,6 +43,15 @@ pub(in crate::daemon) async fn authorize_panel_readiness_call( .await } +pub(in crate::daemon) async fn authorize_interaction_call( + state: &Arc, + header: &Header<'_>, + method: &'static str, +) -> zbus::fdo::Result<()> { + authorize_control_call_for_executables(state, header, method, &TRUSTED_INTERACTION_EXECUTABLES) + .await +} + pub(in crate::daemon) async fn authorize_popup_readiness_call( state: &Arc, header: &Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/auth/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/mod.rs index 9426ff683..44391958b 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/mod.rs @@ -19,7 +19,8 @@ mod policy; mod process_identity; pub(super) use authorization::{ - authorize_control_call, authorize_panel_readiness_call, authorize_popup_readiness_call, + authorize_control_call, authorize_interaction_call, authorize_panel_readiness_call, + authorize_popup_readiness_call, }; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/auth/policy.rs b/crates/unixnotis-daemon/src/daemon/auth/policy.rs index 29a0df074..7c2dc5a1a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/policy.rs @@ -11,6 +11,10 @@ pub(in crate::daemon) const TRUSTED_CONTROL_EXECUTABLES: [&str; 4] = [ "unixnotis-daemon", ]; +// Only interactive renderers may assert that a user confirmed an application action +pub(in crate::daemon) const TRUSTED_INTERACTION_EXECUTABLES: [&str; 2] = + ["unixnotis-center", "unixnotis-popups"]; + // Only the center process may publish panel readiness state pub(in crate::daemon) const TRUSTED_PANEL_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-center"]; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index fef07ef82..0ab1770ef 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -3,12 +3,13 @@ use zbus::Message; #[cfg(target_os = "linux")] use super::authorization::required_linux_process_fd; use super::authorization::{ - authorize_control_call, authorize_panel_readiness_call, authorize_popup_readiness_call, - control_executable_error, control_owner_uid_error, + authorize_control_call, authorize_interaction_call, authorize_panel_readiness_call, + authorize_popup_readiness_call, control_executable_error, control_owner_uid_error, }; #[cfg(target_os = "linux")] use super::credentials::CallerCredentials; use super::executable_trust::paths::canonicalize_best_effort; +use super::policy::TRUSTED_INTERACTION_EXECUTABLES; use super::support::write_executable; use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; @@ -46,6 +47,19 @@ async fn panel_readiness_authorization_rejects_header_without_bus_sender() { assert!(err.to_string().contains("missing sender")); } +#[tokio::test] +async fn interaction_authorization_rejects_header_without_bus_sender() { + let state = daemon_state_for_test(false).await; + let message = message_without_bus_sender(); + let header = message.header(); + + let err = authorize_interaction_call(&state, &header, "InvokeAction") + .await + .expect_err("missing interaction sender must be rejected"); + + assert!(err.to_string().contains("missing sender")); +} + #[tokio::test] async fn popup_readiness_authorization_rejects_header_without_bus_sender() { let state = daemon_state_for_test(false).await; @@ -86,6 +100,34 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { assert!(control_executable_error(Some(&untrusted_name), &["unknown"], true).is_some()); } +#[test] +fn interaction_executable_policy_excludes_noninteractive_control_clients() { + let _guard = env_lock(); + let home = TempRoot::new("auth-interaction-executable"); + let center = home.join(".local/bin/unixnotis-center"); + let popups = home.join(".local/bin/unixnotis-popups"); + let cli = home.join(".local/bin/noticenterctl"); + write_executable(¢er); + write_executable(&popups); + write_executable(&cli); + let _home = EnvVarGuard::set("HOME", home.path()); + + for trusted_ui in [¢er, &popups] { + assert!(control_executable_error( + Some(&canonicalize_best_effort(trusted_ui)), + &TRUSTED_INTERACTION_EXECUTABLES, + true, + ) + .is_none()); + } + assert!(control_executable_error( + Some(&canonicalize_best_effort(&cli)), + &TRUSTED_INTERACTION_EXECUTABLES, + true, + ) + .is_some()); +} + #[cfg(target_os = "linux")] #[test] fn linux_authorization_rejects_credentials_without_a_stable_process_handle() { diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index 5e1e1eac7..9b687903a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -15,10 +15,14 @@ impl ControlServer { &self, notification: NotificationKey, action_key: &str, + confirmed: bool, ) -> zbus::fdo::Result<()> { - self.invoke_validated_action_generation_with_pre_emit(notification, action_key, || { - std::future::ready(()) - }) + self.invoke_validated_action_generation_with_pre_emit( + notification, + action_key, + confirmed, + || std::future::ready(()), + ) .await } @@ -26,6 +30,7 @@ impl ControlServer { &self, notification: NotificationKey, action_key: &str, + confirmed: bool, pre_emit: F, ) -> zbus::fdo::Result<()> where @@ -36,7 +41,7 @@ impl ControlServer { // Capture one concrete generation while validating the stored action identity let store = self.state.store.lock().await; store - .active_action_target_generation(notification, action_key) + .active_action_target_generation(notification, action_key, confirmed) .ok_or_else(|| { zbus::fdo::Error::InvalidArgs( "notification is not live or does not advertise this action".to_string(), diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index e49646ba2..849642c10 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -42,6 +42,15 @@ impl ControlServer { auth::authorize_panel_readiness_call(&self.state, header, method).await } + pub(super) async fn authorize_interaction_call( + &self, + header: &Header<'_>, + method: &'static str, + ) -> zbus::fdo::Result<()> { + // Noninteractive control clients cannot assert a UI confirmation result + auth::authorize_interaction_call(&self.state, header, method).await + } + pub(super) fn ensure_panel_available(&self) -> zbus::fdo::Result<()> { // Rejecting here makes panel outages visible instead of silent if self.state.panel_ready() { @@ -218,12 +227,17 @@ impl ControlServer { id: u32, generation: u64, action_key: &str, + confirmed: bool, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "InvokeActionGeneration") + self.authorize_interaction_call(&header, "InvokeActionGeneration") .await?; - self.invoke_validated_action_generation(NotificationKey { id, generation }, action_key) - .await + self.invoke_validated_action_generation( + NotificationKey { id, generation }, + action_key, + confirmed, + ) + .await } pub(super) async fn reply_notification( @@ -233,7 +247,7 @@ impl ControlServer { reply_text: &str, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "ReplyNotification") + self.authorize_interaction_call(&header, "ReplyNotification") .await?; self.submit_inline_reply(id, generation, reply_text).await } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index ac9f3701b..c947258ce 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -27,7 +27,7 @@ async fn validated_action_emits_only_an_advertised_live_action() { }; ControlServer::new(state) - .invoke_validated_action_generation(notification, "open") + .invoke_validated_action_generation(notification, "open", false) .await .expect("invoke advertised action"); @@ -53,7 +53,7 @@ async fn action_signal_reaches_owner_but_not_unrelated_observer() { }; ControlServer::new(state) - .invoke_validated_action_generation(notification, "open") + .invoke_validated_action_generation(notification, "open", false) .await .expect("invoke owner action"); @@ -86,7 +86,7 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { let server = ControlServer::new(state.clone()); server - .invoke_validated_action_generation(notification, "missing") + .invoke_validated_action_generation(notification, "missing", false) .await .expect_err("unadvertised action must fail"); let replacement_state = state.clone(); @@ -95,6 +95,7 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { .invoke_validated_action_generation_with_pre_emit( notification, "open", + false, move || async move { let replacement = action_notification(&replacement_sender, "different"); let outcome = replacement_state.store.lock().await.insert(replacement, id); @@ -122,7 +123,7 @@ async fn stale_action_does_not_target_same_id_replacement() { }; ControlServer::new(state.clone()) - .invoke_validated_action_generation(stale_key, "delete") + .invoke_validated_action_generation(stale_key, "delete", false) .await .expect_err("a delayed action must not target a same-ID replacement"); @@ -156,7 +157,7 @@ async fn validated_action_rejects_a_conflicting_application_claim() { }; ControlServer::new(state) - .invoke_validated_action_generation(notification, "open") + .invoke_validated_action_generation(notification, "open", false) .await .expect_err("conflicting attribution must not receive an action signal"); } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 23de95a65..7e1b8c4d5 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -315,7 +315,15 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { generation: 0, app_name: "Messages".to_string(), app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated reply test fixture", + "test:verified:messages".to_string(), + ), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: "New message".to_string(), body: "Are you coming?".to_string(), diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index ec284a972..214a0504d 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -229,7 +229,7 @@ async fn generation_action_rejects_unauthorized_sender_before_validation() { let message = control_header_message("InvokeActionGeneration"); server - .invoke_action_generation(7, 11, "default", message.header()) + .invoke_action_generation(7, 11, "default", false, message.header()) .await .expect_err("unauthorized generation action should fail"); } diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 57e1fbf34..73286a971 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -177,6 +177,8 @@ impl NotificationStore { generation: notification.generation, stored: true, attribution: notification.attribution_diagnostics.clone(), + identity_assurance: notification.attribution.assurance, + interaction_policies: notification.attribution.interactions, popup_admission: decision.admission_at_commit, renderer_process_running: decision.renderer_process_running_at_commit, renderer_ready: decision.renderer_ready_at_commit, @@ -274,6 +276,8 @@ impl NotificationStore { .any(|action| action.key == "inline-reply"); (notification.inline_reply.available && notification.generation == generation + && notification.attribution.interactions.inline_reply + == unixnotis_core::InlineReplyPolicy::Allow && notification.inline_reply_policy == unixnotis_core::InlineReplyPolicy::Allow && has_reply_action) .then(|| Arc::clone(notification)) @@ -283,13 +287,20 @@ impl NotificationStore { &self, key: unixnotis_core::NotificationKey, action_key: &str, + confirmed: bool, ) -> Option> { let notification = self.active.get(&key.id)?; if notification.generation != key.generation { return None; } - // Weak or conflicting provenance must not gain an application-directed signal - if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { + // Confirmation is meaningful only for actions the resolver explicitly marked confirmable + let policy = notification.attribution.action_policy(action_key); + let authorized = match policy { + ApplicationActionPolicy::Allow => true, + ApplicationActionPolicy::Confirm => confirmed, + ApplicationActionPolicy::Deny => false, + }; + if !authorized { return None; } // Exact matching prevents a trusted control caller from inventing application actions diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs index 777301f86..8d87376c1 100644 --- a/crates/unixnotis-daemon/src/store/test_support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -24,7 +24,15 @@ pub(in crate::store) fn make_notification(summary: &str) -> Notification { generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "TestApp", + "TestApp", + "org.example.TestApp", + "", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "test:verified:test-app".to_string(), + ), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs index 3807b9fb6..9cfd6aa26 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use unixnotis_core::{ - Action, AttributionReason, CloseReason, Config, InlineReply, InlineReplyPolicy, - NotificationAttribution, PopupAdmissionView, + Action, AttributionReason, CloseReason, Config, IdentityAssurance, InlineReply, + InlineReplyPolicy, InteractionPolicies, NotificationAttribution, PopupAdmissionView, }; use crate::store::test_support::{make_notification, make_store_with_limits}; @@ -394,18 +394,20 @@ fn active_action_target_requires_an_exact_action_on_the_live_generation() { let key = original.key(); let target = store - .active_action_target_generation(key, "open") + .active_action_target_generation(key, "open", false) .expect("stored action should resolve"); assert!(Arc::ptr_eq(&target, &original)); assert!(store - .active_action_target_generation(key, "missing") + .active_action_target_generation(key, "missing", false) .is_none()); assert!(store.is_active_notification_generation(id, &original)); let replacement = store.insert(make_notification("replacement"), id); assert!(replacement.replaced); assert!(!store.is_active_notification_generation(id, &original)); - assert!(store.active_action_target_generation(key, "open").is_none()); + assert!(store + .active_action_target_generation(key, "open", false) + .is_none()); } #[test] @@ -450,13 +452,103 @@ fn active_action_target_denies_every_unverified_sender_class() { assert!( store - .active_action_target_generation(key, "default") + .active_action_target_generation(key, "default", true) .is_none(), "weak attribution should not expose application actions" ); } } +#[test] +fn native_association_allows_default_but_requires_confirmation_for_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + ]; + let key = store.insert(notification, 0).notification.key(); + + assert!( + store + .active_action_target_generation(key, "default", false) + .is_some(), + "native default activation should retain compatibility" + ); + assert!( + store + .active_action_target_generation(key, "archive", false) + .is_none(), + "additional native action must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, "archive", true) + .is_some(), + "additional native action should accept explicit trusted-UI confirmation" + ); +} + +#[test] +fn portal_association_requires_confirmation_for_default_and_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("portal associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Portal App", + "Example Portal App", + "org.example.PortalApp", + "org.example.PortalApp", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal app id without confinement provenance", + "associated:portal-app:org.example.PortalApp".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + ]; + let key = store.insert(notification, 0).notification.key(); + + for action_key in ["default", "open"] { + assert!( + store + .active_action_target_generation(key, action_key, false) + .is_none(), + "portal action {action_key:?} must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, action_key, true) + .is_some(), + "portal action {action_key:?} should accept trusted-UI confirmation" + ); + } +} + #[test] fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { let mut store = make_store_with_limits(12, 20); @@ -512,3 +604,34 @@ fn inline_reply_policy_denies_a_complete_reply_action() { .active_inline_reply_target(notification.id, notification.generation) .is_none()); } + +#[test] +fn native_association_denies_reply_even_if_protocol_metadata_claims_allow() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated reply"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Allow; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let notification = store.insert(notification, 0).notification; + + assert!( + store + .active_inline_reply_target(notification.id, notification.generation) + .is_none(), + "native executable association cannot authorize credential-like reply input" + ); +} diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index 5524b96ec..ad142b7b5 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -16,11 +16,13 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu UiCommand::InvokeAction { notification, action_key, + confirmed, } => { timed_dbus_call(proxy.invoke_action_generation( notification.id, notification.generation, &action_key, + confirmed, )) .await } diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index 3db9bad4b..002c954c5 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -18,6 +18,7 @@ fn drain_offline_commands_removes_all_queued_commands() { generation: 13, }, action_key: "default".to_string(), + confirmed: false, }) .expect("action command should queue"); diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index bf0572cec..bde96500f 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -29,6 +29,7 @@ pub enum UiCommand { InvokeAction { notification: NotificationKey, action_key: String, + confirmed: bool, }, Reply { id: u32, @@ -52,10 +53,12 @@ impl std::fmt::Debug for UiCommand { Self::InvokeAction { notification, action_key, + confirmed, } => formatter .debug_struct("InvokeAction") .field("notification", notification) .field("action_key", action_key) + .field("confirmed", confirmed) .finish(), Self::Reply { id, generation, .. } => formatter .debug_struct("Reply") diff --git a/crates/unixnotis-popups/src/ui/entry/activation.rs b/crates/unixnotis-popups/src/ui/entry/activation.rs index d27a726a3..0e96ad8cd 100644 --- a/crates/unixnotis-popups/src/ui/entry/activation.rs +++ b/crates/unixnotis-popups/src/ui/entry/activation.rs @@ -99,6 +99,7 @@ fn invoke_default_action( UiCommand::InvokeAction { notification, action_key: action_key.to_string(), + confirmed: false, }, ); } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 40906c0b0..5f80acc01 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -1,10 +1,12 @@ //! Shared small primitives used by every popup kind +use std::cell::Cell; + use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; use unixnotis_core::{hooks, NotificationView}; -use unixnotis_ui::presentation::build_semantic_badge; +use unixnotis_ui::presentation::{action_activation, build_semantic_badge, ActionActivation}; use super::super::commands::try_send_command; use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; @@ -180,10 +182,25 @@ fn build_action_button( button.add_css_class("unixnotis-popup-action"); mark_interactive(&button); let action_key = action.key.clone(); + let original_label = action.label.clone(); + let policy = action.policy; let tx = command_tx.clone(); let popover = popover.cloned(); - button.connect_clicked(move |_| { - // Menus close before the exact daemon-validated action is queued + let confirmation_armed = Cell::new(false); + button.connect_clicked(move |button| { + let confirmed = match action_activation(policy, confirmation_armed.get()) { + ActionActivation::Denied => return, + ActionActivation::ArmConfirmation => { + confirmation_armed.set(true); + let confirmation_label = format!("Confirm {original_label}"); + button.set_label(&confirmation_label); + button.set_tooltip_text(Some("Activate again to confirm")); + button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + return; + } + ActionActivation::Invoke { confirmed } => confirmed, + }; + // Menus close only after an action passes its confirmation policy if let Some(popover) = &popover { popover.popdown(); } @@ -192,6 +209,7 @@ fn build_action_button( UiCommand::InvokeAction { notification, action_key: action_key.clone(), + confirmed, }, ); }); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 62826ca37..42057bb69 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -115,15 +115,63 @@ fn action_row_dispatches_the_prepared_action_identity() { UiCommand::InvokeAction { notification, action_key, + confirmed, } => { assert_eq!(notification.id, 41); assert_eq!(notification.generation, 3); - assert_eq!(action_key, "default"); + assert_eq!(action_key, "open"); + assert!(!confirmed, "allowed actions should not claim confirmation"); } command => panic!("unexpected command: {command:?}"), } } +#[gtk::test] +fn confirmable_popup_action_requires_two_clicks_before_dispatch() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let mut notification = notification(); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions.push(Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("confirmable action button"); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "first click must not invoke a confirmable action" + ); + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 41 + && notification.generation == 3 + && action_key == "archive" + )); +} + #[gtk::test] fn extra_safe_action_builds_a_compact_overflow_menu() { let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); @@ -141,6 +189,10 @@ fn extra_safe_action_builds_a_compact_overflow_menu() { key: "archive".to_string(), label: "Archive".to_string(), }, + Action { + key: "mute".to_string(), + label: "Mute".to_string(), + }, ]; let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); @@ -201,7 +253,7 @@ fn view_model() -> PopupEntryViewModel { fn view_model_with_action() -> PopupEntryViewModel { let mut notification = notification(); notification.actions.push(Action { - key: "default".to_string(), + key: "open".to_string(), label: "Open".to_string(), }); PopupEntryViewModel::for_notification_at(¬ification, 1_000) diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index f03cc0537..da2340b41 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -76,8 +76,8 @@ fn user_writable_desktop_association_remains_unverified() { let trust = PopupTrustPresentation::for_notification(&view); - assert_eq!(trust.level, TrustLevel::Recognized); - assert_eq!(trust.short_label.as_deref(), Some("Unverified")); + assert_eq!(trust.level, TrustLevel::UserAssociated); + assert_eq!(trust.short_label.as_deref(), Some("Local app")); assert_eq!(trust.reply, ReplyPresentation::Hidden); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index e70d8a504..1ea7054d0 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -53,6 +53,10 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { key: "archive".to_string(), label: "Archive".to_string(), }, + Action { + key: "mute".to_string(), + label: "Mute".to_string(), + }, ]; let model = PopupEntryViewModel::for_notification_at(&view, 1_000); @@ -60,9 +64,9 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { assert_eq!(model.kind, PopupKind::Utility); assert_eq!(model.default_action_key.as_deref(), Some("default")); assert_eq!(model.primary_actions.len(), 2); - assert_eq!(model.primary_actions[0].key, "default"); + assert_eq!(model.primary_actions[0].key, "folder"); assert_eq!(model.overflow_actions.len(), 1); - assert_eq!(model.overflow_actions[0].key, "archive"); + assert_eq!(model.overflow_actions[0].key, "mute"); } #[test] diff --git a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs index 7397bb208..beae11226 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs @@ -143,9 +143,11 @@ fn assert_default_command(command_rx: &mut tokio::sync::mpsc::Receiver { assert_eq!(notification, KEY); assert_eq!(action_key, "default"); + assert!(!confirmed, "card activation should not claim confirmation"); } command => panic!("unexpected command: {command:?}"), } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs index fb08103c0..cdf7b9535 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs @@ -32,6 +32,7 @@ fn closed_command_queue_drops_action_without_panicking() { generation: 9, }, action_key: "open".to_string(), + confirmed: false, }, ); } diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 90b7b3b2e..ecc9d6091 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -3,8 +3,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unixnotis_core::{ - Action, ApplicationActionPolicy, AttributionStatus, InlineReplyPolicy, NotificationView, - PopupAdmissionView, Urgency, + Action, ApplicationActionPolicy, AttributionStatus, IdentityAssurance, InlineReplyPolicy, + NotificationView, PopupAdmissionView, Urgency, }; use super::text::{ @@ -104,7 +104,10 @@ pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresen let short_label = match level { // Verified and relay primary labels already communicate their source clearly TrustLevel::Verified | TrustLevel::Relay => None, - TrustLevel::Recognized | TrustLevel::Unresolved => Some("Unverified".to_string()), + TrustLevel::SystemAssociated => Some("System associated".to_string()), + TrustLevel::PortalAssociated => Some("Portal mediated".to_string()), + TrustLevel::UserAssociated => Some("Local app".to_string()), + TrustLevel::Unresolved => Some("Unverified".to_string()), TrustLevel::Conflict => Some("Suspicious".to_string()), }; let details_label = nonempty_text(¬ification.attribution.diagnostic_detail); @@ -134,12 +137,14 @@ pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresen } const fn trust_level(notification: &NotificationView) -> TrustLevel { - match notification.attribution.status { - AttributionStatus::Verified => TrustLevel::Verified, - AttributionStatus::Recognized => TrustLevel::Recognized, - AttributionStatus::Unresolved => TrustLevel::Unresolved, - AttributionStatus::Conflict => TrustLevel::Conflict, - AttributionStatus::Relay => TrustLevel::Relay, + match notification.attribution.assurance { + IdentityAssurance::Authenticated => TrustLevel::Verified, + IdentityAssurance::SystemAssociated => TrustLevel::SystemAssociated, + IdentityAssurance::PortalAssociated => TrustLevel::PortalAssociated, + IdentityAssurance::UserAssociated => TrustLevel::UserAssociated, + IdentityAssurance::Unresolved => TrustLevel::Unresolved, + IdentityAssurance::Conflict => TrustLevel::Conflict, + IdentityAssurance::Relay => TrustLevel::Relay, } } @@ -171,7 +176,9 @@ fn identity_presentation( }; let badge = match level { TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, - TrustLevel::Recognized => BadgePresentation::RecognizedApplication, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated => BadgePresentation::RecognizedApplication, TrustLevel::Unresolved => BadgePresentation::UnknownApplication, TrustLevel::Conflict => BadgePresentation::SuspiciousApplication, TrustLevel::Relay => BadgePresentation::CommandLine, @@ -237,15 +244,16 @@ fn communication_category_class(category_class: &str) -> bool { } fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> ActionPresentation { - if notification.attribution.application_action_policy() != ApplicationActionPolicy::Allow { - return ActionPresentation::default(); - } - // A blank default label keeps card activation without creating an empty button - let default_key = notification + let default_policy = notification.attribution.default_activation_policy(); + let button_policy = notification.attribution.action_button_policy(); + // Only unconditional default activation becomes a whole-card action + let advertised_default = notification .actions .iter() - .find(|action| action.key == "default") - .map(|action| action.key.clone()); + .find(|action| action.key == "default"); + let default_key = (default_policy == ApplicationActionPolicy::Allow) + .then(|| advertised_default.map(|action| action.key.clone())) + .flatten(); let mut actions = notification .actions .iter() @@ -254,8 +262,28 @@ fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> A && !action.key.trim().is_empty() && !action.label.trim().is_empty() }) - .map(action_view) + .filter_map(|action| { + let policy = if action.key == "default" { + default_policy + } else { + button_policy + }; + // Allowed defaults use card activation and never duplicate app-owned branding + (policy != ApplicationActionPolicy::Deny + && !(action.key == "default" && policy == ApplicationActionPolicy::Allow)) + .then(|| action_view(action, policy)) + }) .collect::>(); + if default_policy == ApplicationActionPolicy::Confirm + && advertised_default.is_some_and(|action| action.label.trim().is_empty()) + { + // Confirmable blank defaults need an explicit control instead of hidden card activation + actions.push(ActionView { + key: "default".to_string(), + label: "Open notification".to_string(), + policy: ApplicationActionPolicy::Confirm, + }); + } let overflow = actions.split_off(actions.len().min(kind.action_limit())); ActionPresentation { default_key, @@ -264,10 +292,11 @@ fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> A } } -fn action_view(action: &Action) -> ActionView { +fn action_view(action: &Action, policy: ApplicationActionPolicy) -> ActionView { ActionView { key: action.key.clone(), label: clamp_label_text(&action.label, ACTION_LABEL_MAX_CHARS).into_owned(), + policy, } } @@ -285,8 +314,10 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { .unwrap_or_default() .eq_ignore_ascii_case(category) }); - let identity_is_verified = - matches!(notification.attribution.status, AttributionStatus::Verified); + let identity_is_verified = matches!( + notification.attribution.assurance, + IdentityAssurance::Authenticated + ); if !identity_is_verified { // Untrusted senders need an explicit media category before large imagery is shown return if category_is_media { @@ -310,9 +341,6 @@ fn image_path_matches_authenticated_badge(notification: &NotificationView) -> bo return false; } let image_path = notification.image.image_path.trim(); - if image_path.is_empty() { - return false; - } if image_path == badge { return true; } diff --git a/crates/unixnotis-ui/src/presentation/interaction.rs b/crates/unixnotis-ui/src/presentation/interaction.rs new file mode 100644 index 000000000..a83421025 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/interaction.rs @@ -0,0 +1,25 @@ +//! Shared confirmation state for application-owned controls + +use unixnotis_core::ApplicationActionPolicy; + +/// Result of one user activation attempt +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionActivation { + Denied, + ArmConfirmation, + Invoke { confirmed: bool }, +} + +/// Convert policy and local confirmation state into one safe UI action +#[must_use] +pub const fn action_activation( + policy: ApplicationActionPolicy, + confirmation_armed: bool, +) -> ActionActivation { + match (policy, confirmation_armed) { + (ApplicationActionPolicy::Allow, _) => ActionActivation::Invoke { confirmed: false }, + (ApplicationActionPolicy::Confirm, false) => ActionActivation::ArmConfirmation, + (ApplicationActionPolicy::Confirm, true) => ActionActivation::Invoke { confirmed: true }, + (ApplicationActionPolicy::Deny, _) => ActionActivation::Denied, + } +} diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs index 2e0a0a9b6..1f57465f5 100644 --- a/crates/unixnotis-ui/src/presentation/mod.rs +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -2,11 +2,13 @@ mod badges; mod build; +mod interaction; mod text; mod types; pub use badges::{apply_semantic_badge, build_semantic_badge, register_semantic_badges}; pub use build::NotificationPresentation; +pub use interaction::{action_activation, ActionActivation}; pub use text::{ clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, BODY_LABEL_MAX_CHARS, SUMMARY_LABEL_MAX_CHARS, diff --git a/crates/unixnotis-ui/src/presentation/tests/interaction.rs b/crates/unixnotis-ui/src/presentation/tests/interaction.rs new file mode 100644 index 000000000..6c72abf7f --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/interaction.rs @@ -0,0 +1,39 @@ +//! Application action confirmation regressions + +use unixnotis_core::ApplicationActionPolicy; + +use super::super::{action_activation, ActionActivation}; + +#[test] +fn allowed_action_invokes_without_confirmation() { + assert_eq!( + action_activation(ApplicationActionPolicy::Allow, false), + ActionActivation::Invoke { confirmed: false }, + "allowed actions should invoke without adding confirmation state" + ); +} + +#[test] +fn confirm_action_requires_two_activation_attempts() { + assert_eq!( + action_activation(ApplicationActionPolicy::Confirm, false), + ActionActivation::ArmConfirmation, + "the first activation should only arm confirmation" + ); + assert_eq!( + action_activation(ApplicationActionPolicy::Confirm, true), + ActionActivation::Invoke { confirmed: true }, + "the second activation should carry explicit confirmation" + ); +} + +#[test] +fn denied_action_never_invokes_even_when_armed() { + for armed in [false, true] { + assert_eq!( + action_activation(ApplicationActionPolicy::Deny, armed), + ActionActivation::Denied, + "denied actions must not inherit stale confirmation state" + ); + } +} diff --git a/crates/unixnotis-ui/src/presentation/tests/mod.rs b/crates/unixnotis-ui/src/presentation/tests/mod.rs index 0bb91928e..ddd6c940e 100644 --- a/crates/unixnotis-ui/src/presentation/tests/mod.rs +++ b/crates/unixnotis-ui/src/presentation/tests/mod.rs @@ -1,5 +1,6 @@ //! Shared notification presentation regression tests +mod interaction; mod presentation; mod support; mod text; diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index cb7f2037e..d3bf8b086 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -1,6 +1,6 @@ use unixnotis_core::{ - Action, AttributionReason, AttributionStatus, ImageData, InlineReplyPolicy, - NotificationAttribution, Urgency, + Action, AttributionReason, AttributionStatus, IdentityAssurance, ImageData, InlineReplyPolicy, + InteractionPolicies, NotificationAttribution, Urgency, }; use super::super::{ @@ -41,14 +41,95 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { BadgePresentation::AuthenticatedApplication ); assert_eq!(presentation.media.thumbnail, ThumbnailKind::Content); - assert_eq!(presentation.actions.primary.len(), 1); - assert_eq!(presentation.actions.primary[0].key, "default"); - assert_eq!(presentation.actions.primary[0].label, "Open"); + assert!(presentation.actions.primary.is_empty()); assert!(presentation.actions.overflow.is_empty()); assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); assert_eq!(presentation.timestamp, "2m"); } +#[test] +fn native_association_keeps_card_activation_and_confirms_only_extra_buttons() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + view.inline_reply.available = true; + view.inline_reply_policy = InlineReplyPolicy::Deny; + view.actions = vec![ + Action { + key: "default".to_string(), + label: "Open conversation".to_string(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::SystemAssociated); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert_eq!(presentation.actions.primary.len(), 1); + assert_eq!(presentation.actions.primary[0].key, "archive"); + assert_eq!( + presentation.actions.primary[0].policy, + unixnotis_core::ApplicationActionPolicy::Confirm + ); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); +} + +#[test] +fn portal_association_exposes_confirmable_default_as_one_explicit_control() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Portal App", + "Example Portal App", + "org.example.PortalApp", + "org.example.PortalApp", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal app id without confinement provenance", + "associated:portal-app:org.example.PortalApp".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + + let blank = NotificationPresentation::from_view_at(&view, 1_000); + assert!(blank.actions.default_key.is_none()); + assert_eq!(blank.actions.primary.len(), 1); + assert_eq!(blank.actions.primary[0].key, "default"); + assert_eq!(blank.actions.primary[0].label, "Open notification"); + assert_eq!( + blank.actions.primary[0].policy, + unixnotis_core::ApplicationActionPolicy::Confirm + ); + + view.actions[0].label = "Open portal item".to_string(); + let labeled = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(labeled.actions.primary.len(), 1); + assert_eq!(labeled.actions.primary[0].label, "Open portal item"); + + view.actions.clear(); + let missing = NotificationPresentation::from_view_at(&view, 1_000); + assert!(missing.actions.primary.is_empty()); +} + #[test] fn blank_default_action_keeps_card_activation_without_rendering_a_button() { let mut view = notification(); @@ -143,6 +224,36 @@ fn unknown_claim_stays_secondary_and_unverified() { ); } +#[test] +fn associated_identity_discloses_a_different_caller_label_only() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Chat", + "Caller alias", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + + let differing = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + differing.identity.secondary_claim.as_deref(), + Some("App label: Caller alias"), + "a differing protocol label must remain visible as untrusted metadata" + ); + + view.attribution.claimed_name = "example chat".to_string(); + let matching = NotificationPresentation::from_view_at(&view, 1_000); + assert!( + matching.identity.secondary_claim.is_none(), + "case-only canonical label differences should not duplicate identity text" + ); +} + #[test] fn unresolved_claim_has_no_application_actions_or_reply() { let mut view = notification(); @@ -194,6 +305,40 @@ fn communication_layout_is_preserved_for_unverified_sender() { assert_eq!(presentation.trust.level, TrustLevel::Unresolved); } +#[test] +fn reply_metadata_and_action_each_select_communication_layout() { + let mut metadata = notification(); + metadata.inline_reply.available = true; + assert_eq!( + NotificationPresentation::from_view_at(&metadata, 1_000).kind, + NotificationKind::Communication, + "reply metadata should preserve message hierarchy without a category" + ); + + let mut action = notification(); + action.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + assert_eq!( + NotificationPresentation::from_view_at(&action, 1_000).kind, + NotificationKind::Communication, + "an explicit reply action should preserve message hierarchy" + ); +} + +#[test] +fn media_category_selects_media_layout_without_image_content() { + let mut view = notification(); + view.category = "media.player".to_string(); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000).kind, + NotificationKind::Media, + "media semantics must not depend on an optional thumbnail" + ); +} + #[test] fn untrusted_non_media_notification_cannot_render_content_art() { let mut view = notification(); @@ -355,6 +500,91 @@ fn verified_media_category_or_pixel_data_can_override_duplicate_badge_suppressio } } +#[test] +fn verified_plain_image_path_suppresses_only_duplicate_badging() { + let mut view = notification(); + view.attribution.badge_icon = "same-icon".to_string(); + view.image.image_path = "same-icon".to_string(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::None, + "the authenticated badge must not be repeated as content" + ); + + view.image.image_path = "different-content".to_string(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "a distinct explicit content path should remain visible" + ); + + view.attribution.badge_icon.clear(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "a missing badge cannot make explicit content look duplicated" + ); + + for (badge, image_path) in [ + ("relative-badge", "/absolute/content.png"), + ("/absolute/badge.png", "relative-content"), + ] { + view.attribution.badge_icon = badge.to_string(); + view.image.image_path = image_path.to_string(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "mixed absolute and symbolic sources cannot establish duplicate identity" + ); + } + + let fixture = std::fs::canonicalize("Cargo.toml").expect("resolve package manifest fixture"); + view.attribution.badge_icon = "Cargo.toml".to_string(); + view.image.image_path = fixture.to_string_lossy().into_owned(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "a relative badge name must not alias an absolute content path" + ); +} + +#[cfg(unix)] +#[test] +fn verified_badge_symlink_is_suppressed_by_canonical_file_identity() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "unixnotis-presentation-badge-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create badge fixture directory"); + let badge = root.join("badge.svg"); + let alias = root.join("badge-alias.svg"); + std::fs::write(&badge, b"").expect("write badge fixture"); + let _ = std::fs::remove_file(&alias); + symlink(&badge, &alias).expect("create badge alias"); + + let mut view = notification(); + view.attribution.badge_icon = badge.to_string_lossy().into_owned(); + view.image.image_path = alias.to_string_lossy().into_owned(); + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.media.thumbnail, ThumbnailKind::None); + std::fs::remove_file(&alias).expect("remove badge alias"); + std::fs::remove_file(&badge).expect("remove badge fixture"); + std::fs::remove_dir(&root).expect("remove badge fixture directory"); +} + #[test] fn shared_model_keeps_user_association_unverified_and_noninteractive() { let mut view = notification(); @@ -374,7 +604,7 @@ fn shared_model_keeps_user_association_unverified_and_noninteractive() { let presentation = NotificationPresentation::from_view_at(&view, 1_000); - assert_eq!(presentation.trust.level, TrustLevel::Recognized); + assert_eq!(presentation.trust.level, TrustLevel::UserAssociated); assert_eq!( presentation.identity.badge, BadgePresentation::RecognizedApplication @@ -429,11 +659,17 @@ fn shared_model_requires_verified_identity_and_exact_critical_urgency() { }); view.attribution.status = AttributionStatus::Recognized; + view.attribution.assurance = IdentityAssurance::SystemAssociated; + view.attribution.interactions = InteractionPolicies::NATIVE_COMPATIBILITY; + view.inline_reply_policy = InlineReplyPolicy::Deny; let unverified = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!(unverified.trust.reply, ReplyPresentation::Unavailable); assert!(!unverified.critical); view.attribution.status = AttributionStatus::Verified; + view.attribution.assurance = IdentityAssurance::Authenticated; + view.attribution.interactions = InteractionPolicies::AUTHENTICATED; + view.inline_reply_policy = InlineReplyPolicy::Allow; view.urgency = Urgency::Critical as u8; let critical = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!(critical.trust.reply, ReplyPresentation::Available); diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index da4bdb27e..d984cdb44 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -36,7 +36,9 @@ impl NotificationKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrustLevel { Verified, - Recognized, + SystemAssociated, + PortalAssociated, + UserAssociated, Unresolved, Conflict, Relay, @@ -47,7 +49,7 @@ impl TrustLevel { pub const fn css_class(self) -> &'static str { match self { Self::Verified => "verified", - Self::Recognized => "recognized", + Self::SystemAssociated | Self::PortalAssociated | Self::UserAssociated => "recognized", Self::Unresolved => "unresolved", Self::Conflict => "conflict", Self::Relay => "relay", @@ -103,6 +105,7 @@ pub struct IdentityPresentation { pub struct ActionView { pub key: String, pub label: String, + pub policy: unixnotis_core::ApplicationActionPolicy, } /// Compact actions split without silently dropping safe overflow From 5d937d1260a1bf385a9674bcde9c4ab025e92c8b Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 20:38:59 -0500 Subject: [PATCH 168/275] fix(portal): filter backend candidates before admission Summary: filter backend candidates before admission. Scope: portal. --- .../identity/desktop_index/index.rs | 46 ++++++++---- .../identity/desktop_index/tests/index.rs | 75 ++++++++++++++++++- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index e1d6a7e70..c8e7adbed 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -231,24 +231,12 @@ impl DesktopIdentityIndex { } pub(super) fn index_trusted_portals_in(&mut self, directory: &Path) { - const MAX_PORTAL_CANDIDATES: usize = 256; - - let Ok(entries) = std::fs::read_dir(directory) else { - return; - }; - for entry in entries.take(MAX_PORTAL_CANDIDATES).flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !name.starts_with("xdg-desktop-portal") { - continue; - } + for path in portal_candidate_paths(directory) { let Some(evidence) = executable_evidence_for_path(&path) else { continue; }; // Portal authority is accepted only from protected system integration binaries - if evidence.identity.is_system_managed() && evidence.identity.is_executable_regular() { + if portal_identity_is_trusted(evidence.identity) { self.trusted_portals.push(ExecutableIdentity { path: evidence.canonical_path, identity: evidence.identity, @@ -364,6 +352,36 @@ impl DesktopIdentityIndex { } } +pub(in crate::daemon::notifications::identity) const fn portal_identity_is_trusted( + identity: FileIdentity, +) -> bool { + identity.is_system_managed() && identity.is_executable_regular() +} + +pub(in crate::daemon::notifications::identity) fn portal_candidate_paths( + directory: &Path, +) -> Vec { + const MAX_PORTAL_CANDIDATES: usize = 256; + + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + // Walk every entry in the directory + // Only entries with a matching name count toward the cap + // Filtering first means a directory full of unrelated files cannot hide a real portal + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("xdg-desktop-portal")) + .then_some(path) + }) + .take(MAX_PORTAL_CANDIDATES) + .collect() +} + fn protected_payload_signature(record: &DesktopRecord) -> Vec<(usize, u64, u64)> { record .launch_spec diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs index 49314f8d6..713a45e23 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs @@ -2,9 +2,13 @@ use std::collections::HashSet; +use super::super::index::{portal_candidate_paths, portal_identity_is_trusted}; use super::super::model::{DesktopIdentityIndex, DesktopRecord, LaunchSpec}; use crate::daemon::notifications::identity::desktop_index::provenance::InstallProvenance; -use crate::daemon::notifications::identity::executable::FileIdentity; +use crate::daemon::notifications::identity::executable::{ + executable_evidence_for_path, FileIdentity, +}; +use crate::test_support::TempRoot; #[test] fn executable_index_rebuild_replaces_stale_runtime_identity() { @@ -25,6 +29,75 @@ fn executable_index_rebuild_replaces_stale_runtime_identity() { assert_eq!(index.records_for_executable(new).len(), 1); } +#[test] +fn portal_discovery_filters_before_applying_the_candidate_limit() { + let root = TempRoot::new("portal-discovery-filter-order"); + for index in 0..300 { + std::fs::write( + root.join(format!("ordinary-library-{index:03}")), + b"fixture", + ) + .expect("write non-portal directory entry"); + } + let portal = root.join("xdg-desktop-portal-example"); + std::fs::write(&portal, b"portal fixture").expect("write portal directory entry"); + + let candidates = portal_candidate_paths(root.path()); + + assert_eq!(candidates, vec![portal]); +} + +#[test] +fn portal_identity_requires_both_system_management_and_executable_file_type() { + let trusted = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + assert!(portal_identity_is_trusted(trusted)); + assert!(!portal_identity_is_trusted(FileIdentity { + uid: 1_000, + ..trusted + })); + assert!(!portal_identity_is_trusted(FileIdentity { + mode: 0o100_644, + ..trusted + })); +} + +#[test] +fn installed_protected_portal_is_indexed_when_available() { + let installed = [ + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ] + .into_iter() + .find_map(|directory| { + portal_candidate_paths(std::path::Path::new(directory)) + .into_iter() + .find_map(|path| { + let evidence = executable_evidence_for_path(&path)?; + portal_identity_is_trusted(evidence.identity).then_some((path, evidence.identity)) + }) + }); + let Some((portal, identity)) = installed else { + // Platforms without an installed portal backend have no system fixture to index + return; + }; + let directory = portal.parent().expect("installed portal parent directory"); + let mut index = DesktopIdentityIndex::default(); + + index.index_trusted_portals_in(directory); + + assert!(index + .trusted_portals + .iter() + .any(|candidate| candidate.identity.same_file(identity))); +} + fn record(runtime: FileIdentity) -> DesktopRecord { DesktopRecord { id: "org.example.App".to_string(), From b67bb1a8d3fd940de0afe9d490daa8dbce6db9e6 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 20:39:08 -0500 Subject: [PATCH 169/275] fix(daemon): deny inline reply from action dispatch Summary: deny inline reply from action dispatch. Scope: daemon. --- .../unixnotis-core/src/model/attribution.rs | 13 +- .../src/model/tests/attribution.rs | 10 + crates/unixnotis-daemon/src/store/runtime.rs | 6 + .../src/store/tests/runtime.rs | 637 ------------------ .../src/store/tests/runtime/action_target.rs | 222 ++++++ .../src/store/tests/runtime/config.rs | 28 + .../src/store/tests/runtime/inline_reply.rs | 130 ++++ .../src/store/tests/runtime/mod.rs | 4 + .../src/store/tests/runtime/popup.rs | 315 +++++++++ 9 files changed, 723 insertions(+), 642 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/store/tests/runtime.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/config.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/mod.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/popup.rs diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index f1bed92e0..e55a94636 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -313,13 +313,16 @@ impl NotificationAttribution { self.interactions.action_buttons } - /// Policy for one exact advertised action key + // Decide what happens when a specific action key is activated + // "default" follows the card-level activation rules so physical gestures still work + // "inline-reply" is always blocked here — the dedicated reply method handles that + // everything else uses the normal button policy from the identity resolver #[must_use] pub fn action_policy(&self, action_key: &str) -> ApplicationActionPolicy { - if action_key == "default" { - self.default_activation_policy() - } else { - self.action_button_policy() + match action_key { + "default" => self.default_activation_policy(), + "inline-reply" => ApplicationActionPolicy::Deny, + _ => self.action_button_policy(), } } } diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 4d5875247..d66cbe48d 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -224,6 +224,11 @@ fn authenticated_and_native_policies_keep_action_surfaces_separate() { verified.action_policy("open"), ApplicationActionPolicy::Allow ); + assert_eq!( + verified.action_policy("inline-reply"), + ApplicationActionPolicy::Deny, + "even fully verified attributions must reject inline-reply through action dispatch" + ); let native = NotificationAttribution::associated( "System app", @@ -251,6 +256,11 @@ fn authenticated_and_native_policies_keep_action_surfaces_separate() { ApplicationActionPolicy::Allow, "the protocol default key should use default activation policy" ); + assert_eq!( + native.action_policy("inline-reply"), + ApplicationActionPolicy::Deny, + "the inline-reply key must be rejected regardless of button policy" + ); assert_eq!( native.action_policy("archive"), ApplicationActionPolicy::Confirm, diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index 73286a971..b970b244d 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -293,6 +293,12 @@ impl NotificationStore { if notification.generation != key.generation { return None; } + // "inline-reply" is a fake action key used only by the reply text method + // Block it here even though action_policy already rejects it — that way a caller + // that skips the policy check still cannot reach the reply action + if action_key == "inline-reply" { + return None; + } // Confirmation is meaningful only for actions the resolver explicitly marked confirmable let policy = notification.attribution.action_policy(action_key); let authorized = match policy { diff --git a/crates/unixnotis-daemon/src/store/tests/runtime.rs b/crates/unixnotis-daemon/src/store/tests/runtime.rs deleted file mode 100644 index 9cfd6aa26..000000000 --- a/crates/unixnotis-daemon/src/store/tests/runtime.rs +++ /dev/null @@ -1,637 +0,0 @@ -use std::sync::Arc; - -use unixnotis_core::{ - Action, AttributionReason, CloseReason, Config, IdentityAssurance, InlineReply, - InlineReplyPolicy, InteractionPolicies, NotificationAttribution, PopupAdmissionView, -}; - -use crate::store::test_support::{make_notification, make_store_with_limits}; -use crate::store::NotificationStore; - -#[test] -fn config_accessor_returns_runtime_config_snapshot() { - let mut config = Config::default(); - config.history.max_entries = 77; - config.history.max_active = 3; - let store = NotificationStore::new(config); - - assert_eq!(store.config().history.max_entries, 77); - assert_eq!(store.config().history.max_active, 3); -} - -#[test] -fn active_notification_view_returns_current_active_payload() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert(make_notification("visible"), 0); - - let view = store - .active_notification_view(outcome.notification.id) - .expect("active notification should be visible"); - - assert_eq!(view.id, outcome.notification.id); - assert_eq!(view.summary, "visible"); -} - -#[test] -fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { - let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("allowed"), 0).notification; - let mut suppressed = make_notification("rule suppressed"); - suppressed.suppress_popup = true; - let replacement = store.insert(suppressed, original.id).notification; - - let candidate = store - .popup_candidate(original.id) - .expect("replacement should remain an active popup candidate"); - - assert_eq!(candidate.notification.generation, replacement.generation); - assert_eq!(candidate.notification.summary, "rule suppressed"); - assert_eq!(candidate.admission, PopupAdmissionView::Rule); -} - -#[test] -fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { - let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("allowed"), 0).notification; - store.set_dnd(true); - let replacement = store - .insert(make_notification("dnd suppressed"), original.id) - .notification; - - let candidate = store - .popup_candidate(original.id) - .expect("replacement should remain active during DND"); - - assert_eq!(candidate.notification.generation, replacement.generation); - assert_eq!(candidate.notification.summary, "dnd suppressed"); - assert_eq!(candidate.admission, PopupAdmissionView::Dnd); -} - -#[test] -fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() { - let mut store = make_store_with_limits(10, 10); - let visible = store.insert(make_notification("visible"), 0).notification; - let unavailable = store - .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) - .expect("active notification diagnostics"); - - assert_eq!( - unavailable.popup_admission, - PopupAdmissionView::RendererUnavailable - ); - assert!(!unavailable.renderer_process_running); - assert!(!unavailable.renderer_ready); - - store.set_dnd(true); - let dnd_suppressed = store - .insert(make_notification("DND suppressed"), 0) - .notification; - store.set_dnd(false); - let ready = unixnotis_core::UiHealth { - popups_process_running: true, - popups_ready: true, - ..unixnotis_core::UiHealth::default() - }; - let suppressed = store - .notification_diagnostics(dnd_suppressed.id, &ready) - .expect("DND diagnostics"); - - assert_eq!(suppressed.popup_admission, PopupAdmissionView::Dnd); - assert!(!suppressed.renderer_process_running); - assert!(!suppressed.renderer_ready); -} - -#[test] -fn notification_diagnostics_require_both_renderer_process_and_readiness() { - let mut store = make_store_with_limits(10, 10); - for (process_running, ready, expected) in [ - (false, false, PopupAdmissionView::RendererUnavailable), - (true, false, PopupAdmissionView::RendererUnavailable), - (false, true, PopupAdmissionView::RendererUnavailable), - (true, true, PopupAdmissionView::Show), - ] { - let health = unixnotis_core::UiHealth { - popups_process_running: process_running, - popups_ready: ready, - ..unixnotis_core::UiHealth::default() - }; - let visible = store.insert(make_notification("visible"), 0).notification; - store.record_popup_commit_environment( - visible.key(), - crate::store::PopupAdmission::Show, - &health, - ); - let diagnostics = store - .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) - .expect("active notification diagnostics"); - - assert_eq!( - diagnostics.popup_admission, expected, - "process_running={process_running}, ready={ready}" - ); - } -} - -#[test] -fn disabled_popups_are_recorded_when_max_visible_is_zero() { - let mut config = Config::default(); - config.popups.max_visible = 0; - let mut store = NotificationStore::new(config); - let notification = store.insert(make_notification("disabled"), 0).notification; - let ready = unixnotis_core::UiHealth { - popups_process_running: true, - popups_ready: true, - ..unixnotis_core::UiHealth::default() - }; - store.record_popup_commit_environment( - notification.key(), - crate::store::PopupAdmission::Show, - &ready, - ); - - let diagnostics = store - .notification_diagnostics(notification.id, &ready) - .expect("disabled popup diagnostics"); - - assert_eq!( - diagnostics.popup_admission, - PopupAdmissionView::RendererDisabled - ); - assert_eq!(diagnostics.configured_max_visible, 0); -} - -#[test] -fn archived_notification_keeps_its_arrival_popup_explanation() { - let mut store = make_store_with_limits(10, 10); - store.set_dnd(true); - let notification = store - .insert(make_notification("archived DND"), 0) - .notification; - store.close(notification.id, CloseReason::Expired); - store.set_dnd(false); - - let diagnostics = store - .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) - .expect("history diagnostics should remain available"); - - assert_eq!(diagnostics.generation, notification.generation); - assert_eq!(diagnostics.popup_admission, PopupAdmissionView::Dnd); -} - -#[test] -fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { - let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; - let ready = unixnotis_core::UiHealth { - popups_process_running: true, - popups_ready: true, - ..unixnotis_core::UiHealth::default() - }; - store.record_popup_commit_environment( - notification.key(), - crate::store::PopupAdmission::Show, - &ready, - ); - - let candidate = store - .popup_candidate(notification.id) - .expect("admitted popup candidate"); - assert_eq!(candidate.admission, PopupAdmissionView::Show); - assert_eq!( - store - .notification_diagnostics(notification.id, &ready) - .expect("fetched diagnostics") - .delivery_stage, - unixnotis_core::PopupDeliveryStage::RendererFetched - ); - - assert_eq!( - store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::Visible, - ), - crate::store::DeliveryStageUpdate::Advanced - ); - assert_eq!( - store - .notification_diagnostics(notification.id, &ready) - .expect("rendered diagnostics") - .delivery_stage, - unixnotis_core::PopupDeliveryStage::Visible - ); -} - -#[test] -fn delivery_stage_never_moves_backward() { - let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; - - assert_eq!( - store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::Visible, - ), - crate::store::DeliveryStageUpdate::Advanced - ); - assert_eq!( - store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::RendererFetched, - ), - crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond - ); - - assert_eq!( - store - .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) - .expect("delivery diagnostics") - .delivery_stage, - unixnotis_core::PopupDeliveryStage::Visible, - "later duplicate fetches must not regress delivery history" - ); -} - -#[test] -fn duplicate_popup_stage_acknowledgement_is_idempotent() { - let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; - - assert_eq!( - store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::Visible, - ), - crate::store::DeliveryStageUpdate::Advanced - ); - assert_eq!( - store.record_popup_delivery_stage( - notification.key(), - unixnotis_core::PopupDeliveryStage::Visible, - ), - crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond, - "a retained generation must accept a duplicate renderer callback" - ); -} - -#[test] -fn popup_stage_acknowledgement_rejects_a_missing_generation() { - let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("original"), 0).notification; - let _replacement = store - .insert(make_notification("replacement"), original.id) - .notification; - - assert_eq!( - store.record_popup_delivery_stage( - original.key(), - unixnotis_core::PopupDeliveryStage::Visible, - ), - crate::store::DeliveryStageUpdate::MissingGeneration, - "a stale generation must remain distinct from an idempotent current callback" - ); -} - -#[test] -fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering() { - let mut store = make_store_with_limits(10, 10); - let ready = unixnotis_core::UiHealth { - popups_process_running: true, - popups_ready: true, - ..unixnotis_core::UiHealth::default() - }; - - let mut rule_suppressed = make_notification("persistent suppression"); - rule_suppressed.suppress_popup = true; - let rule_suppressed = store.insert(rule_suppressed, 0).notification; - store.record_popup_commit_environment( - rule_suppressed.key(), - crate::store::PopupAdmission::Show, - &ready, - ); - - let arrival_suppressed = store - .insert(make_notification("arrival suppression"), 0) - .notification; - store.record_popup_commit_environment( - arrival_suppressed.key(), - crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), - &ready, - ); - - let admitted = store.insert(make_notification("admitted"), 0).notification; - store.record_popup_commit_environment( - admitted.key(), - crate::store::PopupAdmission::Show, - &ready, - ); - - let candidates = store.list_popup_candidates(); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].key(), admitted.key()); -} - -#[test] -fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { - let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("retained"), 0).notification; - - assert!(store.popup_decisions.contains_key(¬ification.key())); - store.close(notification.id, CloseReason::Expired); - assert!(store.popup_decisions.contains_key(¬ification.key())); - - store.clear_history(); - assert!(store.popup_decisions.is_empty()); -} - -#[test] -fn active_inline_reply_target_requires_a_live_explicit_reply_action() { - let mut store = make_store_with_limits(12, 20); - let ordinary = store.insert(make_notification("ordinary"), 0).notification; - let mut reply = make_notification("reply"); - reply.inline_reply = InlineReply { - available: true, - label: "Reply".to_string(), - ..InlineReply::default() - }; - reply.actions.push(Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }); - let reply = store.insert(reply, 0).notification; - - assert!(store - .active_inline_reply_target(ordinary.id, ordinary.generation) - .is_none()); - let target = store - .active_inline_reply_target(reply.id, reply.generation) - .expect("reply target"); - assert_eq!(target.id, reply.id); - assert!(!target.is_resident); - assert!(store - .active_inline_reply_target(reply.id, reply.generation.saturating_sub(1)) - .is_none()); -} - -#[test] -fn active_action_target_requires_an_exact_action_on_the_live_generation() { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("action"); - notification.attribution = NotificationAttribution::verified( - "Action source", - "Action source", - "org.example.ActionSource", - "", - AttributionReason::ExactSystemExecutable, - "exact system executable", - "system-app:org.example.ActionSource".to_string(), - ); - notification.actions.push(Action { - key: "open".to_string(), - label: "Open".to_string(), - }); - let original = store.insert(notification, 0).notification; - let id = original.id; - let key = original.key(); - - let target = store - .active_action_target_generation(key, "open", false) - .expect("stored action should resolve"); - assert!(Arc::ptr_eq(&target, &original)); - assert!(store - .active_action_target_generation(key, "missing", false) - .is_none()); - assert!(store.is_active_notification_generation(id, &original)); - - let replacement = store.insert(make_notification("replacement"), id); - assert!(replacement.replaced); - assert!(!store.is_active_notification_generation(id, &original)); - assert!(store - .active_action_target_generation(key, "open", false) - .is_none()); -} - -#[test] -fn active_action_target_denies_every_unverified_sender_class() { - for attribution in [ - NotificationAttribution::recognized( - "User application", - "User application", - "org.example.UserApplication", - "", - AttributionReason::ExactUserExecutable, - "exact user executable", - "user-app:org.example.UserApplication".to_string(), - ), - NotificationAttribution::unresolved( - "Signal", - AttributionReason::NoDesktopCandidate, - "source /tmp/fake", - "unknown:signal".to_string(), - ), - NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", - AttributionReason::ExecutableMismatch, - "source /tmp/fake", - "conflict:signal".to_string(), - ), - NotificationAttribution::relay( - "Signal", - "trusted relay /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), - ), - ] { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("untrusted action"); - notification.attribution = attribution; - notification.actions.push(Action { - key: "default".to_string(), - label: "Open".to_string(), - }); - let key = store.insert(notification, 0).notification.key(); - - assert!( - store - .active_action_target_generation(key, "default", true) - .is_none(), - "weak attribution should not expose application actions" - ); - } -} - -#[test] -fn native_association_allows_default_but_requires_confirmation_for_buttons() { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("native associated actions"); - notification.attribution = NotificationAttribution::associated( - "Example Chat", - "Example Chat", - "org.example.Chat", - "org.example.Chat", - IdentityAssurance::SystemAssociated, - InteractionPolicies::NATIVE_COMPATIBILITY, - AttributionReason::ExactSystemExecutable, - "protected executable association", - "associated:system-app:org.example.Chat".to_string(), - ); - notification.actions = vec![ - Action { - key: "default".to_string(), - label: String::new(), - }, - Action { - key: "archive".to_string(), - label: "Archive".to_string(), - }, - ]; - let key = store.insert(notification, 0).notification.key(); - - assert!( - store - .active_action_target_generation(key, "default", false) - .is_some(), - "native default activation should retain compatibility" - ); - assert!( - store - .active_action_target_generation(key, "archive", false) - .is_none(), - "additional native action must reject an unconfirmed request" - ); - assert!( - store - .active_action_target_generation(key, "archive", true) - .is_some(), - "additional native action should accept explicit trusted-UI confirmation" - ); -} - -#[test] -fn portal_association_requires_confirmation_for_default_and_buttons() { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("portal associated actions"); - notification.attribution = NotificationAttribution::associated( - "Example Portal App", - "Example Portal App", - "org.example.PortalApp", - "org.example.PortalApp", - IdentityAssurance::PortalAssociated, - InteractionPolicies::CONFIRM_ACTIONS, - AttributionReason::PortalAppIdAssociation, - "portal app id without confinement provenance", - "associated:portal-app:org.example.PortalApp".to_string(), - ); - notification.actions = vec![ - Action { - key: "default".to_string(), - label: String::new(), - }, - Action { - key: "open".to_string(), - label: "Open".to_string(), - }, - ]; - let key = store.insert(notification, 0).notification.key(); - - for action_key in ["default", "open"] { - assert!( - store - .active_action_target_generation(key, action_key, false) - .is_none(), - "portal action {action_key:?} must reject an unconfirmed request" - ); - assert!( - store - .active_action_target_generation(key, action_key, true) - .is_some(), - "portal action {action_key:?} should accept trusted-UI confirmation" - ); - } -} - -#[test] -fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { - let mut store = make_store_with_limits(12, 20); - let mut reply = make_notification("resident reply"); - reply.inline_reply.available = true; - reply.actions.push(Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }); - reply.is_resident = true; - let reply = store.insert(reply, 0).notification; - - assert!( - store - .active_inline_reply_target(reply.id, reply.generation) - .expect("resident reply target") - .is_resident - ); - - store.close(reply.id, CloseReason::Expired); - - assert!(store - .active_inline_reply_target(reply.id, reply.generation) - .is_none()); - assert!(store.list_history().iter().any(|view| view.id == reply.id)); -} - -#[test] -fn inline_reply_metadata_without_the_protocol_action_is_rejected() { - let mut store = make_store_with_limits(12, 20); - let mut malformed = make_notification("metadata only"); - malformed.inline_reply.available = true; - let malformed = store.insert(malformed, 0).notification; - - assert!(store - .active_inline_reply_target(malformed.id, malformed.generation) - .is_none()); -} - -#[test] -fn inline_reply_policy_denies_a_complete_reply_action() { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("unassociated reply"); - notification.inline_reply.available = true; - notification.inline_reply_policy = InlineReplyPolicy::Deny; - notification.actions.push(Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }); - let notification = store.insert(notification, 0).notification; - - assert!(store - .active_inline_reply_target(notification.id, notification.generation) - .is_none()); -} - -#[test] -fn native_association_denies_reply_even_if_protocol_metadata_claims_allow() { - let mut store = make_store_with_limits(12, 20); - let mut notification = make_notification("native associated reply"); - notification.attribution = NotificationAttribution::associated( - "Example Chat", - "Example Chat", - "org.example.Chat", - "org.example.Chat", - IdentityAssurance::SystemAssociated, - InteractionPolicies::NATIVE_COMPATIBILITY, - AttributionReason::ExactSystemExecutable, - "protected executable association", - "associated:system-app:org.example.Chat".to_string(), - ); - notification.inline_reply.available = true; - notification.inline_reply_policy = InlineReplyPolicy::Allow; - notification.actions.push(Action { - key: "inline-reply".to_string(), - label: "Reply".to_string(), - }); - let notification = store.insert(notification, 0).notification; - - assert!( - store - .active_inline_reply_target(notification.id, notification.generation) - .is_none(), - "native executable association cannot authorize credential-like reply input" - ); -} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs new file mode 100644 index 000000000..edcb20779 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -0,0 +1,222 @@ +use std::sync::Arc; + +use unixnotis_core::{ + Action, AttributionReason, IdentityAssurance, InteractionPolicies, + NotificationAttribution, +}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn active_action_target_requires_an_exact_action_on_the_live_generation() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("action"); + notification.attribution = NotificationAttribution::verified( + "Action source", + "Action source", + "org.example.ActionSource", + "", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.ActionSource".to_string(), + ); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + let original = store.insert(notification, 0).notification; + let id = original.id; + let key = original.key(); + + let target = store + .active_action_target_generation(key, "open", false) + .expect("stored action should resolve"); + assert!(Arc::ptr_eq(&target, &original)); + assert!(store + .active_action_target_generation(key, "missing", false) + .is_none()); + assert!(store.is_active_notification_generation(id, &original)); + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + assert!(!store.is_active_notification_generation(id, &original)); + assert!(store + .active_action_target_generation(key, "open", false) + .is_none()); +} + +#[test] +fn active_action_target_denies_every_unverified_sender_class() { + for attribution in [ + NotificationAttribution::recognized( + "User application", + "User application", + "org.example.UserApplication", + "", + AttributionReason::ExactUserExecutable, + "exact user executable", + "user-app:org.example.UserApplication".to_string(), + ), + NotificationAttribution::unresolved( + "Signal", + AttributionReason::NoDesktopCandidate, + "source /tmp/fake", + "unknown:signal".to_string(), + ), + NotificationAttribution::conflict( + "Signal", + "org.signal.Signal", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", + "conflict:signal".to_string(), + ), + NotificationAttribution::relay( + "Signal", + "trusted relay /usr/bin/notify-send", + "relay:notify-send:signal".to_string(), + ), + ] { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("untrusted action"); + notification.attribution = attribution; + notification.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + let key = store.insert(notification, 0).notification.key(); + + assert!( + store + .active_action_target_generation(key, "default", true) + .is_none(), + "weak attribution should not expose application actions" + ); + } +} + +#[test] +fn native_association_allows_default_but_requires_confirmation_for_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + ]; + let key = store.insert(notification, 0).notification.key(); + + assert!( + store + .active_action_target_generation(key, "default", false) + .is_some(), + "native default activation should retain compatibility" + ); + assert!( + store + .active_action_target_generation(key, "archive", false) + .is_none(), + "additional native action must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, "archive", true) + .is_some(), + "additional native action should accept explicit trusted-UI confirmation" + ); +} + +#[test] +fn portal_association_requires_confirmation_for_default_and_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("portal associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Portal App", + "Example Portal App", + "org.example.PortalApp", + "org.example.PortalApp", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal app id without confinement provenance", + "associated:portal-app:org.example.PortalApp".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + ]; + let key = store.insert(notification, 0).notification.key(); + + for action_key in ["default", "open"] { + assert!( + store + .active_action_target_generation(key, action_key, false) + .is_none(), + "portal action {action_key:?} must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, action_key, true) + .is_some(), + "portal action {action_key:?} should accept trusted-UI confirmation" + ); + } +} + +#[test] +fn active_action_target_rejects_inline_reply_even_when_confirmed() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("inline-reply action target"); + notification.attribution = NotificationAttribution::verified( + "Verified source", + "Verified source", + "org.example.Verified", + "", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.Verified".to_string(), + ); + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + let key = store.insert(notification, 0).notification.key(); + + assert!( + store + .active_action_target_generation(key, "inline-reply", true) + .is_none(), + "inline-reply must be rejected through action dispatch even with confirmed=true" + ); + assert!( + store + .active_action_target_generation(key, "open", false) + .is_some(), + "unrelated actions must still resolve normally" + ); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs new file mode 100644 index 000000000..7120552d0 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs @@ -0,0 +1,28 @@ +use unixnotis_core::Config; + +use crate::store::test_support::{make_notification, make_store_with_limits}; +use crate::store::NotificationStore; + +#[test] +fn config_accessor_returns_runtime_config_snapshot() { + let mut config = Config::default(); + config.history.max_entries = 77; + config.history.max_active = 3; + let store = NotificationStore::new(config); + + assert_eq!(store.config().history.max_entries, 77); + assert_eq!(store.config().history.max_active, 3); +} + +#[test] +fn active_notification_view_returns_current_active_payload() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert(make_notification("visible"), 0); + + let view = store + .active_notification_view(outcome.notification.id) + .expect("active notification should be visible"); + + assert_eq!(view.id, outcome.notification.id); + assert_eq!(view.summary, "visible"); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs new file mode 100644 index 000000000..0f760042d --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs @@ -0,0 +1,130 @@ +use unixnotis_core::{ + Action, AttributionReason, CloseReason, IdentityAssurance, InlineReply, + InlineReplyPolicy, InteractionPolicies, NotificationAttribution, +}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn active_inline_reply_target_requires_a_live_explicit_reply_action() { + let mut store = make_store_with_limits(12, 20); + let ordinary = store.insert(make_notification("ordinary"), 0).notification; + let mut reply = make_notification("reply"); + reply.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let reply = store.insert(reply, 0).notification; + + assert!(store + .active_inline_reply_target(ordinary.id, ordinary.generation) + .is_none()); + let target = store + .active_inline_reply_target(reply.id, reply.generation) + .expect("reply target"); + assert_eq!(target.id, reply.id); + assert!(!target.is_resident); + assert!(store + .active_inline_reply_target(reply.id, reply.generation.saturating_sub(1)) + .is_none()); +} + +#[test] +fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { + let mut store = make_store_with_limits(12, 20); + let mut reply = make_notification("resident reply"); + reply.inline_reply.available = true; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + reply.is_resident = true; + let reply = store.insert(reply, 0).notification; + + assert!( + store + .active_inline_reply_target(reply.id, reply.generation) + .expect("resident reply target") + .is_resident + ); + + let key = reply.key(); + assert!( + store + .active_action_target_generation(key, "inline-reply", true) + .is_none(), + "inline-reply action must be rejected through action dispatch even when confirmed" + ); + + store.close(reply.id, CloseReason::Expired); + + assert!(store + .active_inline_reply_target(reply.id, reply.generation) + .is_none()); + assert!(store.list_history().iter().any(|view| view.id == reply.id)); +} + +#[test] +fn inline_reply_metadata_without_the_protocol_action_is_rejected() { + let mut store = make_store_with_limits(12, 20); + let mut malformed = make_notification("metadata only"); + malformed.inline_reply.available = true; + let malformed = store.insert(malformed, 0).notification; + + assert!(store + .active_inline_reply_target(malformed.id, malformed.generation) + .is_none()); +} + +#[test] +fn inline_reply_policy_denies_a_complete_reply_action() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("unassociated reply"); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Deny; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let notification = store.insert(notification, 0).notification; + + assert!(store + .active_inline_reply_target(notification.id, notification.generation) + .is_none()); +} + +#[test] +fn native_association_denies_reply_even_if_protocol_metadata_claims_allow() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated reply"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Allow; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let notification = store.insert(notification, 0).notification; + + assert!( + store + .active_inline_reply_target(notification.id, notification.generation) + .is_none(), + "native executable association cannot authorize credential-like reply input" + ); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs new file mode 100644 index 000000000..e63bc0cdb --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs @@ -0,0 +1,4 @@ +mod action_target; +mod config; +mod inline_reply; +mod popup; diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs new file mode 100644 index 000000000..65babbc95 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -0,0 +1,315 @@ +use unixnotis_core::{CloseReason, Config, PopupAdmissionView}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; +use crate::store::NotificationStore; + +#[test] +fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("allowed"), 0).notification; + let mut suppressed = make_notification("rule suppressed"); + suppressed.suppress_popup = true; + let replacement = store.insert(suppressed, original.id).notification; + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain an active popup candidate"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "rule suppressed"); + assert_eq!(candidate.admission, PopupAdmissionView::Rule); +} + +#[test] +fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("allowed"), 0).notification; + store.set_dnd(true); + let replacement = store + .insert(make_notification("dnd suppressed"), original.id) + .notification; + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain active during DND"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "dnd suppressed"); + assert_eq!(candidate.admission, PopupAdmissionView::Dnd); +} + +#[test] +fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() { + let mut store = make_store_with_limits(10, 10); + let visible = store.insert(make_notification("visible"), 0).notification; + let unavailable = store + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) + .expect("active notification diagnostics"); + + assert_eq!( + unavailable.popup_admission, + PopupAdmissionView::RendererUnavailable + ); + assert!(!unavailable.renderer_process_running); + assert!(!unavailable.renderer_ready); + + store.set_dnd(true); + let dnd_suppressed = store + .insert(make_notification("DND suppressed"), 0) + .notification; + store.set_dnd(false); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + let suppressed = store + .notification_diagnostics(dnd_suppressed.id, &ready) + .expect("DND diagnostics"); + + assert_eq!(suppressed.popup_admission, PopupAdmissionView::Dnd); + assert!(!suppressed.renderer_process_running); + assert!(!suppressed.renderer_ready); +} + +#[test] +fn notification_diagnostics_require_both_renderer_process_and_readiness() { + let mut store = make_store_with_limits(10, 10); + for (process_running, ready, expected) in [ + (false, false, PopupAdmissionView::RendererUnavailable), + (true, false, PopupAdmissionView::RendererUnavailable), + (false, true, PopupAdmissionView::RendererUnavailable), + (true, true, PopupAdmissionView::Show), + ] { + let health = unixnotis_core::UiHealth { + popups_process_running: process_running, + popups_ready: ready, + ..unixnotis_core::UiHealth::default() + }; + let visible = store.insert(make_notification("visible"), 0).notification; + store.record_popup_commit_environment( + visible.key(), + crate::store::PopupAdmission::Show, + &health, + ); + let diagnostics = store + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) + .expect("active notification diagnostics"); + + assert_eq!( + diagnostics.popup_admission, expected, + "process_running={process_running}, ready={ready}" + ); + } +} + +#[test] +fn disabled_popups_are_recorded_when_max_visible_is_zero() { + let mut config = Config::default(); + config.popups.max_visible = 0; + let mut store = NotificationStore::new(config); + let notification = store.insert(make_notification("disabled"), 0).notification; + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let diagnostics = store + .notification_diagnostics(notification.id, &ready) + .expect("disabled popup diagnostics"); + + assert_eq!( + diagnostics.popup_admission, + PopupAdmissionView::RendererDisabled + ); + assert_eq!(diagnostics.configured_max_visible, 0); +} + +#[test] +fn archived_notification_keeps_its_arrival_popup_explanation() { + let mut store = make_store_with_limits(10, 10); + store.set_dnd(true); + let notification = store + .insert(make_notification("archived DND"), 0) + .notification; + store.close(notification.id, CloseReason::Expired); + store.set_dnd(false); + + let diagnostics = store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("history diagnostics should remain available"); + + assert_eq!(diagnostics.generation, notification.generation); + assert_eq!(diagnostics.popup_admission, PopupAdmissionView::Dnd); +} + +#[test] +fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("admitted popup candidate"); + assert_eq!(candidate.admission, PopupAdmissionView::Show); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("fetched diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::RendererFetched + ); + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("rendered diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible + ); +} + +#[test] +fn delivery_stage_never_moves_backward() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::RendererFetched, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond + ); + + assert_eq!( + store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("delivery diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible, + "later duplicate fetches must not regress delivery history" + ); +} + +#[test] +fn duplicate_popup_stage_acknowledgement_is_idempotent() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("delivery"), 0).notification; + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond, + "a retained generation must accept a duplicate renderer callback" + ); +} + +#[test] +fn popup_stage_acknowledgement_rejects_a_missing_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store.insert(make_notification("original"), 0).notification; + let _replacement = store + .insert(make_notification("replacement"), original.id) + .notification; + + assert_eq!( + store.record_popup_delivery_stage( + original.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::MissingGeneration, + "a stale generation must remain distinct from an idempotent current callback" + ); +} + +#[test] +fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering() { + let mut store = make_store_with_limits(10, 10); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + + let mut rule_suppressed = make_notification("persistent suppression"); + rule_suppressed.suppress_popup = true; + let rule_suppressed = store.insert(rule_suppressed, 0).notification; + store.record_popup_commit_environment( + rule_suppressed.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let arrival_suppressed = store + .insert(make_notification("arrival suppression"), 0) + .notification; + store.record_popup_commit_environment( + arrival_suppressed.key(), + crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), + &ready, + ); + + let admitted = store.insert(make_notification("admitted"), 0).notification; + store.record_popup_commit_environment( + admitted.key(), + crate::store::PopupAdmission::Show, + &ready, + ); + + let candidates = store.list_popup_candidates(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].key(), admitted.key()); +} + +#[test] +fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("retained"), 0).notification; + + assert!(store.popup_decisions.contains_key(¬ification.key())); + store.close(notification.id, CloseReason::Expired); + assert!(store.popup_decisions.contains_key(¬ification.key())); + + store.clear_history(); + assert!(store.popup_decisions.is_empty()); +} From d6b8eae071685b49986bb3ee09bac7437180b0d4 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 21:32:20 -0500 Subject: [PATCH 170/275] fix(ui): share generation-safe confirmation state Summary: share generation-safe confirmation state. Scope: ui. --- .../row/notification/update/actions.rs | 85 ++++++++++++- .../row/notification/update/tests/actions.rs | 112 +++++++++++++++++- .../src/ui/entry/builders/common.rs | 91 +++++++++++++- .../src/ui/entry/builders/tests/common.rs | 87 ++++++++++++++ 4 files changed, 365 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 5d3850368..9d26cf1c6 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -3,7 +3,9 @@ use std::borrow::Cow; use std::cell::Cell; use std::rc::Rc; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use gtk::glib; use gtk::prelude::*; use tokio::sync::mpsc; @@ -22,6 +24,10 @@ use super::super::state::{NotificationRowWidgets, MAX_ACTION_LABEL_CHARS}; use super::labels::clamp_label_text; const ACTION_BUTTON_GUARD_MS: u64 = 180; +// Clicks inside this window after arming are treated as accidental double-taps +const MIN_CONFIRM_INTERVAL_MS: u64 = 350; +// Armed state expires after this long and the button goes back to normal +const MAX_CONFIRM_TIMEOUT_MS: u64 = 5000; pub(super) fn clamp_action_label_text(text: &str) -> Cow<'_, str> { // Action text uses the same clamp rule every time so row width stays stable @@ -191,24 +197,93 @@ fn build_action_button( let original_label = clamp_action_label_text(&action.label).into_owned(); let policy = action.policy; let tx = command_tx.clone(); - let confirmation_armed = Cell::new(false); + // Single shared state: None = not armed, Some(instant) = armed at that time + // Using Rc> so both the click handler and the timeout callback read and + // write the same cell. The timeout captures `now` at arm time and only resets + // the button if that exact timestamp is still current — this prevents a stale + // timer from the first cycle from wiping the visual state of a newer cycle. + let armed_at = Rc::new(Cell::new(None::)); let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); button.connect_clicked(move |button| { if !action_gate.try_start() { return; } - let confirmed = match action_activation(policy, confirmation_armed.get()) { + let confirmed = match action_activation(policy, armed_at.get().is_some()) { ActionActivation::Denied => return, ActionActivation::ArmConfirmation => { - confirmation_armed.set(true); + let now = Instant::now(); + armed_at.set(Some(now)); let confirmation_label = format!("Confirm {original_label}"); button.set_label(&confirmation_label); button.set_tooltip_text(Some("Activate again to confirm")); button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + // Clean up the armed state after a timeout so the button does not stay in + // confirm mode forever + let expire_button = button.clone(); + let expire_label = original_label.clone(); + let expire_armed_at = Rc::clone(&armed_at); + glib::timeout_add_local_once( + Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS), + move || { + if expire_armed_at.get() == Some(now) { + expire_armed_at.set(None); + expire_button.set_label(&expire_label); + expire_button.set_tooltip_text(None); + expire_button.update_property(&[ + gtk::accessible::Property::Label(&expire_label), + ]); + } + }, + ); return; } - ActionActivation::Invoke { confirmed } => confirmed, + ActionActivation::Invoke { confirmed } => { + // Only check timing when the action was actually confirmed + // Allow-policy actions skip this path entirely + if confirmed { + let elapsed = armed_at.get().map(|t| t.elapsed()); + match elapsed { + // No arm time recorded means something went wrong + // Clean up instead of dispatching + None => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[ + gtk::accessible::Property::Label(&original_label), + ]); + return; + } + // Click came too fast after arming + // Probably an accidental double-tap, stay armed so the next click + // can still go through + Some(d) if d < Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => { + return; + } + // Confirmation took too long + // Reset the button and make the person re-arm + Some(d) if d > Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[ + gtk::accessible::Property::Label(&original_label), + ]); + return; + } + // Right amount of time passed, dispatch the action + _ => {} + } + } + confirmed + } }; + // Reset everything after a successful dispatch + // The next click will start a fresh confirmation cycle instead of invoking again + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label(&original_label)]); debug!( id = notification.id, generation = notification.generation, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index b495b007c..5e654581a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -410,7 +410,7 @@ fn confirmable_panel_action_requires_two_clicks_before_dispatch() { "first click must not invoke a confirmable action" ); - std::thread::sleep(std::time::Duration::from_millis(200)); + std::thread::sleep(std::time::Duration::from_millis(400)); let context = gtk::glib::MainContext::default(); while context.pending() { context.iteration(false); @@ -426,6 +426,116 @@ fn confirmable_panel_action_requires_two_clicks_before_dispatch() { && notification.generation == 1 && action_key == "archive" )); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "third click must re-arm rather than dispatching" + ); +} + +#[gtk::test] +fn confirmable_panel_action_stale_timer_does_not_disarm_newer_cycle() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("confirmable action button"); + let context = gtk::glib::MainContext::default(); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + std::thread::sleep(std::time::Duration::from_millis(400)); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 1 && notification.generation == 1 && action_key == "archive" + )); + + // Wait for click cooldown before re-arming. + std::thread::sleep(std::time::Duration::from_millis(200)); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer A (from first arm at t=0) fires at t=5000. We are at t=600 now. + // Sleep 4400ms -> t=5000. Process timer A. It should NOT clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(4400)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer B (from second arm at t=600) fires at t=5600. We are at t=5000. + // Sleep 600ms -> t=5600. Process timer B. It SHOULD clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(600)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Archive")); + assert!(command_rx.try_recv().is_err()); + + // Next click re-arms rather than invokes. + std::thread::sleep(std::time::Duration::from_millis(200)); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); } #[gtk::test] diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 5f80acc01..a3de7ea8f 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -1,7 +1,10 @@ //! Shared small primitives used by every popup kind use std::cell::Cell; +use std::rc::Rc; +use std::time::Instant; +use gtk::glib; use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; @@ -14,6 +17,11 @@ use crate::dbus::UiCommand; use crate::ui::entry::activation::mark_interactive; use crate::ui::UiState; +// Clicks inside this window after arming are treated as accidental double-taps +const MIN_CONFIRM_INTERVAL_MS: u64 = 350; +// Armed state expires after this long and the button goes back to normal +const MAX_CONFIRM_TIMEOUT_MS: u64 = 5000; + pub(super) struct IdentityAvatar { pub(super) widget: gtk::Box, } @@ -186,20 +194,95 @@ fn build_action_button( let policy = action.policy; let tx = command_tx.clone(); let popover = popover.cloned(); - let confirmation_armed = Cell::new(false); + // Single shared state: None = not armed, Some(instant) = armed at that time + // Using Rc> so both the click handler and the timeout callback read and + // write the same cell. The timeout captures `now` at arm time and only resets + // the button if that exact timestamp is still current — this prevents a stale + // timer from the first cycle from wiping the visual state of a newer cycle. + let armed_at = Rc::new(Cell::new(None::)); button.connect_clicked(move |button| { - let confirmed = match action_activation(policy, confirmation_armed.get()) { + let confirmed = match action_activation(policy, armed_at.get().is_some()) { ActionActivation::Denied => return, ActionActivation::ArmConfirmation => { - confirmation_armed.set(true); + let now = Instant::now(); + armed_at.set(Some(now)); let confirmation_label = format!("Confirm {original_label}"); button.set_label(&confirmation_label); button.set_tooltip_text(Some("Activate again to confirm")); button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + // Clean up the armed state after a timeout so the button does not stay in + // confirm mode forever + let expire_button = button.clone(); + let expire_label = original_label.clone(); + let expire_armed_at = Rc::clone(&armed_at); + glib::timeout_add_local_once( + std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS), + move || { + if expire_armed_at.get() == Some(now) { + expire_armed_at.set(None); + expire_button.set_label(&expire_label); + expire_button.set_tooltip_text(None); + expire_button.update_property(&[ + gtk::accessible::Property::Label(&expire_label), + ]); + } + }, + ); return; } - ActionActivation::Invoke { confirmed } => confirmed, + ActionActivation::Invoke { confirmed } => { + // Only check timing when the action was actually confirmed + // Allow-policy actions skip this path entirely + if confirmed { + let elapsed = armed_at.get().map(|t| t.elapsed()); + match elapsed { + // No arm time recorded means something went wrong + // Clean up instead of dispatching + None => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[ + gtk::accessible::Property::Label(&original_label), + ]); + return; + } + // Click came too fast after arming + // Probably an accidental double-tap, stay armed so the next click + // can still go through + Some(d) + if d + < std::time::Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => + { + return; + } + // Confirmation took too long + // Reset the button and make the person re-arm + Some(d) + if d + > std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => + { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[ + gtk::accessible::Property::Label(&original_label), + ]); + return; + } + // Right amount of time passed, dispatch the action + _ => {} + } + } + confirmed + } }; + // Reset everything after a successful dispatch + // The next click will start a fresh confirmation cycle instead of invoking again + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label(&original_label)]); // Menus close only after an action passes its confirmation policy if let Some(popover) = &popover { popover.popdown(); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 42057bb69..2a4f580d0 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -159,6 +159,12 @@ fn confirmable_popup_action_requires_two_clicks_before_dispatch() { "first click must not invoke a confirmable action" ); + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); assert!(matches!( command_rx.try_recv(), @@ -170,6 +176,87 @@ fn confirmable_popup_action_requires_two_clicks_before_dispatch() { && notification.generation == 3 && action_key == "archive" )); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "third click must re-arm rather than dispatching" + ); +} + +#[gtk::test] +fn confirmable_popup_action_stale_timer_does_not_disarm_newer_cycle() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = notification(); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions.push(Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("confirmable action button"); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 41 && notification.generation == 3 && action_key == "archive" + )); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer A (from first arm at t=0) fires at t=5000. We are at t=400 now. + // Sleep 4600ms -> t=5000. Process timer A. It should NOT clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(4600)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer B (from second arm at t=400) fires at t=5400. We are at t=5000. + // Sleep 400ms -> t=5400. Process timer B. It SHOULD clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(400)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Archive")); + assert!(command_rx.try_recv().is_err()); + + // Next click re-arms rather than invokes. + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); } #[gtk::test] From 1c9144d746b61cd53db80260c045ec0b389f0d32 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 22:10:04 -0500 Subject: [PATCH 171/275] style(popups): reserve symmetric card shadow margins Summary: reserve symmetric card shadow margins. Scope: popups. --- crates/unixnotis-core/src/config/layout/common.rs | 5 ++--- crates/unixnotis-core/src/config/layout/popup.rs | 7 ++++++- .../unixnotis-core/src/config/layout/tests/popup.rs | 12 ++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/unixnotis-core/src/config/layout/common.rs b/crates/unixnotis-core/src/config/layout/common.rs index 9718f667e..58f06e84a 100644 --- a/crates/unixnotis-core/src/config/layout/common.rs +++ b/crates/unixnotis-core/src/config/layout/common.rs @@ -50,10 +50,9 @@ pub struct Margins { impl Default for Margins { fn default() -> Self { - // Default padding around the panel. Keeping it symmetric produces a balanced look by default. - // Users can override individual edges in config for tighter or asymmetric layouts. + // Neutral default shared by panel and popup. Callers that need edge + // clearance for shadows should set margins explicitly. Self { - // Matches the default popup stack spacing for a cohesive baseline layout. top: 14, right: 14, bottom: 14, diff --git a/crates/unixnotis-core/src/config/layout/popup.rs b/crates/unixnotis-core/src/config/layout/popup.rs index dad934c90..c70e5dbdb 100644 --- a/crates/unixnotis-core/src/config/layout/popup.rs +++ b/crates/unixnotis-core/src/config/layout/popup.rs @@ -22,7 +22,12 @@ impl Default for PopupConfig { fn default() -> Self { Self { anchor: Anchor::TopRight, - margin: Margins::default(), + margin: Margins { + top: 14, + right: 18, + bottom: 14, + left: 18, + }, width: 360, spacing: 12, max_visible: 3, diff --git a/crates/unixnotis-core/src/config/layout/tests/popup.rs b/crates/unixnotis-core/src/config/layout/tests/popup.rs index fb2117ca7..fc24e3622 100644 --- a/crates/unixnotis-core/src/config/layout/tests/popup.rs +++ b/crates/unixnotis-core/src/config/layout/tests/popup.rs @@ -6,3 +6,15 @@ fn popup_defaults_limit_the_visible_stack_to_three_notifications() { assert_eq!(popup.max_visible, 3); } + +#[test] +fn popup_defaults_include_edge_clearance_for_card_shadow() { + let popup = PopupConfig::default(); + + // Both left and right margins accommodate the card box-shadow (~17px blur) + // so the shadow is not clipped at the work-area boundary regardless of anchor. + assert_eq!(popup.margin.left, 18); + assert_eq!(popup.margin.right, 18); + assert_eq!(popup.margin.top, 14); + assert_eq!(popup.margin.bottom, 14); +} From e137aa6758b49e35d3cc8b07ce28170d2a7b079f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 23:20:26 -0500 Subject: [PATCH 172/275] fix(auth): fingerprint process executable descriptors Summary: fingerprint process executable descriptors. Scope: auth. --- .../src/daemon/auth/authorization.rs | 57 ++++++++++++++----- .../auth/executable_trust/fingerprint.rs | 45 +++++++++++++++ .../daemon/auth/executable_trust/metadata.rs | 14 +++++ .../src/daemon/auth/executable_trust/mod.rs | 2 + .../src/daemon/auth/executable_trust/paths.rs | 47 ++++++++++++++- .../auth/executable_trust/tests/strict.rs | 15 +++-- .../src/daemon/auth/process_identity.rs | 29 +++++++++- .../src/daemon/auth/tests/authorization.rs | 14 +++-- 8 files changed, 196 insertions(+), 27 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index 9a9d76870..c5b9a93af 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -11,6 +11,8 @@ use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; use super::executable_trust::is_trusted_control_executable_path; +#[cfg(target_os = "linux")] +use super::executable_trust::is_trusted_control_executable_from_fd; use super::policy::{ TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES, TRUSTED_POPUP_READINESS_EXECUTABLES, @@ -18,7 +20,9 @@ use super::policy::{ #[cfg(not(target_os = "linux"))] use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] -use super::process_identity::read_process_executable_path_from_pidfd; +use super::process_identity::{ + open_process_executable_from_pidfd, read_process_executable_path_from_pidfd, +}; pub(in crate::daemon) async fn authorize_control_call( state: &Arc, @@ -109,16 +113,23 @@ async fn authorize_control_call_for_executables( zbus::fdo::Error::AccessDenied("caller process id is unavailable".to_string()) })?; #[cfg(target_os = "linux")] - let exe_path = { + let (exe_path, exe_fd) = { // Linux must use the stable process handle from the same credential snapshot let pidfd = required_linux_process_fd(&credentials)?; - read_process_executable_path_from_pidfd(pidfd, pid) + let exe_path = read_process_executable_path_from_pidfd(pidfd, pid); + // Open /proc//exe as a descriptor to fingerprint the actual file object + // rather than a pathname that could be shadowed by a mount namespace + let exe_fd = open_process_executable_from_pidfd(pidfd, pid); + (exe_path, exe_fd) }; #[cfg(not(target_os = "linux"))] - let exe_path = read_process_executable_path(pid).await; - if let Some(err) = - control_executable_error(exe_path.as_deref(), allowed_executables, state.trial_mode()) - { + let (exe_path, exe_fd) = (read_process_executable_path(pid).await, None); + if let Some(err) = control_executable_error( + exe_path.as_deref(), + exe_fd.as_ref(), + allowed_executables, + state.trial_mode(), + ) { warn!( method, sender = %sender_name, @@ -163,25 +174,45 @@ pub(in crate::daemon) fn control_owner_uid_error( )) } -pub(in crate::daemon) fn control_executable_is_allowed( - path: &Path, +pub(in crate::daemon) fn control_executable_is_allowed( + path: Option<&Path>, + exe_fd: Option<&Fd>, allowed_executables: &[&str], relaxed: bool, ) -> bool { - // Name allowlist and path trust are separate checks; both must pass + // Name allowlist is required; path trust is a separate check that must also pass + let Some(path) = path else { + return false; + }; let name_allowed = path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| allowed_executables.contains(&name)); - name_allowed && is_trusted_control_executable_path(path, relaxed) + if !name_allowed { + return false; + } + + // On Linux, verify the executable via its file descriptor to prevent + // mount-namespace bypass (UNX-4-001). The path is only used for the + // name allowlist above; the actual trust check uses the kernel file object. + #[cfg(target_os = "linux")] + { + if let Some(fd) = exe_fd { + return is_trusted_control_executable_from_fd(fd, path, relaxed); + } + } + + // Fallback for non-Linux or when fd is unavailable: use path-based trust + is_trusted_control_executable_path(path, relaxed) } -pub(in crate::daemon) fn control_executable_error( +pub(in crate::daemon) fn control_executable_error( path: Option<&Path>, + exe_fd: Option<&Fd>, allowed_executables: &[&str], relaxed: bool, ) -> Option { - if path.is_some_and(|path| control_executable_is_allowed(path, allowed_executables, relaxed)) { + if control_executable_is_allowed(path, exe_fd, allowed_executables, relaxed) { return None; } Some(zbus::fdo::Error::AccessDenied( diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs index 6f28e701d..a70ca9338 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs @@ -1,5 +1,6 @@ //! Fingerprint cache for trusted executable files +use std::os::unix::io::AsFd; use std::path::Path; use std::sync::{Mutex, OnceLock}; @@ -7,6 +8,8 @@ use super::super::policy::{ FileFingerprint, FileFingerprintSignature, FingerprintCacheEntry, FINGERPRINT_CACHE_CAPACITY, }; use super::metadata::trusted_control_file_metadata_is_safe; +#[cfg(target_os = "linux")] +use super::metadata::trusted_control_file_metadata_is_safe_from_stat; pub(in crate::daemon) fn file_fingerprint(path: &Path) -> Option { let metadata = std::fs::metadata(path).ok()?; @@ -27,6 +30,30 @@ pub(in crate::daemon) fn file_fingerprint(path: &Path) -> Option( + fd: &Fd, + path: &Path, +) -> Option { + // Open /proc//exe as a descriptor and fingerprint the actual kernel + // file object, not a pathname that could be shadowed by a mount namespace. + // This prevents the UNX-4-001 mount-namespace bypass. + let stat = rustix::fs::fstat(fd.as_fd()).ok()?; + if !rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() { + return None; + } + if !trusted_control_file_metadata_is_safe_from_stat(&stat) { + return None; + } + let signature = file_fingerprint_signature_from_stat(&stat)?; + if let Some(cached) = load_cached_fingerprint(path, signature) { + return Some(cached); + } + + let fingerprint = FileFingerprint { signature }; + store_cached_fingerprint(path, signature, fingerprint.clone()); + Some(fingerprint) +} + pub(in crate::daemon) fn file_fingerprint_signature( metadata: &std::fs::Metadata, ) -> Option { @@ -56,6 +83,24 @@ pub(in crate::daemon) fn file_fingerprint_signature( } } +#[cfg(target_os = "linux")] +fn file_fingerprint_signature_from_stat( + stat: &rustix::fs::Stat, +) -> Option { + Some(FileFingerprintSignature { + len: stat.st_size as u64, + dev: stat.st_dev, + ino: stat.st_ino, + mode: stat.st_mode, + uid: stat.st_uid, + gid: stat.st_gid, + mtime: stat.st_mtime, + mtime_nsec: stat.st_mtime_nsec as i64, + ctime: stat.st_ctime, + ctime_nsec: stat.st_ctime_nsec as i64, + }) +} + pub(in crate::daemon) fn fingerprint_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(Vec::new())) diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs index f5f3a89ce..1bf52489b 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs @@ -19,6 +19,20 @@ pub(in crate::daemon) fn trusted_control_file_metadata_is_safe( trusted_control_owner_uid_is_allowed(uid, expected_uid) } +#[cfg(target_os = "linux")] +pub(in crate::daemon) fn trusted_control_file_metadata_is_safe_from_stat( + stat: &rustix::fs::Stat, +) -> bool { + // Group/world writable binaries can be replaced by accounts outside the trust boundary + if stat.st_mode & 0o022 != 0 { + return false; + } + + // User installs should be owned by the desktop user, while distro packages may be root + let expected_uid = geteuid().as_raw(); + trusted_control_owner_uid_is_allowed(stat.st_uid, expected_uid) +} + pub(in crate::daemon) const fn trusted_control_owner_uid_is_allowed( uid: u32, expected_uid: u32, diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs index 97b7b050c..8843ed7df 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -6,6 +6,8 @@ pub(in crate::daemon::auth) mod paths; mod snapshots; pub(super) use paths::is_trusted_control_executable_path; +#[cfg(target_os = "linux")] +pub(super) use paths::is_trusted_control_executable_from_fd; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 892e5a39d..ba5a45315 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -1,9 +1,10 @@ //! Trusted executable path matching +use std::os::unix::io::AsFd; use std::path::{Path, PathBuf}; use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; -use super::fingerprint::file_fingerprint; +use super::fingerprint::{file_fingerprint, file_fingerprint_from_fd}; use super::metadata::trusted_control_file_metadata_is_safe; use super::snapshots::trusted_control_snapshot; @@ -124,3 +125,47 @@ pub(in crate::daemon::auth) fn trusted_snapshot_matches_observed( // Live fingerprint must still match the pinned startup snapshot file_fingerprint(observed).is_some_and(|fingerprint| fingerprint == snapshot.fingerprint) } + +#[cfg(target_os = "linux")] +pub(in crate::daemon::auth) fn is_trusted_control_executable_from_fd( + fd: &Fd, + path: &Path, + relaxed: bool, +) -> bool { + // Trust only known sibling binaries from the daemon install/build directory + let Some(trusted_dir) = trusted_control_directory() else { + return false; + }; + + let observed = canonicalize_best_effort(path); + let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { + return false; + } + + // Fingerprint the kernel file object via the descriptor, not the pathname. + // This prevents the UNX-4-001 mount-namespace bypass where an attacker + // shadows a trusted path with a different executable in their own namespace. + let fingerprint = match file_fingerprint_from_fd(fd, path) { + Some(fingerprint) => fingerprint, + None => return false, + }; + + if relaxed { + // Relaxed mode checks the path is in a trusted location, then verifies + // the descriptor fingerprint matches the live file at that path + if !is_trusted_control_executable_path_relaxed_in_dir(&observed, &trusted_dir) { + return false; + } + // Verify the descriptor fingerprint matches what we'd get from the path + file_fingerprint(path).is_some_and(|path_fingerprint| path_fingerprint == fingerprint) + } else { + // Strict mode: the descriptor fingerprint must match the startup snapshot + let Some(snapshot) = trusted_control_snapshot(&trusted_dir, observed_name) else { + return false; + }; + fingerprint == snapshot.fingerprint + } +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs index 417166e81..7cdb284e7 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -29,18 +29,21 @@ fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { assert!(is_trusted_control_executable_path(&trusted, false)); assert!(!is_trusted_control_executable_path(&foreign, false)); - assert!(control_executable_is_allowed( - &trusted, + assert!(control_executable_is_allowed::( + Some(&trusted), + None, &["noticenterctl"], false )); - assert!(!control_executable_is_allowed( - &trusted, + assert!(!control_executable_is_allowed::( + Some(&trusted), + None, &["unixnotis-center"], false )); - assert!(!control_executable_is_allowed( - &foreign, + assert!(!control_executable_is_allowed::( + Some(&foreign), + None, &["noticenterctl"], false )); diff --git a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs index 18e7d1615..e18bf33ce 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs @@ -1,11 +1,12 @@ //! Process metadata helpers for authorization checks +use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; #[cfg(target_os = "linux")] use std::io::Read; #[cfg(target_os = "linux")] -use std::os::fd::{AsFd, AsRawFd}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd}; #[cfg(target_os = "linux")] const MAX_PIDFD_INFO_BYTES: u64 = 4_096; @@ -37,6 +38,32 @@ pub(in crate::daemon) fn read_process_executable_path_from_pidfd( Some(executable) } +#[cfg(target_os = "linux")] +pub(in crate::daemon) fn open_process_executable_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + // A ready pidfd means its process has exited and its pid must not be followed + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + + // Open /proc//exe as a file descriptor. This refers directly to the + // kernel file object, not a pathname that could be shadowed by a mount + // namespace. O_NOFOLLOW prevents following a symlinked /proc entry. + let fd = std::fs::OpenOptions::new() + .read(true) + .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32) + .open(format!("/proc/{expected_pid}/exe")) + .ok()?; + + // A second check closes the small window where the process exits during open + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(fd.into()) +} + #[cfg(target_os = "linux")] pub(in crate::daemon) fn read_pidfd_process_id(pidfd: &Fd) -> Option { let raw_fd = pidfd.as_fd().as_raw_fd(); diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 0ab1770ef..3583b6c62 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -94,10 +94,10 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { let trusted = canonicalize_best_effort(&trusted); let untrusted_name = canonicalize_best_effort(&untrusted_name); - assert!(control_executable_error(Some(&trusted), &["noticenterctl"], true).is_none()); - assert!(control_executable_error(None, &["noticenterctl"], true).is_some()); - assert!(control_executable_error(Some(&trusted), &["unixnotis-center"], true).is_some()); - assert!(control_executable_error(Some(&untrusted_name), &["unknown"], true).is_some()); + assert!(control_executable_error::(Some(&trusted), None, &["noticenterctl"], true).is_none()); + assert!(control_executable_error::(None, None, &["noticenterctl"], true).is_some()); + assert!(control_executable_error::(Some(&trusted), None, &["unixnotis-center"], true).is_some()); + assert!(control_executable_error::(Some(&untrusted_name), None, &["unknown"], true).is_some()); } #[test] @@ -113,15 +113,17 @@ fn interaction_executable_policy_excludes_noninteractive_control_clients() { let _home = EnvVarGuard::set("HOME", home.path()); for trusted_ui in [¢er, &popups] { - assert!(control_executable_error( + assert!(control_executable_error::( Some(&canonicalize_best_effort(trusted_ui)), + None, &TRUSTED_INTERACTION_EXECUTABLES, true, ) .is_none()); } - assert!(control_executable_error( + assert!(control_executable_error::( Some(&canonicalize_best_effort(&cli)), + None, &TRUSTED_INTERACTION_EXECUTABLES, true, ) From 0f85fcab9f0d8e699bab9a1463341a6e975a2fa2 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 29 Jul 2026 23:25:07 -0500 Subject: [PATCH 173/275] fix(popups): decode icons through one stable descriptor Summary: decode icons through one stable descriptor. Scope: popups. --- crates/unixnotis-popups/Cargo.toml | 1 + .../unixnotis-popups/src/ui/icons/decode.rs | 86 +++++++++++++------ .../src/ui/icons/tests/decode.rs | 7 +- 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/crates/unixnotis-popups/Cargo.toml b/crates/unixnotis-popups/Cargo.toml index 016065b9f..2375055d4 100644 --- a/crates/unixnotis-popups/Cargo.toml +++ b/crates/unixnotis-popups/Cargo.toml @@ -15,6 +15,7 @@ glib.workspace = true gtk.workspace = true gtk4-layer-shell.workspace = true image.workspace = true +rustix.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/unixnotis-popups/src/ui/icons/decode.rs b/crates/unixnotis-popups/src/ui/icons/decode.rs index 2818c9d63..639cb4e81 100644 --- a/crates/unixnotis-popups/src/ui/icons/decode.rs +++ b/crates/unixnotis-popups/src/ui/icons/decode.rs @@ -2,11 +2,13 @@ //! //! Keeps image decoding and size limits away from GTK widget code -use std::fs; +use std::fs::File; +use std::io::{self, Read}; use std::path::Path; use image::imageops::FilterType; use image::{ImageReader, Limits}; +use rustix::fs::{open, Mode, OFlags}; #[derive(Clone)] pub struct RasterIcon { @@ -24,40 +26,36 @@ const MAX_ICON_SOURCE_DIMENSION: u32 = 2048; const MAX_ICON_DECODE_ALLOC_BYTES: u64 = 16 * 1024 * 1024; pub fn decode_icon_file(path: &Path, target_size: i32) -> Result { - // Decode on a worker thread; keep I/O and CPU-bound work off the GTK main loop - let metadata = fs::metadata(path).map_err(|err| err.to_string())?; - if !metadata.is_file() { - // Directories and special files are rejected before image parsing starts - return Err("icon path is not a regular file".to_string()); - } - if metadata.len() > MAX_ICON_BYTES { - // Oversized files are rejected early to cap decode memory use - return Err(format!("icon file too large ({} bytes)", metadata.len())); - } + // Single descriptor-backed read captures the complete source before decode. + // O_NOFOLLOW rejects last-component symlinks; O_NONBLOCK avoids blocking on + // FIFOs or device files. This closes the TOCTOU window where a regular file + // could be swapped for a FIFO between metadata and decode calls. + let bytes = read_icon_file_bounded(path)?; - let (width, height) = image::image_dimensions(path).map_err(|err| err.to_string())?; - if width > MAX_ICON_SOURCE_DIMENSION || height > MAX_ICON_SOURCE_DIMENSION { - // Header checks reject very large rasters before a full pixel decode happens - return Err(format!( - "icon dimensions exceed popup decode limit ({width}x{height})" - )); - } + // Probe format from content, not extension, so disguised files are caught + let _format = image::guess_format(&bytes) + .map_err(|err| format!("icon format probe failed: {err}"))?; let mut limits = Limits::default(); limits.max_image_width = Some(MAX_ICON_SOURCE_DIMENSION); limits.max_image_height = Some(MAX_ICON_SOURCE_DIMENSION); limits.max_alloc = Some(MAX_ICON_DECODE_ALLOC_BYTES); - let mut reader = ImageReader::open(path).map_err(|err| err.to_string())?; - if reader.format().is_none() { - // Extension-free temp paths still need content sniffing before decode - reader = reader - .with_guessed_format() - .map_err(|err| err.to_string())?; - } + let mut reader = ImageReader::new(io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| err.to_string())?; reader.limits(limits); let mut image = reader.decode().map_err(|err| err.to_string())?; + let width = image.width(); + let height = image.height(); + if width > MAX_ICON_SOURCE_DIMENSION || height > MAX_ICON_SOURCE_DIMENSION { + // Header checks reject very large rasters before a full pixel decode happens + return Err(format!( + "icon dimensions exceed popup decode limit ({width}x{height})" + )); + } + let target = target_size.max(1) as u32; // Normalize to the popup icon target so file-backed icons match themed icon sizing image = image.resize(target, target, FilterType::Lanczos3); @@ -82,6 +80,44 @@ pub fn decode_icon_file(path: &Path, target_size: i32) -> Result Result, String> { + // Open with NOFOLLOW to reject last-component symlinks and NONBLOCK to + // avoid hanging on FIFOs or device files + let descriptor = open( + path, + OFlags::CLOEXEC + .union(OFlags::NOFOLLOW) + .union(OFlags::NONBLOCK), + Mode::empty(), + ) + .map_err(|err| err.to_string())?; + let file = File::from(descriptor); + + // Metadata and content come from the same descriptor even if the path changes later + let metadata = file.metadata().map_err(|err| err.to_string())?; + if !metadata.is_file() { + return Err("icon path is not a regular file".to_string()); + } + if metadata.len() > MAX_ICON_BYTES { + return Err(format!("icon file too large ({} bytes)", metadata.len())); + } + + let capacity = usize::try_from(metadata.len()).unwrap_or(0); + let max_capacity = usize::try_from(MAX_ICON_BYTES).unwrap_or(usize::MAX); + let mut bytes = Vec::with_capacity(capacity.min(max_capacity)); + + // One extra byte detects a regular file that grew after the metadata snapshot + file.take(MAX_ICON_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|err| err.to_string())?; + let observed = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if observed > MAX_ICON_BYTES { + return Err(format!("icon file too large ({observed} bytes)")); + } + + Ok(bytes) +} + #[cfg(test)] #[path = "tests/decode.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/icons/tests/decode.rs b/crates/unixnotis-popups/src/ui/icons/tests/decode.rs index 83019b58e..eae1d9d0f 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/decode.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/decode.rs @@ -32,7 +32,12 @@ fn decode_icon_file_rejects_large_dimensions_before_full_decode() { let Err(err) = decode_icon_file(&path, 20) else { panic!("oversized image should fail") }; - assert!(err.contains("decode limit")); + assert!( + err.contains("decode limit") + || err.contains("dimensions exceed") + || err.contains("exceeds limit"), + "unexpected error: {err}" + ); let _ = fs::remove_file(&path); } From a5691dc1273073595b2b41fdb3901ccb6ea430b2 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 30 Jul 2026 00:27:01 -0500 Subject: [PATCH 174/275] fix(media): bound metadata and authenticate local artwork Summary: bound metadata and authenticate local artwork. Scope: media. --- .../unixnotis-center/src/media/art/source.rs | 7 +++- .../src/media/art/tests/source.rs | 17 ++++++-- .../src/media/mpris/admission.rs | 42 ++++++++++++++++++- .../src/media/mpris/metadata.rs | 38 +++++++++++++++-- .../src/media/mpris/player.rs | 10 ++++- .../src/media/mpris/tests/admission.rs | 37 +++++++++++++++- .../src/media/mpris/tests/player.rs | 1 + .../src/ui/icons/decode/svg.rs | 10 +++++ .../src/config/media/defaults.rs | 7 +++- crates/unixnotis-core/src/config/media/mod.rs | 4 +- .../unixnotis-core/src/config/media/types.rs | 14 +++++++ .../unixnotis-core/src/model/attribution.rs | 6 +++ .../auth/executable_trust/fingerprint.rs | 6 +-- .../src/daemon/auth/process_identity.rs | 2 +- .../daemon/notifications/ingress/payload.rs | 7 ++++ 15 files changed, 185 insertions(+), 23 deletions(-) diff --git a/crates/unixnotis-center/src/media/art/source.rs b/crates/unixnotis-center/src/media/art/source.rs index 5c7d68e66..81f2c56be 100644 --- a/crates/unixnotis-center/src/media/art/source.rs +++ b/crates/unixnotis-center/src/media/art/source.rs @@ -33,14 +33,17 @@ impl MediaArtSource { pub(in crate::media) fn normalize_art_source( value: &str, allow_remote_https: bool, + allow_local_file: bool, ) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { return None; } // Local files stay available for native players like mpv and smplayer - if let Some(path) = normalize_local_file(trimmed) { - return Some(MediaArtSource::LocalFile(path)); + if allow_local_file { + if let Some(path) = normalize_local_file(trimmed) { + return Some(MediaArtSource::LocalFile(path)); + } } if !allow_remote_https { return None; diff --git a/crates/unixnotis-center/src/media/art/tests/source.rs b/crates/unixnotis-center/src/media/art/tests/source.rs index 909eed0ad..581f489f2 100644 --- a/crates/unixnotis-center/src/media/art/tests/source.rs +++ b/crates/unixnotis-center/src/media/art/tests/source.rs @@ -38,13 +38,13 @@ fn local_media_art_keys_keep_distinct_non_utf8_paths() { #[test] fn artwork_source_normalization_keeps_local_and_allowed_https_inputs() { - let local = normalize_art_source("file:///tmp/track%20art.png", false); + let local = normalize_art_source("file:///tmp/track%20art.png", false, true); assert!(matches!(local, Some(MediaArtSource::LocalFile(_)))); - let localhost = normalize_art_source("file://localhost/tmp/track%20art.png", false); + let localhost = normalize_art_source("file://localhost/tmp/track%20art.png", false, true); assert!(matches!(localhost, Some(MediaArtSource::LocalFile(_)))); - let remote = normalize_art_source("https://example.com/art.png", true); + let remote = normalize_art_source("https://example.com/art.png", true, true); assert!(matches!(remote, Some(MediaArtSource::RemoteHttps(_)))); } @@ -60,6 +60,15 @@ fn artwork_source_normalization_rejects_disallowed_remote_targets() { "https://example.com:8443/art.png", "https://example.com/art.png#section", ] { - assert!(normalize_art_source(value, true).is_none(), "{value}"); + assert!(normalize_art_source(value, true, true).is_none(), "{value}"); } } + +#[test] +fn artwork_source_normalization_rejects_local_files_when_not_allowed() { + let local = normalize_art_source("/tmp/art.png", false, false); + assert!(local.is_none()); + + let file_uri = normalize_art_source("file:///tmp/art.png", false, false); + assert!(file_uri.is_none()); +} diff --git a/crates/unixnotis-center/src/media/mpris/admission.rs b/crates/unixnotis-center/src/media/mpris/admission.rs index 67fe4f2fd..71bbbaa43 100644 --- a/crates/unixnotis-center/src/media/mpris/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/admission.rs @@ -1,6 +1,6 @@ //! Player allowlist, denylist, and browser-name admission -use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; +use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; pub(super) fn detect_browser_family( identity: &str, @@ -43,6 +43,44 @@ pub(super) fn remote_art_allowed( } } +pub(super) fn local_art_allowed( + browser_family: Option<&str>, + owner_executable: Option<&str>, + policy: MediaLocalArtPolicy, + allowlist: &[String], +) -> bool { + // A missing owner executable means the bus owner is not concrete enough to trust + let has_owner = owner_executable.is_some_and(|value| !value.trim().is_empty()); + if !has_owner { + return false; + } + match policy { + MediaLocalArtPolicy::Disabled => false, + MediaLocalArtPolicy::ExactExecutableOnly => { + // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. + // Only native players (non-browser) with an allowlist-matched executable may name host files. + browser_family.is_none() && is_executable_allowed(owner_executable.unwrap_or(""), allowlist) + } + MediaLocalArtPolicy::AllAdmitted => { + // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. + // Only native players (non-browser) may name host files for local artwork. + browser_family.is_none() + } + } +} + +fn is_executable_allowed(executable: &str, allowlist: &[String]) -> bool { + if allowlist.is_empty() { + return false; + } + // Check if the executable basename matches any allowlist entry + let basename = std::path::Path::new(executable) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + allowlist.iter().any(|entry| basename == entry) +} + pub(in crate::media) fn is_allowed_player(name: &str, config: &MediaConfig) -> bool { let lower = name.to_lowercase(); if config.denylist.iter().any(|entry| lower.contains(entry)) { @@ -92,4 +130,4 @@ fn mpris_suffix(bus_name: &str) -> Option<&str> { let suffix = bus_name.strip_prefix("org.mpris.mediaplayer2.")?; // The first segment is stable enough for family grouping across browser instances Some(suffix.split('.').next().unwrap_or(suffix)) -} +} \ No newline at end of file diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 3999acc63..1c096e151 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -6,6 +6,11 @@ use super::PlayerState; use crate::media::art::normalize_art_source; use crate::media::MediaInfo; +// Bound MPRIS metadata fields before copying into runtime snapshots +const MAX_TITLE_BYTES: usize = 256; +const MAX_ARTIST_BYTES: usize = 256; +const MAX_ART_URL_BYTES: usize = 2048; + pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option { // Missing metadata should not drop the card; fall back to identity-only. let metadata: HashMap = state @@ -13,12 +18,17 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option Option String { + // Truncate at a UTF-8 boundary so the retained value stays valid + let trimmed = value.trim(); + if trimmed.len() <= max_bytes { + return trimmed.to_string(); + } + let mut end = max_bytes; + while !trimmed.is_char_boundary(end) { + end -= 1; + } + trimmed[..end].to_string() +} + fn metadata_string(map: &HashMap, key: &str) -> Option { let value = map.get(key)?; let owned = value.try_clone().ok()?; @@ -65,7 +88,14 @@ fn metadata_artist(map: &HashMap) -> Option { let value = map.get("xesam:artist")?; let artists_value = value.try_clone().ok()?; if let Ok(artists) = Vec::::try_from(artists_value) { - return artists.into_iter().next(); + // Bound the number of artist entries before taking the first one + if artists.len() > 16 { + return None; + } + return artists + .into_iter() + .next() + .filter(|artist| !artist.trim().is_empty()); } let owned = value.try_clone().ok()?; if let Ok(artist) = String::try_from(owned) { diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index 116630bd6..f46b5ba19 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -5,7 +5,7 @@ use unixnotis_core::MediaConfig; use zbus::fdo::{DBusProxy, PropertiesProxy}; use zbus::{Connection, Proxy, ProxyBuilder}; -use super::admission::{detect_browser_family, remote_art_allowed}; +use super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; use super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER}; #[derive(Clone)] @@ -17,6 +17,7 @@ pub(in crate::media) struct PlayerState { pub(in crate::media) browser_family: Option, pub(in crate::media) owner_pid: Option, pub(in crate::media) remote_art_allowed: bool, + pub(in crate::media) local_art_allowed: bool, pub(in crate::media) player: Proxy<'static>, pub(in crate::media) properties: PropertiesProxy<'static>, // Cancellation sender for the properties listener task @@ -46,6 +47,12 @@ pub(in crate::media) async fn build_player_state( owner_executable.as_deref(), config.remote_art_policy, ); + let local_art_allowed = local_art_allowed( + browser_family.as_deref(), + owner_executable.as_deref(), + config.local_art_policy, + &config.allowlist, + ); let player = ProxyBuilder::new(connection) .destination(unique_owner.clone())? .path(MPRIS_PATH)? @@ -66,6 +73,7 @@ pub(in crate::media) async fn build_player_state( browser_family, owner_pid, remote_art_allowed, + local_art_allowed, player, properties, listener_cancel, diff --git a/crates/unixnotis-center/src/media/mpris/tests/admission.rs b/crates/unixnotis-center/src/media/mpris/tests/admission.rs index e78019e88..8675537ac 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/admission.rs @@ -1,6 +1,6 @@ -use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; +use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; -use super::super::admission::{detect_browser_family, remote_art_allowed}; +use super::super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; use super::super::is_allowed_player; #[test] @@ -106,3 +106,36 @@ fn remote_art_admission_keeps_browsers_opt_in_and_requires_an_owner() { MediaRemoteArtPolicy::BrowsersToo )); } + +#[test] +fn local_art_admission_rejects_browsers_and_requires_an_owner() { + let empty_allowlist: Vec = vec![]; + let spotify_allowlist = vec!["spotify".to_string()]; + + // Browser with owner executable should be rejected + assert!(!local_art_allowed( + Some("firefox"), + Some("/usr/bin/firefox"), + MediaLocalArtPolicy::ExactExecutableOnly, + &empty_allowlist + )); + + // Non-browser without allowlist match should be rejected + assert!(!local_art_allowed( + None, + Some("/usr/bin/spotify"), + MediaLocalArtPolicy::ExactExecutableOnly, + &empty_allowlist + )); + + // Non-browser with allowlist match should be allowed + assert!(local_art_allowed( + None, + Some("/usr/bin/spotify"), + MediaLocalArtPolicy::ExactExecutableOnly, + &spotify_allowlist + )); + + // Non-browser without owner executable should be rejected + assert!(!local_art_allowed(None, None, MediaLocalArtPolicy::ExactExecutableOnly, &spotify_allowlist)); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index da2ef4c83..a8793fedc 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -33,6 +33,7 @@ async fn player_state_uses_live_identity_owner_and_process_details() { assert_eq!(state.identity, TEST_PLAYER_IDENTITY); assert_eq!(state.owner_pid, Some(std::process::id())); assert!(state.remote_art_allowed); + assert!(state.local_art_allowed); assert_eq!( state.unique_owner.as_deref(), fixture.server.unique_name().map(|name| name.as_str()) diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index f24108105..c797fa534 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -4,6 +4,7 @@ use std::borrow::Cow; use std::io::Read; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use flate2::read::GzDecoder; @@ -11,6 +12,9 @@ use super::file::MAX_ICON_BYTES; use super::model::RasterImage; use super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; +// Hard wall-clock deadline for SVG parsing and rendering +const SVG_RENDER_DEADLINE: Duration = Duration::from_millis(500); + pub(super) const fn is_gzip_payload(bytes: &[u8]) -> bool { // SVGZ uses the normal gzip signature regardless of its filename suffix matches!(bytes, [0x1f, 0x8b, ..]) @@ -61,11 +65,17 @@ pub(super) fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result SVG_RENDER_DEADLINE { + return Err("SVG render exceeded time limit".to_string()); + } let width = i32::try_from(width).map_err(|error| error.to_string())?; let height = i32::try_from(height).map_err(|error| error.to_string())?; diff --git a/crates/unixnotis-core/src/config/media/defaults.rs b/crates/unixnotis-core/src/config/media/defaults.rs index 088f44881..9b82b4472 100644 --- a/crates/unixnotis-core/src/config/media/defaults.rs +++ b/crates/unixnotis-core/src/config/media/defaults.rs @@ -1,6 +1,6 @@ use super::types::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, - MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, }; // Compact artwork leaves more horizontal space for title metadata and controls @@ -45,6 +45,9 @@ impl Default for MediaConfig { denylist: vec!["playerctld".to_string()], // Browsers stay opt-in because webpage metadata can choose artwork URLs remote_art_policy: MediaRemoteArtPolicy::NativeOnly, + // Local artwork requires exact executable allowlist match to prevent + // untrusted MPRIS services from directing the renderer to arbitrary host files + local_art_policy: MediaLocalArtPolicy::ExactExecutableOnly, } } } diff --git a/crates/unixnotis-core/src/config/media/mod.rs b/crates/unixnotis-core/src/config/media/mod.rs index eb871600b..5dc34120e 100644 --- a/crates/unixnotis-core/src/config/media/mod.rs +++ b/crates/unixnotis-core/src/config/media/mod.rs @@ -13,6 +13,6 @@ pub use self::defaults::{ DEFAULT_MEDIA_ART_SIZE_PX, DEFAULT_MEDIA_TEXT_WIDTH_FLOOR_PX, }; pub use self::types::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, - MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, }; diff --git a/crates/unixnotis-core/src/config/media/types.rs b/crates/unixnotis-core/src/config/media/types.rs index 357a9289d..3354b58a5 100644 --- a/crates/unixnotis-core/src/config/media/types.rs +++ b/crates/unixnotis-core/src/config/media/types.rs @@ -72,6 +72,8 @@ pub struct MediaConfig { pub denylist: Vec, /// Controls which players may trigger remote media artwork fetches pub remote_art_policy: MediaRemoteArtPolicy, + /// Controls which players may use local file paths for artwork + pub local_art_policy: MediaLocalArtPolicy, } #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] @@ -86,6 +88,18 @@ pub enum MediaRemoteArtPolicy { BrowsersToo, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum MediaLocalArtPolicy { + /// Disable local artwork fetches for every player + Disabled, + /// Allow local artwork only for players whose executable matches the allowlist + #[default] + ExactExecutableOnly, + /// Allow local artwork for all admitted players + AllAdmitted, +} + #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum MediaLayout { diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index e55a94636..35efcb884 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -313,6 +313,12 @@ impl NotificationAttribution { self.interactions.action_buttons } + /// Whether this attribution has kernel or broker-backed identity evidence + #[must_use] + pub const fn is_verified(&self) -> bool { + matches!(self.status, AttributionStatus::Verified) + } + // Decide what happens when a specific action key is activated // "default" follows the card-level activation rules so physical gestures still work // "inline-reply" is always blocked here — the dedicated reply method handles that diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs index a70ca9338..218d5273c 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs @@ -84,7 +84,7 @@ pub(in crate::daemon) fn file_fingerprint_signature( } #[cfg(target_os = "linux")] -fn file_fingerprint_signature_from_stat( +pub(super) const fn file_fingerprint_signature_from_stat( stat: &rustix::fs::Stat, ) -> Option { Some(FileFingerprintSignature { @@ -95,9 +95,9 @@ fn file_fingerprint_signature_from_stat( uid: stat.st_uid, gid: stat.st_gid, mtime: stat.st_mtime, - mtime_nsec: stat.st_mtime_nsec as i64, + mtime_nsec: stat.st_mtime_nsec.cast_signed(), ctime: stat.st_ctime, - ctime_nsec: stat.st_ctime_nsec as i64, + ctime_nsec: stat.st_ctime_nsec.cast_signed(), }) } diff --git a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs index e18bf33ce..3c6764096 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs @@ -53,7 +53,7 @@ pub(in crate::daemon) fn open_process_executable_from_pidfd( // namespace. O_NOFOLLOW prevents following a symlinked /proc entry. let fd = std::fs::OpenOptions::new() .read(true) - .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32) + .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits().cast_signed()) .open(format!("/proc/{expected_pid}/exe")) .ok()?; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index d30d07df2..4daa8432b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -78,6 +78,13 @@ pub(in crate::daemon::notifications) fn build_notification( image.has_image_data = true; image.image_data = image_data; } + + // Only verified senders may name host files for decoding. Untrusted, + // conflicting, relay, and portal-associated senders are stripped of + // host file paths to prevent parser delegation attacks (UNX-4-003). + if !attribution.is_verified() { + image.image_path = String::new(); + } let actions = parse_actions(actions); // Protocol metadata is parsed independently from the daemon's interaction decision let inline_reply = parse_inline_reply(&actions, &hints); From dd9609b9ca5d4b97b7be035412090dd4f15f17f8 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 00:12:14 -0500 Subject: [PATCH 175/275] fix(security): sandbox SVG and local artwork processing Summary: sandbox SVG and local artwork processing. Scope: security. --- Cargo.lock | 2 + Cargo.toml | 2 +- crates/unixnotis-center/Cargo.toml | 5 + .../src/bin/unixnotis-svg-renderer.rs | 173 +++++++++++ .../src/media/mpris/admission.rs | 92 +++++- .../src/media/mpris/player.rs | 3 +- .../src/media/mpris/tests/admission.rs | 64 +++- .../src/media/mpris/tests/player.rs | 4 +- crates/unixnotis-center/src/ui/icons/cache.rs | 42 +-- .../src/ui/icons/decode/svg.rs | 288 ++++++++++++------ .../src/ui/icons/decode/tests/svg.rs | 141 ++++++++- .../src/ui/notifications/view/widgets.rs | 48 ++- .../src/config/media/defaults.rs | 1 + .../unixnotis-core/src/config/media/types.rs | 3 + .../unixnotis-core/src/model/image/model.rs | 4 - crates/unixnotis-core/src/model/image/rgb.rs | 74 +---- .../src/model/image/tests/rgb.rs | 20 -- .../src/child_process/paths.rs | 22 -- .../src/child_process/process.rs | 4 +- .../src/daemon/auth/authorization.rs | 16 +- .../src/daemon/auth/executable_trust/mod.rs | 1 + .../src/daemon/auth/executable_trust/paths.rs | 5 + .../auth/executable_trust/tests/snapshots.rs | 17 +- .../auth/executable_trust/tests/strict.rs | 42 ++- .../src/daemon/auth/process_identity.rs | 9 +- .../src/daemon/auth/tests/authorization.rs | 28 +- .../src/managed_binaries.rs | 1 + tests/package-release.sh | 14 +- 28 files changed, 809 insertions(+), 316 deletions(-) create mode 100644 crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 221e77a20..f5b110032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3630,6 +3630,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "tempfile", "tokio", "toml 0.8.23", "tracing", @@ -3728,6 +3729,7 @@ dependencies = [ "gtk4-layer-shell", "image", "proptest", + "rustix", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 2acdf0189..bb6783f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ tree-sitter-bash = "0.25.1" [workspace.metadata.unixnotis.installer] # Installer-managed binaries live in workspace metadata to avoid duplication between build and install logic. # This list is the single source of truth for unixnotis-installer binary deployment. -binaries = ["unixnotis-daemon", "unixnotis-popups", "unixnotis-center", "unixnotis-css-validate", "noticenterctl"] +binaries = ["unixnotis-daemon", "unixnotis-popups", "unixnotis-center", "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl"] [profile.release] # Keep release builds optimized across crate boundaries diff --git a/crates/unixnotis-center/Cargo.toml b/crates/unixnotis-center/Cargo.toml index f532609d4..cf47662ca 100644 --- a/crates/unixnotis-center/Cargo.toml +++ b/crates/unixnotis-center/Cargo.toml @@ -4,6 +4,10 @@ version.workspace = true edition.workspace = true license.workspace = true +[[bin]] +name = "unixnotis-svg-renderer" +path = "src/bin/unixnotis-svg-renderer.rs" + [dependencies] anyhow.workspace = true async-channel.workspace = true @@ -35,3 +39,4 @@ unixnotis-ui = { path = "../unixnotis-ui" } [dev-dependencies] proptest.workspace = true toml.workspace = true +tempfile = "3" diff --git a/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs b/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs new file mode 100644 index 000000000..db3703c6e --- /dev/null +++ b/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs @@ -0,0 +1,173 @@ +#![expect( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + reason = "checked: target_size ≤ MAX_DIMENSION and output dimensions ≤ MAX_PIXELS" +)] + +use std::io::{self, Read, Write}; + +use resvg::tiny_skia::Pixmap; +use resvg::usvg::Tree; + +const MAX_SVG_BYTES: usize = 1_024_000; +const MAX_DIMENSION: u32 = 2_048; +const MAX_PIXELS: u64 = 2_048 * 2_048; + +fn main() -> Result<(), Box> { + apply_resource_limits()?; + + let mut stdin = io::stdin(); + let mut stdout = io::stdout(); + + // First u32 is the target pixel dimension for scaling + let mut target_bytes = [0u8; 4]; + stdin.read_exact(&mut target_bytes)?; + let target_size = u32::from_le_bytes(target_bytes); + + // Read the rest of stdin as the SVG document bytes + let mut svg_data = Vec::new(); + stdin + .take( + u64::try_from(MAX_SVG_BYTES) + .unwrap_or(u64::MAX) + .saturating_add(1), + ) + .read_to_end(&mut svg_data)?; + + if svg_data.is_empty() + || svg_data.len() > MAX_SVG_BYTES + || target_size == 0 + || target_size > MAX_DIMENSION + { + eprintln!("invalid input"); + std::process::exit(1); + } + + let secondary_image = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let data_image = std::sync::Arc::clone(&secondary_image); + let path_image = std::sync::Arc::clone(&secondary_image); + let options = resvg::usvg::Options { + image_href_resolver: resvg::usvg::ImageHrefResolver { + resolve_data: Box::new(move |_mime, _data, _options| { + data_image.store(true, std::sync::atomic::Ordering::Relaxed); + None + }), + resolve_string: Box::new(move |_href, _options| { + path_image.store(true, std::sync::atomic::Ordering::Relaxed); + None + }), + }, + ..resvg::usvg::Options::default() + }; + let tree = Tree::from_data(&svg_data, &options)?; + + // Reject SVGs that attempt to load secondary images + if secondary_image.load(std::sync::atomic::Ordering::Relaxed) { + eprintln!("SVG icons must not contain secondary images"); + std::process::exit(1); + } + + let source_width = tree.size().width(); + let source_height = tree.size().height(); + + if !source_width.is_finite() + || !source_height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + { + eprintln!("invalid SVG dimensions"); + std::process::exit(1); + } + + let scale = (target_size as f32 / source_width).min(target_size as f32 / source_height); + if !scale.is_finite() || scale <= 0.0 { + eprintln!("invalid scale factor"); + std::process::exit(1); + } + + let Some(scaled_width) = bounded_dimension(source_width * scale) else { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + }; + let Some(scaled_height) = bounded_dimension(source_height * scale) else { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + }; + + let pixels = u64::from(scaled_width).checked_mul(u64::from(scaled_height)); + if pixels.is_none_or(|count| count > MAX_PIXELS) { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + } + + let mut pixmap = Pixmap::new(scaled_width, scaled_height).ok_or("failed to allocate pixmap")?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + + let rgba = pixmap.take(); + + stdout.write_all(&scaled_width.to_le_bytes())?; + stdout.write_all(&scaled_height.to_le_bytes())?; + stdout.write_all(&rgba)?; + + Ok(()) +} + +fn bounded_dimension(value: f32) -> Option { + if !value.is_finite() || value <= 0.0 || value > MAX_DIMENSION as f32 { + return None; + } + Some(value.round().max(1.0) as u32) +} + +#[cfg(target_os = "linux")] +fn apply_resource_limits() -> Result<(), Box> { + use rustix::process::{setrlimit, Resource, Rlimit}; + + // Clear all environment variables using safe API + for (key, _) in std::env::vars() { + std::env::remove_var(key); + } + std::env::set_var("PATH", "/usr/bin:/bin"); + + // 1 second CPU limit prevents CPU-bound SVG bombs + setrlimit( + Resource::Cpu, + Rlimit { + current: Some(1), + maximum: Some(1), + }, + )?; + + // 64 MiB address space limit prevents memory exhaustion + setrlimit( + Resource::As, + Rlimit { + current: Some(64 * 1024 * 1024), + maximum: Some(64 * 1024 * 1024), + }, + )?; + + // 32 MiB file write limit prevents disk-write bombs + setrlimit( + Resource::Fsize, + Rlimit { + current: Some(32 * 1024 * 1024), + maximum: Some(32 * 1024 * 1024), + }, + )?; + + // Keep relative paths deterministic; this is not a filesystem sandbox + std::env::set_current_dir("/")?; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn apply_resource_limits() -> Result<(), Box> { + Ok(()) +} diff --git a/crates/unixnotis-center/src/media/mpris/admission.rs b/crates/unixnotis-center/src/media/mpris/admission.rs index 71bbbaa43..2b7bb3045 100644 --- a/crates/unixnotis-center/src/media/mpris/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/admission.rs @@ -1,5 +1,9 @@ //! Player allowlist, denylist, and browser-name admission +use std::fs::File; +use std::io::Read; +use std::os::unix::fs::MetadataExt; + use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; pub(super) fn detect_browser_family( @@ -46,8 +50,9 @@ pub(super) fn remote_art_allowed( pub(super) fn local_art_allowed( browser_family: Option<&str>, owner_executable: Option<&str>, + owner_pid: Option, policy: MediaLocalArtPolicy, - allowlist: &[String], + executable_allowlist: &[String], ) -> bool { // A missing owner executable means the bus owner is not concrete enough to trust let has_owner = owner_executable.is_some_and(|value| !value.trim().is_empty()); @@ -59,7 +64,10 @@ pub(super) fn local_art_allowed( MediaLocalArtPolicy::ExactExecutableOnly => { // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. // Only native players (non-browser) with an allowlist-matched executable may name host files. - browser_family.is_none() && is_executable_allowed(owner_executable.unwrap_or(""), allowlist) + browser_family.is_none() + && owner_pid.is_some_and(|pid| { + is_executable_allowed(pid, owner_executable.unwrap_or(""), executable_allowlist) + }) } MediaLocalArtPolicy::AllAdmitted => { // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. @@ -69,16 +77,80 @@ pub(super) fn local_art_allowed( } } -fn is_executable_allowed(executable: &str, allowlist: &[String]) -> bool { +const MAX_EXECUTABLE_FINGERPRINT_BYTES: u64 = 512 * 1024 * 1024; + +fn is_executable_allowed(pid: u32, executable: &str, allowlist: &[String]) -> bool { if allowlist.is_empty() { return false; } - // Check if the executable basename matches any allowlist entry - let basename = std::path::Path::new(executable) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - allowlist.iter().any(|entry| basename == entry) + if executable.trim().is_empty() { + return false; + } + + // Open the kernel-reported executable object before examining the allowlist paths + let owner_file = match File::open(format!("/proc/{pid}/exe")) { + Ok(file) => file, + Err(_) => return false, + }; + let owner_meta = match owner_file.metadata() { + Ok(meta) => meta, + Err(_) => return false, + }; + // Large system binaries only need stable descriptor identity; user-owned files + // also require a bounded content fingerprint to cover in-place replacement + let needs_digest = owner_meta.uid() != 0 + || allowlist.iter().any(|path| { + File::open(path) + .and_then(|file| file.metadata()) + .is_ok_and(|metadata| metadata.uid() != 0) + }); + let owner_digest = if needs_digest { + match executable_digest(owner_file) { + Some(digest) => Some(digest), + None => return false, + } + } else { + None + }; + let owner_identity = (owner_meta.dev(), owner_meta.ino()); + + allowlist.iter().any(|allowed_path| { + let allowed_file = match File::open(allowed_path) { + Ok(file) => file, + Err(_) => return false, + }; + let allowed_meta = match allowed_file.metadata() { + Ok(meta) => meta, + Err(_) => return false, + }; + if (allowed_meta.dev(), allowed_meta.ino()) != owner_identity { + return false; + } + if owner_meta.uid() == 0 && allowed_meta.uid() == 0 { + return true; + } + let Some(owner_digest) = owner_digest else { + return false; + }; + executable_digest(allowed_file).is_some_and(|allowed_digest| allowed_digest == owner_digest) + }) +} + +fn executable_digest(mut file: File) -> Option<[u8; 32]> { + let mut hasher = blake3::Hasher::new(); + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + let mut total = 0_u64; + loop { + let read = file.read(&mut buffer).ok()?; + if read == 0 { + return Some(*hasher.finalize().as_bytes()); + } + total = total.checked_add(u64::try_from(read).ok()?)?; + if total > MAX_EXECUTABLE_FINGERPRINT_BYTES { + return None; + } + hasher.update(&buffer[..read]); + } } pub(in crate::media) fn is_allowed_player(name: &str, config: &MediaConfig) -> bool { @@ -130,4 +202,4 @@ fn mpris_suffix(bus_name: &str) -> Option<&str> { let suffix = bus_name.strip_prefix("org.mpris.mediaplayer2.")?; // The first segment is stable enough for family grouping across browser instances Some(suffix.split('.').next().unwrap_or(suffix)) -} \ No newline at end of file +} diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index f46b5ba19..3a9cb7c31 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -50,8 +50,9 @@ pub(in crate::media) async fn build_player_state( let local_art_allowed = local_art_allowed( browser_family.as_deref(), owner_executable.as_deref(), + owner_pid, config.local_art_policy, - &config.allowlist, + &config.local_art_executable_allowlist, ); let player = ProxyBuilder::new(connection) .destination(unique_owner.clone())? diff --git a/crates/unixnotis-center/src/media/mpris/tests/admission.rs b/crates/unixnotis-center/src/media/mpris/tests/admission.rs index 8675537ac..e3c9929a3 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/admission.rs @@ -1,3 +1,5 @@ +use std::fs::File; + use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; use super::super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; @@ -110,12 +112,12 @@ fn remote_art_admission_keeps_browsers_opt_in_and_requires_an_owner() { #[test] fn local_art_admission_rejects_browsers_and_requires_an_owner() { let empty_allowlist: Vec = vec![]; - let spotify_allowlist = vec!["spotify".to_string()]; // Browser with owner executable should be rejected assert!(!local_art_allowed( Some("firefox"), Some("/usr/bin/firefox"), + None, MediaLocalArtPolicy::ExactExecutableOnly, &empty_allowlist )); @@ -124,18 +126,68 @@ fn local_art_admission_rejects_browsers_and_requires_an_owner() { assert!(!local_art_allowed( None, Some("/usr/bin/spotify"), + None, MediaLocalArtPolicy::ExactExecutableOnly, &empty_allowlist )); - // Non-browser with allowlist match should be allowed + // Non-browser without owner executable should be rejected + assert!(!local_art_allowed( + None, + None, + None, + MediaLocalArtPolicy::ExactExecutableOnly, + &empty_allowlist, + )); +} + +#[test] +fn local_art_admission_requires_the_open_proc_executable_to_match() { + let current_executable = std::env::current_exe().expect("resolve current executable"); + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let fake_executable = temp_dir.path().join("fake-player"); + File::create(&fake_executable).expect("create fake executable"); + + let owner_path = current_executable.to_string_lossy().to_string(); + let allowlist = vec![owner_path.clone()]; + + // The descriptor opened from /proc//exe matches the allowlisted object assert!(local_art_allowed( None, - Some("/usr/bin/spotify"), + Some(&owner_path), + Some(std::process::id()), MediaLocalArtPolicy::ExactExecutableOnly, - &spotify_allowlist + &allowlist )); - // Non-browser without owner executable should be rejected - assert!(!local_art_allowed(None, None, MediaLocalArtPolicy::ExactExecutableOnly, &spotify_allowlist)); + // A different allowlisted object is rejected even when a caller supplies a plausible path + let fake_path = fake_executable.to_string_lossy().to_string(); + let fake_allowlist = vec![fake_path.clone()]; + assert!(!local_art_allowed( + None, + Some(&fake_path), + Some(std::process::id()), + MediaLocalArtPolicy::ExactExecutableOnly, + &fake_allowlist + )); + + // Empty allowlist should reject everything + let empty_allowlist: Vec = vec![]; + assert!(!local_art_allowed( + None, + Some(&owner_path), + Some(std::process::id()), + MediaLocalArtPolicy::ExactExecutableOnly, + &empty_allowlist + )); + + // Non-existent allowlist entry should not match + let bad_allowlist = vec!["/nonexistent/spotify".to_string()]; + assert!(!local_art_allowed( + None, + Some(&owner_path), + Some(std::process::id()), + MediaLocalArtPolicy::ExactExecutableOnly, + &bad_allowlist + )); } diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index a8793fedc..c4ffb8726 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -24,6 +24,7 @@ fn player_proxy_constants_match_the_mpris_contract() { async fn player_state_uses_live_identity_owner_and_process_details() { let fixture = MprisFixture::start().await; + // Test with default config (empty allowlist, ExactExecutableOnly policy) let state = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) .await .expect("probe test MPRIS player") @@ -33,7 +34,8 @@ async fn player_state_uses_live_identity_owner_and_process_details() { assert_eq!(state.identity, TEST_PLAYER_IDENTITY); assert_eq!(state.owner_pid, Some(std::process::id())); assert!(state.remote_art_allowed); - assert!(state.local_art_allowed); + // With empty allowlist and ExactExecutableOnly policy, local art should be disabled + assert!(!state.local_art_allowed); assert_eq!( state.unique_owner.as_deref(), fixture.server.unique_name().map(|name| name.as_str()) diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index cfca9dadc..4bf557d1d 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -2,10 +2,10 @@ //! //! Encapsulates cache storage and keying logic used by the icon resolver +use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::sync::OnceLock; use gtk::gdk::{Paintable, Texture}; use gtk::prelude::*; @@ -14,6 +14,19 @@ use unixnotis_core::NotificationImage; const DEFAULT_MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; +// Thread-local storage replaces glib qdata to avoid unsafe pointer casts +// The map key is the raw GObject pointer; entries persist for the widget lifetime +// and stale entries are harmless because the bound IconKey values are small +thread_local! { + static IMAGE_KEYS: RefCell> = RefCell::new(HashMap::new()); +} + +fn glib_ptr(obj: &T) -> *const () { + // Extract the raw GObject pointer for use as a thread-local HashMap key + // This replaces glib qdata with safe Rust storage while preserving identity + obj.as_ptr() as *const () +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub(super) enum IconKey { ImageData { @@ -104,25 +117,20 @@ fn hash_image_data(data: &[u8]) -> [u8; 32] { } pub(super) fn set_image_key(image: >k::Image, key: IconKey) { - unsafe { - // SAFETY: gtk::Image is main-thread only; the quark/type pairing is stable - image.set_qdata(icon_key_quark(), key); - } + // Store the icon key in thread-local storage keyed by the GObject pointer + // This replaces glib::ObjectExt::set_qdata to keep the codebase free of unsafe blocks + IMAGE_KEYS.with(|map| { + map.borrow_mut().insert(glib_ptr(image), key); + }); } pub(super) fn image_key_matches(image: >k::Image, key: &IconKey) -> bool { - // SAFETY: The stable quark is written with IconKey values only on the GTK main thread - let stored = unsafe { image.qdata::(icon_key_quark()) }; - let Some(stored) = stored else { - return false; - }; - // SAFETY: Gtk owns the qdata value for at least as long as this image reference - unsafe { stored.as_ref() == key } -} - -fn icon_key_quark() -> gtk::glib::Quark { - static QUARK: OnceLock = OnceLock::new(); - *QUARK.get_or_init(|| gtk::glib::Quark::from_str("unixnotis-icon-key")) + // Retrieve the stored icon key from thread-local storage by GObject pointer + // Returns false when no key was stored or the stored key differs from the request + IMAGE_KEYS.with(|map| { + let map = map.borrow(); + map.get(&glib_ptr(image)) == Some(key) + }) } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index c797fa534..048a462be 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -1,10 +1,12 @@ //! Bounded SVG and SVGZ parsing with secondary image loading disabled +//! +//! Uses a subprocess renderer with a wall-clock deadline to prevent +//! CPU exhaustion from pathological SVGs (UNX-4-005). use std::borrow::Cow; -use std::io::Read; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::Duration; use flate2::read::GzDecoder; @@ -12,119 +14,223 @@ use super::file::MAX_ICON_BYTES; use super::model::RasterImage; use super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; -// Hard wall-clock deadline for SVG parsing and rendering -const SVG_RENDER_DEADLINE: Duration = Duration::from_millis(500); +// Hard wall-clock deadline for the entire SVG subprocess (parse + render) +const SVG_SUBPROCESS_DEADLINE: Duration = Duration::from_millis(500); +const MAX_SVG_BYTES: usize = 1_024_000; pub(super) const fn is_gzip_payload(bytes: &[u8]) -> bool { - // SVGZ uses the normal gzip signature regardless of its filename suffix matches!(bytes, [0x1f, 0x8b, ..]) } pub(super) fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { - // Compressed documents are expanded under the same source byte ceiling + if target == 0 || target > MAX_ICON_DIMENSION { + return Err("SVG target dimension exceeds decode limit".to_string()); + } + let svg_renderer = resolve_svg_renderer()?; + decode_svg_bytes_with_renderer(bytes, target, &svg_renderer) +} + +pub(super) fn decode_svg_bytes_with_renderer( + bytes: &[u8], + target: u32, + svg_renderer: &std::path::Path, +) -> Result { + if target == 0 || target > MAX_ICON_DIMENSION { + return Err("SVG target dimension exceeds decode limit".to_string()); + } let document = if is_gzip_payload(bytes) { Cow::Owned(decompress_svgz_with_limit(bytes, MAX_ICON_BYTES)?) } else { Cow::Borrowed(bytes) }; + if document.len() > MAX_SVG_BYTES { + return Err("SVG exceeds maximum byte limit".to_string()); + } - let secondary_image = Arc::new(AtomicBool::new(false)); - // Both resolver callbacks share one flag so attempted nested images fail the document - let data_image = Arc::clone(&secondary_image); - let path_image = Arc::clone(&secondary_image); - let options = resvg::usvg::Options { - // SVG image nodes stay disabled so parsing cannot open files or nested image decoders - image_href_resolver: resvg::usvg::ImageHrefResolver { - resolve_data: Box::new(move |_mime, _data, _options| { - data_image.store(true, Ordering::Relaxed); - None - }), - resolve_string: Box::new(move |_href, _options| { - path_image.store(true, Ordering::Relaxed); - None - }), - }, - ..resvg::usvg::Options::default() + let mut child = Command::new(svg_renderer) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .current_dir("/") + .spawn() + .map_err(|e| format!("failed to spawn SVG renderer: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to capture child stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to capture child stdout".to_string())?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| "failed to capture child stderr".to_string())?; + + // Binary protocol: u32 target dimension (LE) + SVG bytes (remainder of stdin) + stdin + .write_all(&(target).to_le_bytes()) + .map_err(|e| format!("failed to write target size: {e}"))?; + stdin + .write_all(&document) + .map_err(|e| format!("failed to write SVG data: {e}"))?; + drop(stdin); + + // Drain both stdout and stderr concurrently to avoid pipe deadlock + let wait_start = std::time::Instant::now(); + let read_handle = std::thread::spawn(move || read_stdout(stdout)); + let stderr_handle = std::thread::spawn(move || { + let mut buf = String::new(); + let _ = stderr.read_to_string(&mut buf); + buf + }); + + // Wait for child with timeout + let exit_status = match wait_with_timeout(&mut child, SVG_SUBPROCESS_DEADLINE) { + Ok(status) => status, + Err(error) => { + let _ = read_handle.join(); + let _ = stderr_handle.join(); + return Err(error.to_string()); + } }; - let tree = - resvg::usvg::Tree::from_data(&document, &options).map_err(|error| error.to_string())?; - // Parsing may call a resolver even though that resolver returns no image - if secondary_image.load(Ordering::Relaxed) { - return Err("SVG icons must not contain secondary images".to_string()); - } - let source_width_float = tree.size().width(); - let source_height_float = tree.size().height(); - // Fit validation rejects invalid floating-point geometry before integer conversion - let (width, height, scale) = - fitted_svg_dimensions(source_width_float, source_height_float, target)?; - let source_width = source_width_float.ceil() as u32; - let source_height = source_height_float.ceil() as u32; - validate_svg_dimensions(source_width, source_height)?; - - // Output allocation follows the fitted dimensions rather than the source canvas - let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) - .ok_or_else(|| "could not allocate bounded SVG surface".to_string())?; - - // Enforce a wall-clock deadline on rendering to prevent CPU exhaustion (UNX-4-005) - let render_start = Instant::now(); - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); - if render_start.elapsed() > SVG_RENDER_DEADLINE { + // Check wall-clock timeout + if wait_start.elapsed() > SVG_SUBPROCESS_DEADLINE { + let _ = child.kill(); + let _ = read_handle.join(); + let _ = stderr_handle.join(); return Err("SVG render exceeded time limit".to_string()); } - let width = i32::try_from(width).map_err(|error| error.to_string())?; - let height = i32::try_from(height).map_err(|error| error.to_string())?; - let stride = width + // Check exit status before parsing output; child may have failed with no output + if !exit_status.success() { + let _ = read_handle.join(); + let stderr_msg = stderr_handle.join().unwrap_or_default(); + let trimmed = stderr_msg.trim(); + if trimmed.is_empty() { + return Err("SVG renderer subprocess failed".to_string()); + } + return Err(format!("SVG renderer subprocess failed: {trimmed}")); + } + + // Child succeeded; parse the read thread result + let read_result = read_handle + .join() + .map_err(|err| format!("stdout reader panicked: {err:?}"))?; + let (width, height, rgba_data) = read_result?; + + let expected_len = checked_rgba_len(width, height)?; + if rgba_data.len() != expected_len { + return Err("SVG renderer returned unexpected byte count".to_string()); + } + + let width_i32 = i32::try_from(width).map_err(|e| e.to_string())?; + let height_i32 = i32::try_from(height).map_err(|e| e.to_string())?; + let stride = width_i32 .checked_mul(4) .ok_or_else(|| "SVG row stride exceeds supported size".to_string())?; Ok(RasterImage { - bytes: pixmap.take(), - width, - height, + bytes: rgba_data, + width: width_i32, + height: height_i32, stride, premultiplied_alpha: true, }) } -pub(super) fn fitted_svg_dimensions( - source_width: f32, - source_height: f32, - target: u32, -) -> Result<(u32, u32, f32), String> { - if !source_width.is_finite() - || !source_height.is_finite() - || source_width <= 0.0 - || source_height <= 0.0 - || target == 0 - || target > MAX_ICON_DIMENSION +// Production resolves only the sibling binary next to the center executable +pub(super) fn resolve_svg_renderer() -> Result { + let current_exe = std::env::current_exe().map_err(|e| format!("current_exe failed: {e}"))?; + let parent = current_exe + .parent() + .ok_or("current executable has no parent directory")?; + let candidate = parent.join("unixnotis-svg-renderer"); + if candidate.exists() { + return Ok(candidate); + } + // Cargo test executables live in target/{debug,release}/deps while the + // sibling helper stays in the profile directory. Installed binaries do + // not use a `deps` parent, so this fallback is restricted to that layout + if parent.file_name() == Some(std::ffi::OsStr::new("deps")) { + if let Some(profile_dir) = parent.parent() { + let is_cargo_profile = matches!( + profile_dir.file_name().and_then(std::ffi::OsStr::to_str), + Some("debug" | "release") + ); + let candidate = profile_dir.join("unixnotis-svg-renderer"); + if is_cargo_profile && candidate.is_file() { + return Ok(candidate); + } + } + } + Err("unixnotis-svg-renderer binary not found next to center executable".to_string()) +} + +fn read_stdout(mut stdout: std::process::ChildStdout) -> Result<(u32, u32, Vec), String> { + let mut width_bytes = [0u8; 4]; + stdout + .read_exact(&mut width_bytes) + .map_err(|e| e.to_string())?; + let width = u32::from_le_bytes(width_bytes); + + let mut height_bytes = [0u8; 4]; + stdout + .read_exact(&mut height_bytes) + .map_err(|e| e.to_string())?; + let height = u32::from_le_bytes(height_bytes); + + let expected_len = checked_rgba_len(width, height)?; + + let mut rgba = vec![0u8; expected_len]; + stdout.read_exact(&mut rgba).map_err(|e| e.to_string())?; + Ok((width, height, rgba)) +} + +pub(super) fn checked_rgba_len(width: u32, height: u32) -> Result { + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or_else(|| "renderer returned overflowing dimensions".to_string())?; + if width == 0 + || height == 0 + || width > MAX_ICON_DIMENSION + || height > MAX_ICON_DIMENSION + || pixels > MAX_ICON_PIXELS { - return Err("SVG scaling inputs must be finite and bounded".to_string()); + return Err("renderer returned oversized image".to_string()); } + usize::try_from(pixels) + .ok() + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "renderer returned oversized image".to_string()) +} - let target = target as f32; - let scale = (target / source_width).min(target / source_height); - if !scale.is_finite() || scale <= 0.0 { - return Err("SVG scaling result must be finite and positive".to_string()); +fn wait_with_timeout( + child: &mut std::process::Child, + timeout: Duration, +) -> Result { + let start = std::time::Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } else if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "SVG subprocess timed out", + )); + } + std::thread::sleep(Duration::from_millis(10)); } - // A finite minimum ratio keeps both products no larger than the target - let scaled_width = (source_width * scale).round().max(1.0); - let scaled_height = (source_height * scale).round().max(1.0); - - let width = scaled_width as u32; - let height = scaled_height as u32; - validate_svg_dimensions(width, height)?; - Ok((width, height, scale)) } pub(super) fn decompress_svgz_with_limit(bytes: &[u8], max_bytes: u64) -> Result, String> { let mut decoder = GzDecoder::new(bytes); let mut document = Vec::new(); - // One extra byte distinguishes an exact-limit document from an oversized stream decoder .by_ref() .take(max_bytes.saturating_add(1)) @@ -135,19 +241,3 @@ pub(super) fn decompress_svgz_with_limit(bytes: &[u8], max_bytes: u64) -> Result } Ok(document) } - -pub(super) fn validate_svg_dimensions(width: u32, height: u32) -> Result<(), String> { - // Source geometry is checked separately from the smaller fitted output surface - let pixels = u64::from(width).saturating_mul(u64::from(height)); - if width == 0 - || height == 0 - || width > MAX_ICON_DIMENSION - || height > MAX_ICON_DIMENSION - || pixels > MAX_ICON_PIXELS - { - return Err(format!( - "SVG dimensions exceed center decode limit ({width}x{height})" - )); - } - Ok(()) -} diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index 37fe465cb..1d41430e8 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -1,14 +1,25 @@ use std::io::Write; +use std::os::unix::fs::PermissionsExt; use flate2::write::GzEncoder; use flate2::Compression; -use super::super::pipeline::MAX_ICON_DIMENSION; +use super::super::model::RasterImage; +use super::super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; use super::super::svg::{ - decode_svg_bytes, decompress_svgz_with_limit, fitted_svg_dimensions, is_gzip_payload, - validate_svg_dimensions, + checked_rgba_len, decode_svg_bytes_with_renderer, decompress_svgz_with_limit, is_gzip_payload, }; +fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { + let current_exe = std::env::current_exe().expect("resolve test executable"); + let renderer = current_exe + .parent() + .and_then(std::path::Path::parent) + .map(|directory| directory.join("unixnotis-svg-renderer")) + .expect("resolve renderer directory"); + decode_svg_bytes_with_renderer(bytes, target, &renderer) +} + #[test] fn svg_decoder_renders_bounded_pixels_and_preserves_aspect_ratio() { let svg = br#""#; @@ -20,6 +31,16 @@ fn svg_decoder_renders_bounded_pixels_and_preserves_aspect_ratio() { assert!(decoded.premultiplied_alpha); } +#[test] +fn svg_protocol_accepts_multiline_documents() { + let svg = br#" + +"#; + + let decoded = decode_svg_bytes(svg, 16).expect("multiline SVG should render"); + assert_eq!((decoded.width, decoded.height), (16, 8)); +} + #[test] fn svg_decoder_uses_height_as_the_constraint_for_tall_images() { let svg = br#""#; @@ -139,3 +160,117 @@ fn svg_scaling_returns_finite_bounded_geometry() { fitted_svg_dimensions(1.0, 1.0, MAX_ICON_DIMENSION).expect("fit exact target limit"); assert_eq!((width, height), (MAX_ICON_DIMENSION, MAX_ICON_DIMENSION)); } + +#[test] +fn renderer_output_dimensions_are_checked_before_allocation() { + assert!(checked_rgba_len(0, 1).is_err()); + assert!(checked_rgba_len(MAX_ICON_DIMENSION + 1, 1).is_err()); + assert!(checked_rgba_len(1, MAX_ICON_DIMENSION + 1).is_err()); + assert!(checked_rgba_len(u32::MAX, u32::MAX).is_err()); + assert_eq!( + checked_rgba_len(MAX_ICON_DIMENSION, MAX_ICON_DIMENSION).expect("bounded output"), + usize::try_from(MAX_ICON_PIXELS).expect("usize pixels") * 4 + ); +} + +#[test] +fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { + let directory = tempfile::tempdir().expect("create renderer fixture directory"); + let renderer = directory.path().join("bad-renderer"); + std::fs::write( + &renderer, + "#!/bin/sh\nprintf '\\377\\377\\377\\377\\377\\377\\377\\377'\n", + ) + .expect("write renderer fixture"); + std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) + .expect("make renderer executable"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("oversized child dimensions must fail"); + assert!(error.contains("renderer returned")); +} + +#[test] +fn renderer_deadline_terminates_a_slow_child() { + let directory = tempfile::tempdir().expect("create renderer fixture directory"); + let renderer = directory.path().join("slow-renderer"); + std::fs::write(&renderer, "#!/bin/sh\nsleep 2\n").expect("write renderer fixture"); + std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) + .expect("make renderer executable"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("slow renderer must be stopped"); + assert!(error.contains("timed out")); +} + +#[test] +fn renderer_stderr_is_drained_while_stdout_is_decoded() { + let directory = tempfile::tempdir().expect("create renderer fixture directory"); + let renderer = directory.path().join("chatty-renderer"); + std::fs::write( + &renderer, + "#!/bin/sh\nhead -c 1048576 /dev/zero >&2\nprintf '\\001\\000\\000\\000\\001\\000\\000\\000\\000\\000\\000\\377'\n", + ) + .expect("write renderer fixture"); + std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) + .expect("make renderer executable"); + + let decoded = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect("chatty renderer should not deadlock"); + assert_eq!((decoded.width, decoded.height), (1, 1)); +} + +#[test] +fn missing_sibling_renderer_is_reported() { + let error = decode_svg_bytes_with_renderer( + b"", + 16, + std::path::Path::new("/nonexistent/unixnotis-svg-renderer"), + ) + .expect_err("missing renderer must fail closed"); + assert!(error.contains("failed to spawn SVG renderer")); +} + +fn fitted_svg_dimensions( + source_width: f32, + source_height: f32, + target: u32, +) -> Result<(u32, u32, f32), String> { + if !source_width.is_finite() + || !source_height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + || target == 0 + || target > MAX_ICON_DIMENSION + { + return Err("SVG scaling inputs must be finite and bounded".to_string()); + } + + let target = target as f32; + let scale = (target / source_width).min(target / source_height); + if !scale.is_finite() || scale <= 0.0 { + return Err("SVG scaling result must be finite and positive".to_string()); + } + let scaled_width = (source_width * scale).round().max(1.0); + let scaled_height = (source_height * scale).round().max(1.0); + + let width = scaled_width as u32; + let height = scaled_height as u32; + validate_svg_dimensions(width, height)?; + Ok((width, height, scale)) +} + +fn validate_svg_dimensions(width: u32, height: u32) -> Result<(), String> { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 + || height == 0 + || width > MAX_ICON_DIMENSION + || height > MAX_ICON_DIMENSION + || pixels > MAX_ICON_PIXELS + { + return Err(format!( + "SVG dimensions exceed center decode limit ({width}x{height})" + )); + } + Ok(()) +} diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 902c3166e..4fba1f690 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -3,8 +3,8 @@ //! Keeps GTK widget creation and updates isolated from list state use std::cell::RefCell; -use std::rc::Rc; -use std::sync::OnceLock; +use std::collections::HashMap; +use std::rc::{Rc, Weak}; use async_channel::Sender; use gtk::prelude::*; @@ -31,9 +31,11 @@ pub(super) struct RowWidgets { command_tx: mpsc::Sender, } -fn row_widgets_quark() -> gtk::glib::Quark { - static QUARK: OnceLock = OnceLock::new(); - *QUARK.get_or_init(|| gtk::glib::Quark::from_str("unixnotis-row-widgets")) +// Thread-local storage replaces glib qdata to keep the codebase free of unsafe blocks +// Weak refs let stale entries be collected without explicit destroy signal handling +// The map key is the raw GObject pointer, scoped to the GTK main thread +thread_local! { + static ROW_WIDGETS: RefCell>> = RefCell::new(HashMap::new()); } impl RowWidgets { @@ -141,20 +143,34 @@ pub(super) fn set_row_widgets(item: >k::ListItem, widgets: Rc) { // Attach the actual row root whenever the cached widget bundle changes // Setup also uses this so GTK never keeps an empty placeholder child item.set_child(Some(&widgets.root)); - unsafe { - // SAFETY: gtk::ListItem stays on the GTK main thread and never crosses threads - // RowWidgets uses Rc and is only accessed from list factory callbacks on the - // main thread. Data is replaced in ensure_row_widgets when the row kind changes - // and otherwise kept to let GTK reuse the row widgets across scroll events - item.set_qdata(row_widgets_quark(), widgets); - } + // Store a weak reference in thread-local storage so get_row_widgets can retrieve + // the cached bundle without holding any Rc strong count from the map + ROW_WIDGETS.with(|map| { + map.borrow_mut() + .insert(glib_ptr(item), Rc::downgrade(&widgets)); + }); } pub(super) fn get_row_widgets(item: >k::ListItem) -> Option> { - // SAFETY: The stable quark is written with Rc on the GTK main thread only - let stored = unsafe { item.qdata::>(row_widgets_quark()) }?; - // SAFETY: Gtk owns the qdata value while the list item remains alive - Some(unsafe { stored.as_ref().clone() }) + // Look up the cached RowWidgets bundle by GObject pointer + // Stale weak refs (from destroyed or recycled list items) are removed on access + ROW_WIDGETS.with(|map| { + let mut map = map.borrow_mut(); + let key = glib_ptr(item); + match map.get(&key) { + Some(weak) => weak.upgrade().or_else(|| { + map.remove(&key); + None + }), + None => None, + } + }) +} + +fn glib_ptr(obj: &T) -> *const () { + // Extract the raw GObject pointer for use as a thread-local HashMap key + // This replaces glib qdata with safe Rust storage while preserving identity + obj.as_ptr() as *const () } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/media/defaults.rs b/crates/unixnotis-core/src/config/media/defaults.rs index 9b82b4472..2f45c2ea6 100644 --- a/crates/unixnotis-core/src/config/media/defaults.rs +++ b/crates/unixnotis-core/src/config/media/defaults.rs @@ -48,6 +48,7 @@ impl Default for MediaConfig { // Local artwork requires exact executable allowlist match to prevent // untrusted MPRIS services from directing the renderer to arbitrary host files local_art_policy: MediaLocalArtPolicy::ExactExecutableOnly, + local_art_executable_allowlist: Vec::new(), } } } diff --git a/crates/unixnotis-core/src/config/media/types.rs b/crates/unixnotis-core/src/config/media/types.rs index 3354b58a5..23a1f1b90 100644 --- a/crates/unixnotis-core/src/config/media/types.rs +++ b/crates/unixnotis-core/src/config/media/types.rs @@ -74,6 +74,9 @@ pub struct MediaConfig { pub remote_art_policy: MediaRemoteArtPolicy, /// Controls which players may use local file paths for artwork pub local_art_policy: MediaLocalArtPolicy, + /// Exact executable paths allowed for local artwork (device/inode verified) + #[serde(default)] + pub local_art_executable_allowlist: Vec, } #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index 1d0b181e1..dec13586c 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -20,10 +20,6 @@ pub struct ImageData { } /// Image information derived from standard hints and `app_icon` -#[expect( - clippy::unsafe_derive_deserialize, - reason = "deserialization only fills owned fields; nested image methods validate buffers before unsafe SIMD access" -)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct NotificationImage { pub has_image_data: bool, diff --git a/crates/unixnotis-core/src/model/image/rgb.rs b/crates/unixnotis-core/src/model/image/rgb.rs index d340aab6b..4208f8a2c 100644 --- a/crates/unixnotis-core/src/model/image/rgb.rs +++ b/crates/unixnotis-core/src/model/image/rgb.rs @@ -1,7 +1,8 @@ //! RGB-to-RGBA image expansion - -#[cfg(target_arch = "x86_64")] -use std::sync::OnceLock; +//! +//! Uses the scalar path exclusively to keep the module free of unsafe SIMD intrinsics. +//! Modern x86_64 compilers auto-vectorize the hot pixel loop, and notification images +//! are small enough that any performance difference is negligible. use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; @@ -22,15 +23,6 @@ impl NotificationImage { } let mut rgba = vec![0u8; output_len]; - #[cfg(target_arch = "x86_64")] - let use_simd = { - // Cache CPUID once so repeated image hints do not re-run feature detection - static HAS_SSSE3: OnceLock = OnceLock::new(); - *HAS_SSSE3.get_or_init(|| std::is_x86_feature_detected!("ssse3")) - }; - #[cfg(not(target_arch = "x86_64"))] - let use_simd = false; - for y in 0..height { let row_start = y.saturating_mul(rowstride); let row_bytes = width.checked_mul(3)?; @@ -42,17 +34,7 @@ impl NotificationImage { let dst_start = (y * width) * 4; let dst_end = dst_start + width * 4; let dst_row = &mut rgba[dst_start..dst_end]; - if use_simd { - #[cfg(target_arch = "x86_64")] - // SAFETY: Guarded by SSSE3 detection; row slices are bounded to full pixels - unsafe { - expand_rgb_row_ssse3(row, dst_row); - } - #[cfg(not(target_arch = "x86_64"))] - expand_rgb_row_scalar(row, dst_row); - } else { - expand_rgb_row_scalar(row, dst_row); - } + expand_rgb_row_scalar(row, dst_row); } Some(ImageData { @@ -75,49 +57,3 @@ pub(in crate::model) fn expand_rgb_row_scalar(src: &[u8], dst: &mut [u8]) { dst[dst_index..dst_index + 4].copy_from_slice(&packed.to_le_bytes()); } } - -#[cfg(target_arch = "x86_64")] -#[target_feature(enable = "ssse3")] -#[expect( - clippy::cast_ptr_alignment, - reason = "the SSSE3 loadu and storeu intrinsics explicitly support unaligned byte buffers" -)] -pub(in crate::model) unsafe fn expand_rgb_row_ssse3(src: &[u8], dst: &mut [u8]) { - // SSSE3 shuffles 12-byte RGB quads into 16-byte RGBA blocks with a fixed alpha mask - use std::arch::x86_64::{ - __m128i, _mm_loadu_si128, _mm_or_si128, _mm_setr_epi8, _mm_shuffle_epi8, _mm_storeu_si128, - }; - - let mut s = 0usize; - let mut d = 0usize; - - let mask: __m128i = _mm_setr_epi8(0, 1, 2, -128, 3, 4, 5, -128, 6, 7, 8, -128, 9, 10, 11, -128); - let alpha: __m128i = _mm_setr_epi8(0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1); - - // Process 4 pixels at a time (12 bytes -> 16 bytes). Read requires 16 bytes - while s + 16 <= src.len() { - // SAFETY: The loop guard proves the 16-byte unaligned read remains inside src - let src_ptr = unsafe { src.as_ptr().add(s) }; - // SAFETY: SSSE3 permits this pointer to be unaligned - let chunk = unsafe { _mm_loadu_si128(src_ptr.cast::<__m128i>()) }; - let shuffled = _mm_shuffle_epi8(chunk, mask); - let with_alpha = _mm_or_si128(shuffled, alpha); - // SAFETY: Four source pixels always map to the next 16-byte destination block - let dst_ptr = unsafe { dst.as_mut_ptr().add(d) }; - // SAFETY: The caller allocates four RGBA bytes for every source RGB pixel - unsafe { _mm_storeu_si128(dst_ptr.cast::<__m128i>(), with_alpha) }; - s += 12; - d += 16; - } - - // Tail handles the remaining one to three pixels - let remaining_pixels = (src.len().saturating_sub(s)) / 3; - for index in 0..remaining_pixels { - let s = s + index * 3; - let d = d + index * 4; - dst[d] = src[s]; - dst[d + 1] = src[s + 1]; - dst[d + 2] = src[s + 2]; - dst[d + 3] = 255; - } -} diff --git a/crates/unixnotis-core/src/model/image/tests/rgb.rs b/crates/unixnotis-core/src/model/image/tests/rgb.rs index 621480641..3f9285b9d 100644 --- a/crates/unixnotis-core/src/model/image/tests/rgb.rs +++ b/crates/unixnotis-core/src/model/image/tests/rgb.rs @@ -1,6 +1,4 @@ use super::super::rgb::expand_rgb_row_scalar; -#[cfg(target_arch = "x86_64")] -use super::super::rgb::expand_rgb_row_ssse3; use super::super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; #[test] @@ -82,21 +80,3 @@ fn scalar_rgb_expansion_writes_expected_alpha_bytes() { assert_eq!(out, vec![1, 2, 3, 255, 4, 5, 6, 255]); } - -#[cfg(target_arch = "x86_64")] -#[test] -fn ssse3_rgb_expansion_matches_scalar_when_supported() { - if !std::is_x86_feature_detected!("ssse3") { - return; - } - - let src = [1_u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; - let mut scalar = vec![0; src.len() / 3 * 4]; - let mut simd = vec![0; scalar.len()]; - - expand_rgb_row_scalar(&src, &mut scalar); - // SAFETY: The test is guarded by the same runtime feature probe as production - unsafe { expand_rgb_row_ssse3(&src, &mut simd) }; - - assert_eq!(simd, scalar); -} diff --git a/crates/unixnotis-daemon/src/child_process/paths.rs b/crates/unixnotis-daemon/src/child_process/paths.rs index 79a4f0070..9672d90c3 100644 --- a/crates/unixnotis-daemon/src/child_process/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/paths.rs @@ -3,14 +3,6 @@ use std::env; use std::path::PathBuf; -use tokio::process::Command; - -#[cfg(target_os = "linux")] -use std::os::unix::process::CommandExt; - -#[cfg(unix)] -use rustix::process::{set_parent_process_death_signal, Signal}; - fn resolve_sibling_binary(name: &str) -> Option { let exe = env::current_exe().ok()?; let dir = exe.parent()?; @@ -38,20 +30,6 @@ pub(super) fn resolve_center_path() -> Option { resolve_sibling_binary("unixnotis-center") } -#[cfg(target_os = "linux")] -pub(super) fn apply_parent_death_signal(command: &mut Command) { - // If the daemon dies, the UI child should not linger alone - // SAFETY: The pre-exec closure performs only the Linux prctl call before process launch - unsafe { - command.as_std_mut().pre_exec(|| { - set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from) - }); - } -} - -#[cfg(not(target_os = "linux"))] -pub(super) fn apply_parent_death_signal(_command: &mut Command) {} - #[cfg(test)] #[path = "tests/paths.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index 20253f60f..da91bc11f 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -15,7 +15,7 @@ use unixnotis_core::util::CONFIG_PATH_ENV; use crate::cli::Args; use crate::daemon::DaemonState; -use super::paths::{apply_parent_death_signal, resolve_center_path, resolve_popups_path}; +use super::paths::{resolve_center_path, resolve_popups_path}; use super::supervisor::supervise_process; // A short loop should not hammer respawns forever @@ -68,8 +68,6 @@ impl UiProcessKind { command.stdout(Stdio::inherit()); command.stderr(Stdio::inherit()); - apply_parent_death_signal(&mut command); - if let Some(config) = args.config.as_ref() { // GTK re-parses argv in child apps, so custom config paths travel by env instead command.env(CONFIG_PATH_ENV, child_config_env_path(config)); diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index c5b9a93af..5107ca059 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -10,6 +10,7 @@ use zbus::message::Header; use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; +#[cfg(not(target_os = "linux"))] use super::executable_trust::is_trusted_control_executable_path; #[cfg(target_os = "linux")] use super::executable_trust::is_trusted_control_executable_from_fd; @@ -195,15 +196,20 @@ pub(in crate::daemon) fn control_executable_is_allowed( diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs index 8843ed7df..7f786f9fe 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -5,6 +5,7 @@ mod metadata; pub(in crate::daemon::auth) mod paths; mod snapshots; +#[cfg(not(target_os = "linux"))] pub(super) use paths::is_trusted_control_executable_path; #[cfg(target_os = "linux")] pub(super) use paths::is_trusted_control_executable_from_fd; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index ba5a45315..7e036fff1 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -3,7 +3,10 @@ use std::os::unix::io::AsFd; use std::path::{Path, PathBuf}; +#[cfg(not(target_os = "linux"))] use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; +#[cfg(target_os = "linux")] +use super::super::policy::TRUSTED_CONTROL_EXECUTABLES; use super::fingerprint::{file_fingerprint, file_fingerprint_from_fd}; use super::metadata::trusted_control_file_metadata_is_safe; use super::snapshots::trusted_control_snapshot; @@ -13,6 +16,7 @@ pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_error| path.to_path_buf()) } +#[cfg(not(target_os = "linux"))] pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed: bool) -> bool { // Trust only known sibling binaries from the daemon install/build directory let Some(trusted_dir) = trusted_control_directory() else { @@ -114,6 +118,7 @@ fn trusted_control_directory() -> Option { current_exe.parent().map(Path::to_path_buf) } +#[cfg(not(target_os = "linux"))] pub(in crate::daemon::auth) fn trusted_snapshot_matches_observed( snapshot: &TrustedExecutableSnapshot, observed: &Path, diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs index 04f6c7fb9..1c2397e75 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs @@ -1,10 +1,12 @@ -use super::super::paths::{canonicalize_best_effort, trusted_snapshot_matches_observed}; -use super::super::snapshots::build_trusted_control_snapshots; -use crate::daemon::auth::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; -use crate::daemon::auth::support::write_executable; -use crate::test_support::TempRoot; -use std::collections::HashMap; -use std::path::Path; +#[cfg(not(target_os = "linux"))] +mod strict_snapshot_tests { + use super::super::paths::{canonicalize_best_effort, trusted_snapshot_matches_observed}; + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + use std::collections::HashMap; + use std::path::Path; fn is_trusted_control_executable_path_in_dir( path: &Path, @@ -135,3 +137,4 @@ fn strict_snapshot_rejects_group_writable_trusted_binary() { &snapshots, )); } +} \ No newline at end of file diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs index 7cdb284e7..256d81c1a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -1,12 +1,20 @@ -use super::super::fingerprint::fingerprint_cache; -use super::super::paths::is_trusted_control_executable_path; -use super::super::snapshots::trusted_snapshot_cache; -use crate::daemon::auth::authorization::control_executable_is_allowed; -use crate::daemon::auth::support::write_executable; -use crate::test_support::{env_lock, TempRoot}; +#[cfg(not(target_os = "linux"))] +mod strict_path_tests { + use super::super::fingerprint::fingerprint_cache; + use super::super::paths::is_trusted_control_executable_path; + use super::super::snapshots::trusted_snapshot_cache; + use crate::daemon::auth::authorization::control_executable_is_allowed; + use crate::daemon::auth::support::write_executable; + use crate::test_support::{env_lock, TempRoot}; + use std::fs::File; + use std::os::fd::OwnedFd; -#[test] -fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { + fn open_test_executable(path: &std::path::Path) -> OwnedFd { + File::open(path).expect("open test executable").into() + } + + #[test] + fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { let _guard = env_lock(); let current_exe = std::env::current_exe().expect("current test executable"); let trusted_dir = current_exe @@ -29,24 +37,28 @@ fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { assert!(is_trusted_control_executable_path(&trusted, false)); assert!(!is_trusted_control_executable_path(&foreign, false)); - assert!(control_executable_is_allowed::( + let trusted_fd = open_test_executable(&trusted); + let foreign_fd = open_test_executable(&foreign); + assert!(control_executable_is_allowed::( Some(&trusted), - None, + Some(&trusted_fd), &["noticenterctl"], false )); - assert!(!control_executable_is_allowed::( + assert!(!control_executable_is_allowed::( Some(&trusted), - None, + Some(&trusted_fd), &["unixnotis-center"], false )); - assert!(!control_executable_is_allowed::( + // Foreign path must be checked with its own fd to verify it's a different executable + assert!(!control_executable_is_allowed::( Some(&foreign), - None, + Some(&foreign_fd), &["noticenterctl"], false )); - let _ = std::fs::remove_file(trusted); +let _ = std::fs::remove_file(trusted); + } } diff --git a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs index 3c6764096..47470e2f9 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs @@ -1,6 +1,5 @@ //! Process metadata helpers for authorization checks -use std::os::unix::fs::OpenOptionsExt; use std::path::PathBuf; #[cfg(target_os = "linux")] @@ -48,12 +47,12 @@ pub(in crate::daemon) fn open_process_executable_from_pidfd( return None; } - // Open /proc//exe as a file descriptor. This refers directly to the - // kernel file object, not a pathname that could be shadowed by a mount - // namespace. O_NOFOLLOW prevents following a symlinked /proc entry. + // Open /proc//exe as a file descriptor. This follows the procfs + // magic symlink to the actual executable object. The resulting descriptor + // refers to the kernel file object, not a pathname that could be shadowed + // by a mount namespace. let fd = std::fs::OpenOptions::new() .read(true) - .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits().cast_signed()) .open(format!("/proc/{expected_pid}/exe")) .ok()?; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 3583b6c62..22067be27 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::os::fd::OwnedFd; use zbus::Message; #[cfg(target_os = "linux")] @@ -13,6 +15,12 @@ use super::policy::TRUSTED_INTERACTION_EXECUTABLES; use super::support::write_executable; use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; +fn open_test_executable(path: &std::path::Path) -> OwnedFd { + File::open(path) + .expect("open test executable") + .into() +} + fn message_without_bus_sender() -> Message { // Locally built messages have no unique bus sender, which must fail auth early Message::method("/", "Ping") @@ -94,10 +102,12 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { let trusted = canonicalize_best_effort(&trusted); let untrusted_name = canonicalize_best_effort(&untrusted_name); - assert!(control_executable_error::(Some(&trusted), None, &["noticenterctl"], true).is_none()); - assert!(control_executable_error::(None, None, &["noticenterctl"], true).is_some()); - assert!(control_executable_error::(Some(&trusted), None, &["unixnotis-center"], true).is_some()); - assert!(control_executable_error::(Some(&untrusted_name), None, &["unknown"], true).is_some()); + let trusted_fd = open_test_executable(&trusted); + + assert!(control_executable_error(Some(&trusted), Some(&trusted_fd), &["noticenterctl"], true).is_none()); + assert!(control_executable_error::(None, None::<&OwnedFd>, &["noticenterctl"], true).is_some()); + assert!(control_executable_error(Some(&trusted), Some(&trusted_fd), &["unixnotis-center"], true).is_some()); + assert!(control_executable_error(Some(&untrusted_name), Some(&trusted_fd), &["unknown"], true).is_some()); } #[test] @@ -113,17 +123,19 @@ fn interaction_executable_policy_excludes_noninteractive_control_clients() { let _home = EnvVarGuard::set("HOME", home.path()); for trusted_ui in [¢er, &popups] { - assert!(control_executable_error::( + let trusted_fd = open_test_executable(trusted_ui); + assert!(control_executable_error::( Some(&canonicalize_best_effort(trusted_ui)), - None, + Some(&trusted_fd), &TRUSTED_INTERACTION_EXECUTABLES, true, ) .is_none()); } - assert!(control_executable_error::( + let cli_fd = open_test_executable(&cli); + assert!(control_executable_error::( Some(&canonicalize_best_effort(&cli)), - None, + Some(&cli_fd), &TRUSTED_INTERACTION_EXECUTABLES, true, ) diff --git a/crates/unixnotis-installer/src/managed_binaries.rs b/crates/unixnotis-installer/src/managed_binaries.rs index 15eda0273..a0ca9dbe9 100644 --- a/crates/unixnotis-installer/src/managed_binaries.rs +++ b/crates/unixnotis-installer/src/managed_binaries.rs @@ -10,6 +10,7 @@ const SUPPORTED_MANAGED_BINARIES: &[&str] = &[ "unixnotis-daemon", "unixnotis-popups", "unixnotis-center", + "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl", ]; diff --git a/tests/package-release.sh b/tests/package-release.sh index 07ded975d..c89e71e12 100755 --- a/tests/package-release.sh +++ b/tests/package-release.sh @@ -7,6 +7,11 @@ repo_root="$(cd -- "${script_dir}/.." && pwd -P)" cd -- "$repo_root" source scripts/package-release.sh +if ! managed_binaries | grep -Fxq 'unixnotis-svg-renderer'; then + printf 'installer metadata omitted the SVG renderer\n' >&2 + exit 1 +fi + test_root="$(mktemp -d)" trap 'rm -rf -- "${test_root}"' EXIT cd -- "$test_root" @@ -16,16 +21,17 @@ mkdir -p target/release printf 'installer\n' > target/release/unixnotis-installer printf 'daemon\n' > target/release/unixnotis-daemon printf 'center\n' > target/release/unixnotis-center +printf 'svg-renderer\n' > target/release/unixnotis-svg-renderer chmod 0755 target/release/* export SOURCE_DATE_EPOCH=1700000000 -assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center +assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center unixnotis-svg-renderer archive="dist/unixnotis-v9.8.7-x86_64-unknown-linux-gnu.tar.zst" first_digest="$(sha256sum "$archive" | cut -d ' ' -f 1)" # Input timestamps must not influence the published archive touch target/release/* -assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center +assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center unixnotis-svg-renderer second_digest="$(sha256sum "$archive" | cut -d ' ' -f 1)" if [[ "$first_digest" != "$second_digest" ]]; then @@ -55,10 +61,10 @@ cargo_args="${test_root}/cargo-args" cargo() { printf '%s\n' "$@" > "$cargo_args" } -build_release_binaries unixnotis-daemon unixnotis-css-validate +build_release_binaries unixnotis-daemon unixnotis-svg-renderer unixnotis-css-validate unset -f cargo -expected_args=$'build\n--locked\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-css-validate' +expected_args=$'build\n--locked\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-svg-renderer\n--bin\nunixnotis-css-validate' actual_args="$(cat -- "$cargo_args")" if [[ "$actual_args" != "$expected_args" ]]; then printf 'release build did not select exact binary targets\n' >&2 From b43a08e59d25cea91bd4b74eeb080acee315598b Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 00:12:39 -0500 Subject: [PATCH 176/275] fix(runtime): bound UI caches and renderer lifecycle Summary: bound UI caches and renderer lifecycle. Scope: runtime. --- crates/unixnotis-center/src/ui/icons/cache.rs | 47 +++++++------ .../src/ui/icons/tests/cache.rs | 25 ++++++- .../ui/notifications/view/tests/widgets.rs | 15 +++++ .../src/ui/notifications/view/widgets.rs | 59 +++++++++-------- crates/unixnotis-core/src/model/image/rgb.rs | 2 +- .../src/child_process/paths.rs | 22 +++++++ .../src/child_process/process.rs | 4 +- .../src/child_process/tests/paths.rs | 66 +++++++++++++++++++ .../src/actions/binaries.rs | 1 + .../src/actions/tests/binaries.rs | 2 + .../src/tests/managed_binaries.rs | 1 + 11 files changed, 193 insertions(+), 51 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index 4bf557d1d..9e873407f 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -13,18 +13,13 @@ use gtk::IconPaintable; use unixnotis_core::NotificationImage; const DEFAULT_MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; +const MAX_TRACKED_IMAGE_KEYS: usize = 4096; -// Thread-local storage replaces glib qdata to avoid unsafe pointer casts -// The map key is the raw GObject pointer; entries persist for the widget lifetime -// and stale entries are harmless because the bound IconKey values are small +// Weak image references let destroyed images disappear on the next cache access +// A key is retained only while its image can still be upgraded thread_local! { - static IMAGE_KEYS: RefCell> = RefCell::new(HashMap::new()); -} - -fn glib_ptr(obj: &T) -> *const () { - // Extract the raw GObject pointer for use as a thread-local HashMap key - // This replaces glib qdata with safe Rust storage while preserving identity - obj.as_ptr() as *const () + static IMAGE_KEYS: RefCell, IconKey)>> = + const { RefCell::new(Vec::new()) }; } #[derive(Clone, Debug, Hash, PartialEq, Eq)] @@ -117,19 +112,33 @@ fn hash_image_data(data: &[u8]) -> [u8; 32] { } pub(super) fn set_image_key(image: >k::Image, key: IconKey) { - // Store the icon key in thread-local storage keyed by the GObject pointer - // This replaces glib::ObjectExt::set_qdata to keep the codebase free of unsafe blocks - IMAGE_KEYS.with(|map| { - map.borrow_mut().insert(glib_ptr(image), key); + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + if let Some((_, existing)) = entries + .iter_mut() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *image)) + { + *existing = key; + } else { + entries.push((image.downgrade(), key)); + } + // A dead weak reference is normally removed on the next access. Keep a hard + // cap as a second line of defense when images stop being accessed entirely + if entries.len() > MAX_TRACKED_IMAGE_KEYS { + let excess = entries.len() - MAX_TRACKED_IMAGE_KEYS; + entries.drain(..excess); + } }); } pub(super) fn image_key_matches(image: >k::Image, key: &IconKey) -> bool { - // Retrieve the stored icon key from thread-local storage by GObject pointer - // Returns false when no key was stored or the stored key differs from the request - IMAGE_KEYS.with(|map| { - let map = map.borrow(); - map.get(&glib_ptr(image)) == Some(key) + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + entries.iter().any(|(weak, stored)| { + weak.upgrade().is_some_and(|current| current == *image) && stored == key + }) }) } diff --git a/crates/unixnotis-center/src/ui/icons/tests/cache.rs b/crates/unixnotis-center/src/ui/icons/tests/cache.rs index a4a22b5ef..39776b257 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/cache.rs @@ -9,7 +9,7 @@ fn key(name: &str) -> IconKey { } #[gtk::test] -fn image_qdata_key_matches_only_the_stored_icon_request() { +fn image_key_matches_only_the_stored_icon_request() { let image = gtk::Image::new(); let stored = key("network-wireless"); let different = key("audio-volume-high"); @@ -21,6 +21,29 @@ fn image_qdata_key_matches_only_the_stored_icon_request() { assert!(!image_key_matches(&image, &different)); } +#[gtk::test] +fn image_keys_do_not_survive_the_image_object() { + let stored = key("network-wireless"); + let old_image = gtk::Image::new(); + set_image_key(&old_image, stored.clone()); + drop(old_image); + + let new_image = gtk::Image::new(); + assert!(!image_key_matches(&new_image, &stored)); +} + +#[gtk::test] +fn image_key_tracking_has_a_hard_bound_when_images_stop_being_accessed() { + for index in 0..=super::MAX_TRACKED_IMAGE_KEYS { + let image = gtk::Image::new(); + set_image_key(&image, key(&format!("icon-{index}"))); + } + + super::IMAGE_KEYS.with(|entries| { + assert!(entries.borrow().len() <= super::MAX_TRACKED_IMAGE_KEYS); + }); +} + #[test] fn image_data_hash_changes_when_only_the_middle_bytes_change() { let mut first = vec![0x11; 16_384]; diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index 274ec4894..fd3652f02 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -44,6 +44,21 @@ fn set_and_get_row_widgets_round_trips_cached_bundle() { assert!(gtk_item.child().is_some()); } +#[gtk::test] +fn row_widget_cache_keeps_bundle_alive_after_setup_owner_is_dropped() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let gtk_item = new_gtk_item(); + let widgets = Rc::new(RowWidgets::new(RowKind::Notification, command_tx, event_tx)); + let weak = Rc::downgrade(&widgets); + + set_row_widgets(>k_item, widgets); + + // The factory callback may drop its local Rc immediately after setup + assert!(weak.upgrade().is_some()); + assert!(get_row_widgets(>k_item).is_some()); +} + #[gtk::test] fn ensure_row_widgets_reuses_same_kind() { support::init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 4fba1f690..09d5bddea 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -3,8 +3,7 @@ //! Keeps GTK widget creation and updates isolated from list state use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::{Rc, Weak}; +use std::rc::Rc; use async_channel::Sender; use gtk::prelude::*; @@ -31,11 +30,13 @@ pub(super) struct RowWidgets { command_tx: mpsc::Sender, } -// Thread-local storage replaces glib qdata to keep the codebase free of unsafe blocks -// Weak refs let stale entries be collected without explicit destroy signal handling -// The map key is the raw GObject pointer, scoped to the GTK main thread +// Weak item references prevent destroyed list items from keeping entries alive +// Strong widget bundles preserve the factory's reusable row tree between binds +const MAX_TRACKED_ROW_WIDGETS: usize = 4096; + thread_local! { - static ROW_WIDGETS: RefCell>> = RefCell::new(HashMap::new()); + static ROW_WIDGETS: RefCell, Rc)>> = + const { RefCell::new(Vec::new()) }; } impl RowWidgets { @@ -143,36 +144,36 @@ pub(super) fn set_row_widgets(item: >k::ListItem, widgets: Rc) { // Attach the actual row root whenever the cached widget bundle changes // Setup also uses this so GTK never keeps an empty placeholder child item.set_child(Some(&widgets.root)); - // Store a weak reference in thread-local storage so get_row_widgets can retrieve - // the cached bundle without holding any Rc strong count from the map - ROW_WIDGETS.with(|map| { - map.borrow_mut() - .insert(glib_ptr(item), Rc::downgrade(&widgets)); + ROW_WIDGETS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + if let Some((_, existing)) = entries + .iter_mut() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *item)) + { + *existing = widgets; + } else { + entries.push((item.downgrade(), widgets)); + } + // Keep a bounded fallback for unusual list-model churn before another cache access + if entries.len() > MAX_TRACKED_ROW_WIDGETS { + let excess = entries.len() - MAX_TRACKED_ROW_WIDGETS; + entries.drain(..excess); + } }); } pub(super) fn get_row_widgets(item: >k::ListItem) -> Option> { - // Look up the cached RowWidgets bundle by GObject pointer - // Stale weak refs (from destroyed or recycled list items) are removed on access - ROW_WIDGETS.with(|map| { - let mut map = map.borrow_mut(); - let key = glib_ptr(item); - match map.get(&key) { - Some(weak) => weak.upgrade().or_else(|| { - map.remove(&key); - None - }), - None => None, - } + ROW_WIDGETS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + entries + .iter() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *item)) + .map(|(_, widgets)| widgets.clone()) }) } -fn glib_ptr(obj: &T) -> *const () { - // Extract the raw GObject pointer for use as a thread-local HashMap key - // This replaces glib qdata with safe Rust storage while preserving identity - obj.as_ptr() as *const () -} - #[cfg(test)] #[path = "tests/widgets.rs"] mod tests; diff --git a/crates/unixnotis-core/src/model/image/rgb.rs b/crates/unixnotis-core/src/model/image/rgb.rs index 4208f8a2c..72d0e7c13 100644 --- a/crates/unixnotis-core/src/model/image/rgb.rs +++ b/crates/unixnotis-core/src/model/image/rgb.rs @@ -1,7 +1,7 @@ //! RGB-to-RGBA image expansion //! //! Uses the scalar path exclusively to keep the module free of unsafe SIMD intrinsics. -//! Modern x86_64 compilers auto-vectorize the hot pixel loop, and notification images +//! Modern `x86_64` compilers auto-vectorize the hot pixel loop, and notification images //! are small enough that any performance difference is negligible. use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; diff --git a/crates/unixnotis-daemon/src/child_process/paths.rs b/crates/unixnotis-daemon/src/child_process/paths.rs index 9672d90c3..4157cda11 100644 --- a/crates/unixnotis-daemon/src/child_process/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/paths.rs @@ -3,6 +3,14 @@ use std::env; use std::path::PathBuf; +use tokio::process::Command; + +#[cfg(target_os = "linux")] +use std::os::unix::process::CommandExt; + +#[cfg(target_os = "linux")] +use rustix::process::{set_parent_process_death_signal, Signal}; + fn resolve_sibling_binary(name: &str) -> Option { let exe = env::current_exe().ok()?; let dir = exe.parent()?; @@ -30,6 +38,20 @@ pub(super) fn resolve_center_path() -> Option { resolve_sibling_binary("unixnotis-center") } +#[cfg(target_os = "linux")] +pub(super) fn apply_parent_death_signal(command: &mut Command) { + // The kernel clears the child relationship before the new program starts + // SAFETY: This closure only calls prctl through rustix and returns its OS error + unsafe { + command.as_std_mut().pre_exec(|| { + set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from) + }); + } +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn apply_parent_death_signal(_command: &mut Command) {} + #[cfg(test)] #[path = "tests/paths.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index da91bc11f..20253f60f 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -15,7 +15,7 @@ use unixnotis_core::util::CONFIG_PATH_ENV; use crate::cli::Args; use crate::daemon::DaemonState; -use super::paths::{resolve_center_path, resolve_popups_path}; +use super::paths::{apply_parent_death_signal, resolve_center_path, resolve_popups_path}; use super::supervisor::supervise_process; // A short loop should not hammer respawns forever @@ -68,6 +68,8 @@ impl UiProcessKind { command.stdout(Stdio::inherit()); command.stderr(Stdio::inherit()); + apply_parent_death_signal(&mut command); + if let Some(config) = args.config.as_ref() { // GTK re-parses argv in child apps, so custom config paths travel by env instead command.env(CONFIG_PATH_ENV, child_config_env_path(config)); diff --git a/crates/unixnotis-daemon/src/child_process/tests/paths.rs b/crates/unixnotis-daemon/src/child_process/tests/paths.rs index abce75605..7d773b12c 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/paths.rs @@ -55,3 +55,69 @@ fn resolve_sibling_binary_returns_none_when_no_sibling_exists() { assert!(resolve_sibling_binary("unixnotis-missing").is_none()); } + +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_terminates_a_child_when_its_launcher_exits() { + let _guard = env_lock(); + let marker_path = std::env::temp_dir().join(format!( + "unixnotis-pdeath-{}-{}.pid", + std::process::id(), + std::time::Instant::now().elapsed().as_nanos() + )); + let _ = std::fs::remove_file(&marker_path); + let helper = std::env::current_exe().expect("current test executable"); + let status = std::process::Command::new(helper) + .args([ + "--exact", + "child_process::paths::tests::parent_death_signal_child_helper", + "--nocapture", + ]) + .env("UNIXNOTIS_PDEATH_MARKER", &marker_path) + .status() + .expect("launch parent-death helper"); + assert!(status.success(), "helper test failed: {status}"); + + let pid = std::fs::read_to_string(&marker_path) + .expect("helper should publish the child pid") + .trim() + .parse::() + .expect("child pid should be numeric"); + let proc_path = std::path::PathBuf::from(format!("/proc/{pid}")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while proc_path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + if proc_path.exists() { + // Clean up a failed mutation so the test cannot leak a long-running shell + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + assert!(!proc_path.exists(), "child survived launcher exit"); + let _ = std::fs::remove_file(marker_path); +} + +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_child_helper() { + let Some(marker) = std::env::var_os("UNIXNOTIS_PDEATH_MARKER") else { + return; + }; + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "trap 'exit 0' TERM; while :; do sleep 1; done"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + apply_parent_death_signal(&mut command); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .expect("build helper runtime"); + runtime.block_on(async move { + let child = command.spawn().expect("spawn supervised child"); + std::fs::write(marker, child.id().expect("child pid").to_string()) + .expect("write child pid marker"); + }); +} diff --git a/crates/unixnotis-installer/src/actions/binaries.rs b/crates/unixnotis-installer/src/actions/binaries.rs index e095c2d84..aa89a2eee 100644 --- a/crates/unixnotis-installer/src/actions/binaries.rs +++ b/crates/unixnotis-installer/src/actions/binaries.rs @@ -77,6 +77,7 @@ fn legacy_binaries() -> Vec { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string(), ] diff --git a/crates/unixnotis-installer/src/actions/tests/binaries.rs b/crates/unixnotis-installer/src/actions/tests/binaries.rs index ceb3678d5..ede408d47 100644 --- a/crates/unixnotis-installer/src/actions/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/tests/binaries.rs @@ -361,6 +361,7 @@ fn checked_in_workspace_resolves_against_real_cargo_targets() { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string() ] @@ -450,6 +451,7 @@ fn legacy_binaries_keep_full_installed_surface() { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string() ] diff --git a/crates/unixnotis-installer/src/tests/managed_binaries.rs b/crates/unixnotis-installer/src/tests/managed_binaries.rs index 49a2bfaac..ac7039e90 100644 --- a/crates/unixnotis-installer/src/tests/managed_binaries.rs +++ b/crates/unixnotis-installer/src/tests/managed_binaries.rs @@ -22,6 +22,7 @@ fn managed_binary_names_accept_the_complete_runtime_set() { "unixnotis-daemon", "unixnotis-popups", "unixnotis-center", + "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl", ] From b4ec8f0ef8e385e7614b84428eac058d5d9bb827 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 00:58:34 -0500 Subject: [PATCH 177/275] fix(security): complete process-bound hardening Summary: complete process-bound hardening. Scope: security. --- .../src/media/mpris/admission.rs | 42 ++- .../src/media/mpris/credentials.rs | 37 +++ .../src/media/mpris/metadata.rs | 4 +- .../unixnotis-center/src/media/mpris/mod.rs | 2 + .../src/media/mpris/player.rs | 85 +++--- .../src/media/mpris/process.rs | 90 +++++++ .../src/media/mpris/tests/admission.rs | 59 +---- .../src/media/mpris/tests/player.rs | 29 ++- .../src/ui/icons/decode/svg.rs | 21 +- .../src/ui/icons/decode/tests/svg.rs | 17 ++ .../row/notification/update/actions.rs | 18 +- .../src/child_process/paths.rs | 18 +- .../src/child_process/process.rs | 2 +- .../src/child_process/tests/paths.rs | 31 ++- .../src/daemon/auth/authorization.rs | 4 +- .../src/daemon/auth/executable_trust/mod.rs | 4 +- .../src/daemon/auth/executable_trust/paths.rs | 4 +- .../auth/executable_trust/tests/snapshots.rs | 246 +++++++++--------- .../auth/executable_trust/tests/strict.rs | 86 +++--- .../src/daemon/auth/tests/authorization.rs | 27 +- .../src/store/tests/runtime/action_target.rs | 3 +- .../src/store/tests/runtime/inline_reply.rs | 4 +- .../src/ui/entry/builders/common.rs | 26 +- .../unixnotis-popups/src/ui/icons/decode.rs | 4 +- 24 files changed, 535 insertions(+), 328 deletions(-) create mode 100644 crates/unixnotis-center/src/media/mpris/credentials.rs create mode 100644 crates/unixnotis-center/src/media/mpris/process.rs diff --git a/crates/unixnotis-center/src/media/mpris/admission.rs b/crates/unixnotis-center/src/media/mpris/admission.rs index 2b7bb3045..f00defd04 100644 --- a/crates/unixnotis-center/src/media/mpris/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/admission.rs @@ -50,9 +50,8 @@ pub(super) fn remote_art_allowed( pub(super) fn local_art_allowed( browser_family: Option<&str>, owner_executable: Option<&str>, - owner_pid: Option, + owner_executable_is_allowed: bool, policy: MediaLocalArtPolicy, - executable_allowlist: &[String], ) -> bool { // A missing owner executable means the bus owner is not concrete enough to trust let has_owner = owner_executable.is_some_and(|value| !value.trim().is_empty()); @@ -64,10 +63,7 @@ pub(super) fn local_art_allowed( MediaLocalArtPolicy::ExactExecutableOnly => { // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. // Only native players (non-browser) with an allowlist-matched executable may name host files. - browser_family.is_none() - && owner_pid.is_some_and(|pid| { - is_executable_allowed(pid, owner_executable.unwrap_or(""), executable_allowlist) - }) + browser_family.is_none() && owner_executable_is_allowed } MediaLocalArtPolicy::AllAdmitted => { // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. @@ -79,25 +75,11 @@ pub(super) fn local_art_allowed( const MAX_EXECUTABLE_FINGERPRINT_BYTES: u64 = 512 * 1024 * 1024; -fn is_executable_allowed(pid: u32, executable: &str, allowlist: &[String]) -> bool { - if allowlist.is_empty() { - return false; - } - if executable.trim().is_empty() { - return false; - } - - // Open the kernel-reported executable object before examining the allowlist paths - let owner_file = match File::open(format!("/proc/{pid}/exe")) { - Ok(file) => file, - Err(_) => return false, - }; +pub(super) fn executable_file_matches_allowlist(owner_file: File, allowlist: &[String]) -> bool { let owner_meta = match owner_file.metadata() { Ok(meta) => meta, Err(_) => return false, }; - // Large system binaries only need stable descriptor identity; user-owned files - // also require a bounded content fingerprint to cover in-place replacement let needs_digest = owner_meta.uid() != 0 || allowlist.iter().any(|path| { File::open(path) @@ -105,7 +87,10 @@ fn is_executable_allowed(pid: u32, executable: &str, allowlist: &[String]) -> bo .is_ok_and(|metadata| metadata.uid() != 0) }); let owner_digest = if needs_digest { - match executable_digest(owner_file) { + let Some(clone) = owner_file.try_clone().ok() else { + return false; + }; + match executable_digest(clone) { Some(digest) => Some(digest), None => return false, } @@ -113,7 +98,20 @@ fn is_executable_allowed(pid: u32, executable: &str, allowlist: &[String]) -> bo None }; let owner_identity = (owner_meta.dev(), owner_meta.ino()); + executable_file_matches_allowlist_with_owner( + owner_meta, + owner_identity, + owner_digest, + allowlist, + ) +} +fn executable_file_matches_allowlist_with_owner( + owner_meta: std::fs::Metadata, + owner_identity: (u64, u64), + owner_digest: Option<[u8; 32]>, + allowlist: &[String], +) -> bool { allowlist.iter().any(|allowed_path| { let allowed_file = match File::open(allowed_path) { Ok(file) => file, diff --git a/crates/unixnotis-center/src/media/mpris/credentials.rs b/crates/unixnotis-center/src/media/mpris/credentials.rs new file mode 100644 index 000000000..431dc69c3 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/credentials.rs @@ -0,0 +1,37 @@ +//! Process credentials for MPRIS owner checks + +#[cfg(target_os = "linux")] +use zbus::names::BusName; +#[cfg(target_os = "linux")] +use zbus::zvariant::{DeserializeDict, Type}; +#[cfg(target_os = "linux")] +use zbus::{proxy, Connection}; + +#[cfg(target_os = "linux")] +#[derive(Debug, Default, DeserializeDict, Type)] +#[zvariant(signature = "a{sv}")] +pub(super) struct MprisCredentials { + #[zvariant(rename = "ProcessFD")] + pub(super) process_fd: Option, + #[zvariant(rename = "ProcessID")] + pub(super) process_id: Option, +} + +#[cfg(target_os = "linux")] +#[proxy( + interface = "org.freedesktop.DBus", + default_service = "org.freedesktop.DBus", + default_path = "/org/freedesktop/DBus" +)] +trait ConnectionCredentialsDbus { + fn get_connection_credentials(&self, bus_name: BusName<'_>) -> zbus::Result; +} + +#[cfg(target_os = "linux")] +pub(super) async fn get_connection_credentials( + connection: &Connection, + bus_name: BusName<'_>, +) -> Option { + let proxy = ConnectionCredentialsDbusProxy::new(connection).await.ok()?; + proxy.get_connection_credentials(bus_name).await.ok() +} diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 1c096e151..3534bf18b 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -28,7 +28,9 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option zbus::Result> { // D-Bus owner data is captured once so snapshots do not need another bus round trip // Browser bridges may later override this PID with a stronger metadata source PID - let Some((unique_owner, owner_pid, owner_executable)) = - resolve_player_owner(connection, name).await - else { + let Some(owner) = resolve_player_owner(connection, name).await else { // Ownership changed during probing, so a later bus event should rebuild stable data return Ok(None); }; // Every process-bound proxy targets the verified unique owner instead of the mutable alias - let identity = fetch_identity(connection, &unique_owner) + let identity = fetch_identity(connection, &owner.unique_owner) .await .unwrap_or_else(|| name.to_string()); let browser_family = detect_browser_family(&identity, name, &config.browser_tokens); let remote_art_allowed = remote_art_allowed( browser_family.as_deref(), - owner_executable.as_deref(), + owner.executable.as_deref(), config.remote_art_policy, ); + #[cfg(target_os = "linux")] + let owner_executable_is_allowed = executable_allowed_from_pidfd( + &owner.process_fd, + owner.pid, + &config.local_art_executable_allowlist, + ); + #[cfg(not(target_os = "linux"))] + let owner_executable_is_allowed = false; let local_art_allowed = local_art_allowed( browser_family.as_deref(), - owner_executable.as_deref(), - owner_pid, + owner.executable.as_deref(), + owner_executable_is_allowed, config.local_art_policy, - &config.local_art_executable_allowlist, ); let player = ProxyBuilder::new(connection) - .destination(unique_owner.clone())? + .destination(owner.unique_owner.clone())? .path(MPRIS_PATH)? .interface(MPRIS_PLAYER)? .build() .await?; let properties = PropertiesProxy::builder(connection) - .destination(unique_owner.clone())? + .destination(owner.unique_owner.clone())? .path(MPRIS_PATH)? .build() .await?; @@ -69,10 +83,10 @@ pub(in crate::media) async fn build_player_state( Ok(Some(PlayerState { bus_name: name.to_string(), - unique_owner: Some(unique_owner), + unique_owner: Some(owner.unique_owner), identity, browser_family, - owner_pid, + owner_pid: Some(owner.pid), remote_art_allowed, local_art_allowed, player, @@ -98,7 +112,7 @@ pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Optio pub(super) async fn resolve_player_owner( connection: &Connection, name: &str, -) -> Option<(String, Option, Option)> { +) -> Option { // Synthetic names cannot always be converted into a D-Bus bus name let Ok(bus_name) = zbus::names::BusName::try_from(name) else { return None; @@ -107,34 +121,41 @@ pub(super) async fn resolve_player_owner( return None; }; let unique_owner = proxy.get_name_owner(bus_name.clone()).await.ok()?; - // The bus owner PID is useful for normal players and art trust policy - // It is weaker than bridge metadata when a helper owns the MPRIS name + #[cfg(target_os = "linux")] + let credentials = get_connection_credentials(connection, (&unique_owner).into()).await?; + #[cfg(target_os = "linux")] + let (pid, process_fd) = (credentials.process_id?, credentials.process_fd?); + #[cfg(not(target_os = "linux"))] let pid = proxy .get_connection_unix_process_id((&unique_owner).into()) .await - .ok(); - #[cfg(target_os = "linux")] - let executable = match pid { - Some(pid) => read_process_executable_path(pid) - .await - .map(|path| path.display().to_string()), - None => None, - }; - #[cfg(not(target_os = "linux"))] - let executable = None; + .ok()?; let observed_owner = proxy.get_name_owner(bus_name).await.ok()?; if !owner_probe_is_stable(unique_owner.as_str(), observed_owner.as_str()) { return None; } - Some((unique_owner.to_string(), pid, executable)) + #[cfg(target_os = "linux")] + let executable = read_process_executable_path_from_pidfd(&process_fd, pid) + .map(|path| path.display().to_string()); + #[cfg(target_os = "linux")] + executable.as_ref()?; + Some(OwnerProbe { + unique_owner: unique_owner.to_string(), + pid, + executable, + #[cfg(target_os = "linux")] + process_fd, + }) } -pub(super) fn owner_probe_is_stable(initial_owner: &str, observed_owner: &str) -> bool { - initial_owner == observed_owner +pub(super) struct OwnerProbe { + pub(super) unique_owner: String, + pub(super) pid: u32, + pub(super) executable: Option, + #[cfg(target_os = "linux")] + pub(super) process_fd: OwnedFd, } -#[cfg(target_os = "linux")] -async fn read_process_executable_path(pid: u32) -> Option { - // Reading procfs keeps the trust hint tied to the real bus owner process - tokio::fs::read_link(format!("/proc/{pid}/exe")).await.ok() +pub(super) fn owner_probe_is_stable(initial_owner: &str, observed_owner: &str) -> bool { + initial_owner == observed_owner } diff --git a/crates/unixnotis-center/src/media/mpris/process.rs b/crates/unixnotis-center/src/media/mpris/process.rs new file mode 100644 index 000000000..c27638f7c --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/process.rs @@ -0,0 +1,90 @@ +//! Stable process-object checks for MPRIS authorization + +#[cfg(target_os = "linux")] +use std::io::Read; +#[cfg(target_os = "linux")] +use std::os::fd::{AsFd, AsRawFd}; + +#[cfg(target_os = "linux")] +const MAX_PIDFD_INFO_BYTES: u64 = 4_096; + +#[cfg(target_os = "linux")] +pub(super) fn read_process_executable_path_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + let path = std::fs::read_link(format!("/proc/{expected_pid}/exe")).ok()?; + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(path) +} + +#[cfg(target_os = "linux")] +pub(super) fn open_process_executable_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + let file = std::fs::File::open(format!("/proc/{expected_pid}/exe")).ok()?; + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(file) +} + +#[cfg(target_os = "linux")] +fn pidfd_matches_live_process(pidfd: &Fd, expected_pid: u32) -> bool { + pidfd_is_live(pidfd) && read_pidfd_process_id(pidfd) == Some(expected_pid) +} + +#[cfg(target_os = "linux")] +fn pidfd_is_live(pidfd: &Fd) -> bool { + use rustix::event::{poll, PollFd, PollFlags, Timespec}; + + let mut poll_fds = [PollFd::new(pidfd, PollFlags::IN)]; + poll(&mut poll_fds, Some(&Timespec::default())).is_ok_and(|ready| ready == 0) +} + +#[cfg(target_os = "linux")] +fn read_pidfd_process_id(pidfd: &Fd) -> Option { + let raw_fd = pidfd.as_fd().as_raw_fd(); + let file = std::fs::File::open(format!("/proc/self/fdinfo/{raw_fd}")).ok()?; + let mut bytes = Vec::new(); + file.take(MAX_PIDFD_INFO_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + if u64::try_from(bytes.len()).ok()? > MAX_PIDFD_INFO_BYTES { + return None; + } + let mut values = std::str::from_utf8(&bytes) + .ok()? + .lines() + .filter_map(|line| { + line.strip_prefix("Pid:") + .and_then(|value| value.trim().parse::().ok()) + .filter(|pid| *pid > 0) + }); + let pid = values.next()?; + values.next().is_none().then_some(pid) +} + +#[cfg(target_os = "linux")] +pub(super) fn executable_allowed_from_pidfd( + pidfd: &impl AsFd, + expected_pid: u32, + allowlist: &[String], +) -> bool { + if allowlist.is_empty() { + return false; + } + let Some(owner_file) = open_process_executable_from_pidfd(pidfd, expected_pid) else { + return false; + }; + super::admission::executable_file_matches_allowlist(owner_file, allowlist) +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/admission.rs b/crates/unixnotis-center/src/media/mpris/tests/admission.rs index e3c9929a3..c98c5a44e 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/admission.rs @@ -1,5 +1,3 @@ -use std::fs::File; - use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; use super::super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; @@ -111,83 +109,46 @@ fn remote_art_admission_keeps_browsers_opt_in_and_requires_an_owner() { #[test] fn local_art_admission_rejects_browsers_and_requires_an_owner() { - let empty_allowlist: Vec = vec![]; - // Browser with owner executable should be rejected assert!(!local_art_allowed( Some("firefox"), Some("/usr/bin/firefox"), - None, + false, MediaLocalArtPolicy::ExactExecutableOnly, - &empty_allowlist )); // Non-browser without allowlist match should be rejected assert!(!local_art_allowed( None, Some("/usr/bin/spotify"), - None, + false, MediaLocalArtPolicy::ExactExecutableOnly, - &empty_allowlist )); // Non-browser without owner executable should be rejected assert!(!local_art_allowed( None, None, - None, + false, MediaLocalArtPolicy::ExactExecutableOnly, - &empty_allowlist, )); } #[test] -fn local_art_admission_requires_the_open_proc_executable_to_match() { - let current_executable = std::env::current_exe().expect("resolve current executable"); - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let fake_executable = temp_dir.path().join("fake-player"); - File::create(&fake_executable).expect("create fake executable"); - - let owner_path = current_executable.to_string_lossy().to_string(); - let allowlist = vec![owner_path.clone()]; - - // The descriptor opened from /proc//exe matches the allowlisted object +fn local_art_admission_requires_verified_executable_evidence() { + // A verified descriptor comparison is the only exact-policy admission proof assert!(local_art_allowed( None, - Some(&owner_path), - Some(std::process::id()), - MediaLocalArtPolicy::ExactExecutableOnly, - &allowlist - )); - - // A different allowlisted object is rejected even when a caller supplies a plausible path - let fake_path = fake_executable.to_string_lossy().to_string(); - let fake_allowlist = vec![fake_path.clone()]; - assert!(!local_art_allowed( - None, - Some(&fake_path), - Some(std::process::id()), - MediaLocalArtPolicy::ExactExecutableOnly, - &fake_allowlist - )); - - // Empty allowlist should reject everything - let empty_allowlist: Vec = vec![]; - assert!(!local_art_allowed( - None, - Some(&owner_path), - Some(std::process::id()), + Some("/usr/bin/player"), + true, MediaLocalArtPolicy::ExactExecutableOnly, - &empty_allowlist )); - // Non-existent allowlist entry should not match - let bad_allowlist = vec!["/nonexistent/spotify".to_string()]; + // A path hint without descriptor proof remains denied assert!(!local_art_allowed( None, - Some(&owner_path), - Some(std::process::id()), + Some("/usr/bin/player"), + false, MediaLocalArtPolicy::ExactExecutableOnly, - &bad_allowlist )); } diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index c4ffb8726..37798a015 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -52,11 +52,14 @@ async fn player_state_uses_live_identity_owner_and_process_details() { let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) .await .expect("resolve stable test owner"); - assert_eq!(owner.0, state.unique_owner.expect("captured unique owner")); - assert_eq!(owner.1, Some(std::process::id())); + assert_eq!( + owner.unique_owner.as_str(), + state.unique_owner.expect("captured unique owner") + ); + assert_eq!(owner.pid, std::process::id()); #[cfg(target_os = "linux")] assert_eq!( - owner.2.as_deref(), + owner.executable.as_deref(), Some( std::env::current_exe() .expect("resolve current test executable") @@ -65,7 +68,25 @@ async fn player_state_uses_live_identity_owner_and_process_details() { ) ); assert_eq!( - fetch_identity(&fixture.client, owner.0.as_str()).await, + fetch_identity(&fixture.client, owner.unique_owner.as_str()).await, Some(TEST_PLAYER_IDENTITY.to_string()) ); } + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn exact_local_art_policy_uses_the_connection_process_fd() { + let fixture = MprisFixture::start().await; + let current_executable = std::env::current_exe().expect("resolve current test executable"); + let config = MediaConfig { + local_art_executable_allowlist: vec![current_executable.display().to_string()], + ..MediaConfig::default() + }; + + let state = build_player_state(&fixture.client, TEST_PLAYER_NAME, &config) + .await + .expect("probe test MPRIS player") + .expect("stable test MPRIS owner"); + + assert!(state.local_art_allowed); +} diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index 048a462be..20866187b 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -17,6 +17,7 @@ use super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; // Hard wall-clock deadline for the entire SVG subprocess (parse + render) const SVG_SUBPROCESS_DEADLINE: Duration = Duration::from_millis(500); const MAX_SVG_BYTES: usize = 1_024_000; +const MAX_RENDERER_STDERR: usize = 16 * 1024; pub(super) const fn is_gzip_payload(bytes: &[u8]) -> bool { matches!(bytes, [0x1f, 0x8b, ..]) @@ -65,7 +66,7 @@ pub(super) fn decode_svg_bytes_with_renderer( .stdout .take() .ok_or_else(|| "failed to capture child stdout".to_string())?; - let mut stderr = child + let stderr = child .stderr .take() .ok_or_else(|| "failed to capture child stderr".to_string())?; @@ -83,9 +84,16 @@ pub(super) fn decode_svg_bytes_with_renderer( let wait_start = std::time::Instant::now(); let read_handle = std::thread::spawn(move || read_stdout(stdout)); let stderr_handle = std::thread::spawn(move || { - let mut buf = String::new(); - let _ = stderr.read_to_string(&mut buf); - buf + let mut bytes = Vec::new(); + let _ = stderr + .take( + u64::try_from(MAX_RENDERER_STDERR) + .unwrap_or(u64::MAX) + .saturating_add(1), + ) + .read_to_end(&mut bytes); + bytes.truncate(MAX_RENDERER_STDERR); + String::from_utf8_lossy(&bytes).into_owned() }); // Wait for child with timeout @@ -121,6 +129,11 @@ pub(super) fn decode_svg_bytes_with_renderer( let read_result = read_handle .join() .map_err(|err| format!("stdout reader panicked: {err:?}"))?; + // Join the bounded diagnostics reader on success as well, so no helper thread + // outlives the decoder operation + let _ = stderr_handle + .join() + .map_err(|err| format!("stderr reader panicked: {err:?}"))?; let (width, height, rgba_data) = read_result?; let expected_len = checked_rgba_len(width, height)?; diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index 1d41430e8..688ff4c45 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -220,6 +220,23 @@ fn renderer_stderr_is_drained_while_stdout_is_decoded() { assert_eq!((decoded.width, decoded.height), (1, 1)); } +#[test] +fn renderer_stderr_is_bounded_before_error_reporting() { + let directory = tempfile::tempdir().expect("create renderer fixture directory"); + let renderer = directory.path().join("noisy-failing-renderer"); + std::fs::write( + &renderer, + "#!/bin/sh\nyes X | head -c 1048576 >&2\nexit 1\n", + ) + .expect("write renderer fixture"); + std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) + .expect("make renderer executable"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("failing renderer should return an error"); + assert!(error.len() <= 17_000, "stderr exceeded diagnostic cap"); +} + #[test] fn missing_sibling_renderer_is_reported() { let error = decode_svg_bytes_with_renderer( diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 9d26cf1c6..40d9c0586 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -229,9 +229,9 @@ fn build_action_button( expire_armed_at.set(None); expire_button.set_label(&expire_label); expire_button.set_tooltip_text(None); - expire_button.update_property(&[ - gtk::accessible::Property::Label(&expire_label), - ]); + expire_button.update_property(&[gtk::accessible::Property::Label( + &expire_label, + )]); } }, ); @@ -249,9 +249,9 @@ fn build_action_button( armed_at.set(None); button.set_label(&original_label); button.set_tooltip_text(None); - button.update_property(&[ - gtk::accessible::Property::Label(&original_label), - ]); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); return; } // Click came too fast after arming @@ -266,9 +266,9 @@ fn build_action_button( armed_at.set(None); button.set_label(&original_label); button.set_tooltip_text(None); - button.update_property(&[ - gtk::accessible::Property::Label(&original_label), - ]); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); return; } // Right amount of time passed, dispatch the action diff --git a/crates/unixnotis-daemon/src/child_process/paths.rs b/crates/unixnotis-daemon/src/child_process/paths.rs index 4157cda11..22a24da01 100644 --- a/crates/unixnotis-daemon/src/child_process/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/paths.rs @@ -39,18 +39,28 @@ pub(super) fn resolve_center_path() -> Option { } #[cfg(target_os = "linux")] -pub(super) fn apply_parent_death_signal(command: &mut Command) { +pub(super) fn apply_parent_death_signal(command: &mut Command, expected_parent_pid: u32) { // The kernel clears the child relationship before the new program starts // SAFETY: This closure only calls prctl through rustix and returns its OS error unsafe { - command.as_std_mut().pre_exec(|| { - set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from) + command.as_std_mut().pre_exec(move || { + set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from)?; + let current_parent = rustix::process::getppid() + .map(|pid| pid.as_raw_nonzero().get()) + .unwrap_or_default(); + if current_parent != i32::try_from(expected_parent_pid).unwrap_or_default() { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "parent exited before child-death supervision was armed", + )); + } + Ok(()) }); } } #[cfg(not(target_os = "linux"))] -pub(super) fn apply_parent_death_signal(_command: &mut Command) {} +pub(super) fn apply_parent_death_signal(_command: &mut Command, _expected_parent_pid: u32) {} #[cfg(test)] #[path = "tests/paths.rs"] diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index 20253f60f..c9ea77cde 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -68,7 +68,7 @@ impl UiProcessKind { command.stdout(Stdio::inherit()); command.stderr(Stdio::inherit()); - apply_parent_death_signal(&mut command); + apply_parent_death_signal(&mut command, std::process::id()); if let Some(config) = args.config.as_ref() { // GTK re-parses argv in child apps, so custom config paths travel by env instead diff --git a/crates/unixnotis-daemon/src/child_process/tests/paths.rs b/crates/unixnotis-daemon/src/child_process/tests/paths.rs index 7d773b12c..85bbf046d 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/paths.rs @@ -98,6 +98,23 @@ fn parent_death_signal_terminates_a_child_when_its_launcher_exits() { let _ = std::fs::remove_file(marker_path); } +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_rejects_a_changed_parent_before_exec() { + let _guard = env_lock(); + let helper = std::env::current_exe().expect("current test executable"); + let status = std::process::Command::new(helper) + .args([ + "--exact", + "child_process::paths::tests::parent_death_signal_child_helper", + "--nocapture", + ]) + .env("UNIXNOTIS_PDEATH_EXPECT_MISMATCH", "1") + .status() + .expect("launch parent-death race helper"); + assert!(status.success(), "mismatch helper failed: {status}"); +} + #[cfg(target_os = "linux")] #[test] fn parent_death_signal_child_helper() { @@ -110,13 +127,23 @@ fn parent_death_signal_child_helper() { .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); - apply_parent_death_signal(&mut command); + let expected_parent_pid = if std::env::var_os("UNIXNOTIS_PDEATH_EXPECT_MISMATCH").is_some() { + std::process::id().saturating_add(1) + } else { + std::process::id() + }; + apply_parent_death_signal(&mut command, expected_parent_pid); let runtime = tokio::runtime::Builder::new_current_thread() .enable_io() .build() .expect("build helper runtime"); runtime.block_on(async move { - let child = command.spawn().expect("spawn supervised child"); + let child = command.spawn(); + if std::env::var_os("UNIXNOTIS_PDEATH_EXPECT_MISMATCH").is_some() { + assert!(child.is_err(), "mismatched parent must fail before exec"); + return; + } + let child = child.expect("spawn supervised child"); std::fs::write(marker, child.id().expect("child pid").to_string()) .expect("write child pid marker"); }); diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index 5107ca059..764cb4e94 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -10,10 +10,10 @@ use zbus::message::Header; use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; -#[cfg(not(target_os = "linux"))] -use super::executable_trust::is_trusted_control_executable_path; #[cfg(target_os = "linux")] use super::executable_trust::is_trusted_control_executable_from_fd; +#[cfg(not(target_os = "linux"))] +use super::executable_trust::is_trusted_control_executable_path; use super::policy::{ TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES, TRUSTED_POPUP_READINESS_EXECUTABLES, diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs index 7f786f9fe..2204fc8fe 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -5,10 +5,10 @@ mod metadata; pub(in crate::daemon::auth) mod paths; mod snapshots; -#[cfg(not(target_os = "linux"))] -pub(super) use paths::is_trusted_control_executable_path; #[cfg(target_os = "linux")] pub(super) use paths::is_trusted_control_executable_from_fd; +#[cfg(not(target_os = "linux"))] +pub(super) use paths::is_trusted_control_executable_path; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 7e036fff1..2fe81bf5e 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -3,10 +3,10 @@ use std::os::unix::io::AsFd; use std::path::{Path, PathBuf}; -#[cfg(not(target_os = "linux"))] -use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; #[cfg(target_os = "linux")] use super::super::policy::TRUSTED_CONTROL_EXECUTABLES; +#[cfg(not(target_os = "linux"))] +use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; use super::fingerprint::{file_fingerprint, file_fingerprint_from_fd}; use super::metadata::trusted_control_file_metadata_is_safe; use super::snapshots::trusted_control_snapshot; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs index 1c2397e75..9a25bc8da 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs @@ -8,133 +8,133 @@ mod strict_snapshot_tests { use std::collections::HashMap; use std::path::Path; -fn is_trusted_control_executable_path_in_dir( - path: &Path, - _trusted_dir: &Path, - snapshots: &HashMap, -) -> bool { - let observed = canonicalize_best_effort(path); - let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { - return false; - }; - if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { - return false; + fn is_trusted_control_executable_path_in_dir( + path: &Path, + _trusted_dir: &Path, + snapshots: &HashMap, + ) -> bool { + let observed = canonicalize_best_effort(path); + let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { + return false; + } + + snapshots + .get(observed_name) + .is_some_and(|snapshot| trusted_snapshot_matches_observed(snapshot, &observed)) } - snapshots - .get(observed_name) - .is_some_and(|snapshot| trusted_snapshot_matches_observed(snapshot, &observed)) -} - -#[test] -fn strict_snapshot_rejects_unknown_or_untrusted_paths() { - let trusted_dir = TempRoot::new("auth-rejects-unknown"); - let outsider = trusted_dir.join("python3"); - write_executable(&outsider); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - // Random paths and unapproved binary names must not satisfy strict trust - assert!(!is_trusted_control_executable_path_in_dir( - std::path::Path::new("/tmp/noticenterctl"), - trusted_dir.path(), - &snapshots, - )); - assert!(!is_trusted_control_executable_path_in_dir( - &outsider, - trusted_dir.path(), - &snapshots, - )); -} + #[test] + fn strict_snapshot_rejects_unknown_or_untrusted_paths() { + let trusted_dir = TempRoot::new("auth-rejects-unknown"); + let outsider = trusted_dir.join("python3"); + write_executable(&outsider); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + // Random paths and unapproved binary names must not satisfy strict trust + assert!(!is_trusted_control_executable_path_in_dir( + std::path::Path::new("/tmp/noticenterctl"), + trusted_dir.path(), + &snapshots, + )); + assert!(!is_trusted_control_executable_path_in_dir( + &outsider, + trusted_dir.path(), + &snapshots, + )); + } -#[test] -fn strict_snapshot_rejects_trusted_name_alias_suffixes() { - let trusted_dir = TempRoot::new("auth-rejects-alias"); - let alias = trusted_dir.join("noticenterctl.exe"); - write_executable(&alias); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - // Suffix lookalikes should not pass the exact trusted executable list - assert!(!is_trusted_control_executable_path_in_dir( - &alias, - trusted_dir.path(), - &snapshots, - )); -} + #[test] + fn strict_snapshot_rejects_trusted_name_alias_suffixes() { + let trusted_dir = TempRoot::new("auth-rejects-alias"); + let alias = trusted_dir.join("noticenterctl.exe"); + write_executable(&alias); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + // Suffix lookalikes should not pass the exact trusted executable list + assert!(!is_trusted_control_executable_path_in_dir( + &alias, + trusted_dir.path(), + &snapshots, + )); + } -#[test] -fn strict_snapshot_accepts_trusted_sibling_binary_only() { - let trusted_dir = TempRoot::new("auth-accepts-sibling"); - let trusted = trusted_dir.join("noticenterctl"); - write_executable(&trusted); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - assert!(is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); - - let other_dir = TempRoot::new("auth-other-sibling"); - let forged = other_dir.join("noticenterctl"); - write_executable(&forged); - assert!(!is_trusted_control_executable_path_in_dir( - &forged, - trusted_dir.path(), - &snapshots, - )); - - // Same path after replacement must no longer match the pinned startup fingerprint - write_executable(&trusted); - std::fs::write(&trusted, "#!/bin/sh\necho forged\n").expect("overwrite trusted sibling"); - assert!(!is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); -} + #[test] + fn strict_snapshot_accepts_trusted_sibling_binary_only() { + let trusted_dir = TempRoot::new("auth-accepts-sibling"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + assert!(is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + + let other_dir = TempRoot::new("auth-other-sibling"); + let forged = other_dir.join("noticenterctl"); + write_executable(&forged); + assert!(!is_trusted_control_executable_path_in_dir( + &forged, + trusted_dir.path(), + &snapshots, + )); + + // Same path after replacement must no longer match the pinned startup fingerprint + write_executable(&trusted); + std::fs::write(&trusted, "#!/bin/sh\necho forged\n").expect("overwrite trusted sibling"); + assert!(!is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + } -#[test] -fn strict_snapshot_pins_all_trusted_siblings_at_once() { - let trusted_dir = TempRoot::new("auth-pins-all-siblings"); - let ctl = trusted_dir.join("noticenterctl"); - let center = trusted_dir.join("unixnotis-center"); - write_executable(&ctl); - write_executable(¢er); - - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - assert!(is_trusted_control_executable_path_in_dir( - &ctl, - trusted_dir.path(), - &snapshots, - )); - - // A sibling that has not called yet is still pinned by the initial snapshot - std::fs::write(¢er, "#!/bin/sh\necho replaced\n").expect("replace center"); - assert!(!is_trusted_control_executable_path_in_dir( - ¢er, - trusted_dir.path(), - &snapshots, - )); -} + #[test] + fn strict_snapshot_pins_all_trusted_siblings_at_once() { + let trusted_dir = TempRoot::new("auth-pins-all-siblings"); + let ctl = trusted_dir.join("noticenterctl"); + let center = trusted_dir.join("unixnotis-center"); + write_executable(&ctl); + write_executable(¢er); + + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + assert!(is_trusted_control_executable_path_in_dir( + &ctl, + trusted_dir.path(), + &snapshots, + )); + + // A sibling that has not called yet is still pinned by the initial snapshot + std::fs::write(¢er, "#!/bin/sh\necho replaced\n").expect("replace center"); + assert!(!is_trusted_control_executable_path_in_dir( + ¢er, + trusted_dir.path(), + &snapshots, + )); + } -#[cfg(unix)] -#[test] -fn strict_snapshot_rejects_group_writable_trusted_binary() { - use std::os::unix::fs::PermissionsExt; - - let trusted_dir = TempRoot::new("auth-rejects-group-writable"); - let trusted = trusted_dir.join("noticenterctl"); - write_executable(&trusted); - let mut permissions = std::fs::metadata(&trusted).expect("metadata").permissions(); - permissions.set_mode(0o775); - std::fs::set_permissions(&trusted, permissions).expect("set permissions"); - - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - assert!(!is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); + #[cfg(unix)] + #[test] + fn strict_snapshot_rejects_group_writable_trusted_binary() { + use std::os::unix::fs::PermissionsExt; + + let trusted_dir = TempRoot::new("auth-rejects-group-writable"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let mut permissions = std::fs::metadata(&trusted).expect("metadata").permissions(); + permissions.set_mode(0o775); + std::fs::set_permissions(&trusted, permissions).expect("set permissions"); + + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + assert!(!is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + } } -} \ No newline at end of file diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs index 256d81c1a..837a375fd 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -15,50 +15,50 @@ mod strict_path_tests { #[test] fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { - let _guard = env_lock(); - let current_exe = std::env::current_exe().expect("current test executable"); - let trusted_dir = current_exe - .parent() - .expect("current executable should have a parent") - .to_path_buf(); - let trusted = trusted_dir.join("noticenterctl"); - let root = TempRoot::new("auth-strict-foreign"); - let foreign = root.join("noticenterctl"); - write_executable(&trusted); - write_executable(&foreign); - trusted_snapshot_cache() - .lock() - .expect("snapshot cache lock") - .clear(); - fingerprint_cache() - .lock() - .expect("fingerprint cache lock") - .clear(); + let _guard = env_lock(); + let current_exe = std::env::current_exe().expect("current test executable"); + let trusted_dir = current_exe + .parent() + .expect("current executable should have a parent") + .to_path_buf(); + let trusted = trusted_dir.join("noticenterctl"); + let root = TempRoot::new("auth-strict-foreign"); + let foreign = root.join("noticenterctl"); + write_executable(&trusted); + write_executable(&foreign); + trusted_snapshot_cache() + .lock() + .expect("snapshot cache lock") + .clear(); + fingerprint_cache() + .lock() + .expect("fingerprint cache lock") + .clear(); - assert!(is_trusted_control_executable_path(&trusted, false)); - assert!(!is_trusted_control_executable_path(&foreign, false)); - let trusted_fd = open_test_executable(&trusted); - let foreign_fd = open_test_executable(&foreign); - assert!(control_executable_is_allowed::( - Some(&trusted), - Some(&trusted_fd), - &["noticenterctl"], - false - )); - assert!(!control_executable_is_allowed::( - Some(&trusted), - Some(&trusted_fd), - &["unixnotis-center"], - false - )); - // Foreign path must be checked with its own fd to verify it's a different executable - assert!(!control_executable_is_allowed::( - Some(&foreign), - Some(&foreign_fd), - &["noticenterctl"], - false - )); + assert!(is_trusted_control_executable_path(&trusted, false)); + assert!(!is_trusted_control_executable_path(&foreign, false)); + let trusted_fd = open_test_executable(&trusted); + let foreign_fd = open_test_executable(&foreign); + assert!(control_executable_is_allowed::( + Some(&trusted), + Some(&trusted_fd), + &["noticenterctl"], + false + )); + assert!(!control_executable_is_allowed::( + Some(&trusted), + Some(&trusted_fd), + &["unixnotis-center"], + false + )); + // Foreign path must be checked with its own fd to verify it's a different executable + assert!(!control_executable_is_allowed::( + Some(&foreign), + Some(&foreign_fd), + &["noticenterctl"], + false + )); -let _ = std::fs::remove_file(trusted); + let _ = std::fs::remove_file(trusted); } } diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 22067be27..96848d90e 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -16,9 +16,7 @@ use super::support::write_executable; use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; fn open_test_executable(path: &std::path::Path) -> OwnedFd { - File::open(path) - .expect("open test executable") - .into() + File::open(path).expect("open test executable").into() } fn message_without_bus_sender() -> Message { @@ -104,10 +102,25 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { let trusted_fd = open_test_executable(&trusted); - assert!(control_executable_error(Some(&trusted), Some(&trusted_fd), &["noticenterctl"], true).is_none()); - assert!(control_executable_error::(None, None::<&OwnedFd>, &["noticenterctl"], true).is_some()); - assert!(control_executable_error(Some(&trusted), Some(&trusted_fd), &["unixnotis-center"], true).is_some()); - assert!(control_executable_error(Some(&untrusted_name), Some(&trusted_fd), &["unknown"], true).is_some()); + assert!( + control_executable_error(Some(&trusted), Some(&trusted_fd), &["noticenterctl"], true) + .is_none() + ); + assert!( + control_executable_error::(None, None::<&OwnedFd>, &["noticenterctl"], true) + .is_some() + ); + assert!(control_executable_error( + Some(&trusted), + Some(&trusted_fd), + &["unixnotis-center"], + true + ) + .is_some()); + assert!( + control_executable_error(Some(&untrusted_name), Some(&trusted_fd), &["unknown"], true) + .is_some() + ); } #[test] diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs index edcb20779..8a3e9f2b1 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -1,8 +1,7 @@ use std::sync::Arc; use unixnotis_core::{ - Action, AttributionReason, IdentityAssurance, InteractionPolicies, - NotificationAttribution, + Action, AttributionReason, IdentityAssurance, InteractionPolicies, NotificationAttribution, }; use crate::store::test_support::{make_notification, make_store_with_limits}; diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs index 0f760042d..aaf33c271 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs @@ -1,6 +1,6 @@ use unixnotis_core::{ - Action, AttributionReason, CloseReason, IdentityAssurance, InlineReply, - InlineReplyPolicy, InteractionPolicies, NotificationAttribution, + Action, AttributionReason, CloseReason, IdentityAssurance, InlineReply, InlineReplyPolicy, + InteractionPolicies, NotificationAttribution, }; use crate::store::test_support::{make_notification, make_store_with_limits}; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index a3de7ea8f..d9ee5e066 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -222,9 +222,9 @@ fn build_action_button( expire_armed_at.set(None); expire_button.set_label(&expire_label); expire_button.set_tooltip_text(None); - expire_button.update_property(&[ - gtk::accessible::Property::Label(&expire_label), - ]); + expire_button.update_property(&[gtk::accessible::Property::Label( + &expire_label, + )]); } }, ); @@ -242,32 +242,28 @@ fn build_action_button( armed_at.set(None); button.set_label(&original_label); button.set_tooltip_text(None); - button.update_property(&[ - gtk::accessible::Property::Label(&original_label), - ]); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); return; } // Click came too fast after arming // Probably an accidental double-tap, stay armed so the next click // can still go through Some(d) - if d - < std::time::Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => + if d < std::time::Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => { return; } // Confirmation took too long // Reset the button and make the person re-arm - Some(d) - if d - > std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => - { + Some(d) if d > std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => { armed_at.set(None); button.set_label(&original_label); button.set_tooltip_text(None); - button.update_property(&[ - gtk::accessible::Property::Label(&original_label), - ]); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); return; } // Right amount of time passed, dispatch the action diff --git a/crates/unixnotis-popups/src/ui/icons/decode.rs b/crates/unixnotis-popups/src/ui/icons/decode.rs index 639cb4e81..fe249adb4 100644 --- a/crates/unixnotis-popups/src/ui/icons/decode.rs +++ b/crates/unixnotis-popups/src/ui/icons/decode.rs @@ -33,8 +33,8 @@ pub fn decode_icon_file(path: &Path, target_size: i32) -> Result Date: Sat, 1 Aug 2026 02:12:14 -0500 Subject: [PATCH 178/275] ci: stabilize renderer, process, and dependency checks Summary: stabilize renderer, process, and dependency checks. Scope: repository. --- .github/workflows/ci.yml | 3 ++ Cargo.lock | 6 +-- .../src/media/mpris/player.rs | 50 +++++++++++++++---- .../src/media/mpris/tests/player.rs | 37 +++++++++++++- .../src/ui/icons/decode/tests/svg.rs | 5 +- crates/unixnotis-daemon/Cargo.toml | 1 + .../src/child_process/paths.rs | 6 +-- .../src/child_process/tests/paths.rs | 30 +++++++++-- .../desktop_index/tests/launcher/read.rs | 3 +- .../tests/launcher/validation.rs | 3 +- crates/unixnotis-daemon/src/tests/support.rs | 3 +- 11 files changed, 119 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 804837296..4a880f3e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,9 @@ jobs: - name: Test release packaging helpers run: tests/package-release.sh + - name: Build SVG test helper + run: cargo build --package unixnotis-center --bin unixnotis-svg-renderer --all-features + - name: Run workspace tests run: | set -euo pipefail diff --git a/Cargo.lock b/Cargo.lock index f5b110032..389c25aa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,11 +788,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3678,6 +3677,7 @@ dependencies = [ "futures-util", "gio", "indexmap", + "libc", "notify", "rustix", "serde", diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index cde6f168e..da97f4090 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -44,6 +44,19 @@ pub(in crate::media) async fn build_player_state( // Ownership changed during probing, so a later bus event should rebuild stable data return Ok(None); }; + + Ok(Some( + build_player_state_for_owner(connection, name, config, owner).await?, + )) +} + +// Keep credential handling separate so compatibility behavior can be tested without a bus shim +pub(super) async fn build_player_state_for_owner( + connection: &Connection, + name: &str, + config: &MediaConfig, + owner: OwnerProbe, +) -> zbus::Result { // Every process-bound proxy targets the verified unique owner instead of the mutable alias let identity = fetch_identity(connection, &owner.unique_owner) .await @@ -55,11 +68,13 @@ pub(in crate::media) async fn build_player_state( config.remote_art_policy, ); #[cfg(target_os = "linux")] - let owner_executable_is_allowed = executable_allowed_from_pidfd( - &owner.process_fd, - owner.pid, - &config.local_art_executable_allowlist, - ); + let owner_executable_is_allowed = owner.process_fd.as_ref().is_some_and(|process_fd| { + executable_allowed_from_pidfd( + process_fd, + owner.pid, + &config.local_art_executable_allowlist, + ) + }); #[cfg(not(target_os = "linux"))] let owner_executable_is_allowed = false; let local_art_allowed = local_art_allowed( @@ -81,7 +96,7 @@ pub(in crate::media) async fn build_player_state( .await?; let (listener_cancel, _listener_rx) = watch::channel(false); - Ok(Some(PlayerState { + Ok(PlayerState { bus_name: name.to_string(), unique_owner: Some(owner.unique_owner), identity, @@ -92,7 +107,7 @@ pub(in crate::media) async fn build_player_state( player, properties, listener_cancel, - })) + }) } pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Option { @@ -124,7 +139,7 @@ pub(super) async fn resolve_player_owner( #[cfg(target_os = "linux")] let credentials = get_connection_credentials(connection, (&unique_owner).into()).await?; #[cfg(target_os = "linux")] - let (pid, process_fd) = (credentials.process_id?, credentials.process_fd?); + let (pid, process_fd) = (credentials.process_id?, credentials.process_fd); #[cfg(not(target_os = "linux"))] let pid = proxy .get_connection_unix_process_id((&unique_owner).into()) @@ -135,8 +150,8 @@ pub(super) async fn resolve_player_owner( return None; } #[cfg(target_os = "linux")] - let executable = read_process_executable_path_from_pidfd(&process_fd, pid) - .map(|path| path.display().to_string()); + let executable = + read_owner_executable_path(pid, process_fd.as_ref()).map(|path| path.display().to_string()); #[cfg(target_os = "linux")] executable.as_ref()?; Some(OwnerProbe { @@ -153,7 +168,20 @@ pub(super) struct OwnerProbe { pub(super) pid: u32, pub(super) executable: Option, #[cfg(target_os = "linux")] - pub(super) process_fd: OwnedFd, + pub(super) process_fd: Option, +} + +#[cfg(target_os = "linux")] +pub(super) fn read_owner_executable_path( + pid: u32, + process_fd: Option<&OwnedFd>, +) -> Option { + // A ProcessFD gives a stable object; older buses may provide only the PID + if let Some(process_fd) = process_fd { + return read_process_executable_path_from_pidfd(process_fd, pid); + } + + std::fs::read_link(format!("/proc/{pid}/exe")).ok() } pub(super) fn owner_probe_is_stable(initial_owner: &str, observed_owner: &str) -> bool { diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index 37798a015..8a060e662 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -2,7 +2,8 @@ use unixnotis_core::MediaConfig; use super::super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PREFIX}; use super::super::player::{ - build_player_state, fetch_identity, owner_probe_is_stable, resolve_player_owner, + build_player_state, build_player_state_for_owner, fetch_identity, owner_probe_is_stable, + read_owner_executable_path, resolve_player_owner, }; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -12,6 +13,15 @@ fn owner_probe_accepts_only_one_stable_unique_owner() { assert!(!owner_probe_is_stable(":1.40", ":1.41")); } +#[cfg(target_os = "linux")] +#[test] +fn owner_probe_keeps_metadata_when_process_fd_is_unavailable() { + let path = read_owner_executable_path(std::process::id(), None) + .expect("PID fallback should resolve the current executable"); + + assert!(path.is_absolute()); +} + #[test] fn player_proxy_constants_match_the_mpris_contract() { assert_eq!(MPRIS_PREFIX, "org.mpris.MediaPlayer2."); @@ -73,6 +83,31 @@ async fn player_state_uses_live_identity_owner_and_process_details() { ); } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn player_state_without_process_fd_keeps_remote_metadata_and_disables_local_art() { + let fixture = MprisFixture::start().await; + let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) + .await + .expect("resolve stable test owner"); + let owner_pid = owner.pid; + let mut owner_without_process_fd = owner; + owner_without_process_fd.process_fd = None; + + let state = build_player_state_for_owner( + &fixture.client, + TEST_PLAYER_NAME, + &MediaConfig::default(), + owner_without_process_fd, + ) + .await + .expect("build player state without ProcessFD"); + + assert_eq!(state.owner_pid, Some(owner_pid)); + assert!(state.remote_art_allowed); + assert!(!state.local_art_allowed); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn exact_local_art_policy_uses_the_connection_process_fd() { diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index 688ff4c45..ff984a811 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -11,12 +11,13 @@ use super::super::svg::{ }; fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { - let current_exe = std::env::current_exe().expect("resolve test executable"); + let current_exe = + std::env::current_exe().map_err(|error| format!("resolve test executable: {error}"))?; let renderer = current_exe .parent() .and_then(std::path::Path::parent) .map(|directory| directory.join("unixnotis-svg-renderer")) - .expect("resolve renderer directory"); + .ok_or_else(|| "resolve renderer directory".to_string())?; decode_svg_bytes_with_renderer(bytes, target, &renderer) } diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index f321ced6c..ef1238ffe 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -21,6 +21,7 @@ unicode-security.workspace = true zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } indexmap.workspace = true +libc.workspace = true notify.workspace = true rustix.workspace = true shell-words.workspace = true diff --git a/crates/unixnotis-daemon/src/child_process/paths.rs b/crates/unixnotis-daemon/src/child_process/paths.rs index 22a24da01..bbf0f023b 100644 --- a/crates/unixnotis-daemon/src/child_process/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/paths.rs @@ -49,10 +49,8 @@ pub(super) fn apply_parent_death_signal(command: &mut Command, expected_parent_p .map(|pid| pid.as_raw_nonzero().get()) .unwrap_or_default(); if current_parent != i32::try_from(expected_parent_pid).unwrap_or_default() { - return Err(std::io::Error::new( - std::io::ErrorKind::Interrupted, - "parent exited before child-death supervision was armed", - )); + // ESRCH is returned without formatting or allocating after fork + return Err(std::io::Error::from_raw_os_error(libc::ESRCH)); } Ok(()) }); diff --git a/crates/unixnotis-daemon/src/child_process/tests/paths.rs b/crates/unixnotis-daemon/src/child_process/tests/paths.rs index 85bbf046d..f9d564eaf 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/paths.rs @@ -15,6 +15,25 @@ fn write_sibling(name: &str) -> PathBuf { path } +#[cfg(target_os = "linux")] +fn process_is_running(pid: u32) -> bool { + let stat_path = format!("/proc/{pid}/stat"); + let Ok(stat) = std::fs::read_to_string(stat_path) else { + return false; + }; + + // The process name may contain spaces and parentheses, so parse after its final ')' + let Some(state) = stat + .rsplit_once(") ") + .and_then(|(_, rest)| rest.chars().next()) + else { + return false; + }; + + // A zombie has exited but can remain visible until the reaper collects it + !matches!(state, 'Z' | 'X') +} + #[test] fn resolve_sibling_binary_prefers_exact_sibling_name() { let _guard = env_lock(); @@ -83,18 +102,21 @@ fn parent_death_signal_terminates_a_child_when_its_launcher_exits() { .trim() .parse::() .expect("child pid should be numeric"); - let proc_path = std::path::PathBuf::from(format!("/proc/{pid}")); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while proc_path.exists() && std::time::Instant::now() < deadline { + while process_is_running(pid) && std::time::Instant::now() < deadline { std::thread::sleep(std::time::Duration::from_millis(20)); } - if proc_path.exists() { + if process_is_running(pid) { // Clean up a failed mutation so the test cannot leak a long-running shell let _ = std::process::Command::new("kill") .args(["-TERM", &pid.to_string()]) .status(); } - assert!(!proc_path.exists(), "child survived launcher exit"); + let cleanup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process_is_running(pid) && std::time::Instant::now() < cleanup_deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(!process_is_running(pid), "child survived launcher exit"); let _ = std::fs::remove_file(marker_path); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs index c3ba3a5b0..04f3cba7b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs @@ -18,7 +18,8 @@ fn user_writable_launcher_is_not_inspected() { let root = TempRoot::new("user-writable-launcher"); let path = root.join("launcher"); fs::write(&path, "#!/bin/sh\nexec /usr/bin/true \"$@\"\n").expect("write launcher fixture"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + // Keep the fixture user-writable even when tests run as root in CI + fs::set_permissions(&path, fs::Permissions::from_mode(0o775)) .expect("make launcher fixture executable"); let identity = executable_evidence_for_path(&path) .expect("read launcher fixture identity") diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs index 96ecb949f..22df9eb42 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs @@ -40,7 +40,8 @@ fn user_owned_runtime_target_is_rejected() { let root = TempRoot::new("user-runtime-target"); let path = root.join("runtime"); fs::write(&path, "fixture").expect("write runtime target fixture"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + // Keep the fixture user-writable even when tests run as root in CI + fs::set_permissions(&path, fs::Permissions::from_mode(0o775)) .expect("make runtime target executable"); assert!(protected_runtime_target(&path).is_none()); diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index 1e426cc2a..188e1357f 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -19,7 +19,8 @@ pub fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .expect("env lock should not be poisoned") + // A failed subprocess test must not make every later environment test fail + .unwrap_or_else(std::sync::PoisonError::into_inner) } pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { From d4d3519df39844560e0b02de485c8ca98b8c7da5 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 14:18:26 -0500 Subject: [PATCH 179/275] fix(center): bound MPRIS discovery and refresh work Summary: bound MPRIS discovery and refresh work. Scope: center. --- .../src/media/mpris/command.rs | 28 ++++- .../src/media/mpris/constants.rs | 13 ++ .../src/media/mpris/discovery.rs | 70 +++++++++-- .../src/media/mpris/metadata.rs | 102 ++++++++++------ .../unixnotis-center/src/media/mpris/mod.rs | 2 +- .../src/media/mpris/player.rs | 115 ++++++++++++++++-- .../src/media/mpris/tests/discovery.rs | 32 ++++- .../src/media/mpris/tests/metadata.rs | 40 +++--- .../src/media/mpris/tests/player.rs | 26 +++- .../src/media/mpris/tests/support.rs | 16 ++- .../src/media/runtime/cache.rs | 21 +++- .../src/media/runtime/owner.rs | 36 +++++- .../src/media/runtime/snapshot.rs | 3 +- .../src/media/runtime/state.rs | 12 ++ .../src/media/runtime/tests/dispatch.rs | 4 +- .../src/media/runtime/tests/owner.rs | 35 +++++- .../src/media/runtime/tests/refresh.rs | 2 +- .../src/media/runtime/tests/state.rs | 21 ++++ 18 files changed, 482 insertions(+), 96 deletions(-) diff --git a/crates/unixnotis-center/src/media/mpris/command.rs b/crates/unixnotis-center/src/media/mpris/command.rs index 6341c47a2..ed704791f 100644 --- a/crates/unixnotis-center/src/media/mpris/command.rs +++ b/crates/unixnotis-center/src/media/mpris/command.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use unixnotis_core::PanelDebugLevel; +use super::constants::MPRIS_PROPERTY_TIMEOUT_MS; use super::PlayerState; use crate::diagnostics::panel_debug as debug; use crate::media::MediaCommand; @@ -20,7 +21,14 @@ pub(in crate::media) async fn handle_command( format!("media command: play/pause {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("PlayPause", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("PlayPause", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) @@ -31,7 +39,14 @@ pub(in crate::media) async fn handle_command( format!("media command: next {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("Next", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("Next", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) @@ -42,7 +57,14 @@ pub(in crate::media) async fn handle_command( format!("media command: previous {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("Previous", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("Previous", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) diff --git a/crates/unixnotis-center/src/media/mpris/constants.rs b/crates/unixnotis-center/src/media/mpris/constants.rs index caaed4b06..23747fe98 100644 --- a/crates/unixnotis-center/src/media/mpris/constants.rs +++ b/crates/unixnotis-center/src/media/mpris/constants.rs @@ -8,3 +8,16 @@ pub const MPRIS_PATH: &str = "/org/mpris/MediaPlayer2"; pub const MPRIS_PLAYER: &str = "org.mpris.MediaPlayer2.Player"; // Application identity and supported URI schemes use the root interface pub const MPRIS_APP: &str = "org.mpris.MediaPlayer2"; + +/// Every untrusted MPRIS call must complete within one bounded interval +pub const MPRIS_PROPERTY_TIMEOUT_MS: u64 = 500; +/// Reject unusually large property replies before decoding dynamic values +pub const MAX_MPRIS_PROPERTY_REPLY_BYTES: usize = 512 * 1024; +pub const MPRIS_TIMEOUT_QUARANTINE_AFTER: u8 = 3; +pub const MPRIS_TIMEOUT_QUARANTINE_MS: u64 = 5_000; + +/// Discovery is capped so one bus connection cannot create unbounded state +pub const MAX_MPRIS_PLAYERS: usize = 32; + +/// Metadata maps are retained only when they remain reasonably small +pub const MAX_METADATA_ENTRIES: usize = 256; diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index ae34ec15c..c8ad84495 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -3,13 +3,14 @@ use std::collections::{HashMap, HashSet}; use std::num::NonZeroUsize; +use futures_util::stream::{self, StreamExt}; use tokio::sync::mpsc::Sender; use tracing::warn; use unixnotis_core::{MediaConfig, PanelDebugLevel}; use zbus::fdo::DBusProxy; use zbus::Connection; -use super::constants::MPRIS_PREFIX; +use super::constants::{MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; use super::{build_player_state, is_allowed_player, spawn_properties_listener, PlayerState}; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::MediaSignal; @@ -32,10 +33,13 @@ pub(in crate::media) async fn refresh_players( allowed.insert(name); } + let allowed = select_player_names(allowed); + let allowed_set = allowed.iter().map(String::as_str).collect::>(); + // Remove players that no longer exist on the bus to avoid stale UI cards let mut removed_names = Vec::new(); for name in players.keys() { - if !allowed.contains(name) { + if !allowed_set.contains(name.as_str()) { removed_names.push(name.clone()); } } @@ -51,19 +55,47 @@ pub(in crate::media) async fn refresh_players( }); } - for name in allowed { - if players.contains_key(&name) { - continue; - } - // New players are probed once before entering the live cache - let state = match build_player_state(connection, &name, config).await { + let mut owners = players + .values() + .filter_map(|player| player.unique_owner.clone()) + .collect::>(); + let names_to_probe = allowed + .iter() + .filter(|name| !players.contains_key(*name)) + .cloned() + .collect::>(); + let mut probed = stream::iter(names_to_probe) + .map(|name| async move { + let result = build_player_state(connection, &name, config).await; + (name, result) + }) + .buffer_unordered(4) + .collect::>() + .await; + // Concurrency must not change which alias wins owner deduplication + probed.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + let mut failed_probes = 0usize; + for (name, result) in probed { + // New players are probed concurrently, but admitted state is committed in name order + let state = match result { Ok(state) => state, Err(err) => { - warn!(?err, player = %name, "failed to build media player state"); + failed_probes = failed_probes.saturating_add(1); + debug::log(PanelDebugLevel::Verbose, || { + format!("failed to build media player state for {name}: {err}") + }); continue; } }; if let Some(state) = state { + if state + .unique_owner + .as_ref() + .is_some_and(|owner| !owners.insert(owner.clone())) + { + // Several well-known names may point to one owner; one listener is enough + continue; + } // Each player gets a properties listener so updates stay event-driven spawn_properties_listener( state.properties.clone(), @@ -77,6 +109,12 @@ pub(in crate::media) async fn refresh_players( }); } } + if failed_probes > 0 { + warn!( + failed = failed_probes, + "one or more MPRIS player probes failed" + ); + } Ok(()) } @@ -84,3 +122,17 @@ pub(in crate::media) async fn refresh_players( pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) } + +pub(super) fn select_player_names(names: HashSet) -> Vec { + let mut names: Vec = names.into_iter().collect(); + names.sort_unstable(); + if names.len() > MAX_MPRIS_PLAYERS { + warn!( + admitted = names.len(), + limit = MAX_MPRIS_PLAYERS, + "MPRIS player limit reached; retaining deterministic prefix" + ); + names.truncate(MAX_MPRIS_PLAYERS); + } + names +} diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 3534bf18b..5fb856040 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -2,9 +2,13 @@ use std::collections::HashMap; use zbus::zvariant::OwnedValue; -use super::PlayerState; +use super::constants::{ + MAX_METADATA_ENTRIES, MAX_MPRIS_PROPERTY_REPLY_BYTES, MPRIS_PROPERTY_TIMEOUT_MS, +}; +use super::player::PlayerState; use crate::media::art::normalize_art_source; use crate::media::MediaInfo; +use zbus::Proxy; // Bound MPRIS metadata fields before copying into runtime snapshots const MAX_TITLE_BYTES: usize = 256; @@ -12,11 +16,20 @@ const MAX_ARTIST_BYTES: usize = 256; const MAX_ART_URL_BYTES: usize = 2048; pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option { - // Missing metadata should not drop the card; fall back to identity-only. - let metadata: HashMap = state - .player - .get_property("Metadata") - .await + if state.timeout.is_quarantined() { + return None; + } + let timeout = std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS); + let (metadata, playback_status, can_play, can_pause, can_next, can_prev) = tokio::join!( + bounded_property::>(&state.property_calls, "Metadata", timeout,), + bounded_property::(&state.property_calls, "PlaybackStatus", timeout), + bounded_property::(&state.property_calls, "CanPlay", timeout), + bounded_property::(&state.property_calls, "CanPause", timeout), + bounded_property::(&state.property_calls, "CanGoNext", timeout), + bounded_property::(&state.property_calls, "CanGoPrevious", timeout), + ); + let metadata = metadata + .filter(|map| metadata_entry_count_allowed(map.len())) .unwrap_or_default(); let title = metadata_string(&metadata, "xesam:title") .map(|value| bound_string(&value, MAX_TITLE_BYTES)) @@ -24,8 +37,6 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option Option Option( + proxy: &Proxy<'static>, + property: &str, + timeout: std::time::Duration, +) -> Option +where + T: TryFrom, +{ + let reply = tokio::time::timeout( + timeout, + proxy.call_method("Get", &(super::constants::MPRIS_PLAYER, property)), + ) + .await + .ok()? + .ok()?; + if !property_reply_body_allowed(reply.body().len()) { + return None; + } + let value: OwnedValue = reply.body().deserialize().ok()?; + T::try_from(value).ok() +} + +pub(super) const fn metadata_entry_count_allowed(count: usize) -> bool { + count <= MAX_METADATA_ENTRIES +} + +pub(super) const fn property_reply_body_allowed(body_len: usize) -> bool { + body_len <= MAX_MPRIS_PROPERTY_REPLY_BYTES +} + fn bound_string(value: &str, max_bytes: usize) -> String { // Truncate at a UTF-8 boundary so the retained value stays valid let trimmed = value.trim(); @@ -107,18 +144,3 @@ fn metadata_artist(map: &HashMap) -> Option { } None } - -pub(super) fn metadata_pid(map: &HashMap) -> Option { - let value = map.get("kde:pid")?; - // KDE currently sends this as an integer PID, but bindings may expose signed values - let owned = value.try_clone().ok()?; - if let Ok(pid) = i32::try_from(owned) { - return u32::try_from(pid).ok(); - } - // Accept unsigned variants too so callers do not depend on one zvariant shape - let owned = value.try_clone().ok()?; - if let Ok(pid) = u32::try_from(owned) { - return Some(pid); - } - None -} diff --git a/crates/unixnotis-center/src/media/mpris/mod.rs b/crates/unixnotis-center/src/media/mpris/mod.rs index 5feb62b11..ff402aa0a 100644 --- a/crates/unixnotis-center/src/media/mpris/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/mod.rs @@ -12,7 +12,7 @@ mod process; pub(in crate::media) use admission::is_allowed_player; pub(in crate::media) use command::handle_command; -pub(in crate::media) use constants::MPRIS_PREFIX; +pub(in crate::media) use constants::{MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; pub(in crate::media) use discovery::refresh_players; pub(in crate::media) use listener::spawn_properties_listener; pub(in crate::media) use metadata::fetch_media_info; diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index da97f4090..f602e02db 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -1,12 +1,19 @@ //! Construction and process-bound identity for one MPRIS player +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + use tokio::sync::watch; use unixnotis_core::MediaConfig; use zbus::fdo::{DBusProxy, PropertiesProxy}; use zbus::{Connection, Proxy, ProxyBuilder}; use super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; -use super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER}; +use super::constants::{ + MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PROPERTY_TIMEOUT_MS, MPRIS_TIMEOUT_QUARANTINE_AFTER, + MPRIS_TIMEOUT_QUARANTINE_MS, +}; #[cfg(target_os = "linux")] use super::process::executable_allowed_from_pidfd; #[cfg(target_os = "linux")] @@ -28,18 +35,72 @@ pub(in crate::media) struct PlayerState { pub(in crate::media) remote_art_allowed: bool, pub(in crate::media) local_art_allowed: bool, pub(in crate::media) player: Proxy<'static>, + // Raw property calls allow reply-size checks before dynamic deserialization + pub(in crate::media) property_calls: Proxy<'static>, pub(in crate::media) properties: PropertiesProxy<'static>, + // Timeout state is shared by cloned refresh jobs for this player + pub(super) timeout: PlayerTimeoutState, // Cancellation sender for the properties listener task pub(in crate::media) listener_cancel: watch::Sender, } +#[derive(Clone)] +pub(super) struct PlayerTimeoutState { + streak: Arc, + quarantined_until: Arc>>, +} + +impl PlayerTimeoutState { + pub(super) fn new() -> Self { + Self { + streak: Arc::new(AtomicU8::new(0)), + quarantined_until: Arc::new(Mutex::new(None)), + } + } + + pub(super) fn is_quarantined(&self) -> bool { + let Ok(mut until) = self.quarantined_until.lock() else { + return true; + }; + let Some(deadline) = *until else { + return false; + }; + if Instant::now() < deadline { + return true; + } + *until = None; + self.streak.store(0, Ordering::Release); + false + } + + pub(super) fn record_timeout(&self) { + let streak = self.streak.fetch_add(1, Ordering::AcqRel).saturating_add(1); + if streak >= MPRIS_TIMEOUT_QUARANTINE_AFTER { + if let Ok(mut until) = self.quarantined_until.lock() { + *until = Some( + Instant::now() + .checked_add(Duration::from_millis(MPRIS_TIMEOUT_QUARANTINE_MS)) + .unwrap_or_else(Instant::now), + ); + } + } + } + + pub(super) fn clear_timeout(&self) { + self.streak.store(0, Ordering::Release); + if let Ok(mut until) = self.quarantined_until.lock() { + *until = None; + } + } +} + pub(in crate::media) async fn build_player_state( connection: &Connection, name: &str, config: &MediaConfig, ) -> zbus::Result> { // D-Bus owner data is captured once so snapshots do not need another bus round trip - // Browser bridges may later override this PID with a stronger metadata source PID + // The broker-derived PID remains authoritative even when player metadata supplies hints let Some(owner) = resolve_player_owner(connection, name).await else { // Ownership changed during probing, so a later bus event should rebuild stable data return Ok(None); @@ -89,6 +150,12 @@ pub(super) async fn build_player_state_for_owner( .interface(MPRIS_PLAYER)? .build() .await?; + let property_calls = ProxyBuilder::new(connection) + .destination(owner.unique_owner.clone())? + .path(MPRIS_PATH)? + .interface("org.freedesktop.DBus.Properties")? + .build() + .await?; let properties = PropertiesProxy::builder(connection) .destination(owner.unique_owner.clone())? .path(MPRIS_PATH)? @@ -105,7 +172,9 @@ pub(super) async fn build_player_state_for_owner( remote_art_allowed, local_art_allowed, player, + property_calls, properties, + timeout: PlayerTimeoutState::new(), listener_cancel, }) } @@ -121,7 +190,13 @@ pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Optio .build() .await .ok()?; - proxy.get_property("Identity").await.ok() + tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_property("Identity"), + ) + .await + .ok()? + .ok() } pub(super) async fn resolve_player_owner( @@ -135,17 +210,37 @@ pub(super) async fn resolve_player_owner( let Ok(proxy) = DBusProxy::new(connection).await else { return None; }; - let unique_owner = proxy.get_name_owner(bus_name.clone()).await.ok()?; + let unique_owner = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_name_owner(bus_name.clone()), + ) + .await + .ok()? + .ok()?; #[cfg(target_os = "linux")] - let credentials = get_connection_credentials(connection, (&unique_owner).into()).await?; + let credentials = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + get_connection_credentials(connection, (&unique_owner).into()), + ) + .await + .ok()??; #[cfg(target_os = "linux")] let (pid, process_fd) = (credentials.process_id?, credentials.process_fd); #[cfg(not(target_os = "linux"))] - let pid = proxy - .get_connection_unix_process_id((&unique_owner).into()) - .await - .ok()?; - let observed_owner = proxy.get_name_owner(bus_name).await.ok()?; + let pid = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_connection_unix_process_id((&unique_owner).into()), + ) + .await + .ok()? + .ok()?; + let observed_owner = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_name_owner(bus_name), + ) + .await + .ok()? + .ok()?; if !owner_probe_is_stable(unique_owner.as_str(), observed_owner.as_str()) { return None; } diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index 353eb1263..732d34de4 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -1,11 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::fdo::DBusProxy; -use super::super::discovery::{is_discoverable_player, refresh_players}; +use super::super::constants::MAX_MPRIS_PLAYERS; +use super::super::discovery::{is_discoverable_player, refresh_players, select_player_names}; use super::super::player::build_player_state; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -27,6 +28,33 @@ fn discovery_requires_an_mpris_name_that_passes_admission() { )); } +#[test] +fn discovery_caps_names_deterministically() { + let names = (0..(MAX_MPRIS_PLAYERS + 16)) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let selected = select_player_names(names); + + assert_eq!(selected.len(), MAX_MPRIS_PLAYERS); + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-000") + ); + assert_eq!( + selected.last().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-031") + ); +} + +#[test] +fn discovery_keeps_exactly_the_player_cap() { + let names = (0..MAX_MPRIS_PLAYERS) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + + assert_eq!(select_player_names(names).len(), MAX_MPRIS_PLAYERS); +} + #[tokio::test] async fn discovery_adds_live_players_and_removes_stale_entries() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index 3182c8794..3a7aab0bd 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -1,21 +1,31 @@ -use std::collections::HashMap; - -use zbus::zvariant::OwnedValue; - -use super::super::metadata::metadata_pid; +use super::super::constants::MAX_MPRIS_PROPERTY_REPLY_BYTES; +use super::super::metadata::{metadata_entry_count_allowed, property_reply_body_allowed}; +use super::super::{build_player_state, fetch_media_info}; +use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use unixnotis_core::MediaConfig; #[test] -fn metadata_pid_reads_unsigned_kde_pid() { - let mut metadata = HashMap::new(); - metadata.insert("kde:pid".to_string(), OwnedValue::from(103_380_u32)); - - assert_eq!(metadata_pid(&metadata), Some(103_380)); +fn metadata_limits_accept_exact_boundaries_only() { + assert_eq!(MAX_MPRIS_PROPERTY_REPLY_BYTES, 512 * 1024); + assert!(metadata_entry_count_allowed(256)); + assert!(!metadata_entry_count_allowed(257)); + assert!(property_reply_body_allowed(MAX_MPRIS_PROPERTY_REPLY_BYTES)); + assert!(!property_reply_body_allowed( + MAX_MPRIS_PROPERTY_REPLY_BYTES + 1 + )); } -#[test] -fn metadata_pid_rejects_negative_kde_pid() { - let mut metadata = HashMap::new(); - metadata.insert("kde:pid".to_string(), OwnedValue::from(-1_i32)); +#[tokio::test] +async fn oversized_metadata_reply_is_rejected_before_dynamic_decode() { + let fixture = MprisFixture::start_with_metadata_bytes(MAX_MPRIS_PROPERTY_REPLY_BYTES + 1).await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build oversized-metadata fixture player") + .expect("fixture owner should remain stable"); - assert_eq!(metadata_pid(&metadata), None); + let info = fetch_media_info(&player) + .await + .expect("required playback status remains available"); + assert!(info.title.is_empty()); + assert!(info.artist.is_empty()); } diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index 8a060e662..c00eb9653 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -3,7 +3,7 @@ use unixnotis_core::MediaConfig; use super::super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PREFIX}; use super::super::player::{ build_player_state, build_player_state_for_owner, fetch_identity, owner_probe_is_stable, - read_owner_executable_path, resolve_player_owner, + read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, }; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -30,6 +30,30 @@ fn player_proxy_constants_match_the_mpris_contract() { assert_eq!(MPRIS_APP, "org.mpris.MediaPlayer2"); } +#[test] +fn player_timeout_state_quarantines_after_repeated_failures() { + let state = PlayerTimeoutState::new(); + + assert!(!state.is_quarantined()); + state.record_timeout(); + state.record_timeout(); + assert!(!state.is_quarantined()); + state.record_timeout(); + assert!(state.is_quarantined()); +} + +#[test] +fn player_timeout_state_clear_releases_a_quarantine() { + let state = PlayerTimeoutState::new(); + for _ in 0..3 { + state.record_timeout(); + } + + assert!(state.is_quarantined()); + state.clear_timeout(); + assert!(!state.is_quarantined()); +} + #[tokio::test] async fn player_state_uses_live_identity_owner_and_process_details() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index f02a9036c..40a4b7671 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -103,6 +103,7 @@ struct CommandCounts { struct TestMprisPlayer { commands: Arc, + metadata_bytes: usize, } #[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] @@ -122,7 +123,15 @@ impl TestMprisPlayer { #[zbus(property)] fn metadata(&self) -> HashMap { - // Empty metadata keeps the fixture focused on transport behavior + // The optional payload exercises the raw reply budget without a real player + if self.metadata_bytes > 0 { + let large_value = "x".repeat(self.metadata_bytes); + return HashMap::from([( + "test:large".to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(large_value.as_str())) + .expect("build large metadata value"), + )]); + } HashMap::new() } @@ -162,6 +171,10 @@ pub(in crate::media) struct MprisFixture { impl MprisFixture { pub(in crate::media) async fn start() -> Self { + Self::start_with_metadata_bytes(0).await + } + + pub(in crate::media) async fn start_with_metadata_bytes(metadata_bytes: usize) -> Self { let broker = PrivateBroker::start(); let commands = Arc::new(CommandCounts::default()); // The service exports both MPRIS interfaces at the standard object path @@ -175,6 +188,7 @@ impl MprisFixture { MPRIS_PATH, TestMprisPlayer { commands: commands.clone(), + metadata_bytes, }, ) .expect("register test MPRIS player") diff --git a/crates/unixnotis-center/src/media/runtime/cache.rs b/crates/unixnotis-center/src/media/runtime/cache.rs index 1e53613f8..1aa87b6c2 100644 --- a/crates/unixnotis-center/src/media/runtime/cache.rs +++ b/crates/unixnotis-center/src/media/runtime/cache.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use futures_util::stream::{self, StreamExt}; + use super::super::mpris::{fetch_media_info, PlayerState}; use super::super::MediaInfo; @@ -18,16 +20,23 @@ pub(super) async fn refresh_cache( // Move the old cache out so the merge path can reuse prior snapshots // without cloning the whole map on every refresh let previous = std::mem::take(cache); + let results = stream::iter(players.values().cloned()) + .map(|state| async move { + let info = fetch_media_info(&state).await; + (state.bus_name.clone(), info) + }) + .buffer_unordered(4) + .collect::>() + .await; let mut next = HashMap::with_capacity(players.len()); - for state in players.values() { - // A transient DBus read error should not blank a live player card - // Keep the last good snapshot until a fresh read succeeds or the player disappears + for (bus_name, fetched) in results { + // A transient D-Bus read error should not blank a live player card if let Some(info) = merge_media_info( - previous.get(&state.bus_name), - fetch_media_info(state).await, + previous.get(&bus_name), + fetched, MediaCacheMergeMode::Stable, ) { - next.insert(state.bus_name.clone(), info); + next.insert(bus_name, info); } } *cache = next; diff --git a/crates/unixnotis-center/src/media/runtime/owner.rs b/crates/unixnotis-center/src/media/runtime/owner.rs index b3bf68169..df43b5717 100644 --- a/crates/unixnotis-center/src/media/runtime/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/owner.rs @@ -11,7 +11,8 @@ use super::state::MediaRuntimeState; use super::MediaSignal; use crate::control::UiEvent; use crate::media::mpris::{ - build_player_state, is_allowed_player, spawn_properties_listener, MPRIS_PREFIX, + build_player_state, is_allowed_player, spawn_properties_listener, MAX_MPRIS_PLAYERS, + MPRIS_PREFIX, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -51,6 +52,11 @@ pub(super) async fn apply_owner_change( return Ok(OwnerChangeOutcome::Removed); } + if should_retry_for_capacity(state.players.contains_key(name), state.players.len()) { + // Full discovery will choose the deterministic prefix on the next pass + return Ok(OwnerChangeOutcome::RetryNeeded); + } + if state .players .get(name) @@ -72,6 +78,21 @@ pub(super) async fn apply_owner_change( let rebuilt = build_player_state(connection, name, config).await; if let Ok(Some(player_state)) = rebuilt.as_ref() { + let duplicate_owner = state.players.iter().any(|(existing_name, existing)| { + owner_is_duplicate( + existing_name, + name, + existing.unique_owner.as_deref(), + player_state.unique_owner.as_deref(), + ) + }); + if duplicate_owner { + if removed_previous { + // The old alias was removed before deduplication and still needs a UI update + send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; + } + return Ok(OwnerChangeOutcome::Applied); + } // Start the listener before publishing state so late property traffic is retained spawn_properties_listener( player_state.properties.clone(), @@ -134,6 +155,19 @@ pub(super) fn owner_is_unchanged( current_owner.is_some() && current_owner == announced_owner } +pub(super) const fn should_retry_for_capacity(tracked: bool, player_count: usize) -> bool { + !tracked && player_count >= MAX_MPRIS_PLAYERS +} + +pub(super) fn owner_is_duplicate( + existing_name: &str, + requested_name: &str, + existing_owner: Option<&str>, + requested_owner: Option<&str>, +) -> bool { + existing_name != requested_name && existing_owner == requested_owner +} + async fn remove_player( name: &str, state: &mut MediaRuntimeState, diff --git a/crates/unixnotis-center/src/media/runtime/snapshot.rs b/crates/unixnotis-center/src/media/runtime/snapshot.rs index 743076a04..1372dccf9 100644 --- a/crates/unixnotis-center/src/media/runtime/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/snapshot.rs @@ -98,8 +98,7 @@ fn dedupe_key(info: &MediaInfo) -> Option { let title = info.title.trim(); if let Some(family) = info.browser_family.as_deref() { if let Some(pid) = info.owner_pid { - // Browser bridges can publish the same tab under different MPRIS names - // The source PID is the strongest signal that both cards mirror one source + // Only the broker-derived owner PID is safe for cross-name deduplication return Some(format!("browser-pid:{pid}")); } if !title.is_empty() { diff --git a/crates/unixnotis-center/src/media/runtime/state.rs b/crates/unixnotis-center/src/media/runtime/state.rs index 714658176..431bfe18d 100644 --- a/crates/unixnotis-center/src/media/runtime/state.rs +++ b/crates/unixnotis-center/src/media/runtime/state.rs @@ -28,3 +28,15 @@ impl MediaRuntimeState { } } } + +impl Drop for MediaRuntimeState { + fn drop(&mut self) { + // Connection teardown must cancel delayed work instead of detaching it + for task in self.delayed_refreshes.drain().map(|(_, task)| task) { + task.abort(); + } + for player in self.players.values() { + let _ = player.listener_cancel.send(true); + } + } +} diff --git a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs index 91270e6de..028136884 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs @@ -93,7 +93,7 @@ async fn runtime_command_dispatches_and_schedules_a_targeted_refresh() { assert_eq!(fixture.next_calls(), 1); assert!(state.delayed_refreshes.contains_key(TEST_PLAYER_NAME)); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } @@ -146,7 +146,7 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { }] )); let _ = cancel_tx.send(true); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/owner.rs b/crates/unixnotis-center/src/media/runtime/tests/owner.rs index f191e8a7e..f69b2d3fb 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/owner.rs @@ -1,11 +1,12 @@ use super::super::owner::{ - apply_owner_change, owner_is_unchanged, owner_rebuild_outcome, - replacement_removal_needs_snapshot, OwnerChangeOutcome, + apply_owner_change, owner_is_duplicate, owner_is_unchanged, owner_rebuild_outcome, + replacement_removal_needs_snapshot, should_retry_for_capacity, OwnerChangeOutcome, }; use super::super::state::MediaRuntimeState; use super::support::receive_ui_event; use crate::control::UiEvent; use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; +use crate::media::mpris::MAX_MPRIS_PLAYERS; use crate::media::mpris::{build_player_state, fetch_media_info}; use unixnotis_core::MediaConfig; @@ -50,6 +51,36 @@ fn stable_owner_rebuild_does_not_publish_an_empty_replacement_snapshot() { assert!(!replacement_removal_needs_snapshot(true, outcome)); } +#[test] +fn owner_capacity_retry_applies_only_to_new_players() { + assert!(should_retry_for_capacity(false, MAX_MPRIS_PLAYERS)); + assert!(should_retry_for_capacity(false, MAX_MPRIS_PLAYERS + 1)); + assert!(!should_retry_for_capacity(true, MAX_MPRIS_PLAYERS)); + assert!(!should_retry_for_capacity(false, MAX_MPRIS_PLAYERS - 1)); +} + +#[test] +fn owner_duplicate_check_requires_a_different_name_and_same_owner() { + assert!(owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.two", + Some(":1.42"), + Some(":1.42"), + )); + assert!(!owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.one", + Some(":1.42"), + Some(":1.42"), + )); + assert!(!owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.two", + Some(":1.42"), + Some(":1.43"), + )); +} + #[tokio::test] async fn unrelated_owner_change_is_ignored() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/runtime/tests/refresh.rs b/crates/unixnotis-center/src/media/runtime/tests/refresh.rs index e21ae315c..82e6af1da 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/refresh.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/refresh.rs @@ -56,7 +56,7 @@ async fn full_refresh_discovers_caches_and_publishes_live_players() { UiEvent::MediaUpdated(infos) if infos[0].bus_name == TEST_PLAYER_NAME )); let _ = state.players[TEST_PLAYER_NAME].listener_cancel.send(true); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/state.rs b/crates/unixnotis-center/src/media/runtime/tests/state.rs index d2f8a7bed..069ca9d69 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/state.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/state.rs @@ -1,4 +1,5 @@ use super::super::state::MediaRuntimeState; +use std::time::Duration; #[test] fn new_runtime_state_starts_without_players_cache_or_delayed_work() { @@ -9,3 +10,23 @@ fn new_runtime_state_starts_without_players_cache_or_delayed_work() { assert!(state.last_snapshot.is_empty()); assert!(state.delayed_refreshes.is_empty()); } + +#[tokio::test] +async fn dropping_runtime_state_aborts_delayed_refresh_tasks() { + let (completed_tx, completed_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_mins(1)).await; + let _ = completed_tx.send(()); + }); + let mut state = MediaRuntimeState::new(); + state + .delayed_refreshes + .insert("org.mpris.MediaPlayer2.test".to_string(), task); + + drop(state); + + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), completed_rx).await, + Ok(Err(_)) + )); +} From 7f34ee559054983448f13da5c4022ee0ccb4340b Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 14:18:35 -0500 Subject: [PATCH 180/275] fix(control): make snapshots and clear-all mutations atomic Summary: make snapshots and clear-all mutations atomic. Scope: control. --- .../src/doctor/checks/dbus/tests/support.rs | 1 + .../unixnotis-center/src/control/commands.rs | 8 +--- crates/unixnotis-center/src/control/seed.rs | 34 ++++---------- .../src/control/tests/commands.rs | 4 +- .../unixnotis-core/src/control/diagnostics.rs | 1 + .../src/control/notification.rs | 2 + crates/unixnotis-core/src/control/proxy.rs | 6 ++- crates/unixnotis-core/src/control/state.rs | 12 +++++ .../src/daemon/control/query.rs | 16 ++++++- .../src/daemon/control/server.rs | 28 +++++++---- .../src/daemon/control/tests/server.rs | 46 ++++++++++++++++++- .../src/store/notifications/insertion.rs | 28 ++++++++--- .../src/store/notifications/lifecycle.rs | 8 ++++ crates/unixnotis-daemon/src/store/runtime.rs | 2 + .../src/store/tests/runtime/lifecycle.rs | 26 +++++++++++ .../src/store/tests/runtime/mod.rs | 1 + .../src/store/tests/runtime/popup.rs | 20 ++++++++ crates/unixnotis-daemon/src/tests/support.rs | 9 +++- 18 files changed, 199 insertions(+), 53 deletions(-) create mode 100644 crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs index 9b70ac06c..ca9c112f0 100644 --- a/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs @@ -92,6 +92,7 @@ impl TestControl { center_ready: true, popups_process_running: true, popups_ready: true, + revision: 0, }) } } diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index f605e7819..727fb677d 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -151,16 +151,12 @@ pub async fn flush_offline_commands( } pub fn drop_stale_offline_commands(offline: &mut VecDeque) { - // Drop notification-key commands after reconnect because daemon generations are process-local - // Commands that do not depend on old notification ids are kept + // Destructive notification commands cannot cross a daemon generation let before = offline.len(); offline.retain(|command| { matches!( command, - UiCommand::ClearAll - | UiCommand::SetDnd(_) - | UiCommand::SetDndUntil(_) - | UiCommand::ClosePanel + UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_) | UiCommand::ClosePanel ) }); let dropped = before.saturating_sub(offline.len()); diff --git a/crates/unixnotis-center/src/control/seed.rs b/crates/unixnotis-center/src/control/seed.rs index a9366f6f7..6444e7cee 100644 --- a/crates/unixnotis-center/src/control/seed.rs +++ b/crates/unixnotis-center/src/control/seed.rs @@ -21,28 +21,15 @@ pub async fn seed_state( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, ) -> Result<(), SeedError> { - // GetState is the handshake and must succeed before snapshot methods are issued - let state = timed_dbus_call(proxy.get_state()) - .await - .map_err(|error| SeedError { - state_error: Some(error.to_string()), - active_error: None, - history_error: None, - send_error: None, - })?; - let (active, history) = tokio::join!( - timed_dbus_call(proxy.list_active()), - timed_dbus_call(proxy.list_history()) - ); - - match (active, history) { - (Ok(active), Ok(history)) => { + // The daemon captures state and rows under one store lock + match timed_dbus_call(proxy.get_snapshot()).await { + Ok(snapshot) => { // Publish only complete snapshots so the UI never mixes generations sender .send(UiEvent::Seed { - state, - active, - history, + state: snapshot.state, + active: snapshot.active, + history: snapshot.history, }) .await .map_err(|error| SeedError { @@ -53,11 +40,10 @@ pub async fn seed_state( })?; Ok(()) } - // Individual errors remain separate for useful diagnostics - (active, history) => Err(SeedError { - state_error: None, - active_error: active.err().map(|err| err.to_string()), - history_error: history.err().map(|err| err.to_string()), + Err(error) => Err(SeedError { + state_error: Some(error.to_string()), + active_error: None, + history_error: None, send_error: None, }), } diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index bae568810..1a80143d4 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -24,11 +24,11 @@ fn drop_stale_offline_commands_retains_safe_actions() { drop_stale_offline_commands(&mut offline); // Only commands that can survive reconnect without id drift should remain - assert_eq!(offline.len(), 3); + assert_eq!(offline.len(), 2); assert!(offline .iter() .any(|cmd| matches!(cmd, UiCommand::SetDnd(true)))); - assert!(offline.iter().any(|cmd| matches!(cmd, UiCommand::ClearAll))); + assert!(!offline.iter().any(|cmd| matches!(cmd, UiCommand::ClearAll))); assert!(offline .iter() .any(|cmd| matches!(cmd, UiCommand::ClosePanel))); diff --git a/crates/unixnotis-core/src/control/diagnostics.rs b/crates/unixnotis-core/src/control/diagnostics.rs index e83e767e6..c6386952a 100644 --- a/crates/unixnotis-core/src/control/diagnostics.rs +++ b/crates/unixnotis-core/src/control/diagnostics.rs @@ -20,6 +20,7 @@ pub struct NotificationDiagnosticsView { pub popup_admission: PopupAdmissionView, pub renderer_process_running: bool, pub renderer_ready: bool, + pub renderer_health_revision: u64, pub configured_max_visible: u32, pub decided_at_unix_ms: i64, pub delivery_stage: PopupDeliveryStage, diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index 5318b41c9..c025a1342 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -64,6 +64,8 @@ pub struct PopupDecisionRecord { pub admission_at_commit: PopupAdmissionView, pub renderer_process_running_at_commit: bool, pub renderer_ready_at_commit: bool, + /// Readiness revision observed while the notification was committed + pub renderer_health_revision_at_commit: u64, pub max_visible_at_commit: u32, pub decided_at_unix_ms: i64, pub delivery_stage: PopupDeliveryStage, diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 641e47afb..2e7305034 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -11,8 +11,8 @@ use zbus::proxy; use crate::{NotificationDiagnosticsView, NotificationView, PopupCandidate}; use super::{ - CloseReason, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, PopupGateState, - UiHealth, + CloseReason, ControlSnapshot, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, + PopupGateState, UiHealth, }; #[proxy( @@ -25,6 +25,8 @@ trait Control { fn get_api_version(&self) -> zbus::Result; /// Current daemon state fn get_state(&self) -> zbus::Result; + /// Complete active/history seed captured under one store lock + fn get_snapshot(&self) -> zbus::Result; /// Readiness of the daemon-managed center and popup clients fn get_ui_health(&self) -> zbus::Result; /// Active notifications intended for popups diff --git a/crates/unixnotis-core/src/control/state.rs b/crates/unixnotis-core/src/control/state.rs index 11afb2166..e12eb3fa5 100644 --- a/crates/unixnotis-core/src/control/state.rs +++ b/crates/unixnotis-core/src/control/state.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::Type; +use crate::NotificationView; + /// Control-plane state broadcast to the UI #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct ControlState { @@ -16,6 +18,14 @@ pub struct ControlState { pub inhibitor_count: u32, } +/// Active and historical rows captured under one daemon store lock +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +pub struct ControlSnapshot { + pub state: ControlState, + pub active: Vec, + pub history: Vec, +} + /// Popup gating fields that affect toast visibility #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct PopupGateState { @@ -30,6 +40,8 @@ pub struct UiHealth { pub center_ready: bool, pub popups_process_running: bool, pub popups_ready: bool, + /// Monotonic readiness revision sampled with popup admission + pub revision: u64, } /// Tuple layout for inhibitor listings: identifier, reason, scope, and owner diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 004437dfe..3522ce94f 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -3,7 +3,8 @@ //! Keeps read-only control methods grouped outside the main interface file use unixnotis_core::{ - ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationView, PopupCandidate, + ControlSnapshot, ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationView, + PopupCandidate, }; use zbus::message::Header; @@ -17,6 +18,19 @@ impl ControlServer { Ok(store.control_state()) } + pub(super) async fn query_snapshot( + &self, + header: &Header<'_>, + ) -> zbus::fdo::Result { + self.authorize_control_call(header, "GetSnapshot").await?; + let store = self.state.store.lock().await; + Ok(ControlSnapshot { + state: store.control_state(), + active: store.list_active(), + history: store.list_history(), + }) + } + pub(super) async fn query_active( &self, header: &Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 849642c10..7dbe6bb9d 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -61,14 +61,18 @@ impl ControlServer { )) } + pub(super) async fn clear_all_notifications(&self) -> Vec { + let mut store = self.state.store.lock().await; + let keys = store.clear_all(); + // Cancellation follows the same serialized mutation snapshot + self.state.cancel_expirations(&keys); + keys + } + pub(super) async fn drain_active_notifications(&self) -> Vec { - let keys = { - let mut store = self.state.store.lock().await; - let keys = store.drain_active_keys(); - // Cancellation is sent before same-ID replacements can commit - self.state.cancel_expirations(&keys); - keys - }; + let mut store = self.state.store.lock().await; + let keys = store.drain_active_keys(); + self.state.cancel_expirations(&keys); keys } @@ -88,6 +92,13 @@ impl ControlServer { self.query_state().await } + pub(super) async fn get_snapshot( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + self.query_snapshot(&header).await + } + async fn get_ui_health(&self) -> zbus::fdo::Result { Ok(self.state.ui_health()) } @@ -257,8 +268,7 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearAll").await?; - let ids = self.drain_active_notifications().await; - self.clear_saved_history().await; + let ids = self.clear_all_notifications().await; self.state.publish_notifications_cleared(ids).await; Ok(()) } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 214a0504d..dfcdcfd69 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -8,7 +8,7 @@ use zbus::Message; use super::super::ControlServer; use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::test_support::daemon_state_for_test; +use crate::test_support::{daemon_state_for_test, daemon_state_for_test_with_owner}; fn notification(summary: &str) -> Notification { Notification { @@ -148,6 +148,50 @@ async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { assert_eq!(state.store.lock().await.list_active().len(), 1); } +#[tokio::test] +async fn authorized_snapshot_is_one_store_consistent_read() { + let state = daemon_state_for_test_with_owner(false, Some(":1.4242")).await; + let server = ControlServer::new(state.clone()); + { + let mut store = state.store.lock().await; + store.insert(notification("active"), 0); + let history_id = store.insert(notification("history"), 0).notification.id; + store.close(history_id, CloseReason::Undefined); + } + + let message = control_header_message("GetSnapshot"); + let snapshot = server + .get_snapshot(message.header()) + .await + .expect("pre-authorized control owner can read a snapshot"); + + assert_eq!(snapshot.active.len(), 1); + assert_eq!(snapshot.history.len(), 1); + assert_eq!(snapshot.state.history_count, 1); +} + +#[tokio::test] +async fn authorized_clear_all_removes_active_and_history_together() { + let state = daemon_state_for_test_with_owner(false, Some(":1.4242")).await; + let server = ControlServer::new(state.clone()); + { + let mut store = state.store.lock().await; + store.insert(notification("active"), 0); + let history_id = store.insert(notification("history"), 0).notification.id; + store.close(history_id, CloseReason::Undefined); + } + + let message = control_header_message("ClearAll"); + server + .clear_all(message.header()) + .await + .expect("pre-authorized control owner can clear all"); + + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); +} + #[tokio::test] async fn clear_active_rejects_unauthorized_sender_before_mutating_state() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index 168e6c705..6f96e16aa 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use unixnotis_core::{ popup_allowed_by_state, should_archive_closed_notification, CloseReason, ControlState, - Notification, NotificationKey, Urgency, + Notification, NotificationKey, UiHealth, Urgency, }; use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; @@ -11,7 +11,25 @@ use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppre const ACTIVE_HARD_CAP: usize = 12; impl NotificationStore { - pub fn insert(&mut self, mut notification: Notification, replaces_id: u32) -> InsertOutcome { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "legacy in-crate test fixtures use the neutral health wrapper" + ) + )] + pub(crate) fn insert(&mut self, notification: Notification, replaces_id: u32) -> InsertOutcome { + // Test and legacy in-crate callers use the neutral health snapshot + // Production notification ingress calls insert_with_ui_health directly + self.insert_with_ui_health(notification, replaces_id, &UiHealth::default()) + } + + pub fn insert_with_ui_health( + &mut self, + mut notification: Notification, + replaces_id: u32, + ui_health: &UiHealth, + ) -> InsertOutcome { // Rule transforms happen before any storage decision self.apply_rules(&mut notification); if self.should_drop_inhibited() { @@ -68,11 +86,7 @@ impl NotificationStore { let evicted = self.enforce_active_limit(); let popup_admission = self.popup_admission(¬ification); - self.record_popup_commit_environment( - notification.key(), - popup_admission, - &unixnotis_core::UiHealth::default(), - ); + self.record_popup_commit_environment(notification.key(), popup_admission, ui_health); InsertOutcome { popup_admission, allow_sound: self.should_play_sound(¬ification), diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index ab6fb067c..10702a674 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -105,6 +105,14 @@ impl NotificationStore { keys } + /// Clear active and archived notifications at one store linearization point + pub fn clear_all(&mut self) -> Vec { + let keys = self.drain_active_keys(); + self.clear_history(); + self.prune_popup_decisions(); + keys + } + pub fn set_expiration( &mut self, notification: &Arc, diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index b970b244d..b88fe3654 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -182,6 +182,7 @@ impl NotificationStore { popup_admission: decision.admission_at_commit, renderer_process_running: decision.renderer_process_running_at_commit, renderer_ready: decision.renderer_ready_at_commit, + renderer_health_revision: decision.renderer_health_revision_at_commit, configured_max_visible: decision.max_visible_at_commit, decided_at_unix_ms: decision.decided_at_unix_ms, delivery_stage: decision.delivery_stage, @@ -215,6 +216,7 @@ impl NotificationStore { admission_at_commit: effective_admission, renderer_process_running_at_commit: ui_health.popups_process_running, renderer_ready_at_commit: ui_health.popups_ready, + renderer_health_revision_at_commit: ui_health.revision, max_visible_at_commit: max_visible, decided_at_unix_ms: chrono::Utc::now().timestamp_millis(), delivery_stage, diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs new file mode 100644 index 000000000..5a7ffdd14 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs @@ -0,0 +1,26 @@ +use unixnotis_core::{CloseReason, NotificationKey}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn clear_all_removes_active_history_and_expiration_state_together() { + let mut store = make_store_with_limits(10, 10); + let active = store.insert(make_notification("active"), 0).notification; + let archived = store.insert(make_notification("archived"), 0).notification; + store.close(archived.id, CloseReason::Expired); + store.set_expiration(&active, Some(std::time::Instant::now())); + + let removed = store.clear_all(); + + assert_eq!( + removed, + vec![NotificationKey { + id: active.id, + generation: active.generation, + }] + ); + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); + assert!(store.expirations.is_empty()); + assert!(store.popup_decisions.is_empty()); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs index e63bc0cdb..08f5f9fcc 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs @@ -1,4 +1,5 @@ mod action_target; mod config; mod inline_reply; +mod lifecycle; mod popup; diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs index 65babbc95..29cdd18ad 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -103,6 +103,26 @@ fn notification_diagnostics_require_both_renderer_process_and_readiness() { } } +#[test] +fn popup_diagnostics_keep_the_readiness_revision_sampled_at_commit() { + let mut store = make_store_with_limits(10, 10); + let health = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + revision: 17, + ..unixnotis_core::UiHealth::default() + }; + let notification = store + .insert_with_ui_health(make_notification("revision"), 0, &health) + .notification; + + let diagnostics = store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("notification diagnostics"); + + assert_eq!(diagnostics.renderer_health_revision, 17); +} + #[test] fn disabled_popups_are_recorded_when_max_visible_is_zero() { let mut config = Config::default(); diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index 188e1357f..ca2dcc4dd 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -24,6 +24,13 @@ pub fn env_lock() -> MutexGuard<'static, ()> { } pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { + daemon_state_for_test_with_owner(trial_mode, None).await +} + +pub async fn daemon_state_for_test_with_owner( + trial_mode: bool, + control_owner: Option<&str>, +) -> Arc { // Signal-heavy daemon tests only need a session connection and default state let connection = Connection::session() .await @@ -37,7 +44,7 @@ pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { sound, trial_mode, Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), - None, + control_owner.map(str::to_owned), ) } From 3d0b16e8ff1dad0e3ad10b39e5abe6d557de696c Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 14:18:44 -0500 Subject: [PATCH 181/275] fix(daemon): bound attribution work and readiness leases Summary: bound attribution work and readiness leases. Scope: daemon. --- .../src/daemon/notifications/identity/mod.rs | 2 +- .../notifications/identity/resolver/mod.rs | 2 +- .../identity/resolver/pipeline.rs | 81 +++++++++++++++---- .../identity/resolver/tests/mod.rs | 27 ++++++- .../resolver/tests/pipeline/provenance.rs | 6 ++ .../identity/tests/executable.rs | 13 +++ .../src/daemon/notifications/server/flow.rs | 37 +++++---- .../src/daemon/state/model.rs | 5 +- .../src/daemon/state/status.rs | 5 ++ .../src/presentation/tests/presentation.rs | 1 + 10 files changed, 138 insertions(+), 41 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 8c7face72..acbde72f9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -10,7 +10,7 @@ mod sender_cache; pub use desktop_index::DesktopIndexSnapshot; pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; -pub(in crate::daemon) use resolver::{resolve_attribution, unknown_reply_denied, AppClaim}; +pub(in crate::daemon) use resolver::{resolve_attribution_owned, unknown_reply_denied, AppClaim}; pub(in crate::daemon) use sender::resolve_sender_metadata; pub(super) use sender::SenderMetadata; pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs index 5d3edff91..2e9aebcff 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs @@ -10,7 +10,7 @@ mod sender_context; mod validation; pub(in crate::daemon) use model::{AppClaim, AttributionResolution}; -pub(in crate::daemon) use pipeline::resolve_attribution; +pub(in crate::daemon) use pipeline::resolve_attribution_owned; pub(in crate::daemon) use resolution::unknown_reply_denied; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index da58d542c..d588d829f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -1,5 +1,6 @@ //! Ordered attribution pipeline and candidate orchestration +use std::sync::Arc; use unixnotis_core::{AttributionStatus, InteractionPolicies, RecordTrust}; use super::super::desktop_index::{ @@ -15,31 +16,77 @@ use super::evidence::verify_record_sender; use super::model::{AppClaim, AttributionResolution, CandidateVerification}; use super::resolution::{ conflict_from_candidate, resolution_for_portal_record, resolution_for_record, - trusted_portal_path, + trusted_portal_path, unknown_reply_denied, }; use super::sender_context::enrich_sender_install_provenance; use super::validation::validate_desktop_id; -pub(in crate::daemon) async fn resolve_attribution( - claim: AppClaim<'_>, - sender: &SenderMetadata, - index: &DesktopIdentityIndex, +/// Production entry point that moves procfs and filesystem work off Tokio workers +pub(in crate::daemon) async fn resolve_attribution_owned( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, ) -> AttributionResolution { - // Cached process data is refreshed before it affects attribution - let mut sender = refresh_sender_security_evidence(sender); - let initial = resolve_with_evidence(claim, &sender, index); - let needs_provenance = needs_sender_provenance( - initial.attribution.status, - initial.attribution.interactions, - claim_has_index_candidate(claim, index), - ); - if !needs_provenance { + let initial = tokio::task::spawn_blocking({ + let reported_name = reported_name.clone(); + let desktop_entry = desktop_entry.clone(); + let index = Arc::clone(&index); + let sender = sender.clone(); + move || { + let sender = refresh_sender_security_evidence(&sender); + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + let resolution = resolve_with_evidence(claim, &sender, &index); + let needs = needs_sender_provenance( + resolution.attribution.status, + resolution.attribution.interactions, + claim_has_index_candidate(claim, &index), + ); + (sender, resolution, needs) + } + }) + .await + .ok(); + let Some((mut sender, initial, needs)) = initial else { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied( + claim, + &SenderMetadata::default(), + "attribution worker stopped", + ); + }; + if should_return_initial_resolution(needs) { return initial; } + enrich_sender_install_provenance(&mut sender, &index).await; + let fallback_name = reported_name.clone(); + let fallback_entry = desktop_entry.clone(); + let fallback_sender = sender.clone(); + tokio::task::spawn_blocking(move || { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + resolve_with_evidence(claim, &sender, &index) + }) + .await + .unwrap_or_else(|_| { + let claim = AppClaim { + reported_name: &fallback_name, + desktop_entry: fallback_entry.as_deref(), + }; + unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped") + }) +} - // Ownership is needed only to distinguish a probable helper from a different installed app - enrich_sender_install_provenance(&mut sender, index).await; - resolve_with_evidence(claim, &sender, index) +pub(super) const fn should_return_initial_resolution(needs_provenance: bool) -> bool { + !needs_provenance } pub(super) fn needs_sender_provenance( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs index 5ae0af498..bb4be7cfa 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -12,8 +12,10 @@ use super::candidates::{resolve_unverified_candidates, strongest_verified_result use super::evidence::verify_record_sender; use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; use super::pipeline::{ - claim_has_index_candidate, needs_sender_provenance, resolve_attribution, resolve_with_evidence, + claim_has_index_candidate, needs_sender_provenance, resolve_with_evidence, + should_return_initial_resolution, }; +use super::sender_context::enrich_sender_install_provenance; use super::AppClaim; use crate::daemon::notifications::identity::desktop_index::model::{ ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, @@ -25,7 +27,8 @@ use crate::daemon::notifications::identity::desktop_index::{ }; use crate::daemon::notifications::identity::executable::executable_evidence_for_path; use crate::daemon::notifications::identity::sender::{ - CommandLineEvidence, CommandLineQuality, ProcessLineageEvidence, SenderMetadata, + refresh_sender_security_evidence, CommandLineEvidence, CommandLineQuality, + ProcessLineageEvidence, SenderMetadata, }; use crate::daemon::notifications::identity::FileIdentity; @@ -33,6 +36,26 @@ mod support; use support::*; +pub(super) async fn resolve_attribution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> super::model::AttributionResolution { + // Test-only direct entry point keeps asynchronous package enrichment covered + let mut sender = refresh_sender_security_evidence(sender); + let initial = resolve_with_evidence(claim, &sender, index); + let needs_provenance = needs_sender_provenance( + initial.attribution.status, + initial.attribution.interactions, + claim_has_index_candidate(claim, index), + ); + if should_return_initial_resolution(needs_provenance) { + return initial; + } + enrich_sender_install_provenance(&mut sender, index).await; + resolve_with_evidence(claim, &sender, index) +} + mod candidates; mod diagnostics; mod evidence; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index aee58057d..9995670e2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -2,6 +2,12 @@ use super::super::*; +#[test] +fn initial_resolution_skips_provenance_when_no_lookup_is_needed() { + assert!(should_return_initial_resolution(false)); + assert!(!should_return_initial_resolution(true)); +} + #[test] fn provenance_enrichment_is_limited_to_denied_association_candidates() { for (status, policies, has_candidate, expected) in [ diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs index eef9adfe4..6a5bad57f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs @@ -104,6 +104,19 @@ fn deleted_running_executable_has_no_trusted_identity_evidence() { .stderr(Stdio::null()) .spawn() .expect("spawn copied executable"); + // Wait until the child has completed exec before unlinking its file + // Otherwise a fast scheduler can remove the path while the child is still + // in the fork/exec transition and make the regression test timing-sensitive + let proc_executable = std::path::PathBuf::from(format!("/proc/{}/exe", child.id())); + let mut exec_ready = false; + for _ in 0..100 { + if std::fs::read_link(&proc_executable).is_ok_and(|path| path == executable) { + exec_ready = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(exec_ready, "child did not finish exec before unlink"); std::fs::remove_file(&executable).expect("unlink running executable"); let evidence = executable_evidence_for_pid(child.id()); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 851331dbe..ea6d01c53 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -8,7 +8,7 @@ use zbus::zvariant::OwnedValue; use crate::daemon::notifications::identity::resolve_sender_metadata; use crate::daemon::notifications::identity::{ - resolve_attribution, unknown_reply_denied, AppClaim, SenderMetadata, + resolve_attribution_owned, unknown_reply_denied, AppClaim, SenderMetadata, }; use crate::daemon::notifications::ingress::payload::{ build_notification, owned_to_string, resolve_expiration, NotificationInput, @@ -132,21 +132,25 @@ impl NotificationServer { }; let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); let desktop_identity_index = self.state.desktop_identity_index.load_full(); - let claim = AppClaim { - reported_name: &input.app_name, - desktop_entry: desktop_entry.as_deref(), - }; - let resolution = if let Ok(resolution) = tokio::time::timeout( + let resolution = tokio::time::timeout( ATTRIBUTION_TIMEOUT, - resolve_attribution(claim, &sender, &desktop_identity_index), + resolve_attribution_owned( + input.app_name.clone(), + desktop_entry.clone(), + sender.clone(), + desktop_identity_index, + ), ) .await - { - resolution - } else { + .ok() + .unwrap_or_else(|| { warn!("notification attribution timed out and failed closed"); + let claim = AppClaim { + reported_name: &input.app_name, + desktop_entry: desktop_entry.as_deref(), + }; unknown_reply_denied(claim, &sender, "attribution timed out") - }; + }); if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -195,17 +199,12 @@ impl NotificationServer { replaces_id: u32, ) -> StoredNotification { // Store mutation and scheduler delivery share one serialized lock scope - let ui_health = self.state.ui_health(); let outcome = { let mut store = self.state.store.lock().await; - let outcome = store.insert(notification, replaces_id); + // Sample renderer health immediately before the serialized commit + let ui_health = self.state.ui_health(); + let outcome = store.insert_with_ui_health(notification, replaces_id, &ui_health); if !outcome.dropped { - // Commit-time renderer state is retained before it can change again - store.record_popup_commit_environment( - outcome.notification.key(), - outcome.popup_admission, - &ui_health, - ); // Resolve timeout after insertion so rule-mapped fields are already final let expiration = resolve_expiration(store.config(), &outcome.notification); store.set_expiration(&outcome.notification, expiration); diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 075860e10..3d1b778db 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use arc_swap::ArcSwap; @@ -30,6 +30,8 @@ pub struct DaemonState { pub(in crate::daemon::state) center_process_running: AtomicBool, pub(in crate::daemon::state) popups_process_running: AtomicBool, pub(in crate::daemon::state) popups_ready: AtomicBool, + // Changes whenever process or readiness ownership changes + pub(in crate::daemon::state) ui_health_revision: AtomicU64, pub(in crate::daemon::state) popups_unready_warning_emitted: AtomicBool, // The unique D-Bus owner prevents an older popup generation from clearing a newer one pub(in crate::daemon::state) popups_ready_owner: StdMutex>, @@ -96,6 +98,7 @@ impl DaemonState { center_process_running: AtomicBool::new(false), popups_process_running: AtomicBool::new(false), popups_ready: AtomicBool::new(false), + ui_health_revision: AtomicU64::new(0), popups_unready_warning_emitted: AtomicBool::new(false), popups_ready_owner: StdMutex::new(None), scheduler: OnceLock::new(), diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 27975938f..e3873bf4b 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -21,6 +21,7 @@ impl DaemonState { *current_owner = None; self.panel_ready.store(false, Ordering::SeqCst); } + self.ui_health_revision.fetch_add(1, Ordering::SeqCst); } fn clear_panel_ready(&self) { @@ -36,6 +37,7 @@ impl DaemonState { self.center_process_running.store(running, Ordering::SeqCst); // Every process generation must complete its own subscription handshake self.clear_panel_ready(); + self.ui_health_revision.fetch_add(1, Ordering::SeqCst); } pub(crate) fn set_popups_process_running(&self, running: bool) { @@ -44,6 +46,7 @@ impl DaemonState { if !running { self.clear_popups_ready(); } + self.ui_health_revision.fetch_add(1, Ordering::SeqCst); } pub(crate) fn set_popups_ready(&self, owner: &str, ready: bool) { @@ -60,6 +63,7 @@ impl DaemonState { *current_owner = None; self.popups_ready.store(false, Ordering::SeqCst); } + self.ui_health_revision.fetch_add(1, Ordering::SeqCst); } fn clear_popups_ready(&self) { @@ -93,6 +97,7 @@ impl DaemonState { center_ready: self.panel_ready(), popups_process_running: self.popups_process_running.load(Ordering::SeqCst), popups_ready: self.popups_ready(), + revision: self.ui_health_revision.load(Ordering::SeqCst), } } diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index d3bf8b086..9de538fb9 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -372,6 +372,7 @@ fn popup_status_uses_the_committed_reason_instead_of_current_state() { admission_at_commit: unixnotis_core::PopupAdmissionView::RendererDisabled, renderer_process_running_at_commit: true, renderer_ready_at_commit: true, + renderer_health_revision_at_commit: 0, max_visible_at_commit: 0, decided_at_unix_ms: 1_000, delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, From ba8577d64fce8d437a4f4e60049bcfcf103798a2 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 14:18:50 -0500 Subject: [PATCH 182/275] fix(runtime): make popup shutdown and icon workers cancellable Summary: make popup shutdown and icon workers cancellable. Scope: runtime. --- .../unixnotis-center/src/ui/icons/resolver.rs | 7 +- crates/unixnotis-popups/src/app/command.rs | 16 +-- crates/unixnotis-popups/src/dbus/commands.rs | 7 +- .../src/dbus/runtime/bootstrap.rs | 7 +- .../src/dbus/runtime/connection.rs | 113 ++++++++++++------ .../src/dbus/runtime/generation.rs | 37 +++--- .../unixnotis-popups/src/dbus/runtime/mod.rs | 7 ++ .../src/dbus/tests/commands.rs | 22 +--- .../unixnotis-popups/src/dbus/tests/types.rs | 17 --- crates/unixnotis-popups/src/dbus/types.rs | 3 - 10 files changed, 123 insertions(+), 113 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index ff3e0d1a8..5abfb74ad 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -33,11 +33,14 @@ impl IconResolver { missing_names: RefCell::new(MissingIconCache::new(512)), worker, }); - let update_target = Rc::clone(&inner); + let update_target = Rc::downgrade(&inner); glib::MainContext::default().spawn_local(async move { while let Ok(update) = update_rx.recv().await { // GTK objects are updated only from the owning main context - update_target.handle_update(update); + let Some(inner) = update_target.upgrade() else { + break; + }; + inner.handle_update(update); } }); diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index 6b5605ea2..096d131a9 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -76,21 +76,9 @@ pub fn run(args: Args) -> Result<()> { let (event_tx, event_rx) = async_channel::bounded(UI_EVENT_QUEUE_CAPACITY); let dbus_runtime = dbus::start_dbus_runtime(event_tx.clone()); let command_tx = dbus_runtime.command_sender(); - let shutdown_tx = command_tx.clone(); + let shutdown = dbus_runtime.clone(); app.connect_shutdown(move |_| { - let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); - if shutdown_tx - .blocking_send(dbus::UiCommand::Shutdown(acknowledgement_tx)) - .is_err() - { - return; - } - if acknowledgement_rx - .recv_timeout(unixnotis_core::INTERNAL_DBUS_CALL_TIMEOUT) - .is_err() - { - warn!("popup readiness cleanup timed out during GTK shutdown"); - } + shutdown.request_shutdown(); }); let reload_gate = Arc::new(ReloadGate::new()); // Timer state keeps only one flush source alive at a time diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index ad142b7b5..6134a8596 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -45,16 +45,12 @@ pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> Zbu timed_dbus_call(proxy.mark_popup_visible(notification.id, notification.generation)) .await } - UiCommand::Shutdown(_) => Ok(()), } } -pub fn drain_offline_commands( - command_rx: &mut mpsc::Receiver, -) -> Option> { +pub fn drain_offline_commands(command_rx: &mut mpsc::Receiver) { while let Ok(command) = command_rx.try_recv() { match command { - UiCommand::Shutdown(acknowledgement) => return Some(acknowledgement), UiCommand::Reply { outcome, .. } => { let _ = outcome.send(Err("notification service is unavailable".to_string())); } @@ -66,7 +62,6 @@ pub fn drain_offline_commands( // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); } - None } #[cfg(test)] diff --git a/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs index b9f6480df..d09ccda9c 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs @@ -12,10 +12,12 @@ use crate::dbus::{UiCommand, UiEvent}; pub(super) fn start_runtime(sender: async_channel::Sender) -> PopupRuntime { let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); let (gtk_ready_tx, gtk_ready_rx) = watch::channel(false); - spawn_runtime_thread(sender, command_rx, gtk_ready_rx); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + spawn_runtime_thread(sender, command_rx, gtk_ready_rx, shutdown_rx); PopupRuntime { command_tx, gtk_ready_tx, + shutdown_tx, } } @@ -23,13 +25,14 @@ fn spawn_runtime_thread( sender: async_channel::Sender, command_rx: mpsc::Receiver, gtk_ready_rx: watch::Receiver, + shutdown_rx: watch::Receiver, ) { thread::spawn(move || { // The GTK main thread never blocks on bus calls or retry delays let Some(runtime) = build_runtime() else { return; }; - runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx)); + runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx, shutdown_rx)); }); } diff --git a/crates/unixnotis-popups/src/dbus/runtime/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/connection.rs index 18262eefe..c998f10e2 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/connection.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/connection.rs @@ -25,50 +25,85 @@ pub(super) async fn run_dbus_loop( sender: async_channel::Sender, mut command_rx: mpsc::Receiver, mut gtk_ready_rx: watch::Receiver, + mut shutdown_rx: watch::Receiver, ) { + // Backoff state survives owner changes but resets after a healthy connection let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); let mut connect_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); let mut subscribe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); loop { - let connection = connect_session_bus(&mut connect_backoff, &mut connect_log).await; - let Some(retry_delay) = run_connection_once( + if *shutdown_rx.borrow() { + return; + } + let Some(connection) = + connect_session_bus(&mut connect_backoff, &mut connect_log, &mut shutdown_rx).await + else { + return; + }; + let retry_delay = run_connection_once( &connection, &sender, &mut command_rx, &mut subscribe_backoff, &mut subscribe_log, &mut gtk_ready_rx, + &mut shutdown_rx, ) - .await - else { + .await; + let Some(retry_delay) = retry_delay else { return; }; - tokio::time::sleep(retry_delay).await; + tokio::select! { + () = tokio::time::sleep(retry_delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return; } + } + } } } async fn connect_session_bus( connect_backoff: &mut Backoff, connect_log: &mut RetryLog, -) -> Connection { + shutdown_rx: &mut watch::Receiver, +) -> Option { loop { - match Connection::session().await { + let result = tokio::select! { + result = Connection::session() => result, + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + continue; + } + }; + match result { Ok(connection) => { if let Err(error) = log_session_bus_identity(&connection, "popups").await { connect_log .warn_or_debug(&error, "session bus identity probe failed; retrying"); - tokio::time::sleep(connect_backoff.next_sleep()).await; + let delay = connect_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + } + } continue; } connect_backoff.reset(); connect_log.reset(); - return connection; + return Some(connection); } Err(error) => { connect_log.warn_or_debug(&error, "failed to connect to the session bus; retrying"); - tokio::time::sleep(connect_backoff.next_sleep()).await; + let delay = connect_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + } + } } } } @@ -81,14 +116,14 @@ async fn run_connection_once( subscribe_backoff: &mut Backoff, subscribe_log: &mut RetryLog, gtk_ready_rx: &mut watch::Receiver, + shutdown_rx: &mut watch::Receiver, ) -> Option { + // One connection owns one stream set and one command-generation boundary let proxy = match ControlProxy::new(connection).await { Ok(proxy) => proxy, Err(error) => { subscribe_log.warn_or_debug(&error, "control interface unavailable; retrying"); - if acknowledge_offline_shutdown(command_rx) { - return None; - } + drain_offline_commands(command_rx); return Some(subscribe_backoff.next_sleep()); } }; @@ -112,12 +147,20 @@ async fn run_connection_once( }; loop { - let owner = - match wait_for_control_owner(&dbus, &mut owner_changes, sender, command_rx).await { - OwnerWait::Ready(owner) => owner, - OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), - OwnerWait::Shutdown => return None, - }; + // Wait for an owner before creating generation-scoped subscriptions + let owner = match wait_for_control_owner( + &dbus, + &mut owner_changes, + sender, + command_rx, + shutdown_rx, + ) + .await + { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), + OwnerWait::Shutdown => return None, + }; let context = PopupGenerationContext::new( &mut owner_changes, sender, @@ -125,13 +168,22 @@ async fn run_connection_once( subscribe_backoff, subscribe_log, gtk_ready_rx, + shutdown_rx, ); match run_owner_generation(&proxy, &owner, context).await { GenerationExit::OwnerChanged => {} GenerationExit::ConnectionLost => return Some(subscribe_backoff.next_sleep()), GenerationExit::Shutdown => return None, GenerationExit::Retry => { - tokio::time::sleep(subscribe_backoff.next_sleep()).await; + let retry_delay = subscribe_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(retry_delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { + return None; + } + } + } } } } @@ -148,6 +200,7 @@ async fn wait_for_control_owner( owner_changes: &mut OwnerChangedStream<'_>, sender: &async_channel::Sender, command_rx: &mut mpsc::Receiver, + shutdown_rx: &mut watch::Receiver, ) -> OwnerWait { let control_name = BusName::try_from(CONTROL_BUS_NAME).expect("static control bus name must be valid"); @@ -162,17 +215,14 @@ async fn wait_for_control_owner( // An unowned name is a quiet state and must not trigger seed or readiness calls let _ = sender.send(UiEvent::Disconnected).await; - if acknowledge_offline_shutdown(command_rx) { - return OwnerWait::Shutdown; - } + drain_offline_commands(command_rx); loop { tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return OwnerWait::Shutdown; } + } command = command_rx.recv() => { match command { - Some(UiCommand::Shutdown(acknowledgement)) => { - let _ = acknowledgement.send(()); - return OwnerWait::Shutdown; - } Some(_) => warn!("dropping popup command while control has no owner"), None => return OwnerWait::Shutdown, } @@ -187,12 +237,3 @@ async fn wait_for_control_owner( } } } - -fn acknowledge_offline_shutdown(command_rx: &mut mpsc::Receiver) -> bool { - if let Some(acknowledgement) = drain_offline_commands(command_rx) { - let _ = acknowledgement.send(()); - true - } else { - false - } -} diff --git a/crates/unixnotis-popups/src/dbus/runtime/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/generation.rs index 702f3d0c5..60dbacf3a 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/generation.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/generation.rs @@ -17,6 +17,7 @@ use crate::dbus::seed::{seed_state, PopupSeedSource, SeedError, SeedSnapshot}; use crate::dbus::{UiCommand, UiEvent}; struct ControlProxySeedSource<'proxy, 'connection> { + // The proxy is borrowed for exactly one owner generation proxy: &'proxy ControlProxy<'connection>, } @@ -48,6 +49,7 @@ pub(super) struct PopupGenerationContext<'context, 'stream> { subscribe_backoff: &'context mut Backoff, subscribe_log: &'context mut RetryLog, gtk_ready_rx: &'context mut watch::Receiver, + shutdown_rx: &'context mut watch::Receiver, } impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { @@ -58,6 +60,7 @@ impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { subscribe_backoff: &'context mut Backoff, subscribe_log: &'context mut RetryLog, gtk_ready_rx: &'context mut watch::Receiver, + shutdown_rx: &'context mut watch::Receiver, ) -> Self { Self { owner_changes, @@ -66,11 +69,13 @@ impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { subscribe_backoff, subscribe_log, gtk_ready_rx, + shutdown_rx, } } } struct GenerationStreams<'proxy> { + // Each stream belongs to the same verified control owner added: NotificationAddedStream<'proxy>, updated: NotificationUpdatedStream<'proxy>, closed: NotificationClosedStream<'proxy>, @@ -79,6 +84,7 @@ struct GenerationStreams<'proxy> { } struct SubscribeError { + // Keep the signal name next to its original D-Bus error for useful retry logs signal: &'static str, source: zbus::Error, } @@ -87,6 +93,7 @@ impl GenerationStreams<'_> { async fn subscribe<'proxy>( proxy: &'proxy ControlProxy<'_>, ) -> Result, SubscribeError> { + // Subscribe in a fixed order so partial setup has a deterministic failure point let added = proxy .receive_notification_added() .await @@ -94,6 +101,7 @@ impl GenerationStreams<'_> { signal: "notification_added", source, })?; + // Updates share the same generation boundary as additions let updated = proxy .receive_notification_updated() .await @@ -101,6 +109,7 @@ impl GenerationStreams<'_> { signal: "notification_updated", source, })?; + // Close events remove rows only when their generation still matches let closed = proxy .receive_notification_closed() .await @@ -108,6 +117,7 @@ impl GenerationStreams<'_> { signal: "notification_closed", source, })?; + // Gate changes update popup admission without rebuilding the owner connection let gate = proxy .receive_popup_gate_changed() .await @@ -115,6 +125,7 @@ impl GenerationStreams<'_> { signal: "popup_gate_changed", source, })?; + // Invalidations request a fresh seed after a missed or coalesced change let invalidated = proxy .receive_snapshot_invalidated() .await @@ -144,7 +155,9 @@ pub(super) async fn run_owner_generation( subscribe_backoff, subscribe_log, gtk_ready_rx, + shutdown_rx, } = context; + // A failed subscription cannot safely share a partial generation let mut streams = match GenerationStreams::subscribe(proxy).await { Ok(streams) => streams, Err(error) => { @@ -157,14 +170,17 @@ pub(super) async fn run_owner_generation( }; // Subscription precedes the seed so no change can fall between both phases + // Seed after subscriptions so buffered signals can repair any boundary race if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); return GenerationExit::Retry; } + // Readiness is required before the daemon treats this owner as renderable if !wait_for_gtk_runtime(gtk_ready_rx).await { warn!("popup GTK runtime did not become ready"); return GenerationExit::Retry; } + // The lease is cleared on every exit path after successful publication let mut readiness = PopupReadinessLease::new(proxy); if let Err(error) = readiness.publish().await { subscribe_log.warn_or_debug(&error, "failed to mark popup renderer ready"); @@ -174,23 +190,19 @@ pub(super) async fn run_owner_generation( subscribe_log.reset(); info!(owner, "UnixNotis control service ready"); - let mut shutdown_acknowledgement = None; let exit = loop { tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { + break GenerationExit::Shutdown; + } + } command = command_rx.recv() => { let Some(command) = command else { break GenerationExit::Shutdown; }; - match command { - UiCommand::Shutdown(acknowledgement) => { - shutdown_acknowledgement = Some(acknowledgement); - break GenerationExit::Shutdown; - } - command => { - if let Err(error) = handle_command(proxy, command).await { - warn!(?error, "popup control command failed"); - } - } + if let Err(error) = handle_command(proxy, command).await { + warn!(?error, "popup control command failed"); } } signal = streams.added.next() => { @@ -285,8 +297,5 @@ pub(super) async fn run_owner_generation( }; readiness.clear().await; - if let Some(acknowledgement) = shutdown_acknowledgement { - let _ = acknowledgement.send(()); - } exit } diff --git a/crates/unixnotis-popups/src/dbus/runtime/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/mod.rs index d51139da4..64334bb8b 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/mod.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/mod.rs @@ -13,9 +13,11 @@ use super::types::{UiCommand, UiEvent}; // A bounded queue prevents a stalled D-Bus connection from growing memory without limit pub(super) const UI_COMMAND_QUEUE_CAPACITY: usize = 64; +#[derive(Clone)] pub struct PopupRuntime { command_tx: mpsc::Sender, gtk_ready_tx: watch::Sender, + shutdown_tx: watch::Sender, } impl PopupRuntime { @@ -27,6 +29,11 @@ impl PopupRuntime { // Readiness is published only after the GTK state owns its complete widget tree let _ = self.gtk_ready_tx.send(true); } + + pub fn request_shutdown(&self) { + // Shutdown has its own non-blocking channel and cannot be starved by UI events + let _ = self.shutdown_tx.send(true); + } } pub fn start_dbus_runtime(sender: async_channel::Sender) -> PopupRuntime { diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index 002c954c5..7c1b1a33d 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -22,7 +22,7 @@ fn drain_offline_commands_removes_all_queued_commands() { }) .expect("action command should queue"); - assert!(drain_offline_commands(&mut rx).is_none()); + drain_offline_commands(&mut rx); // Stale commands are intentionally discarded while popups are offline assert!(rx.try_recv().is_err()); @@ -32,27 +32,11 @@ fn drain_offline_commands_removes_all_queued_commands() { fn drain_offline_commands_accepts_empty_queue() { let (_tx, mut rx) = mpsc::channel(1); - assert!(drain_offline_commands(&mut rx).is_none()); + drain_offline_commands(&mut rx); assert!(rx.try_recv().is_err()); } -#[test] -fn drain_offline_commands_returns_shutdown_acknowledgement() { - let (tx, mut rx) = mpsc::channel(1); - let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); - tx.try_send(UiCommand::Shutdown(acknowledgement_tx)) - .expect("shutdown command should queue"); - - let acknowledgement = - drain_offline_commands(&mut rx).expect("shutdown acknowledgement should be preserved"); - acknowledgement.send(()).expect("acknowledge shutdown"); - - acknowledgement_rx - .recv() - .expect("receive shutdown acknowledgement"); -} - #[test] fn drain_offline_commands_reports_reply_delivery_failure() { let (tx, mut rx) = mpsc::channel(1); @@ -65,7 +49,7 @@ fn drain_offline_commands_reports_reply_delivery_failure() { }) .expect("reply command should queue"); - assert!(drain_offline_commands(&mut rx).is_none()); + drain_offline_commands(&mut rx); assert_eq!( result.try_recv().expect("reply result should be ready"), Err("notification service is unavailable".to_string()) diff --git a/crates/unixnotis-popups/src/dbus/tests/types.rs b/crates/unixnotis-popups/src/dbus/tests/types.rs index 39ef3b1b3..e65b55944 100644 --- a/crates/unixnotis-popups/src/dbus/tests/types.rs +++ b/crates/unixnotis-popups/src/dbus/tests/types.rs @@ -14,23 +14,6 @@ fn dismiss_command_preserves_notification_generation() { )); } -#[test] -fn shutdown_command_preserves_the_cleanup_acknowledgement() { - let (acknowledgement_tx, acknowledgement_rx) = std::sync::mpsc::sync_channel(1); - let command = UiCommand::Shutdown(acknowledgement_tx); - - if let UiCommand::Shutdown(acknowledgement) = command { - acknowledgement - .send(()) - .expect("send shutdown acknowledgement"); - } else { - panic!("shutdown command variant should remain intact"); - } - acknowledgement_rx - .recv() - .expect("receive shutdown acknowledgement"); -} - #[test] fn reply_debug_output_redacts_private_message_text() { let (outcome, _result) = tokio::sync::oneshot::channel(); diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index bde96500f..326e60477 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -39,8 +39,6 @@ pub enum UiCommand { }, Materialized(NotificationKey), Visible(NotificationKey), - // A synchronous acknowledgement lets GTK wait for MarkPopupsNotReady before process exit - Shutdown(std::sync::mpsc::SyncSender<()>), } impl std::fmt::Debug for UiCommand { @@ -75,7 +73,6 @@ impl std::fmt::Debug for UiCommand { .debug_tuple("Visible") .field(notification) .finish(), - Self::Shutdown(_) => formatter.write_str("Shutdown(..)"), } } } From f0091775932743bd32fe8e238bc22e8a7b397090 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 14:18:54 -0500 Subject: [PATCH 183/275] ci: pin validation toolchain inputs Summary: pin validation toolchain inputs. Scope: repository. --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++--- .github/workflows/mutation.yml | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a880f3e0..d08182234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -30,13 +33,29 @@ jobs: workspace: name: Workspace checks runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 45 steps: - name: Install system dependencies run: | set -euo pipefail + rm -f /etc/apt/sources.list + printf '%s\n' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot apt-get update apt-get install -y --no-install-recommends \ bash \ @@ -64,8 +83,16 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.96.1 --profile minimal --component rustfmt,clippy + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" -y --profile minimal --default-toolchain 1.96.1 \ + --component rustfmt,clippy --no-modify-path + rm -f "$rustup_init" echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" rustup default 1.96.1 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 243f27372..1337468a3 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -22,6 +22,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -31,13 +34,29 @@ jobs: mutation: name: Cargo mutants runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 120 steps: - name: Install system dependencies run: | set -euo pipefail + rm -f /etc/apt/sources.list + printf '%s\n' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot apt-get update apt-get install -y --no-install-recommends \ bash \ @@ -58,8 +77,16 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --profile minimal + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" -y --profile minimal --default-toolchain 1.96.1 \ + --no-modify-path + rm -f "$rustup_init" echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" rustup default 1.96.1 From a1f1d7633666783e739df3f89361c1cb615d32e4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 15:55:31 -0500 Subject: [PATCH 184/275] fix(runtime): bound media discovery and attribution sampling Summary: bound media discovery and attribution sampling. Scope: runtime. --- .../src/media/mpris/constants.rs | 2 + .../src/media/mpris/discovery.rs | 35 ++++++-- .../src/media/mpris/metadata.rs | 62 ++++++++++---- .../src/media/mpris/player.rs | 24 ++++-- .../src/media/mpris/tests/discovery.rs | 27 ++++--- .../src/media/mpris/tests/metadata.rs | 80 ++++++++++++++++++- .../src/media/mpris/tests/player.rs | 11 ++- .../src/media/mpris/tests/support.rs | 25 +++++- .../src/media/runtime/owner.rs | 16 ++-- .../src/media/runtime/tests/owner.rs | 11 +-- .../identity/resolver/pipeline.rs | 29 ++++++- .../identity/resolver/resolution.rs | 2 +- .../src/daemon/state/status.rs | 29 +++++-- 13 files changed, 280 insertions(+), 73 deletions(-) diff --git a/crates/unixnotis-center/src/media/mpris/constants.rs b/crates/unixnotis-center/src/media/mpris/constants.rs index 23747fe98..4e3c14e39 100644 --- a/crates/unixnotis-center/src/media/mpris/constants.rs +++ b/crates/unixnotis-center/src/media/mpris/constants.rs @@ -13,6 +13,8 @@ pub const MPRIS_APP: &str = "org.mpris.MediaPlayer2"; pub const MPRIS_PROPERTY_TIMEOUT_MS: u64 = 500; /// Reject unusually large property replies before decoding dynamic values pub const MAX_MPRIS_PROPERTY_REPLY_BYTES: usize = 512 * 1024; +/// Identity is shown in the panel but is never allowed to grow without bound +pub const MAX_MPRIS_IDENTITY_BYTES: usize = 512; pub const MPRIS_TIMEOUT_QUARANTINE_AFTER: u8 = 3; pub const MPRIS_TIMEOUT_QUARANTINE_MS: u64 = 5_000; diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index c8ad84495..b34b62ee7 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -33,6 +33,8 @@ pub(in crate::media) async fn refresh_players( allowed.insert(name); } + // Owner capacity is enforced after probing so aliases cannot occupy a + // deterministic name prefix and starve an unrelated player let allowed = select_player_names(allowed); let allowed_set = allowed.iter().map(String::as_str).collect::>(); @@ -75,6 +77,7 @@ pub(in crate::media) async fn refresh_players( // Concurrency must not change which alias wins owner deduplication probed.sort_unstable_by(|left, right| left.0.cmp(&right.0)); let mut failed_probes = 0usize; + let mut capacity_skipped = 0usize; for (name, result) in probed { // New players are probed concurrently, but admitted state is committed in name order let state = match result { @@ -88,6 +91,15 @@ pub(in crate::media) async fn refresh_players( } }; if let Some(state) = state { + let owner_is_tracked = state + .unique_owner + .as_ref() + .is_some_and(|owner| owners.contains(owner)); + if should_skip_for_owner_capacity(owners.len(), MAX_MPRIS_PLAYERS, owner_is_tracked) { + // The owner was resolved, but the bounded state set is full + capacity_skipped = capacity_skipped.saturating_add(1); + continue; + } if state .unique_owner .as_ref() @@ -115,6 +127,13 @@ pub(in crate::media) async fn refresh_players( "one or more MPRIS player probes failed" ); } + if capacity_skipped > 0 { + warn!( + skipped = capacity_skipped, + limit = MAX_MPRIS_PLAYERS, + "MPRIS player capacity reached; additional owners were ignored" + ); + } Ok(()) } @@ -126,13 +145,13 @@ pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { pub(super) fn select_player_names(names: HashSet) -> Vec { let mut names: Vec = names.into_iter().collect(); names.sort_unstable(); - if names.len() > MAX_MPRIS_PLAYERS { - warn!( - admitted = names.len(), - limit = MAX_MPRIS_PLAYERS, - "MPRIS player limit reached; retaining deterministic prefix" - ); - names.truncate(MAX_MPRIS_PLAYERS); - } names } + +pub(super) const fn should_skip_for_owner_capacity( + owner_count: usize, + capacity: usize, + owner_is_tracked: bool, +) -> bool { + owner_count >= capacity && !owner_is_tracked +} diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 5fb856040..7c52ec6c7 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -21,12 +21,42 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option>(&state.property_calls, "Metadata", timeout,), - bounded_property::(&state.property_calls, "PlaybackStatus", timeout), - bounded_property::(&state.property_calls, "CanPlay", timeout), - bounded_property::(&state.property_calls, "CanPause", timeout), - bounded_property::(&state.property_calls, "CanGoNext", timeout), - bounded_property::(&state.property_calls, "CanGoPrevious", timeout), + bounded_property::>( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "Metadata", + timeout, + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "PlaybackStatus", + timeout, + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanPlay", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanPause", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanGoNext", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanGoPrevious", + timeout + ), ); let metadata = metadata .filter(|map| metadata_entry_count_allowed(map.len())) @@ -74,21 +104,19 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option( +pub(super) async fn bounded_property( proxy: &Proxy<'static>, + interface: &str, property: &str, timeout: std::time::Duration, ) -> Option where T: TryFrom, { - let reply = tokio::time::timeout( - timeout, - proxy.call_method("Get", &(super::constants::MPRIS_PLAYER, property)), - ) - .await - .ok()? - .ok()?; + let reply = tokio::time::timeout(timeout, proxy.call_method("Get", &(interface, property))) + .await + .ok()? + .ok()?; if !property_reply_body_allowed(reply.body().len()) { return None; } @@ -104,7 +132,7 @@ pub(super) const fn property_reply_body_allowed(body_len: usize) -> bool { body_len <= MAX_MPRIS_PROPERTY_REPLY_BYTES } -fn bound_string(value: &str, max_bytes: usize) -> String { +pub(super) fn bound_string(value: &str, max_bytes: usize) -> String { // Truncate at a UTF-8 boundary so the retained value stays valid let trimmed = value.trim(); if trimmed.len() <= max_bytes { @@ -117,13 +145,13 @@ fn bound_string(value: &str, max_bytes: usize) -> String { trimmed[..end].to_string() } -fn metadata_string(map: &HashMap, key: &str) -> Option { +pub(super) fn metadata_string(map: &HashMap, key: &str) -> Option { let value = map.get(key)?; let owned = value.try_clone().ok()?; String::try_from(owned).ok() } -fn metadata_artist(map: &HashMap) -> Option { +pub(super) fn metadata_artist(map: &HashMap) -> Option { let value = map.get("xesam:artist")?; let artists_value = value.try_clone().ok()?; if let Ok(artists) = Vec::::try_from(artists_value) { diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index f602e02db..5d37cdf0a 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -11,9 +11,10 @@ use zbus::{Connection, Proxy, ProxyBuilder}; use super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; use super::constants::{ - MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PROPERTY_TIMEOUT_MS, MPRIS_TIMEOUT_QUARANTINE_AFTER, - MPRIS_TIMEOUT_QUARANTINE_MS, + MAX_MPRIS_IDENTITY_BYTES, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PROPERTY_TIMEOUT_MS, + MPRIS_TIMEOUT_QUARANTINE_AFTER, MPRIS_TIMEOUT_QUARANTINE_MS, }; +use super::metadata::bounded_property; #[cfg(target_os = "linux")] use super::process::executable_allowed_from_pidfd; #[cfg(target_os = "linux")] @@ -65,7 +66,7 @@ impl PlayerTimeoutState { let Some(deadline) = *until else { return false; }; - if Instant::now() < deadline { + if quarantine_active(Instant::now(), deadline) { return true; } *until = None; @@ -94,6 +95,10 @@ impl PlayerTimeoutState { } } +pub(super) fn quarantine_active(now: Instant, deadline: Instant) -> bool { + now < deadline +} + pub(in crate::media) async fn build_player_state( connection: &Connection, name: &str, @@ -185,18 +190,21 @@ pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Optio .ok()? .path(MPRIS_PATH) .ok()? - .interface(MPRIS_APP) + .interface("org.freedesktop.DBus.Properties") .ok()? .build() .await .ok()?; - tokio::time::timeout( + bounded_property::( + &proxy, + super::constants::MPRIS_APP, + "Identity", std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), - proxy.get_property("Identity"), ) .await - .ok()? - .ok() + .filter(|identity| identity.len() <= MAX_MPRIS_IDENTITY_BYTES) + .map(|identity| identity.trim().to_string()) + .filter(|identity| !identity.is_empty()) } pub(super) async fn resolve_player_owner( diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index 732d34de4..c531b611d 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -5,8 +5,9 @@ use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::fdo::DBusProxy; -use super::super::constants::MAX_MPRIS_PLAYERS; -use super::super::discovery::{is_discoverable_player, refresh_players, select_player_names}; +use super::super::discovery::{ + is_discoverable_player, refresh_players, select_player_names, should_skip_for_owner_capacity, +}; use super::super::player::build_player_state; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -29,30 +30,38 @@ fn discovery_requires_an_mpris_name_that_passes_admission() { } #[test] -fn discovery_caps_names_deterministically() { - let names = (0..(MAX_MPRIS_PLAYERS + 16)) +fn discovery_orders_all_names_before_owner_capacity_is_applied() { + let names = (0..48) .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) .collect::>(); let selected = select_player_names(names); - assert_eq!(selected.len(), MAX_MPRIS_PLAYERS); + assert_eq!(selected.len(), 48); assert_eq!( selected.first().map(String::as_str), Some("org.mpris.MediaPlayer2.player-000") ); assert_eq!( selected.last().map(String::as_str), - Some("org.mpris.MediaPlayer2.player-031") + Some("org.mpris.MediaPlayer2.player-047") ); } #[test] -fn discovery_keeps_exactly_the_player_cap() { - let names = (0..MAX_MPRIS_PLAYERS) +fn discovery_keeps_all_admitted_names_for_owner_resolution() { + let names = (0..32) .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) .collect::>(); - assert_eq!(select_player_names(names).len(), MAX_MPRIS_PLAYERS); + assert_eq!(select_player_names(names).len(), 32); +} + +#[test] +fn discovery_capacity_keeps_aliases_but_rejects_new_owners_at_the_limit() { + assert!(!should_skip_for_owner_capacity(31, 32, false)); + assert!(!should_skip_for_owner_capacity(32, 32, true)); + assert!(should_skip_for_owner_capacity(32, 32, false)); + assert!(should_skip_for_owner_capacity(33, 32, false)); } #[tokio::test] diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index 3a7aab0bd..14e1d4265 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -1,8 +1,72 @@ use super::super::constants::MAX_MPRIS_PROPERTY_REPLY_BYTES; -use super::super::metadata::{metadata_entry_count_allowed, property_reply_body_allowed}; +use super::super::metadata::{ + bound_string, metadata_artist, metadata_entry_count_allowed, metadata_string, + property_reply_body_allowed, +}; use super::super::{build_player_state, fetch_media_info}; use super::support::{MprisFixture, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; +use zbus::zvariant::{OwnedValue, Value}; + +#[test] +fn bounded_metadata_strings_trim_and_preserve_utf8_boundaries() { + assert_eq!(bound_string(" title ", 32), "title"); + assert_eq!(bound_string("éé", 3), "é"); + assert_eq!(bound_string("title", 0), ""); +} + +#[test] +fn metadata_fields_accept_expected_string_shapes() { + let title = OwnedValue::try_from(Value::from("A title")).expect("title value"); + let artists = + OwnedValue::try_from(Value::from(vec!["Artist".to_string()])).expect("artist value"); + let metadata = std::collections::HashMap::from([ + ("xesam:title".to_string(), title), + ("xesam:artist".to_string(), artists), + ]); + + assert_eq!( + metadata_string(&metadata, "xesam:title").as_deref(), + Some("A title") + ); + assert_eq!(metadata_artist(&metadata).as_deref(), Some("Artist")); +} + +#[test] +fn metadata_artist_rejects_empty_and_oversized_artist_lists() { + let empty = + OwnedValue::try_from(Value::from(vec![" ".to_string()])).expect("empty artist value"); + let oversized = OwnedValue::try_from(Value::from( + (0..17) + .map(|index| format!("Artist {index}")) + .collect::>(), + )) + .expect("oversized artist value"); + let maximum = OwnedValue::try_from(Value::from( + (0..16) + .map(|index| format!("Artist {index}")) + .collect::>(), + )) + .expect("maximum artist value"); + let scalar = OwnedValue::try_from(Value::from("Solo artist")).expect("scalar artist value"); + + let empty_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), empty)]); + let oversized_metadata = + std::collections::HashMap::from([("xesam:artist".to_string(), oversized)]); + let maximum_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), maximum)]); + let scalar_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), scalar)]); + + assert_eq!(metadata_artist(&empty_metadata), None); + assert_eq!(metadata_artist(&oversized_metadata), None); + assert_eq!( + metadata_artist(&maximum_metadata).as_deref(), + Some("Artist 0") + ); + assert_eq!( + metadata_artist(&scalar_metadata).as_deref(), + Some("Solo artist") + ); +} #[test] fn metadata_limits_accept_exact_boundaries_only() { @@ -29,3 +93,17 @@ async fn oversized_metadata_reply_is_rejected_before_dynamic_decode() { assert!(info.title.is_empty()); assert!(info.artist.is_empty()); } + +#[tokio::test] +async fn oversized_art_url_is_not_retained() { + let fixture = MprisFixture::start_with_art_url_bytes(2_049).await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build oversized-art fixture player") + .expect("fixture owner should remain stable"); + + let info = fetch_media_info(&player) + .await + .expect("playback status remains available"); + assert_eq!(info.art_source, None); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index c00eb9653..73da2f031 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -1,9 +1,11 @@ +use std::time::{Duration, Instant}; + use unixnotis_core::MediaConfig; use super::super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PREFIX}; use super::super::player::{ build_player_state, build_player_state_for_owner, fetch_identity, owner_probe_is_stable, - read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, + quarantine_active, read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, }; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -13,6 +15,13 @@ fn owner_probe_accepts_only_one_stable_unique_owner() { assert!(!owner_probe_is_stable(":1.40", ":1.41")); } +#[test] +fn quarantine_deadline_is_exclusive() { + let now = Instant::now(); + assert!(quarantine_active(now, now + Duration::from_millis(1))); + assert!(!quarantine_active(now, now)); +} + #[cfg(target_os = "linux")] #[test] fn owner_probe_keeps_metadata_when_process_fd_is_unavailable() { diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index 40a4b7671..caf700da9 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -104,6 +104,7 @@ struct CommandCounts { struct TestMprisPlayer { commands: Arc, metadata_bytes: usize, + art_url_bytes: usize, } #[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] @@ -124,15 +125,24 @@ impl TestMprisPlayer { #[zbus(property)] fn metadata(&self) -> HashMap { // The optional payload exercises the raw reply budget without a real player + let mut metadata = HashMap::new(); if self.metadata_bytes > 0 { let large_value = "x".repeat(self.metadata_bytes); - return HashMap::from([( + metadata.insert( "test:large".to_string(), OwnedValue::try_from(zbus::zvariant::Value::from(large_value.as_str())) .expect("build large metadata value"), - )]); + ); } - HashMap::new() + if self.art_url_bytes > 0 { + let art_url = format!("https://example.com/{}", "x".repeat(self.art_url_bytes)); + metadata.insert( + "mpris:artUrl".to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(art_url.as_str())) + .expect("build art URL value"), + ); + } + metadata } #[zbus(property)] @@ -175,6 +185,14 @@ impl MprisFixture { } pub(in crate::media) async fn start_with_metadata_bytes(metadata_bytes: usize) -> Self { + Self::start_with_payload(metadata_bytes, 0).await + } + + pub(in crate::media) async fn start_with_art_url_bytes(art_url_bytes: usize) -> Self { + Self::start_with_payload(0, art_url_bytes).await + } + + async fn start_with_payload(metadata_bytes: usize, art_url_bytes: usize) -> Self { let broker = PrivateBroker::start(); let commands = Arc::new(CommandCounts::default()); // The service exports both MPRIS interfaces at the standard object path @@ -189,6 +207,7 @@ impl MprisFixture { TestMprisPlayer { commands: commands.clone(), metadata_bytes, + art_url_bytes, }, ) .expect("register test MPRIS player") diff --git a/crates/unixnotis-center/src/media/runtime/owner.rs b/crates/unixnotis-center/src/media/runtime/owner.rs index df43b5717..bf22bdeb0 100644 --- a/crates/unixnotis-center/src/media/runtime/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/owner.rs @@ -52,11 +52,6 @@ pub(super) async fn apply_owner_change( return Ok(OwnerChangeOutcome::Removed); } - if should_retry_for_capacity(state.players.contains_key(name), state.players.len()) { - // Full discovery will choose the deterministic prefix on the next pass - return Ok(OwnerChangeOutcome::RetryNeeded); - } - if state .players .get(name) @@ -93,6 +88,13 @@ pub(super) async fn apply_owner_change( } return Ok(OwnerChangeOutcome::Applied); } + if state.players.len() >= MAX_MPRIS_PLAYERS { + // A distinct owner was found, but the bounded state set is full + if removed_previous { + send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; + } + return Ok(OwnerChangeOutcome::RetryNeeded); + } // Start the listener before publishing state so late property traffic is retained spawn_properties_listener( player_state.properties.clone(), @@ -155,10 +157,6 @@ pub(super) fn owner_is_unchanged( current_owner.is_some() && current_owner == announced_owner } -pub(super) const fn should_retry_for_capacity(tracked: bool, player_count: usize) -> bool { - !tracked && player_count >= MAX_MPRIS_PLAYERS -} - pub(super) fn owner_is_duplicate( existing_name: &str, requested_name: &str, diff --git a/crates/unixnotis-center/src/media/runtime/tests/owner.rs b/crates/unixnotis-center/src/media/runtime/tests/owner.rs index f69b2d3fb..223e69a85 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/owner.rs @@ -1,12 +1,11 @@ use super::super::owner::{ apply_owner_change, owner_is_duplicate, owner_is_unchanged, owner_rebuild_outcome, - replacement_removal_needs_snapshot, should_retry_for_capacity, OwnerChangeOutcome, + replacement_removal_needs_snapshot, OwnerChangeOutcome, }; use super::super::state::MediaRuntimeState; use super::support::receive_ui_event; use crate::control::UiEvent; use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; -use crate::media::mpris::MAX_MPRIS_PLAYERS; use crate::media::mpris::{build_player_state, fetch_media_info}; use unixnotis_core::MediaConfig; @@ -51,14 +50,6 @@ fn stable_owner_rebuild_does_not_publish_an_empty_replacement_snapshot() { assert!(!replacement_removal_needs_snapshot(true, outcome)); } -#[test] -fn owner_capacity_retry_applies_only_to_new_players() { - assert!(should_retry_for_capacity(false, MAX_MPRIS_PLAYERS)); - assert!(should_retry_for_capacity(false, MAX_MPRIS_PLAYERS + 1)); - assert!(!should_retry_for_capacity(true, MAX_MPRIS_PLAYERS)); - assert!(!should_retry_for_capacity(false, MAX_MPRIS_PLAYERS - 1)); -} - #[test] fn owner_duplicate_check_requires_a_different_name_and_same_owner() { assert!(owner_is_duplicate( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index d588d829f..b6070f6fe 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -1,6 +1,7 @@ //! Ordered attribution pipeline and candidate orchestration -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use unixnotis_core::{AttributionStatus, InteractionPolicies, RecordTrust}; use super::super::desktop_index::{ @@ -21,6 +22,17 @@ use super::resolution::{ use super::sender_context::enrich_sender_install_provenance; use super::validation::validate_desktop_id; +const ATTRIBUTION_WORKER_SLOTS: usize = 8; + +fn attribution_worker_pool() -> Arc { + static POOL: OnceLock> = OnceLock::new(); + Arc::clone(POOL.get_or_init(|| Arc::new(Semaphore::new(ATTRIBUTION_WORKER_SLOTS)))) +} + +fn try_attribution_worker() -> Option { + attribution_worker_pool().try_acquire_owned().ok() +} + /// Production entry point that moves procfs and filesystem work off Tokio workers pub(in crate::daemon) async fn resolve_attribution_owned( reported_name: String, @@ -28,12 +40,22 @@ pub(in crate::daemon) async fn resolve_attribution_owned( sender: SenderMetadata, index: Arc, ) -> AttributionResolution { + let Some(initial_permit) = try_attribution_worker() else { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied(claim, &sender, "attribution worker capacity exhausted"); + }; let initial = tokio::task::spawn_blocking({ let reported_name = reported_name.clone(); let desktop_entry = desktop_entry.clone(); let index = Arc::clone(&index); let sender = sender.clone(); move || { + // The permit lives inside the blocking closure so timeout cancellation + // cannot release capacity while procfs work is still running + let _permit = initial_permit; let sender = refresh_sender_security_evidence(&sender); let claim = AppClaim { reported_name: &reported_name, @@ -65,10 +87,15 @@ pub(in crate::daemon) async fn resolve_attribution_owned( return initial; } enrich_sender_install_provenance(&mut sender, &index).await; + let Some(provenance_permit) = try_attribution_worker() else { + // The initial result is already safe and interaction-denied + return initial; + }; let fallback_name = reported_name.clone(); let fallback_entry = desktop_entry.clone(); let fallback_sender = sender.clone(); tokio::task::spawn_blocking(move || { + let _permit = provenance_permit; let claim = AppClaim { reported_name: &reported_name, desktop_entry: desktop_entry.as_deref(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index 15b8b9687..0f6a0ed1b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -127,7 +127,7 @@ pub(super) fn resolution_for_record( canonical_id, &canonical.badge_icon, IdentityAssurance::UserAssociated, - InteractionPolicies::CONFIRM_ACTIONS, + InteractionPolicies::NATIVE_COMPATIBILITY, AttributionReason::ExactUserExecutable, &source, format!( diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index e3873bf4b..49f85a0a9 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -92,12 +92,31 @@ impl DaemonState { } pub(crate) fn ui_health(&self) -> UiHealth { + // A readiness transition updates the revision after all fields change + // Retry when a concurrent transition would otherwise mix two snapshots + for _ in 0..3 { + let before = self.ui_health_revision.load(Ordering::Acquire); + let health = UiHealth { + center_process_running: self.center_process_running.load(Ordering::Acquire), + center_ready: self.panel_ready.load(Ordering::Acquire), + popups_process_running: self.popups_process_running.load(Ordering::Acquire), + popups_ready: self.popups_ready.load(Ordering::Acquire), + revision: before, + }; + let after = self.ui_health_revision.load(Ordering::Acquire); + if before == after { + return health; + } + } + + // A busy transition still returns a coherent revisioned sample after + // the bounded retries rather than delaying notification admission UiHealth { - center_process_running: self.center_process_running.load(Ordering::SeqCst), - center_ready: self.panel_ready(), - popups_process_running: self.popups_process_running.load(Ordering::SeqCst), - popups_ready: self.popups_ready(), - revision: self.ui_health_revision.load(Ordering::SeqCst), + center_process_running: self.center_process_running.load(Ordering::Acquire), + center_ready: self.panel_ready.load(Ordering::Acquire), + popups_process_running: self.popups_process_running.load(Ordering::Acquire), + popups_ready: self.popups_ready.load(Ordering::Acquire), + revision: self.ui_health_revision.load(Ordering::Acquire), } } From e24b282acde02607c21fa23bbcf6cfec2e12646f Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 15:55:38 -0500 Subject: [PATCH 185/275] fix(ui): share generation-bound default activation Summary: share generation-bound default activation. Scope: ui. --- .../notifications/row/notification/build.rs | 20 ++- .../row/notification/reply/build.rs | 4 + .../notifications/row/notification/state.rs | 3 + .../row/notification/update/actions.rs | 20 ++- .../row/notification/update/row.rs | 20 ++- .../row/notification/update/tests/actions.rs | 8 +- .../src/ui/notifications/view/widgets.rs | 3 + .../src/ui/entry/activation.rs | 118 +++-------------- .../ui/entry/presentation/tests/view_model.rs | 8 +- .../src/ui/entry/tests/activation.rs | 67 ++++++++-- crates/unixnotis-ui/src/presentation/build.rs | 8 +- .../src/presentation/default_activation.rs | 125 ++++++++++++++++++ crates/unixnotis-ui/src/presentation/mod.rs | 1 + .../presentation/tests/default_activation.rs | 111 ++++++++++++++++ .../src/presentation/tests/presentation.rs | 12 +- 15 files changed, 396 insertions(+), 132 deletions(-) create mode 100644 crates/unixnotis-ui/src/presentation/default_activation.rs create mode 100644 crates/unixnotis-ui/src/presentation/tests/default_activation.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 9dd61b702..50adb21d6 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -10,6 +10,9 @@ use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; use unixnotis_core::{css::hooks, NotificationKey}; +use unixnotis_ui::presentation::default_activation::{ + connect_default_activation, mark_interactive, +}; use unixnotis_ui::CutCorner; use crate::control::UiCommand; @@ -55,6 +58,7 @@ pub(in crate::ui::notifications) fn build_notification_row( close_button.set_halign(gtk::Align::End); close_button.set_valign(gtk::Align::Center); close_button.add_css_class("unixnotis-panel-close"); + mark_interactive(&close_button); close_button.update_property(&[gtk::accessible::Property::Label("Dismiss notification")]); // Header owns identity, chronology, and dismiss without covering message content @@ -178,6 +182,7 @@ pub(in crate::ui::notifications) fn build_notification_row( let actions_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); // Action buttons are added on demand during row updates actions_box.add_css_class("unixnotis-notification-actions"); + mark_interactive(&actions_box); let inline_reply = build_inline_reply(command_tx.clone()); // Keep the card tree fully built up front @@ -201,13 +206,26 @@ pub(in crate::ui::notifications) fn build_notification_row( generation: 0, })); // Recycled rows retain the exact generation rather than targeting a reused numeric id - connect_dismiss_button(&close_button, command_tx, notify_key.clone()); + connect_dismiss_button(&close_button, command_tx.clone(), notify_key.clone()); + let default_activation = connect_default_activation(&card, { + move |notification, action_key| { + try_send_command( + &command_tx, + UiCommand::InvokeAction { + notification, + action_key, + confirmed: false, + }, + ); + } + }); // The reusable widget bundle is returned with the root so the list factory // can keep the GTK tree and the cached row state together ( root, NotificationRowWidgets { + default_activation, card, card_plate, stack_middle, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs index 016357081..ba88f5bd2 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs @@ -4,6 +4,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; use crate::control::UiCommand; +use unixnotis_ui::presentation::default_activation::mark_interactive; use super::lifecycle::{cancel_inline_reply, submit_reply, MAX_REPLY_BYTES}; use super::presentation::{clear_reply_error, DEFAULT_PLACEHOLDER, DEFAULT_SUBMIT_LABEL}; @@ -20,6 +21,7 @@ pub(in super::super) fn build_inline_reply( revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); revealer.set_transition_duration(INLINE_REPLY_TRANSITION_MS); revealer.set_reveal_child(false); + mark_interactive(&revealer); let form = gtk::Box::new(gtk::Orientation::Vertical, 4); form.add_css_class("unixnotis-inline-reply"); @@ -30,11 +32,13 @@ pub(in super::super) fn build_inline_reply( entry.set_max_length(MAX_REPLY_CHARS); entry.set_placeholder_text(Some(DEFAULT_PLACEHOLDER)); entry.add_css_class("unixnotis-inline-reply-entry"); + mark_interactive(&entry); let send_button = gtk::Button::with_label(DEFAULT_SUBMIT_LABEL); send_button.set_sensitive(false); send_button.add_css_class("unixnotis-notification-action"); send_button.add_css_class("unixnotis-inline-reply-send"); + mark_interactive(&send_button); let error_label = gtk::Label::new(None); error_label.set_xalign(0.0); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index b1a13484a..04445ac78 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -7,11 +7,14 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use unixnotis_core::{NotificationKey, NotificationView}; +use unixnotis_ui::presentation::default_activation::DefaultActionBinding; use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation}; use super::reply::InlineReplyWidgets; pub(in crate::ui::notifications) struct NotificationRowWidgets { + // Active rows use one shared generation-bound card activation binding + pub(in crate::ui::notifications) default_activation: DefaultActionBinding, // Styled notification card inside the ListView row wrapper pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 40d9c0586..181d2ebd7 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -115,7 +115,7 @@ pub(super) fn update_actions( &presentation.actions.overflow, )); } - if let Some(default_key) = blank_default_action_key(&presentation) { + if let Some(default_key) = hidden_default_action_key(&presentation) { row.actions_box.append(&build_default_action_button( command_tx, notification.key(), @@ -138,7 +138,7 @@ fn action_signature( .chain(&presentation.actions.overflow) .map(|action| (action.key.clone(), action.label.clone(), action.policy)) .collect::>(); - if let Some(default_key) = blank_default_action_key(presentation) { + if let Some(default_key) = hidden_default_action_key(presentation) { // The empty label distinguishes the compact icon-only default control signature.push(( default_key.to_string(), @@ -149,9 +149,17 @@ fn action_signature( signature } -fn blank_default_action_key(presentation: &NotificationPresentation) -> Option<&str> { - // Shared presentation keeps allowed defaults out of the visible button lists - presentation.actions.default_key.as_deref() +fn hidden_default_action_key(presentation: &NotificationPresentation) -> Option<&str> { + // A labeled default is already rendered as a normal action button + let visible_default = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .any(|action| action.key == "default"); + (!visible_default) + .then_some(presentation.actions.default_key.as_deref()) + .flatten() } fn build_default_action_button( @@ -337,6 +345,6 @@ fn visible_action_count_from(presentation: &NotificationPresentation, is_active: } let regular = presentation.actions.primary.len() + presentation.actions.overflow.len(); let reply = presentation.trust.reply == ReplyPresentation::Available; - let blank_default = blank_default_action_key(presentation).is_some(); + let blank_default = hidden_default_action_key(presentation).is_some(); regular + usize::from(reply) + usize::from(blank_default) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 48f99a9eb..4c0f84179 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -2,7 +2,9 @@ use gtk::prelude::*; use tokio::sync::mpsc; -use unixnotis_ui::presentation::{apply_semantic_badge, NotificationPresentation}; +use unixnotis_ui::presentation::{ + apply_semantic_badge, default_activation::DefaultActionTarget, NotificationPresentation, +}; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -29,6 +31,22 @@ pub(in crate::ui::notifications) fn update_notification_row( }; let notification = notification_snapshot.as_ref(); let presentation = NotificationPresentation::from_view(notification); + let default_target = data + .is_active + .then(|| { + presentation + .actions + .default_key + .as_ref() + .map(|action_key| DefaultActionTarget { + notification: notification.key(), + action_key: action_key.clone(), + }) + }) + .flatten(); + // Set this before action-cache early returns so recycled rows cannot retain + // a previous notification generation + row.default_activation.set_target(default_target); let show_identity = !data.collapsed_group_preview && !data.expanded; let has_actions = visible_action_count(notification, data.is_active) > 0; let has_thumbnail = diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index 5e654581a..df96f11bf 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -332,7 +332,7 @@ fn active_blank_default_action_builds_accessible_open_control() { } #[gtk::test] -fn labeled_default_action_uses_one_compact_accessible_open_control() { +fn labeled_default_action_stays_a_visible_one_click_button() { let (_root, row) = notification_row(); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); let mut notification = sample_notification(); @@ -359,9 +359,9 @@ fn labeled_default_action_uses_one_compact_accessible_open_control() { .actions_box .first_child() .and_downcast::() - .expect("compact default action button"); - assert!(button.has_css_class("unixnotis-panel-default-action")); - assert_eq!(button.tooltip_text().as_deref(), Some("Open notification")); + .expect("labeled default action button"); + assert!(!button.has_css_class("unixnotis-panel-default-action")); + assert_eq!(button.label().as_deref(), Some("Open conversation")); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 09d5bddea..4cb8ffc6f 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -94,6 +94,9 @@ impl RowWidgets { pub(super) fn unbind(&self) { self.disconnect(); + if let Some(notification) = &self.notification { + notification.default_activation.set_target(None); + } } fn disconnect(&self) { diff --git a/crates/unixnotis-popups/src/ui/entry/activation.rs b/crates/unixnotis-popups/src/ui/entry/activation.rs index 0e96ad8cd..de3c6520c 100644 --- a/crates/unixnotis-popups/src/ui/entry/activation.rs +++ b/crates/unixnotis-popups/src/ui/entry/activation.rs @@ -2,16 +2,16 @@ use gtk::prelude::*; use unixnotis_core::NotificationKey; +use unixnotis_ui::presentation::default_activation::{ + connect_default_activation, mark_interactive as shared_mark_interactive, DefaultActionTarget, +}; use super::commands::try_send_command; use super::presentation::PopupEntryViewModel; use crate::dbus::UiCommand; -pub(super) const INTERACTIVE_CLASS: &str = "unixnotis-popup-interactive"; - pub(super) fn mark_interactive>(widget: &W) { - // One explicit marker protects current controls and future composite widgets - widget.add_css_class(INTERACTIVE_CLASS); + shared_mark_interactive(widget); } pub(super) fn connect_default_action( @@ -23,109 +23,21 @@ pub(super) fn connect_default_action( let Some(action_key) = view.default_action_key.clone() else { return; }; - - // A blank-label default action still needs a discoverable keyboard target - root.set_focusable(true); - root.set_accessible_role(gtk::AccessibleRole::Button); - root.update_property(&[gtk::accessible::Property::Label("Open notification")]); - root.add_css_class("unixnotis-popup-default-action"); - - let gesture = gtk::GestureClick::new(); - gesture.set_button(1); - let root_weak = root.downgrade(); let click_tx = command_tx.clone(); - let click_key = action_key.clone(); - gesture.connect_released(move |_, _, x, y| { - let Some(root) = root_weak.upgrade() else { - return; - }; - dispatch_default_action( - root.upcast_ref(), - root.pick(x, y, gtk::PickFlags::DEFAULT), - notification, - &click_key, + let binding = connect_default_activation(root, move |notification, action_key| { + try_send_command( &click_tx, + UiCommand::InvokeAction { + notification, + action_key, + confirmed: false, + }, ); }); - root.add_controller(gesture); - - let key_controller = gtk::EventControllerKey::new(); - let root_weak = root.downgrade(); - let key_tx = command_tx.clone(); - key_controller.connect_key_pressed(move |_, key, _, _| { - let Some(root) = root_weak.upgrade() else { - return gtk::glib::Propagation::Proceed; - }; - handle_default_action_key(root.has_focus(), key, notification, &action_key, &key_tx) - }); - root.add_controller(key_controller); -} - -fn handle_default_action_key( - root_has_focus: bool, - key: gtk::gdk::Key, - notification: NotificationKey, - action_key: &str, - command_tx: &tokio::sync::mpsc::Sender, -) -> gtk::glib::Propagation { - if !root_has_focus || !is_default_activation_key(key) { - return gtk::glib::Propagation::Proceed; - } - invoke_default_action(notification, action_key, command_tx); - gtk::glib::Propagation::Stop -} - -fn dispatch_default_action( - root: >k::Widget, - picked: Option, - notification: NotificationKey, - action_key: &str, - command_tx: &tokio::sync::mpsc::Sender, -) { - if picked_widget_blocks_default_action(root, picked) { - return; - } - invoke_default_action(notification, action_key, command_tx); -} - -fn invoke_default_action( - notification: NotificationKey, - action_key: &str, - command_tx: &tokio::sync::mpsc::Sender, -) { - // Presentation policy has already removed default actions from weak identities - try_send_command( - command_tx, - UiCommand::InvokeAction { - notification, - action_key: action_key.to_string(), - confirmed: false, - }, - ); -} - -fn picked_widget_blocks_default_action( - root: >k::Widget, - mut picked: Option, -) -> bool { - while let Some(current) = picked { - if current == *root { - return false; - } - // Focusability is a safe fallback for controls not yet carrying the marker - if current.has_css_class(INTERACTIVE_CLASS) || current.is_focusable() { - return true; - } - picked = current.parent(); - } - false -} - -const fn is_default_activation_key(key: gtk::gdk::Key) -> bool { - matches!( - key, - gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space - ) + binding.set_target(Some(DefaultActionTarget { + notification, + action_key, + })); } #[cfg(test)] diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 1ea7054d0..3e5e4677b 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -64,9 +64,11 @@ fn utility_layout_moves_extra_safe_actions_into_overflow() { assert_eq!(model.kind, PopupKind::Utility); assert_eq!(model.default_action_key.as_deref(), Some("default")); assert_eq!(model.primary_actions.len(), 2); - assert_eq!(model.primary_actions[0].key, "folder"); - assert_eq!(model.overflow_actions.len(), 1); - assert_eq!(model.overflow_actions[0].key, "mute"); + assert_eq!(model.primary_actions[0].key, "default"); + assert_eq!(model.primary_actions[1].key, "folder"); + assert_eq!(model.overflow_actions.len(), 2); + assert_eq!(model.overflow_actions[0].key, "archive"); + assert_eq!(model.overflow_actions[1].key, "mute"); } #[test] diff --git a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs index beae11226..8011059eb 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs @@ -1,12 +1,12 @@ use gtk::prelude::*; use unixnotis_core::NotificationKey; -use super::{ - connect_default_action, dispatch_default_action, handle_default_action_key, - is_default_activation_key, mark_interactive, -}; +use super::{connect_default_action, mark_interactive}; use crate::dbus::UiCommand; use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use unixnotis_ui::presentation::default_activation::{ + is_default_activation_key, picked_widget_blocks_default_action, +}; use unixnotis_ui::presentation::{BadgePresentation, ThumbnailKind, TrustLevel, TrustPresentation}; const KEY: NotificationKey = NotificationKey { @@ -50,7 +50,7 @@ fn clicking_plain_card_content_invokes_default_once() { root.append(&label); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); - dispatch_default_action( + dispatch_default_action_for_test( root.upcast_ref(), Some(label.upcast()), KEY, @@ -106,7 +106,7 @@ fn keyboard_default_action_requires_card_focus_and_enter_or_space() { ] { let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); assert_eq!( - handle_default_action_key(true, key, KEY, "default", &command_tx), + handle_default_action_for_test(true, key, KEY, "default", &command_tx), gtk::glib::Propagation::Stop ); assert_default_command(&mut command_rx); @@ -119,7 +119,7 @@ fn keyboard_default_action_requires_card_focus_and_enter_or_space() { ] { let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); assert_eq!( - handle_default_action_key(focused, key, KEY, "default", &command_tx), + handle_default_action_for_test(focused, key, KEY, "default", &command_tx), gtk::glib::Propagation::Proceed ); assert!(command_rx.try_recv().is_err()); @@ -128,7 +128,7 @@ fn keyboard_default_action_requires_card_focus_and_enter_or_space() { fn assert_pick_does_not_dispatch>(root: >k::Box, picked: &W) { let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); - dispatch_default_action( + dispatch_default_action_for_test( root.upcast_ref(), Some(picked.clone().upcast()), KEY, @@ -138,6 +138,57 @@ fn assert_pick_does_not_dispatch>(root: >k::Box, picked: & assert!(command_rx.try_recv().is_err()); } +fn handle_default_action_for_test( + root_has_focus: bool, + key: gtk::gdk::Key, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) -> gtk::glib::Propagation { + if !root_has_focus || !is_default_activation_key(key) { + return gtk::glib::Propagation::Proceed; + } + invoke_default_action_for_test(notification, action_key, command_tx); + gtk::glib::Propagation::Stop +} + +fn dispatch_default_action_for_test( + root: >k::Widget, + picked: Option, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + if picked_widget_blocks_default_action(root, picked) { + return; + } + invoke_default_action_for_test(notification, action_key, command_tx); +} + +fn invoke_default_action_for_test( + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + try_send_command_for_test( + command_tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.to_string(), + confirmed: false, + }, + ); +} + +fn try_send_command_for_test( + command_tx: &tokio::sync::mpsc::Sender, + command: UiCommand, +) { + command_tx + .try_send(command) + .expect("test command channel has capacity"); +} + fn assert_default_command(command_rx: &mut tokio::sync::mpsc::Receiver) { match command_rx.try_recv().expect("default action command") { UiCommand::InvokeAction { diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index ecc9d6091..5d958db05 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -268,10 +268,12 @@ fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> A } else { button_policy }; - // Allowed defaults use card activation and never duplicate app-owned branding + // Allowed defaults keep a labeled button while blank labels use card activation (policy != ApplicationActionPolicy::Deny - && !(action.key == "default" && policy == ApplicationActionPolicy::Allow)) - .then(|| action_view(action, policy)) + && !(action.key == "default" + && policy == ApplicationActionPolicy::Allow + && action.label.trim().is_empty())) + .then(|| action_view(action, policy)) }) .collect::>(); if default_policy == ApplicationActionPolicy::Confirm diff --git a/crates/unixnotis-ui/src/presentation/default_activation.rs b/crates/unixnotis-ui/src/presentation/default_activation.rs new file mode 100644 index 000000000..36bc35057 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/default_activation.rs @@ -0,0 +1,125 @@ +//! Shared whole-card default activation for popup and panel rows + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +pub const INTERACTIVE_CLASS: &str = "unixnotis-popup-interactive"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultActionTarget { + pub notification: NotificationKey, + pub action_key: String, +} + +#[derive(Clone)] +pub struct DefaultActionBinding { + target: Rc>>, +} + +impl DefaultActionBinding { + pub fn set_target(&self, target: Option) { + *self.target.borrow_mut() = target; + } +} + +pub fn mark_interactive>(widget: &W) { + // Composite controls use the marker even when their leaf widget changes + widget.add_css_class(INTERACTIVE_CLASS); +} + +pub fn connect_default_activation(widget: &W, dispatch: F) -> DefaultActionBinding +where + W: IsA, + F: Fn(NotificationKey, String) + 'static, +{ + let root = widget.clone().upcast::(); + root.set_focusable(true); + root.set_accessible_role(gtk::AccessibleRole::Button); + root.update_property(&[gtk::accessible::Property::Label("Open notification")]); + root.add_css_class("unixnotis-popup-default-action"); + + let target: Rc>> = Rc::new(RefCell::new(None)); + let dispatch = Rc::new(dispatch); + + let gesture = gtk::GestureClick::new(); + gesture.set_button(1); + let click_root = root.clone(); + let click_target = Rc::clone(&target); + let click_dispatch = Rc::clone(&dispatch); + gesture.connect_released(move |_, _, x, y| { + let Some(current) = click_target.borrow().clone() else { + return; + }; + if picked_widget_blocks_default_action( + &click_root, + click_root.pick(x, y, gtk::PickFlags::DEFAULT), + ) { + return; + } + click_dispatch(current.notification, current.action_key); + }); + root.add_controller(gesture); + + let key_controller = gtk::EventControllerKey::new(); + let key_root = root.clone(); + let key_target = Rc::clone(&target); + let key_dispatch = Rc::clone(&dispatch); + key_controller.connect_key_pressed(move |_, key, _, _| { + let current = key_target.borrow().clone(); + if keyboard_activation_is_ready(key_root.has_focus(), key, current.is_some()) { + if let Some(current) = current { + key_dispatch(current.notification, current.action_key); + return gtk::glib::Propagation::Stop; + } + } + gtk::glib::Propagation::Proceed + }); + root.add_controller(key_controller); + + DefaultActionBinding { target } +} + +#[must_use] +pub fn picked_widget_blocks_default_action( + root: >k::Widget, + mut picked: Option, +) -> bool { + while let Some(current) = picked { + if current == *root { + return false; + } + if current.has_css_class(INTERACTIVE_CLASS) + || current.is_focusable() + || current.is::() + || current.is::() + || current.is::() + { + return true; + } + picked = current.parent(); + } + false +} + +#[must_use] +pub const fn is_default_activation_key(key: gtk::gdk::Key) -> bool { + matches!( + key, + gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space + ) +} + +pub(super) const fn keyboard_activation_is_ready( + root_has_focus: bool, + key: gtk::gdk::Key, + has_target: bool, +) -> bool { + root_has_focus && has_target && is_default_activation_key(key) +} + +#[cfg(test)] +#[path = "tests/default_activation.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs index 1f57465f5..be4c4f6cf 100644 --- a/crates/unixnotis-ui/src/presentation/mod.rs +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -2,6 +2,7 @@ mod badges; mod build; +pub mod default_activation; mod interaction; mod text; mod types; diff --git a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs new file mode 100644 index 000000000..1d15bd1d2 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs @@ -0,0 +1,111 @@ +use super::super::default_activation::{ + connect_default_activation, is_default_activation_key, keyboard_activation_is_ready, + mark_interactive, picked_widget_blocks_default_action, DefaultActionTarget, +}; +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +#[test] +fn activation_keys_match_pointer_equivalents() { + assert!(is_default_activation_key(gtk::gdk::Key::Return)); + assert!(is_default_activation_key(gtk::gdk::Key::KP_Enter)); + assert!(is_default_activation_key(gtk::gdk::Key::space)); + assert!(!is_default_activation_key(gtk::gdk::Key::Escape)); +} + +#[gtk::test] +fn binding_replaces_and_clears_the_current_generation() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let binding = connect_default_activation(&card, |_, _| {}); + let first = DefaultActionTarget { + notification: NotificationKey { + id: 7, + generation: 1, + }, + action_key: "default".to_string(), + }; + binding.set_target(Some(first.clone())); + assert_eq!(binding.target.borrow().as_ref(), Some(&first)); + let replacement = DefaultActionTarget { + notification: NotificationKey { + id: 8, + generation: 2, + }, + action_key: "open".to_string(), + }; + binding.set_target(Some(replacement.clone())); + assert_eq!(binding.target.borrow().as_ref(), Some(&replacement)); + binding.set_target(None); + assert!(binding.target.borrow().is_none()); +} + +#[gtk::test] +fn interactive_descendants_block_card_activation() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let marked = gtk::Box::new(gtk::Orientation::Vertical, 0); + marked.set_focusable(false); + mark_interactive(&marked); + card.append(&marked); + + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(marked.upcast()) + )); +} + +#[gtk::test] +fn focusable_or_marked_descendants_block_but_plain_content_does_not() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let focusable = gtk::Box::new(gtk::Orientation::Vertical, 0); + focusable.set_focusable(true); + let plain = gtk::Label::new(Some("Message")); + let menu = gtk::MenuButton::new(); + menu.set_focusable(false); + let entry = gtk::Entry::new(); + entry.set_focusable(false); + card.append(&focusable); + card.append(&plain); + card.append(&menu); + card.append(&entry); + + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(focusable.upcast()) + )); + assert!(!picked_widget_blocks_default_action( + card.upcast_ref(), + Some(plain.upcast()) + )); + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(menu.upcast()) + )); + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(entry.upcast()) + )); +} + +#[test] +fn keyboard_activation_requires_focus_key_and_target() { + assert!(keyboard_activation_is_ready( + true, + gtk::gdk::Key::Return, + true + )); + assert!(!keyboard_activation_is_ready( + false, + gtk::gdk::Key::Return, + true + )); + assert!(!keyboard_activation_is_ready( + true, + gtk::gdk::Key::Escape, + true + )); + assert!(!keyboard_activation_is_ready( + true, + gtk::gdk::Key::Return, + false + )); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 9de538fb9..291894bed 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -41,7 +41,8 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { BadgePresentation::AuthenticatedApplication ); assert_eq!(presentation.media.thumbnail, ThumbnailKind::Content); - assert!(presentation.actions.primary.is_empty()); + assert_eq!(presentation.actions.primary.len(), 1); + assert_eq!(presentation.actions.primary[0].key, "default"); assert!(presentation.actions.overflow.is_empty()); assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); assert_eq!(presentation.timestamp, "2m"); @@ -82,10 +83,15 @@ fn native_association_keeps_card_activation_and_confirms_only_extra_buttons() { assert_eq!(presentation.trust.level, TrustLevel::SystemAssociated); assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); - assert_eq!(presentation.actions.primary.len(), 1); - assert_eq!(presentation.actions.primary[0].key, "archive"); + assert_eq!(presentation.actions.primary.len(), 2); + assert_eq!(presentation.actions.primary[0].key, "default"); + assert_eq!(presentation.actions.primary[1].key, "archive"); assert_eq!( presentation.actions.primary[0].policy, + unixnotis_core::ApplicationActionPolicy::Allow + ); + assert_eq!( + presentation.actions.primary[1].policy, unixnotis_core::ApplicationActionPolicy::Confirm ); assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); From 1eee4caf7480b4d14f114ee4316cbd70070ecf3b Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 15:55:42 -0500 Subject: [PATCH 186/275] ci: bootstrap snapshot certificates before HTTPS Summary: bootstrap snapshot certificates before HTTPS. Scope: repository. --- .github/workflows/ci.yml | 11 ++++++++--- .github/workflows/mutation.yml | 11 ++++++++--- .github/workflows/release.yml | 11 ++++++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d08182234..ff31d4474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,15 +41,16 @@ jobs: run: | set -euo pipefail rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index printf '%s\n' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie trixie-updates' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ '' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie-security' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ @@ -57,10 +58,14 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources + apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ dbus \ git \ diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 1337468a3..b598ee200 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -42,15 +42,16 @@ jobs: run: | set -euo pipefail rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index printf '%s\n' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie trixie-updates' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ '' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie-security' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ @@ -58,10 +59,14 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources + apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ dbus \ git \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1f5d46de..042cc6861 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,15 +57,16 @@ jobs: set -euo pipefail # Immutable snapshots keep package resolution stable across release reruns rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index printf '%s\n' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie trixie-updates' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ '' \ 'Types: deb' \ - "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ 'Suites: trixie-security' \ 'Components: main' \ 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ @@ -74,10 +75,14 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources + apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ gettext-base \ git \ From 584634ff5afa3113865ce223d2ab222b0d4e0bbb Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:00:27 -0500 Subject: [PATCH 187/275] fix(runtime): bound media, attribution, and UI lifecycle Summary: bound media, attribution, and UI lifecycle. Scope: runtime. --- .github/workflows/ci.yml | 18 +++ .github/workflows/mutation.yml | 18 +++ .github/workflows/release.yml | 18 +++ .../src/media/mpris/constants.rs | 2 + .../src/media/mpris/discovery.rs | 116 ++++++++++------ .../unixnotis-center/src/media/mpris/mod.rs | 12 +- .../src/media/mpris/player.rs | 4 + .../src/media/mpris/tests/discovery.rs | 86 +++++++++++- .../src/media/mpris/tests/metadata.rs | 3 +- .../src/media/mpris/tests/player.rs | 13 ++ .../src/media/mpris/tests/support.rs | 31 +++-- .../src/media/runtime/owner.rs | 96 ++++--------- .../src/media/runtime/refresh.rs | 1 + .../src/media/runtime/state.rs | 3 + .../src/media/runtime/tests/owner.rs | 23 +++ .../row/notification/update/row.rs | 5 + .../identity/desktop_index/index.rs | 17 ++- .../identity/resolver/pipeline.rs | 109 +++++++-------- .../identity/resolver/sender_context.rs | 17 ++- .../src/daemon/state/model.rs | 39 +++--- .../src/daemon/state/status.rs | 131 +++++++++--------- .../src/daemon/state/tests/status.rs | 36 +++-- .../src/presentation/default_activation.rs | 52 +++++-- .../presentation/tests/default_activation.rs | 25 ++++ tests/check-release-hardening.sh | 14 ++ 25 files changed, 589 insertions(+), 300 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff31d4474..ad78ef214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,24 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" # Bootstrap the trust store over the signed snapshot index apt-get install -y --no-install-recommends ca-certificates sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index b598ee200..6493e849d 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -59,6 +59,24 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" # Bootstrap the trust store over the signed snapshot index apt-get install -y --no-install-recommends ca-certificates sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 042cc6861..33f5a3c5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,24 @@ jobs: printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ > /etc/apt/apt.conf.d/99snapshot apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" # Bootstrap the trust store over the signed snapshot index apt-get install -y --no-install-recommends ca-certificates sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ diff --git a/crates/unixnotis-center/src/media/mpris/constants.rs b/crates/unixnotis-center/src/media/mpris/constants.rs index 4e3c14e39..f0a860c90 100644 --- a/crates/unixnotis-center/src/media/mpris/constants.rs +++ b/crates/unixnotis-center/src/media/mpris/constants.rs @@ -20,6 +20,8 @@ pub const MPRIS_TIMEOUT_QUARANTINE_MS: u64 = 5_000; /// Discovery is capped so one bus connection cannot create unbounded state pub const MAX_MPRIS_PLAYERS: usize = 32; +/// Candidate owner probes are bounded before any full player construction +pub const MAX_MPRIS_CANDIDATES_PER_PASS: usize = 128; /// Metadata maps are retained only when they remain reasonably small pub const MAX_METADATA_ENTRIES: usize = 256; diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index b34b62ee7..1b3cc2f42 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -10,8 +10,9 @@ use unixnotis_core::{MediaConfig, PanelDebugLevel}; use zbus::fdo::DBusProxy; use zbus::Connection; -use super::constants::{MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; -use super::{build_player_state, is_allowed_player, spawn_properties_listener, PlayerState}; +use super::constants::{MAX_MPRIS_CANDIDATES_PER_PASS, MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; +use super::player::{build_player_state_for_owner, resolve_player_owner, OwnerProbe}; +use super::{is_allowed_player, spawn_properties_listener, PlayerState}; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::MediaSignal; @@ -21,6 +22,7 @@ pub(in crate::media) async fn refresh_players( config: &MediaConfig, signal_tx: &Sender, players: &mut HashMap, + discovery_cursor: &mut usize, ) -> zbus::Result<()> { let names = dbus_proxy.list_names().await?; let mut allowed = HashSet::new(); @@ -33,9 +35,9 @@ pub(in crate::media) async fn refresh_players( allowed.insert(name); } - // Owner capacity is enforced after probing so aliases cannot occupy a - // deterministic name prefix and starve an unrelated player - let allowed = select_player_names(allowed); + // Keep active names, then rotate through the remaining sorted names + let tracked = players.keys().cloned().collect::>(); + let allowed = select_player_names(allowed, &tracked, discovery_cursor); let allowed_set = allowed.iter().map(String::as_str).collect::>(); // Remove players that no longer exist on the bus to avoid stale UI cards @@ -66,9 +68,10 @@ pub(in crate::media) async fn refresh_players( .filter(|name| !players.contains_key(*name)) .cloned() .collect::>(); + // Owner-only probes are bounded before any full player construction let mut probed = stream::iter(names_to_probe) .map(|name| async move { - let result = build_player_state(connection, &name, config).await; + let result = resolve_player_owner(connection, &name).await; (name, result) }) .buffer_unordered(4) @@ -78,9 +81,27 @@ pub(in crate::media) async fn refresh_players( probed.sort_unstable_by(|left, right| left.0.cmp(&right.0)); let mut failed_probes = 0usize; let mut capacity_skipped = 0usize; - for (name, result) in probed { - // New players are probed concurrently, but admitted state is committed in name order - let state = match result { + let mut selected = Vec::<(String, OwnerProbe)>::new(); + for (name, owner) in probed { + let Some(owner) = owner else { + failed_probes = failed_probes.saturating_add(1); + continue; + }; + // Several aliases can resolve to one connection; retain one stable alias + if !owners.insert(owner.unique_owner.clone()) { + continue; + } + if owner_capacity_exceeded(owners.len(), MAX_MPRIS_PLAYERS) { + owners.remove(&owner.unique_owner); + capacity_skipped = capacity_skipped.saturating_add(1); + continue; + } + selected.push((name, owner)); + } + + // Full construction runs once per selected owner, never once per alias + for (name, owner) in selected { + let state = match build_player_state_for_owner(connection, &name, config, owner).await { Ok(state) => state, Err(err) => { failed_probes = failed_probes.saturating_add(1); @@ -90,36 +111,16 @@ pub(in crate::media) async fn refresh_players( continue; } }; - if let Some(state) = state { - let owner_is_tracked = state - .unique_owner - .as_ref() - .is_some_and(|owner| owners.contains(owner)); - if should_skip_for_owner_capacity(owners.len(), MAX_MPRIS_PLAYERS, owner_is_tracked) { - // The owner was resolved, but the bounded state set is full - capacity_skipped = capacity_skipped.saturating_add(1); - continue; - } - if state - .unique_owner - .as_ref() - .is_some_and(|owner| !owners.insert(owner.clone())) - { - // Several well-known names may point to one owner; one listener is enough - continue; - } - // Each player gets a properties listener so updates stay event-driven - spawn_properties_listener( - state.properties.clone(), - name.clone(), - signal_tx.clone(), - state.listener_cancel.subscribe(), - ); - players.insert(name.clone(), state); - debug::log(PanelDebugLevel::Info, || { - format!("media player added: {name}") - }); - } + spawn_properties_listener( + state.properties.clone(), + name.clone(), + signal_tx.clone(), + state.listener_cancel.subscribe(), + ); + players.insert(name.clone(), state); + debug::log(PanelDebugLevel::Info, || { + format!("media player added: {name}") + }); } if failed_probes > 0 { warn!( @@ -142,12 +143,43 @@ pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) } -pub(super) fn select_player_names(names: HashSet) -> Vec { - let mut names: Vec = names.into_iter().collect(); +pub(super) fn select_player_names( + names: HashSet, + tracked: &HashSet, + cursor: &mut usize, +) -> Vec { + let mut names = names.into_iter().collect::>(); names.sort_unstable(); - names + + let mut selected = names + .iter() + .filter(|name| tracked.contains(*name)) + .cloned() + .collect::>(); + let remaining = names + .into_iter() + .filter(|name| !tracked.contains(name)) + .collect::>(); + let room = MAX_MPRIS_CANDIDATES_PER_PASS.saturating_sub(selected.len()); + if room == 0 || remaining.is_empty() { + return selected; + } + + let start = *cursor % remaining.len(); + let count = room.min(remaining.len()); + selected.extend((0..count).map(|offset| remaining[(start + offset) % remaining.len()].clone())); + *cursor = (start + count) % remaining.len(); + selected +} + +pub(super) const fn owner_capacity_exceeded(owner_count: usize, capacity: usize) -> bool { + owner_count > capacity } +#[cfg_attr( + not(test), + expect(dead_code, reason = "capacity helper is exercised by discovery tests") +)] pub(super) const fn should_skip_for_owner_capacity( owner_count: usize, capacity: usize, diff --git a/crates/unixnotis-center/src/media/mpris/mod.rs b/crates/unixnotis-center/src/media/mpris/mod.rs index ff402aa0a..43a8b53d4 100644 --- a/crates/unixnotis-center/src/media/mpris/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/mod.rs @@ -12,11 +12,19 @@ mod process; pub(in crate::media) use admission::is_allowed_player; pub(in crate::media) use command::handle_command; -pub(in crate::media) use constants::{MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; +pub(in crate::media) use constants::MPRIS_PREFIX; pub(in crate::media) use discovery::refresh_players; pub(in crate::media) use listener::spawn_properties_listener; pub(in crate::media) use metadata::fetch_media_info; -pub(in crate::media) use player::{build_player_state, PlayerState}; +#[cfg_attr( + not(test), + expect( + unused_imports, + reason = "the direct builder is retained as an internal integration-test seam" + ) +)] +pub(in crate::media) use player::build_player_state; +pub(in crate::media) use player::PlayerState; #[cfg(test)] pub(in crate::media) mod tests; diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index 5d37cdf0a..1d890f713 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -99,6 +99,10 @@ pub(super) fn quarantine_active(now: Instant, deadline: Instant) -> bool { now < deadline } +#[cfg_attr( + not(test), + expect(dead_code, reason = "direct builder remains available for media tests") +)] pub(in crate::media) async fn build_player_state( connection: &Connection, name: &str, diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index c531b611d..0a69c36a4 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -6,7 +6,8 @@ use unixnotis_core::MediaConfig; use zbus::fdo::DBusProxy; use super::super::discovery::{ - is_discoverable_player, refresh_players, select_player_names, should_skip_for_owner_capacity, + is_discoverable_player, owner_capacity_exceeded, refresh_players, select_player_names, + should_skip_for_owner_capacity, }; use super::super::player::build_player_state; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -34,7 +35,8 @@ fn discovery_orders_all_names_before_owner_capacity_is_applied() { let names = (0..48) .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) .collect::>(); - let selected = select_player_names(names); + let mut cursor = 0; + let selected = select_player_names(names, &HashSet::new(), &mut cursor); assert_eq!(selected.len(), 48); assert_eq!( @@ -53,7 +55,83 @@ fn discovery_keeps_all_admitted_names_for_owner_resolution() { .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) .collect::>(); - assert_eq!(select_player_names(names).len(), 32); + let mut cursor = 0; + assert_eq!( + select_player_names(names, &HashSet::new(), &mut cursor).len(), + 32 + ); +} + +#[test] +fn discovery_caps_candidate_work_and_rotates_untracked_names() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 0; + let first = select_player_names(names.clone(), &HashSet::new(), &mut cursor); + let second = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!(first.len(), 128); + assert_eq!(second.len(), 128); + assert!(first.iter().all(|name| !second.contains(name))); +} + +#[test] +fn discovery_rotation_wraps_from_a_nonzero_cursor() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 130; + + let selected = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-130") + ); + assert_eq!(cursor, 2); +} + +#[test] +fn discovery_always_preserves_tracked_names_before_rotation() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = HashSet::from([ + "org.mpris.MediaPlayer2.player-255".to_string(), + "org.mpris.MediaPlayer2.player-254".to_string(), + ]); + let mut cursor = 0; + let selected = select_player_names(names, &tracked, &mut cursor); + + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-254")); + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-255")); + assert_eq!(selected.len(), 128); +} + +#[test] +fn discovery_selection_handles_empty_and_full_tracked_pages() { + let mut cursor = 0; + assert!(select_player_names(HashSet::new(), &HashSet::new(), &mut cursor).is_empty()); + + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = names.iter().take(128).cloned().collect::>(); + let selected = select_player_names(names, &tracked, &mut cursor); + + assert_eq!(selected.len(), 128); + assert!(selected.iter().all(|name| tracked.contains(name))); +} + +#[test] +fn discovery_owner_capacity_rejects_only_values_above_the_limit() { + assert!(!owner_capacity_exceeded(32, 32)); + assert!(owner_capacity_exceeded(33, 32)); } #[test] @@ -80,6 +158,7 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { stale.bus_name = stale_name.to_string(); let mut stale_cancel = stale.listener_cancel.subscribe(); let mut players = HashMap::from([(stale_name.to_string(), stale)]); + let mut discovery_cursor = 0; tokio::time::timeout( Duration::from_secs(2), @@ -89,6 +168,7 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { &config, &signal_tx, &mut players, + &mut discovery_cursor, ), ) .await diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index 14e1d4265..ee91b8034 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -1,9 +1,10 @@ use super::super::constants::MAX_MPRIS_PROPERTY_REPLY_BYTES; +use super::super::metadata::fetch_media_info; use super::super::metadata::{ bound_string, metadata_artist, metadata_entry_count_allowed, metadata_string, property_reply_body_allowed, }; -use super::super::{build_player_state, fetch_media_info}; +use super::super::player::build_player_state; use super::support::{MprisFixture, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; use zbus::zvariant::{OwnedValue, Value}; diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index 73da2f031..d22d38717 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -141,6 +141,19 @@ async fn player_state_without_process_fd_keeps_remote_metadata_and_disables_loca assert!(!state.local_art_allowed); } +#[tokio::test] +async fn oversized_identity_is_rejected_before_retention() { + let fixture = MprisFixture::start_with_identity_bytes(513).await; + let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) + .await + .expect("resolve stable test owner"); + + assert_eq!( + fetch_identity(&fixture.client, owner.unique_owner.as_str()).await, + None + ); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn exact_local_art_policy_uses_the_connection_process_fd() { diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index caf700da9..ddc05c0ad 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -83,14 +83,16 @@ fn broker_socket() -> PathBuf { root.join("bus.sock") } -struct TestMprisRoot; +struct TestMprisRoot { + identity: String, +} #[zbus::interface(name = "org.mpris.MediaPlayer2")] impl TestMprisRoot { #[zbus(property)] - fn identity(&self) -> &'static str { + fn identity(&self) -> &str { // A fixed identity makes player construction assertions deterministic - TEST_PLAYER_IDENTITY + &self.identity } } @@ -181,26 +183,39 @@ pub(in crate::media) struct MprisFixture { impl MprisFixture { pub(in crate::media) async fn start() -> Self { - Self::start_with_metadata_bytes(0).await + Self::start_with_payload(0, 0, 0).await } pub(in crate::media) async fn start_with_metadata_bytes(metadata_bytes: usize) -> Self { - Self::start_with_payload(metadata_bytes, 0).await + Self::start_with_payload(metadata_bytes, 0, 0).await } pub(in crate::media) async fn start_with_art_url_bytes(art_url_bytes: usize) -> Self { - Self::start_with_payload(0, art_url_bytes).await + Self::start_with_payload(0, art_url_bytes, 0).await + } + + pub(in crate::media) async fn start_with_identity_bytes(identity_bytes: usize) -> Self { + Self::start_with_payload(0, 0, identity_bytes).await } - async fn start_with_payload(metadata_bytes: usize, art_url_bytes: usize) -> Self { + async fn start_with_payload( + metadata_bytes: usize, + art_url_bytes: usize, + identity_bytes: usize, + ) -> Self { let broker = PrivateBroker::start(); let commands = Arc::new(CommandCounts::default()); + let identity = if identity_bytes == 0 { + TEST_PLAYER_IDENTITY.to_string() + } else { + "x".repeat(identity_bytes) + }; // The service exports both MPRIS interfaces at the standard object path let server = ConnectionBuilder::address(broker.address.as_str()) .expect("parse private broker address") .name(TEST_PLAYER_NAME) .expect("request test MPRIS name") - .serve_at(MPRIS_PATH, TestMprisRoot) + .serve_at(MPRIS_PATH, TestMprisRoot { identity }) .expect("register test MPRIS root") .serve_at( MPRIS_PATH, diff --git a/crates/unixnotis-center/src/media/runtime/owner.rs b/crates/unixnotis-center/src/media/runtime/owner.rs index bf22bdeb0..8a61a8d5a 100644 --- a/crates/unixnotis-center/src/media/runtime/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/owner.rs @@ -4,16 +4,12 @@ use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::Connection; -use super::cache::{refresh_player_cache, MediaCacheMergeMode}; -use super::schedule::{cancel_delayed_refresh, schedule_metadata_fallback}; +use super::schedule::cancel_delayed_refresh; use super::snapshot::send_snapshot_if_changed; use super::state::MediaRuntimeState; use super::MediaSignal; use crate::control::UiEvent; -use crate::media::mpris::{ - build_player_state, is_allowed_player, spawn_properties_listener, MAX_MPRIS_PLAYERS, - MPRIS_PREFIX, -}; +use crate::media::mpris::{is_allowed_player, MPRIS_PREFIX}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum OwnerChangeOutcome { @@ -28,9 +24,9 @@ pub(super) enum OwnerChangeOutcome { pub(super) async fn apply_owner_change( name: &str, new_owner: Option<&str>, - connection: &Connection, + _connection: &Connection, config: &MediaConfig, - signal_tx: &mpsc::Sender, + _signal_tx: &mpsc::Sender, state: &mut MediaRuntimeState, sender: &async_channel::Sender, ) -> zbus::Result { @@ -71,70 +67,20 @@ pub(super) async fn apply_owner_change( false }; - let rebuilt = build_player_state(connection, name, config).await; - if let Ok(Some(player_state)) = rebuilt.as_ref() { - let duplicate_owner = state.players.iter().any(|(existing_name, existing)| { - owner_is_duplicate( - existing_name, - name, - existing.unique_owner.as_deref(), - player_state.unique_owner.as_deref(), - ) - }); - if duplicate_owner { - if removed_previous { - // The old alias was removed before deduplication and still needs a UI update - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - return Ok(OwnerChangeOutcome::Applied); - } - if state.players.len() >= MAX_MPRIS_PLAYERS { - // A distinct owner was found, but the bounded state set is full - if removed_previous { - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - return Ok(OwnerChangeOutcome::RetryNeeded); - } - // Start the listener before publishing state so late property traffic is retained - spawn_properties_listener( - player_state.properties.clone(), - name.to_string(), - signal_tx.clone(), - player_state.listener_cancel.subscribe(), - ); - state.players.insert(name.to_string(), player_state.clone()); - refresh_player_cache( - &state.players, - &mut state.cache, - name, - MediaCacheMergeMode::Stable, - ) - .await; + if removed_previous { + // Rebuilding is deferred to one coalesced bounded discovery pass send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - schedule_metadata_fallback( - &mut state.delayed_refreshes, - &state.cache, - signal_tx.clone(), - name, - ); } - - // Removing a prior cache must reach GTK even when replacement probing fails - let outcome = match rebuilt { - Ok(state) => owner_rebuild_outcome(state.is_some()), - Err(err) => { - if removed_previous { - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - return Err(err); - } - }; - if replacement_removal_needs_snapshot(removed_previous, outcome) { - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - Ok(outcome) + Ok(OwnerChangeOutcome::RetryNeeded) } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "outcome helpers document and test refresh semantics" + ) +)] pub(super) const fn owner_rebuild_outcome(rebuilt: bool) -> OwnerChangeOutcome { if rebuilt { OwnerChangeOutcome::Applied @@ -143,6 +89,13 @@ pub(super) const fn owner_rebuild_outcome(rebuilt: bool) -> OwnerChangeOutcome { } } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "outcome helpers document and test refresh semantics" + ) +)] pub(super) const fn replacement_removal_needs_snapshot( removed_previous: bool, outcome: OwnerChangeOutcome, @@ -157,6 +110,13 @@ pub(super) fn owner_is_unchanged( current_owner.is_some() && current_owner == announced_owner } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "alias deduplication rule remains covered by runtime tests" + ) +)] pub(super) fn owner_is_duplicate( existing_name: &str, requested_name: &str, diff --git a/crates/unixnotis-center/src/media/runtime/refresh.rs b/crates/unixnotis-center/src/media/runtime/refresh.rs index 6eeba4a62..139d4b600 100644 --- a/crates/unixnotis-center/src/media/runtime/refresh.rs +++ b/crates/unixnotis-center/src/media/runtime/refresh.rs @@ -31,6 +31,7 @@ pub(super) async fn refresh_all_players( config, signal_tx, &mut state.players, + &mut state.discovery_cursor, ) .await { diff --git a/crates/unixnotis-center/src/media/runtime/state.rs b/crates/unixnotis-center/src/media/runtime/state.rs index 431bfe18d..81466ec92 100644 --- a/crates/unixnotis-center/src/media/runtime/state.rs +++ b/crates/unixnotis-center/src/media/runtime/state.rs @@ -15,6 +15,8 @@ pub(super) struct MediaRuntimeState { pub(super) last_snapshot: Vec, // One delayed retry plan per player pub(super) delayed_refreshes: DelayedRefreshTasks, + // Rotates bounded candidate probes so names outside the first sorted page get a turn + pub(super) discovery_cursor: usize, } impl MediaRuntimeState { @@ -25,6 +27,7 @@ impl MediaRuntimeState { cache: HashMap::new(), last_snapshot: Vec::new(), delayed_refreshes: HashMap::new(), + discovery_cursor: 0, } } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/owner.rs b/crates/unixnotis-center/src/media/runtime/tests/owner.rs index 223e69a85..491228ed3 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/owner.rs @@ -184,3 +184,26 @@ async fn duplicate_owner_change_preserves_existing_player() { assert!(state.players.contains_key(TEST_PLAYER_NAME)); assert!(event_rx.is_empty()); } + +#[tokio::test] +async fn replacement_owner_change_defers_full_probe_to_coalesced_refresh() { + let fixture = MprisFixture::start().await; + let (signal_tx, _signal_rx) = tokio::sync::mpsc::channel(4); + let (event_tx, _event_rx) = async_channel::bounded(4); + let mut state = live_runtime_state(&fixture).await; + + let outcome = apply_owner_change( + TEST_PLAYER_NAME, + Some(":1.replacement"), + &fixture.client, + &MediaConfig::default(), + &signal_tx, + &mut state, + &event_tx, + ) + .await + .expect("defer replacement probe"); + + assert_eq!(outcome, OwnerChangeOutcome::RetryNeeded); + assert!(state.players.is_empty()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 4c0f84179..c53b85132 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -27,6 +27,11 @@ pub(in crate::ui::notifications) fn update_notification_row( .set_reduced_motion(data.presentation.reduced_motion); // Model changes may briefly update a recycled row without notification data let Some(notification_snapshot) = data.notification.as_ref() else { + row.default_activation.set_target(None); + row.notify_key.set(unixnotis_core::NotificationKey { + id: 0, + generation: 0, + }); return; }; let notification = notification_snapshot.as_ref(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs index c8e7adbed..c60dc8e90 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs @@ -165,14 +165,23 @@ impl DesktopIdentityIndex { families.len() == 1 } + #[cfg_attr( + not(test), + expect(dead_code, reason = "async wrapper remains for test seams") + )] pub(in crate::daemon::notifications::identity) async fn install_provenance_for_path_async( &self, path: PathBuf, ) -> super::provenance::InstallProvenance { - let ownership = std::sync::Arc::clone(&self.package_ownership); - tokio::task::spawn_blocking(move || ownership.resolve_one(&path)) - .await - .unwrap_or_default() + self.install_provenance_for_path(path) + } + + pub(in crate::daemon::notifications::identity) fn install_provenance_for_path( + &self, + path: PathBuf, + ) -> super::provenance::InstallProvenance { + // The caller owns the attribution worker permit while this blocking lookup runs + self.package_ownership.resolve_one(&path) } pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index b6070f6fe..aa4b315fc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -19,7 +19,7 @@ use super::resolution::{ conflict_from_candidate, resolution_for_portal_record, resolution_for_record, trusted_portal_path, unknown_reply_denied, }; -use super::sender_context::enrich_sender_install_provenance; +use super::sender_context::enrich_sender_install_provenance_blocking; use super::validation::validate_desktop_id; const ATTRIBUTION_WORKER_SLOTS: usize = 8; @@ -47,71 +47,64 @@ pub(in crate::daemon) async fn resolve_attribution_owned( }; return unknown_reply_denied(claim, &sender, "attribution worker capacity exhausted"); }; - let initial = tokio::task::spawn_blocking({ - let reported_name = reported_name.clone(); - let desktop_entry = desktop_entry.clone(); - let index = Arc::clone(&index); - let sender = sender.clone(); - move || { - // The permit lives inside the blocking closure so timeout cancellation - // cannot release capacity while procfs work is still running - let _permit = initial_permit; - let sender = refresh_sender_security_evidence(&sender); + let fallback_sender = sender.clone(); + let result = tokio::time::timeout( + std::time::Duration::from_millis(500), + tokio::task::spawn_blocking({ + let reported_name = reported_name.clone(); + let desktop_entry = desktop_entry.clone(); + let index = Arc::clone(&index); + let sender = sender.clone(); + move || { + // The permit lives inside the blocking closure so timeout cancellation + // cannot release capacity while procfs work is still running + let _permit = initial_permit; + let sender = refresh_sender_security_evidence(&sender); + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + let resolution = resolve_with_evidence(claim, &sender, &index); + let needs = needs_sender_provenance( + resolution.attribution.status, + resolution.attribution.interactions, + claim_has_index_candidate(claim, &index), + ); + if !needs { + return resolution; + } + + let mut sender = sender; + enrich_sender_install_provenance_blocking(&mut sender, &index); + resolve_with_evidence(claim, &sender, &index) + } + }), + ) + .await; + let resolution = match result { + Ok(Ok(resolution)) => resolution, + Ok(Err(_)) => { let claim = AppClaim { reported_name: &reported_name, desktop_entry: desktop_entry.as_deref(), }; - let resolution = resolve_with_evidence(claim, &sender, &index); - let needs = needs_sender_provenance( - resolution.attribution.status, - resolution.attribution.interactions, - claim_has_index_candidate(claim, &index), - ); - (sender, resolution, needs) + return unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped"); + } + Err(_) => { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied(claim, &fallback_sender, "attribution timed out"); } - }) - .await - .ok(); - let Some((mut sender, initial, needs)) = initial else { - let claim = AppClaim { - reported_name: &reported_name, - desktop_entry: desktop_entry.as_deref(), - }; - return unknown_reply_denied( - claim, - &SenderMetadata::default(), - "attribution worker stopped", - ); - }; - if should_return_initial_resolution(needs) { - return initial; - } - enrich_sender_install_provenance(&mut sender, &index).await; - let Some(provenance_permit) = try_attribution_worker() else { - // The initial result is already safe and interaction-denied - return initial; }; - let fallback_name = reported_name.clone(); - let fallback_entry = desktop_entry.clone(); - let fallback_sender = sender.clone(); - tokio::task::spawn_blocking(move || { - let _permit = provenance_permit; - let claim = AppClaim { - reported_name: &reported_name, - desktop_entry: desktop_entry.as_deref(), - }; - resolve_with_evidence(claim, &sender, &index) - }) - .await - .unwrap_or_else(|_| { - let claim = AppClaim { - reported_name: &fallback_name, - desktop_entry: fallback_entry.as_deref(), - }; - unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped") - }) + resolution } +#[cfg_attr( + not(test), + expect(dead_code, reason = "helper remains as an explicit pipeline test seam") +)] pub(super) const fn should_return_initial_resolution(needs_provenance: bool) -> bool { !needs_provenance } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs index 60ae514e9..d96c4fd8b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs @@ -5,7 +5,7 @@ use super::super::executable::executable_evidence_for_path; use super::super::sender::SenderMetadata; use super::evidence::current_system_identity_matches_sender; -pub(super) async fn enrich_sender_install_provenance( +pub(super) fn enrich_sender_install_provenance_blocking( sender: &mut SenderMetadata, index: &DesktopIdentityIndex, ) { @@ -29,7 +29,16 @@ pub(super) async fn enrich_sender_install_provenance( if !current_system_identity_matches_sender(current.identity, sender_identity) { return; } - sender.install_provenance = index - .install_provenance_for_path_async(current.canonical_path) - .await; + sender.install_provenance = index.install_provenance_for_path(current.canonical_path); +} + +#[cfg_attr( + not(test), + expect(dead_code, reason = "async wrapper remains for resolver tests") +)] +pub(super) async fn enrich_sender_install_provenance( + sender: &mut SenderMetadata, + index: &DesktopIdentityIndex, +) { + enrich_sender_install_provenance_blocking(sender, index); } diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 3d1b778db..0349e16ff 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -1,5 +1,5 @@ -use std::sync::atomic::{AtomicBool, AtomicU64}; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}; use arc_swap::ArcSwap; use tokio::sync::Mutex; @@ -16,6 +16,21 @@ use crate::daemon::notifications::identity::DesktopIdentityIndex; use crate::daemon::notifications::NotificationBurstState; use crate::daemon::notifications::SenderMetadataCache; +#[derive(Clone, Default)] +#[expect( + clippy::struct_excessive_bools, + reason = "the control protocol exposes four independent readiness flags" +)] +pub(in crate::daemon::state) struct UiHealthState { + pub(in crate::daemon::state) center_process_running: bool, + pub(in crate::daemon::state) center_ready: bool, + pub(in crate::daemon::state) panel_ready_owner: Option, + pub(in crate::daemon::state) popups_process_running: bool, + pub(in crate::daemon::state) popups_ready: bool, + pub(in crate::daemon::state) popups_ready_owner: Option, + pub(in crate::daemon::state) revision: u64, +} + /// Shared daemon state guarded behind an async mutex pub struct DaemonState { pub store: Mutex, @@ -24,17 +39,9 @@ pub struct DaemonState { pub(in crate::daemon::state) connection: Connection, // Panel control should only succeed once the center has subscribed // This avoids accepting requests that no live listener can receive - pub(in crate::daemon::state) panel_ready: AtomicBool, - // Unique owner prevents a delayed disconnect from clearing a newer center lease - pub(in crate::daemon::state) panel_ready_owner: StdMutex>, - pub(in crate::daemon::state) center_process_running: AtomicBool, - pub(in crate::daemon::state) popups_process_running: AtomicBool, - pub(in crate::daemon::state) popups_ready: AtomicBool, - // Changes whenever process or readiness ownership changes - pub(in crate::daemon::state) ui_health_revision: AtomicU64, + // One lock keeps process, readiness, owner, and revision values coherent + pub(in crate::daemon::state) ui_health: StdRwLock, pub(in crate::daemon::state) popups_unready_warning_emitted: AtomicBool, - // The unique D-Bus owner prevents an older popup generation from clearing a newer one - pub(in crate::daemon::state) popups_ready_owner: StdMutex>, // Scheduler is installed after state startup so close paths can cancel timers pub(in crate::daemon::state) scheduler: OnceLock, // Warn once if scheduler-backed operations happen before install @@ -93,14 +100,8 @@ impl DaemonState { store: Mutex::new(store), sound, connection: connection.clone(), - panel_ready: AtomicBool::new(false), - panel_ready_owner: StdMutex::new(None), - center_process_running: AtomicBool::new(false), - popups_process_running: AtomicBool::new(false), - popups_ready: AtomicBool::new(false), - ui_health_revision: AtomicU64::new(0), + ui_health: StdRwLock::new(UiHealthState::default()), popups_unready_warning_emitted: AtomicBool::new(false), - popups_ready_owner: StdMutex::new(None), scheduler: OnceLock::new(), scheduler_missing_warned: AtomicBool::new(false), dnd_scheduler: OnceLock::new(), diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 49f85a0a9..5d056b48e 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -8,79 +8,88 @@ use super::DaemonState; impl DaemonState { pub(crate) fn set_panel_ready(&self, owner: &str, ready: bool) { - let mut current_owner = self - .panel_ready_owner - .lock() + let mut health = self + .ui_health + .write() .unwrap_or_else(std::sync::PoisonError::into_inner); if ready { // The latest successful handshake owns the active readiness lease - *current_owner = Some(owner.to_string()); - self.panel_ready.store(true, Ordering::SeqCst); - } else if current_owner.as_deref() == Some(owner) { + health.panel_ready_owner = Some(owner.to_string()); + health.center_ready = true; + } else if health.panel_ready_owner.as_deref() == Some(owner) { // Only the matching center generation can clear its lease - *current_owner = None; - self.panel_ready.store(false, Ordering::SeqCst); + health.panel_ready_owner = None; + health.center_ready = false; } - self.ui_health_revision.fetch_add(1, Ordering::SeqCst); - } - - fn clear_panel_ready(&self) { - let mut current_owner = self - .panel_ready_owner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *current_owner = None; - self.panel_ready.store(false, Ordering::SeqCst); + health.revision = health.revision.saturating_add(1); } pub(crate) fn set_center_process_running(&self, running: bool) { - self.center_process_running.store(running, Ordering::SeqCst); + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + health.center_process_running = running; // Every process generation must complete its own subscription handshake - self.clear_panel_ready(); - self.ui_health_revision.fetch_add(1, Ordering::SeqCst); + health.panel_ready_owner = None; + health.center_ready = false; + health.revision = health.revision.saturating_add(1); } pub(crate) fn set_popups_process_running(&self, running: bool) { // Popup health is tracked for supervision and diagnostics - self.popups_process_running.store(running, Ordering::SeqCst); + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + health.popups_process_running = running; if !running { - self.clear_popups_ready(); + health.popups_ready_owner = None; + health.popups_ready = false; } - self.ui_health_revision.fetch_add(1, Ordering::SeqCst); + health.revision = health.revision.saturating_add(1); } pub(crate) fn set_popups_ready(&self, owner: &str, ready: bool) { - let mut current_owner = self - .popups_ready_owner - .lock() + let mut health = self + .ui_health + .write() .unwrap_or_else(std::sync::PoisonError::into_inner); if ready { - *current_owner = Some(owner.to_string()); - self.popups_ready.store(true, Ordering::SeqCst); + health.popups_ready_owner = Some(owner.to_string()); + health.popups_ready = true; self.popups_unready_warning_emitted .store(false, Ordering::SeqCst); - } else if current_owner.as_deref() == Some(owner) { - *current_owner = None; - self.popups_ready.store(false, Ordering::SeqCst); + } else if health.popups_ready_owner.as_deref() == Some(owner) { + health.popups_ready_owner = None; + health.popups_ready = false; } - self.ui_health_revision.fetch_add(1, Ordering::SeqCst); - } - - fn clear_popups_ready(&self) { - let mut current_owner = self - .popups_ready_owner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *current_owner = None; - self.popups_ready.store(false, Ordering::SeqCst); + health.revision = health.revision.saturating_add(1); } pub(crate) fn panel_ready(&self) -> bool { - self.panel_ready.load(Ordering::SeqCst) + self.ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .center_ready } pub(crate) fn popups_ready(&self) -> bool { - self.popups_ready.load(Ordering::SeqCst) + self.ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .popups_ready + } + + #[cfg_attr( + not(test), + expect(dead_code, reason = "getter is used by child-process tests") + )] + pub(crate) fn popups_process_running(&self) -> bool { + self.ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .popups_process_running } pub(crate) fn should_warn_popups_unready(&self) -> bool { @@ -92,31 +101,17 @@ impl DaemonState { } pub(crate) fn ui_health(&self) -> UiHealth { - // A readiness transition updates the revision after all fields change - // Retry when a concurrent transition would otherwise mix two snapshots - for _ in 0..3 { - let before = self.ui_health_revision.load(Ordering::Acquire); - let health = UiHealth { - center_process_running: self.center_process_running.load(Ordering::Acquire), - center_ready: self.panel_ready.load(Ordering::Acquire), - popups_process_running: self.popups_process_running.load(Ordering::Acquire), - popups_ready: self.popups_ready.load(Ordering::Acquire), - revision: before, - }; - let after = self.ui_health_revision.load(Ordering::Acquire); - if before == after { - return health; - } - } - - // A busy transition still returns a coherent revisioned sample after - // the bounded retries rather than delaying notification admission + // A single read lock prevents mixed fields and revision values + let health = self + .ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); UiHealth { - center_process_running: self.center_process_running.load(Ordering::Acquire), - center_ready: self.panel_ready.load(Ordering::Acquire), - popups_process_running: self.popups_process_running.load(Ordering::Acquire), - popups_ready: self.popups_ready.load(Ordering::Acquire), - revision: self.ui_health_revision.load(Ordering::Acquire), + center_process_running: health.center_process_running, + center_ready: health.center_ready, + popups_process_running: health.popups_process_running, + popups_ready: health.popups_ready, + revision: health.revision, } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs index ed4dbd87d..9d2edbe85 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -1,13 +1,4 @@ -use crate::test_support::daemon_state_for_test; -use std::sync::atomic::Ordering; - -use super::super::DaemonState; - -impl DaemonState { - pub(crate) fn popups_process_running(&self) -> bool { - self.popups_process_running.load(Ordering::SeqCst) - } -} +use crate::test_support::{daemon_state_for_test, daemon_state_for_test_with_owner}; #[tokio::test] async fn daemon_state_boolean_flags_reflect_runtime_updates() { @@ -17,7 +8,7 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { assert!(!state.panel_ready()); assert!(!state.popups_process_running()); - // These atomics gate user-visible command handling, so getters must reflect writes exactly + // These health flags gate user-visible command handling, so getters must reflect writes exactly state.set_center_process_running(true); state.set_panel_ready(":1.20", true); state.set_popups_process_running(true); @@ -119,3 +110,26 @@ async fn daemon_state_trial_mode_can_be_disabled() { // Trial mode changes control authorization, so false must stay observable assert!(!state.trial_mode()); } + +#[tokio::test] +async fn popup_unready_warning_is_emitted_only_once_until_ready() { + let state = daemon_state_for_test(true).await; + + assert!(state.should_warn_popups_unready()); + assert!(!state.should_warn_popups_unready()); + + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + assert!(!state.should_warn_popups_unready()); + state.set_popups_ready(":1.10", false); + + assert!(state.should_warn_popups_unready()); +} + +#[tokio::test] +async fn control_owner_preauthorization_matches_only_the_current_owner() { + let state = daemon_state_for_test_with_owner(true, Some(":1.42")).await; + + assert!(state.control_owner_is_preauthorized(":1.42")); + assert!(!state.control_owner_is_preauthorized(":1.43")); +} diff --git a/crates/unixnotis-ui/src/presentation/default_activation.rs b/crates/unixnotis-ui/src/presentation/default_activation.rs index 36bc35057..64935534e 100644 --- a/crates/unixnotis-ui/src/presentation/default_activation.rs +++ b/crates/unixnotis-ui/src/presentation/default_activation.rs @@ -17,11 +17,35 @@ pub struct DefaultActionTarget { #[derive(Clone)] pub struct DefaultActionBinding { target: Rc>>, + root: gtk::glib::WeakRef, } impl DefaultActionBinding { pub fn set_target(&self, target: Option) { + let enabled = target.is_some(); *self.target.borrow_mut() = target; + + let Some(root) = self.root.upgrade() else { + return; + }; + + // Recycled rows are only keyboard controls while an active generation is bound + root.set_focusable(enabled); + root.set_accessible_role(if enabled { + gtk::AccessibleRole::Button + } else { + gtk::AccessibleRole::Generic + }); + root.update_property(&[gtk::accessible::Property::Label(if enabled { + "Open notification" + } else { + "" + })]); + if enabled { + root.add_css_class("unixnotis-popup-default-action"); + } else { + root.remove_css_class("unixnotis-popup-default-action"); + } } } @@ -36,27 +60,23 @@ where F: Fn(NotificationKey, String) + 'static, { let root = widget.clone().upcast::(); - root.set_focusable(true); - root.set_accessible_role(gtk::AccessibleRole::Button); - root.update_property(&[gtk::accessible::Property::Label("Open notification")]); - root.add_css_class("unixnotis-popup-default-action"); let target: Rc>> = Rc::new(RefCell::new(None)); let dispatch = Rc::new(dispatch); let gesture = gtk::GestureClick::new(); gesture.set_button(1); - let click_root = root.clone(); + let click_root = root.downgrade(); let click_target = Rc::clone(&target); let click_dispatch = Rc::clone(&dispatch); gesture.connect_released(move |_, _, x, y| { + let Some(root) = click_root.upgrade() else { + return; + }; let Some(current) = click_target.borrow().clone() else { return; }; - if picked_widget_blocks_default_action( - &click_root, - click_root.pick(x, y, gtk::PickFlags::DEFAULT), - ) { + if picked_widget_blocks_default_action(&root, root.pick(x, y, gtk::PickFlags::DEFAULT)) { return; } click_dispatch(current.notification, current.action_key); @@ -64,12 +84,15 @@ where root.add_controller(gesture); let key_controller = gtk::EventControllerKey::new(); - let key_root = root.clone(); + let key_root = root.downgrade(); let key_target = Rc::clone(&target); let key_dispatch = Rc::clone(&dispatch); key_controller.connect_key_pressed(move |_, key, _, _| { + let Some(root) = key_root.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; let current = key_target.borrow().clone(); - if keyboard_activation_is_ready(key_root.has_focus(), key, current.is_some()) { + if keyboard_activation_is_ready(root.has_focus(), key, current.is_some()) { if let Some(current) = current { key_dispatch(current.notification, current.action_key); return gtk::glib::Propagation::Stop; @@ -79,7 +102,12 @@ where }); root.add_controller(key_controller); - DefaultActionBinding { target } + let binding = DefaultActionBinding { + target, + root: root.downgrade(), + }; + binding.set_target(None); + binding } #[must_use] diff --git a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs index 1d15bd1d2..c0b9a81f2 100644 --- a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs @@ -37,6 +37,31 @@ fn binding_replaces_and_clears_the_current_generation() { assert_eq!(binding.target.borrow().as_ref(), Some(&replacement)); binding.set_target(None); assert!(binding.target.borrow().is_none()); + assert!(!card.is_focusable()); + assert!(!card.has_css_class("unixnotis-popup-default-action")); +} + +#[gtk::test] +fn activation_callbacks_do_not_keep_destroyed_cards_alive() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let weak = card.downgrade(); + let binding = connect_default_activation(&card, |_, _| {}); + + binding.set_target(Some(DefaultActionTarget { + notification: NotificationKey { + id: 9, + generation: 1, + }, + action_key: "default".to_string(), + })); + drop(binding); + drop(card); + + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + assert!(weak.upgrade().is_none()); } #[gtk::test] diff --git a/tests/check-release-hardening.sh b/tests/check-release-hardening.sh index b53acfdb3..f15626159 100755 --- a/tests/check-release-hardening.sh +++ b/tests/check-release-hardening.sh @@ -43,6 +43,16 @@ assert_count() { fi } +check_bootstrap_index_pins() { + local path="${1}" + + # Bootstrap still uses HTTP only until the CA bundle exists, so each index is hash-pinned + assert_contains "$path" 'verify_snapshot_index() {' + assert_contains "$path" '98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed' + assert_contains "$path" 'bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7' + assert_contains "$path" 'ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41' +} + # The base image and package repository both resolve to immutable inputs assert_contains "$workflow" 'container: debian:trixie-slim@sha256:' assert_contains "$workflow" "snapshot.debian.org/archive/debian/\${DEBIAN_SNAPSHOT}" @@ -84,3 +94,7 @@ assert_contains "$workflow" 'name: unixnotis-${{ inputs.tag }}-unsigned' assert_count "$workflow" 1 'id-token: write' assert_count "$workflow" 1 'attestations: write' assert_count "$workflow" 1 'artifact-metadata: write' + +check_bootstrap_index_pins "${repo_root}/.github/workflows/ci.yml" +check_bootstrap_index_pins "${repo_root}/.github/workflows/mutation.yml" +check_bootstrap_index_pins "$workflow" From e51b77564090feb8cee5cf5757da9ddc56c2e1d1 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:04:49 -0500 Subject: [PATCH 188/275] refactor(identity): separate desktop index responsibilities Summary: separate desktop index responsibilities. Scope: identity. --- .../identity/desktop_index/index.rs | 455 ------------------ .../identity/desktop_index/index/families.rs | 183 +++++++ .../identity/desktop_index/index/lookup.rs | 116 +++++ .../identity/desktop_index/index/mod.rs | 9 + .../identity/desktop_index/index/mutation.rs | 58 +++ .../desktop_index/index/tests/families.rs | 1 + .../desktop_index/index/tests/lookup.rs | 1 + .../identity/desktop_index/index/tests/mod.rs | 4 + .../desktop_index/index/tests/mutation.rs | 65 +++ .../desktop_index/index/tests/trusted.rs | 76 +++ .../identity/desktop_index/index/trusted.rs | 117 +++++ .../identity/desktop_index/launcher.rs | 52 -- .../desktop_index/launcher/binding.rs | 46 ++ .../identity/desktop_index/launcher/mod.rs | 12 + .../launcher => launcher/tests}/binding.rs | 0 .../{tests/launcher => launcher/tests}/mod.rs | 0 .../launcher => launcher/tests}/read.rs | 0 .../launcher => launcher/tests}/syntax.rs | 0 .../launcher => launcher/tests}/validation.rs | 0 .../{provenance.rs => provenance/mod.rs} | 1 - .../provenance.rs => provenance/tests/mod.rs} | 0 .../identity/desktop_index/tests/index.rs | 138 ------ .../{verification.rs => verification/mod.rs} | 1 - .../tests/mod.rs} | 0 24 files changed, 688 insertions(+), 647 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/launcher => launcher/tests}/binding.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/launcher => launcher/tests}/mod.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/launcher => launcher/tests}/read.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/launcher => launcher/tests}/syntax.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/launcher => launcher/tests}/validation.rs (100%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{provenance.rs => provenance/mod.rs} (99%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/provenance.rs => provenance/tests/mod.rs} (100%) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{verification.rs => verification/mod.rs} (99%) rename crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/{tests/verification.rs => verification/tests/mod.rs} (100%) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs deleted file mode 100644 index c60dc8e90..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index.rs +++ /dev/null @@ -1,455 +0,0 @@ -//! Desktop record lookup tables and trusted relay matching - -use std::path::{Path, PathBuf}; - -use super::super::executable::{executable_evidence_for_path, FileIdentity}; -use super::model::{ - DesktopApplicationFamily, DesktopIdentityIndex, DesktopRecord, ExecutableIdentity, - LaunchArgument, -}; -use super::names::{normalize_brand_name, normalize_desktop_id, normalize_name}; - -impl DesktopIdentityIndex { - pub(in crate::daemon::notifications::identity) fn records_for_id( - &self, - id: &str, - ) -> Vec<&DesktopRecord> { - // Duplicate IDs remain separate so origin can be checked by the resolver - self.by_id - .get(&normalize_desktop_id(id)) - .into_iter() - .flatten() - .filter_map(|index| self.records.get(*index)) - .collect() - } - - pub(in crate::daemon::notifications::identity) fn records_for_executable( - &self, - identity: FileIdentity, - ) -> Vec<&DesktopRecord> { - // Device and inode avoid trusting a replaceable executable path - self.by_identity - .get(&(identity.device, identity.inode)) - .into_iter() - .flatten() - .filter_map(|index| self.records.get(*index)) - .collect() - } - - pub(in crate::daemon::notifications::identity) fn records_for_claim( - &self, - claim: &str, - ) -> Vec<&DesktopRecord> { - let normalized = normalize_name(claim); - let mut indices = self - .by_name - .get(&normalized) - .into_iter() - .flatten() - .copied() - .collect::>(); - - // Protected confusable names still resolve to concrete system candidates - let protected = normalize_brand_name(claim); - if !protected.is_empty() { - indices.extend( - self.records - .iter() - .enumerate() - .filter_map(|(index, record)| { - (record.system_origin - && [&record.display_name, &record.id] - .iter() - .any(|name| normalize_brand_name(name) == protected)) - .then_some(index) - }), - ); - } - indices.sort_unstable(); - indices.dedup(); - indices - .into_iter() - .filter_map(|index| self.records.get(index)) - .collect() - } - - pub(in crate::daemon::notifications::identity) fn family_for_record( - &self, - record: &DesktopRecord, - ) -> Option<&DesktopApplicationFamily> { - let record_index = self - .records - .iter() - .position(|candidate| std::ptr::eq(candidate, record))?; - let family_index = *self.family_by_record.get(record_index)?.as_ref()?; - self.families.get(family_index) - } - - pub(in crate::daemon::notifications::identity) fn family_index_for_record( - &self, - record: &DesktopRecord, - ) -> Option { - let record_index = self - .records - .iter() - .position(|candidate| std::ptr::eq(candidate, record))?; - self.family_by_record.get(record_index).copied().flatten() - } - - pub(in crate::daemon::notifications::identity) fn canonical_id_for_record<'record>( - &'record self, - record: &'record DesktopRecord, - ) -> &'record str { - self.family_for_record(record) - .map_or(record.id.as_str(), |family| family.canonical_id.as_str()) - } - - pub(in crate::daemon::notifications::identity) fn canonical_record_for_record<'record>( - &'record self, - record: &'record DesktopRecord, - ) -> &'record DesktopRecord { - let Some(family) = self.family_for_record(record) else { - return record; - }; - family - .records - .iter() - .filter_map(|index| self.records.get(*index)) - .find(|candidate| { - normalize_desktop_id(&candidate.id) == normalize_desktop_id(&family.canonical_id) - }) - .unwrap_or(record) - } - - pub(in crate::daemon::notifications::identity) fn records_share_family( - &self, - left: &DesktopRecord, - right: &DesktopRecord, - ) -> bool { - match ( - self.family_index_for_record(left), - self.family_index_for_record(right), - ) { - (Some(left), Some(right)) => left == right, - _ => std::ptr::eq(left, right), - } - } - - pub(in crate::daemon::notifications::identity) fn record_matches_claim( - &self, - record: &DesktopRecord, - claim: &str, - ) -> bool { - let normalized = normalize_name(claim); - record.claim_matches(claim) - || self - .family_for_record(record) - .is_some_and(|family| family.names.contains(&normalized)) - || self - .records_for_claim(claim) - .iter() - .any(|candidate| std::ptr::eq(*candidate, record)) - } - - pub(in crate::daemon::notifications::identity) fn records_form_one_application_family( - &self, - identity: FileIdentity, - system_origin: bool, - ) -> bool { - let families = self - .records_for_executable(identity) - .into_iter() - .filter(|record| record.system_origin == system_origin) - .filter_map(|record| self.family_index_for_record(record)) - .collect::>(); - families.len() == 1 - } - - #[cfg_attr( - not(test), - expect(dead_code, reason = "async wrapper remains for test seams") - )] - pub(in crate::daemon::notifications::identity) async fn install_provenance_for_path_async( - &self, - path: PathBuf, - ) -> super::provenance::InstallProvenance { - self.install_provenance_for_path(path) - } - - pub(in crate::daemon::notifications::identity) fn install_provenance_for_path( - &self, - path: PathBuf, - ) -> super::provenance::InstallProvenance { - // The caller owns the attribution worker permit while this blocking lookup runs - self.package_ownership.resolve_one(&path) - } - - pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( - &self, - claim: &str, - ) -> bool { - // Confusable spellings share one protected-brand skeleton - let claim = normalize_brand_name(claim); - !claim.is_empty() && self.system_brand_names.contains(&claim) - } - - pub(in crate::daemon::notifications::identity) fn trusted_relay_path( - &self, - identity: FileIdentity, - ) -> Option<&Path> { - self.trusted_relays - .iter() - .find(|relay| relay.identity.same_file(identity)) - .map(|relay| relay.path.as_path()) - } - - pub(in crate::daemon::notifications::identity) fn trusted_portal_path( - &self, - sender_identity: FileIdentity, - sender_path: &Path, - ) -> Option<&Path> { - self.trusted_portals - .iter() - .find(|portal| { - let Some(current) = executable_evidence_for_path(&portal.path) else { - return false; - }; - // Both the running path and installed path must remain under protected roots - trusted_system_executable_path(sender_path) - && trusted_system_executable_path(¤t.canonical_path) - && current.canonical_path == portal.path - && current.identity.same_file(portal.identity) - && current.identity.same_file(sender_identity) - && current.identity.is_system_managed() - && current.identity.is_executable_regular() - }) - .map(|portal| portal.path.as_path()) - } - - pub(super) fn index_trusted_relay(&mut self, path: &Path) { - let Some(evidence) = executable_evidence_for_path(path) else { - return; - }; - // Writable relay binaries stay ordinary unknown senders - if evidence.identity.is_system_managed() { - self.trusted_relays.push(ExecutableIdentity { - path: evidence.canonical_path, - identity: evidence.identity, - }); - } - } - - pub(super) fn index_trusted_portals_in(&mut self, directory: &Path) { - for path in portal_candidate_paths(directory) { - let Some(evidence) = executable_evidence_for_path(&path) else { - continue; - }; - // Portal authority is accepted only from protected system integration binaries - if portal_identity_is_trusted(evidence.identity) { - self.trusted_portals.push(ExecutableIdentity { - path: evidence.canonical_path, - identity: evidence.identity, - }); - } - } - } - - pub(in crate::daemon::notifications::identity) fn index_record( - &mut self, - record: DesktopRecord, - ) { - let record_index = self.records.len(); - if record.system_origin { - // Protected branding excludes generic names and launcher aliases - for brand in [&record.display_name, &record.id] { - let brand = normalize_brand_name(brand); - if !brand.is_empty() { - self.system_brand_names.insert(brand); - } - } - } - self.by_id - .entry(normalize_desktop_id(&record.id)) - .or_default() - .push(record_index); - for name in &record.names { - self.by_name - .entry(name.clone()) - .or_default() - .push(record_index); - } - // Only records with a reproducible launch contract become executable evidence - if record.association_eligible { - if let Some(identity) = record.runtime_executable_identity { - self.by_identity - .entry((identity.device, identity.inode)) - .or_default() - .push(record_index); - } - } - self.records.push(record); - self.index_application_family(record_index); - } - - pub(super) fn rebuild_executable_index(&mut self) { - self.by_identity.clear(); - for (record_index, record) in self.records.iter().enumerate() { - if !record.association_eligible { - continue; - } - if let Some(identity) = record.runtime_executable_identity { - self.by_identity - .entry((identity.device, identity.inode)) - .or_default() - .push(record_index); - } - } - } - - pub(super) fn rebuild_application_families(&mut self) { - self.families.clear(); - self.family_by_record.clear(); - for record_index in 0..self.records.len() { - self.index_application_family(record_index); - } - } - - fn index_application_family(&mut self, record_index: usize) { - let Some(record) = self.records.get(record_index) else { - self.family_by_record.push(None); - return; - }; - let Some(executable_identity) = record.runtime_executable_identity else { - self.family_by_record.push(None); - return; - }; - let protected_payloads = protected_payload_signature(record); - let family_index = self.families.iter().position(|family| { - family.executable_identity.same_file(executable_identity) - && family.system_origin == record.system_origin - && family.system_association == record.system_association - && family - .install_provenance - .same_application_source(&record.runtime_executable_provenance) - && family.protected_payloads == protected_payloads - && family_names_are_compatible(family, record) - }); - - if let Some(family_index) = family_index { - let family = &mut self.families[family_index]; - family.records.push(record_index); - family.names.extend(record.names.iter().cloned()); - if canonical_id_precedes(&record.id, &family.canonical_id) { - family.canonical_id.clone_from(&record.id); - } - self.family_by_record.push(Some(family_index)); - return; - } - - let family_index = self.families.len(); - self.families.push(DesktopApplicationFamily { - canonical_id: record.id.clone(), - executable_identity, - records: vec![record_index], - names: record.names.clone(), - system_origin: record.system_origin, - system_association: record.system_association, - install_provenance: record.runtime_executable_provenance.clone(), - protected_payloads, - }); - self.family_by_record.push(Some(family_index)); - } -} - -pub(in crate::daemon::notifications::identity) const fn portal_identity_is_trusted( - identity: FileIdentity, -) -> bool { - identity.is_system_managed() && identity.is_executable_regular() -} - -pub(in crate::daemon::notifications::identity) fn portal_candidate_paths( - directory: &Path, -) -> Vec { - const MAX_PORTAL_CANDIDATES: usize = 256; - - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - // Walk every entry in the directory - // Only entries with a matching name count toward the cap - // Filtering first means a directory full of unrelated files cannot hide a real portal - entries - .flatten() - .filter_map(|entry| { - let path = entry.path(); - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("xdg-desktop-portal")) - .then_some(path) - }) - .take(MAX_PORTAL_CANDIDATES) - .collect() -} - -fn protected_payload_signature(record: &DesktopRecord) -> Vec<(usize, u64, u64)> { - record - .launch_spec - .iter() - .flat_map(|spec| spec.arguments.iter().enumerate()) - .filter_map(|(position, argument)| { - let LaunchArgument::Literal(literal) = argument else { - return None; - }; - let (_path, identity) = literal.file.as_ref()?; - (!literal.value.starts_with(b"-")).then_some(( - position, - identity.device, - identity.inode, - )) - }) - .collect() -} - -fn family_names_are_compatible(family: &DesktopApplicationFamily, record: &DesktopRecord) -> bool { - if family.names.iter().any(|name| record.names.contains(name)) { - return true; - } - let family_id = normalize_desktop_id(&family.canonical_id); - let record_id = normalize_desktop_id(&record.id); - id_is_alias_of(&family_id, &record_id) -} - -fn id_is_alias_of(left: &str, right: &str) -> bool { - left == right - || left - .strip_prefix(right) - .is_some_and(|suffix| suffix.starts_with('.')) - || right - .strip_prefix(left) - .is_some_and(|suffix| suffix.starts_with('.')) -} - -fn canonical_id_precedes(candidate: &str, current: &str) -> bool { - let candidate = normalize_desktop_id(candidate); - let current = normalize_desktop_id(current); - (candidate.len(), candidate.as_str()) < (current.len(), current.as_str()) -} - -fn trusted_system_executable_path(path: &Path) -> bool { - const ROOTS: [&str; 8] = [ - "/bin", - "/lib", - "/lib64", - "/usr/bin", - "/usr/lib", - "/usr/libexec", - "/usr/local/lib", - "/usr/local/libexec", - ]; - - path.is_absolute() && ROOTS.iter().any(|root| path.starts_with(root)) -} - -#[cfg(test)] -#[path = "tests/index.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs new file mode 100644 index 000000000..001e1c772 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs @@ -0,0 +1,183 @@ +//! Application-family construction and canonical identity selection + +use super::super::super::executable::FileIdentity; +use super::super::model::{ + DesktopApplicationFamily, DesktopIdentityIndex, DesktopRecord, LaunchArgument, +}; +use super::super::names::normalize_desktop_id; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn family_for_record( + &self, + record: &DesktopRecord, + ) -> Option<&DesktopApplicationFamily> { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + let family_index = *self.family_by_record.get(record_index)?.as_ref()?; + self.families.get(family_index) + } + + pub(in crate::daemon::notifications::identity) fn family_index_for_record( + &self, + record: &DesktopRecord, + ) -> Option { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + self.family_by_record.get(record_index).copied().flatten() + } + + pub(in crate::daemon::notifications::identity) fn canonical_id_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record str { + self.family_for_record(record) + .map_or(record.id.as_str(), |family| family.canonical_id.as_str()) + } + + pub(in crate::daemon::notifications::identity) fn canonical_record_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record DesktopRecord { + let Some(family) = self.family_for_record(record) else { + return record; + }; + family + .records + .iter() + .filter_map(|index| self.records.get(*index)) + .find(|candidate| { + normalize_desktop_id(&candidate.id) == normalize_desktop_id(&family.canonical_id) + }) + .unwrap_or(record) + } + + pub(in crate::daemon::notifications::identity) fn records_share_family( + &self, + left: &DesktopRecord, + right: &DesktopRecord, + ) -> bool { + match ( + self.family_index_for_record(left), + self.family_index_for_record(right), + ) { + (Some(left), Some(right)) => left == right, + _ => std::ptr::eq(left, right), + } + } + + pub(in crate::daemon::notifications::identity) fn records_form_one_application_family( + &self, + identity: FileIdentity, + system_origin: bool, + ) -> bool { + let families = self + .records_for_executable(identity) + .into_iter() + .filter(|record| record.system_origin == system_origin) + .filter_map(|record| self.family_index_for_record(record)) + .collect::>(); + families.len() == 1 + } + + pub(in crate::daemon::notifications::identity) fn rebuild_application_families(&mut self) { + self.families.clear(); + self.family_by_record.clear(); + for record_index in 0..self.records.len() { + self.index_application_family(record_index); + } + } + + pub(super) fn index_application_family(&mut self, record_index: usize) { + let Some(record) = self.records.get(record_index) else { + self.family_by_record.push(None); + return; + }; + let Some(executable_identity) = record.runtime_executable_identity else { + self.family_by_record.push(None); + return; + }; + let protected_payloads = protected_payload_signature(record); + let family_index = self.families.iter().position(|family| { + family.executable_identity.same_file(executable_identity) + && family.system_origin == record.system_origin + && family.system_association == record.system_association + && family + .install_provenance + .same_application_source(&record.runtime_executable_provenance) + && family.protected_payloads == protected_payloads + && family_names_are_compatible(family, record) + }); + + if let Some(family_index) = family_index { + let family = &mut self.families[family_index]; + family.records.push(record_index); + family.names.extend(record.names.iter().cloned()); + if canonical_id_precedes(&record.id, &family.canonical_id) { + family.canonical_id.clone_from(&record.id); + } + self.family_by_record.push(Some(family_index)); + return; + } + + let family_index = self.families.len(); + self.families.push(DesktopApplicationFamily { + canonical_id: record.id.clone(), + executable_identity, + records: vec![record_index], + names: record.names.clone(), + system_origin: record.system_origin, + system_association: record.system_association, + install_provenance: record.runtime_executable_provenance.clone(), + protected_payloads, + }); + self.family_by_record.push(Some(family_index)); + } +} + +fn protected_payload_signature(record: &DesktopRecord) -> Vec<(usize, u64, u64)> { + record + .launch_spec + .iter() + .flat_map(|spec| spec.arguments.iter().enumerate()) + .filter_map(|(position, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + let (_path, identity) = literal.file.as_ref()?; + (!literal.value.starts_with(b"-")).then_some(( + position, + identity.device, + identity.inode, + )) + }) + .collect() +} + +fn family_names_are_compatible(family: &DesktopApplicationFamily, record: &DesktopRecord) -> bool { + if family.names.iter().any(|name| record.names.contains(name)) { + return true; + } + let family_id = normalize_desktop_id(&family.canonical_id); + let record_id = normalize_desktop_id(&record.id); + id_is_alias_of(&family_id, &record_id) +} + +fn id_is_alias_of(left: &str, right: &str) -> bool { + left == right + || left + .strip_prefix(right) + .is_some_and(|suffix| suffix.starts_with('.')) + || right + .strip_prefix(left) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn canonical_id_precedes(candidate: &str, current: &str) -> bool { + let candidate = normalize_desktop_id(candidate); + let current = normalize_desktop_id(current); + (candidate.len(), candidate.as_str()) < (current.len(), current.as_str()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs new file mode 100644 index 000000000..aa55d1c6b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs @@ -0,0 +1,116 @@ +//! Read-only lookups over the constructed desktop index + +use std::path::PathBuf; + +use super::super::super::executable::FileIdentity; +use super::super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::super::names::{normalize_brand_name, normalize_desktop_id, normalize_name}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn records_for_id( + &self, + id: &str, + ) -> Vec<&DesktopRecord> { + // Duplicate IDs remain separate so origin can be checked by the resolver + self.by_id + .get(&normalize_desktop_id(id)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn records_for_executable( + &self, + identity: FileIdentity, + ) -> Vec<&DesktopRecord> { + // Device and inode avoid trusting a replaceable executable path + self.by_identity + .get(&(identity.device, identity.inode)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn records_for_claim( + &self, + claim: &str, + ) -> Vec<&DesktopRecord> { + let normalized = normalize_name(claim); + let mut indices = self + .by_name + .get(&normalized) + .into_iter() + .flatten() + .copied() + .collect::>(); + + // Protected confusable names still resolve to concrete system candidates + let protected = normalize_brand_name(claim); + if !protected.is_empty() { + indices.extend( + self.records + .iter() + .enumerate() + .filter_map(|(index, record)| { + (record.system_origin + && [&record.display_name, &record.id] + .iter() + .any(|name| normalize_brand_name(name) == protected)) + .then_some(index) + }), + ); + } + indices.sort_unstable(); + indices.dedup(); + indices + .into_iter() + .filter_map(|index| self.records.get(index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn record_matches_claim( + &self, + record: &DesktopRecord, + claim: &str, + ) -> bool { + let normalized = normalize_name(claim); + record.claim_matches(claim) + || self + .family_for_record(record) + .is_some_and(|family| family.names.contains(&normalized)) + || self + .records_for_claim(claim) + .iter() + .any(|candidate| std::ptr::eq(*candidate, record)) + } + + #[cfg_attr( + not(test), + expect(dead_code, reason = "async wrapper remains for test seams") + )] + pub(in crate::daemon::notifications::identity) async fn install_provenance_for_path_async( + &self, + path: PathBuf, + ) -> super::super::provenance::InstallProvenance { + self.install_provenance_for_path(path) + } + + pub(in crate::daemon::notifications::identity) fn install_provenance_for_path( + &self, + path: PathBuf, + ) -> super::super::provenance::InstallProvenance { + // The caller owns the attribution worker permit while this blocking lookup runs + self.package_ownership.resolve_one(&path) + } + + pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( + &self, + claim: &str, + ) -> bool { + // Confusable spellings share one protected-brand skeleton + let claim = normalize_brand_name(claim); + !claim.is_empty() && self.system_brand_names.contains(&claim) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs new file mode 100644 index 000000000..a210f7792 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs @@ -0,0 +1,9 @@ +//! Desktop record indexes, family construction, and trusted integration lookup + +mod families; +mod lookup; +mod mutation; +mod trusted; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs new file mode 100644 index 000000000..578d1b2b5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs @@ -0,0 +1,58 @@ +//! Mutation of desktop and executable indexes + +use super::super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::super::names::{normalize_brand_name, normalize_desktop_id}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn index_record( + &mut self, + record: DesktopRecord, + ) { + let record_index = self.records.len(); + if record.system_origin { + // Protected branding excludes generic names and launcher aliases + for brand in [&record.display_name, &record.id] { + let brand = normalize_brand_name(brand); + if !brand.is_empty() { + self.system_brand_names.insert(brand); + } + } + } + self.by_id + .entry(normalize_desktop_id(&record.id)) + .or_default() + .push(record_index); + for name in &record.names { + self.by_name + .entry(name.clone()) + .or_default() + .push(record_index); + } + // Only records with a reproducible launch contract become executable evidence + if record.association_eligible { + if let Some(identity) = record.runtime_executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + self.records.push(record); + self.index_application_family(record_index); + } + + pub(in crate::daemon::notifications::identity) fn rebuild_executable_index(&mut self) { + self.by_identity.clear(); + for (record_index, record) in self.records.iter().enumerate() { + if !record.association_eligible { + continue; + } + if let Some(identity) = record.runtime_executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs new file mode 100644 index 000000000..887f201f5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs @@ -0,0 +1 @@ +//! Desktop application-family test module diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs new file mode 100644 index 000000000..b9ea2eed6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs @@ -0,0 +1 @@ +//! Desktop index lookup test module diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs new file mode 100644 index 000000000..82f2f0a55 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs @@ -0,0 +1,4 @@ +mod families; +mod lookup; +mod mutation; +mod trusted; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs new file mode 100644 index 000000000..97f928663 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs @@ -0,0 +1,65 @@ +//! Executable lookup-index mutation cases + +use std::collections::HashSet; + +use super::super::super::model::{DesktopIdentityIndex, DesktopRecord, LaunchSpec}; +use crate::daemon::notifications::identity::desktop_index::provenance::InstallProvenance; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn executable_index_rebuild_replaces_stale_runtime_identity() { + let old = identity(70); + let new = identity(71); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record(old)); + index.records[0].runtime_executable_identity = Some(new); + index.records[0] + .launch_spec + .as_mut() + .expect("runtime launch specification") + .runtime_executable = new; + + index.rebuild_executable_index(); + + assert!(index.records_for_executable(old).is_empty()); + assert_eq!(index.records_for_executable(new).len(), 1); +} + +fn record(runtime: FileIdentity) -> DesktopRecord { + DesktopRecord { + id: "org.example.App".to_string(), + display_name: "Example App".to_string(), + badge_icon: "example-app".to_string(), + desktop_path: None, + declared_executable_path: Some("/usr/bin/example-app".into()), + declared_executable_identity: Some(runtime), + runtime_executable_path: Some("/usr/bin/example-app".into()), + runtime_executable_identity: Some(runtime), + desktop_identity: None, + desktop_provenance: InstallProvenance::Unknown, + declared_executable_provenance: InstallProvenance::Unknown, + runtime_executable_provenance: InstallProvenance::Unknown, + system_origin: false, + system_association: false, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: runtime, + runtime_executable: runtime, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: false, + }), + names: HashSet::new(), + } +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 1_000, + mode: 0o100_755, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs new file mode 100644 index 000000000..82b1708ab --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs @@ -0,0 +1,76 @@ +//! Trusted portal discovery and identity cases + +use super::super::super::model::DesktopIdentityIndex; +use super::super::trusted::{portal_candidate_paths, portal_identity_is_trusted}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::executable::FileIdentity; +use crate::test_support::TempRoot; + +#[test] +fn portal_discovery_filters_before_applying_the_candidate_limit() { + let root = TempRoot::new("portal-discovery-filter-order"); + for index in 0..300 { + std::fs::write( + root.join(format!("ordinary-library-{index:03}")), + b"fixture", + ) + .expect("write non-portal directory entry"); + } + let portal = root.join("xdg-desktop-portal-example"); + std::fs::write(&portal, b"portal fixture").expect("write portal directory entry"); + + let candidates = portal_candidate_paths(root.path()); + + assert_eq!(candidates, vec![portal]); +} + +#[test] +fn portal_identity_requires_both_system_management_and_executable_file_type() { + let trusted = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + assert!(portal_identity_is_trusted(trusted)); + assert!(!portal_identity_is_trusted(FileIdentity { + uid: 1_000, + ..trusted + })); + assert!(!portal_identity_is_trusted(FileIdentity { + mode: 0o100_644, + ..trusted + })); +} + +#[test] +fn installed_protected_portal_is_indexed_when_available() { + let installed = [ + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ] + .into_iter() + .find_map(|directory| { + portal_candidate_paths(std::path::Path::new(directory)) + .into_iter() + .find_map(|path| { + let evidence = executable_evidence_for_path(&path)?; + portal_identity_is_trusted(evidence.identity).then_some((path, evidence.identity)) + }) + }); + let Some((portal, identity)) = installed else { + // Platforms without an installed portal backend have no system fixture to index + return; + }; + let directory = portal.parent().expect("installed portal parent directory"); + let mut index = DesktopIdentityIndex::default(); + + index.index_trusted_portals_in(directory); + + assert!(index + .trusted_portals + .iter() + .any(|candidate| candidate.identity.same_file(identity))); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs new file mode 100644 index 000000000..e4dd8592b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs @@ -0,0 +1,117 @@ +//! Trusted relay and portal integration boundaries + +use std::path::{Path, PathBuf}; + +use super::super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::model::{DesktopIdentityIndex, ExecutableIdentity}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn trusted_relay_path( + &self, + identity: FileIdentity, + ) -> Option<&Path> { + self.trusted_relays + .iter() + .find(|relay| relay.identity.same_file(identity)) + .map(|relay| relay.path.as_path()) + } + + pub(in crate::daemon::notifications::identity) fn trusted_portal_path( + &self, + sender_identity: FileIdentity, + sender_path: &Path, + ) -> Option<&Path> { + self.trusted_portals + .iter() + .find(|portal| { + let Some(current) = executable_evidence_for_path(&portal.path) else { + return false; + }; + // Both the running path and installed path must remain under protected roots + trusted_system_executable_path(sender_path) + && trusted_system_executable_path(¤t.canonical_path) + && current.canonical_path == portal.path + && current.identity.same_file(portal.identity) + && current.identity.same_file(sender_identity) + && current.identity.is_system_managed() + && current.identity.is_executable_regular() + }) + .map(|portal| portal.path.as_path()) + } + + pub(in crate::daemon::notifications::identity) fn index_trusted_relay(&mut self, path: &Path) { + let Some(evidence) = executable_evidence_for_path(path) else { + return; + }; + // Writable relay binaries stay ordinary unknown senders + if evidence.identity.is_system_managed() { + self.trusted_relays.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + + pub(in crate::daemon::notifications::identity) fn index_trusted_portals_in( + &mut self, + directory: &Path, + ) { + for path in portal_candidate_paths(directory) { + let Some(evidence) = executable_evidence_for_path(&path) else { + continue; + }; + // Portal authority is accepted only from protected system integration binaries + if portal_identity_is_trusted(evidence.identity) { + self.trusted_portals.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + } +} + +pub(in crate::daemon::notifications::identity) const fn portal_identity_is_trusted( + identity: FileIdentity, +) -> bool { + identity.is_system_managed() && identity.is_executable_regular() +} + +pub(in crate::daemon::notifications::identity) fn portal_candidate_paths( + directory: &Path, +) -> Vec { + const MAX_PORTAL_CANDIDATES: usize = 256; + + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + // Walk every entry in the directory + // Only entries with a matching name count toward the cap + // Filtering first means a directory full of unrelated files cannot hide a real portal + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("xdg-desktop-portal")) + .then_some(path) + }) + .take(MAX_PORTAL_CANDIDATES) + .collect() +} + +fn trusted_system_executable_path(path: &Path) -> bool { + const ROOTS: [&str; 8] = [ + "/bin", + "/lib", + "/lib64", + "/usr/bin", + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ]; + + path.is_absolute() && ROOTS.iter().any(|root| path.starts_with(root)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs deleted file mode 100644 index 18a338d49..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Protected shell-launcher inspection and runtime binding - -mod read; -mod syntax; -mod validation; - -use std::path::Path; - -use super::super::executable::FileIdentity; -use super::model::PackageLauncherBinding; - -/// Extracts one literal runtime target without running or emulating the launcher -pub(super) fn inspect_package_shell_launcher( - path: &Path, - expected_identity: FileIdentity, -) -> Option { - // Reading through one no-follow descriptor binds syntax to the indexed file - let launcher = read::read_launcher(path, expected_identity)?; - let target_path = syntax::literal_final_exec_target(&launcher.contents)?; - - // The literal target must already be protected before package ownership is queried - let target_identity = validation::protected_runtime_target(&target_path)?; - Some(PackageLauncherBinding { - launcher_path: path.to_path_buf(), - launcher_identity: launcher.identity, - launcher_digest: launcher.digest, - target_path, - target_identity, - }) -} - -/// Reopens both files and repeats the literal-target proof before granting authority -pub(super) fn launcher_binding_is_current(binding: &PackageLauncherBinding) -> bool { - let Some(launcher) = read::read_launcher(&binding.launcher_path, binding.launcher_identity) - else { - return false; - }; - if launcher.digest != binding.launcher_digest { - return false; - } - if syntax::literal_final_exec_target(&launcher.contents).as_ref() != Some(&binding.target_path) - { - return false; - } - - validation::protected_runtime_target(&binding.target_path) - .is_some_and(|current| current.same_file(binding.target_identity)) -} - -#[cfg(test)] -#[path = "tests/launcher/mod.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs new file mode 100644 index 000000000..087883aa4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs @@ -0,0 +1,46 @@ +//! Package-launcher binding orchestration + +use std::path::Path; + +use super::super::super::executable::FileIdentity; +use super::super::model::PackageLauncherBinding; + +/// Extract one literal runtime target without running or emulating the launcher +pub fn inspect_package_shell_launcher( + path: &Path, + expected_identity: FileIdentity, +) -> Option { + // Reading through one no-follow descriptor binds syntax to the indexed file + let launcher = super::read::read_launcher(path, expected_identity)?; + let target_path = super::syntax::literal_final_exec_target(&launcher.contents)?; + + // The literal target must already be protected before package ownership is queried + let target_identity = super::validation::protected_runtime_target(&target_path)?; + Some(PackageLauncherBinding { + launcher_path: path.to_path_buf(), + launcher_identity: launcher.identity, + launcher_digest: launcher.digest, + target_path, + target_identity, + }) +} + +/// Reopen both files and repeat the literal-target proof before granting authority +pub fn launcher_binding_is_current(binding: &PackageLauncherBinding) -> bool { + let Some(launcher) = + super::read::read_launcher(&binding.launcher_path, binding.launcher_identity) + else { + return false; + }; + if launcher.digest != binding.launcher_digest { + return false; + } + if super::syntax::literal_final_exec_target(&launcher.contents).as_ref() + != Some(&binding.target_path) + { + return false; + } + + super::validation::protected_runtime_target(&binding.target_path) + .is_some_and(|current| current.same_file(binding.target_identity)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs new file mode 100644 index 000000000..958989c01 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs @@ -0,0 +1,12 @@ +//! Protected shell-launcher inspection and runtime binding + +mod binding; +mod read; +mod syntax; +mod validation; + +pub(super) use binding::{inspect_package_shell_launcher, launcher_binding_is_current}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/binding.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/binding.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/binding.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/mod.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/mod.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/mod.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/read.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/read.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/read.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/syntax.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/syntax.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/syntax.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/validation.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launcher/validation.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/validation.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs similarity index 99% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs index b72bdbf2f..ab8df5e57 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs @@ -610,5 +610,4 @@ fn terminate_package_query(child: &mut std::process::Child, process_group: Pid) } #[cfg(test)] -#[path = "tests/provenance.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/provenance.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs deleted file mode 100644 index 713a45e23..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/index.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Executable lookup-index rebuild cases - -use std::collections::HashSet; - -use super::super::index::{portal_candidate_paths, portal_identity_is_trusted}; -use super::super::model::{DesktopIdentityIndex, DesktopRecord, LaunchSpec}; -use crate::daemon::notifications::identity::desktop_index::provenance::InstallProvenance; -use crate::daemon::notifications::identity::executable::{ - executable_evidence_for_path, FileIdentity, -}; -use crate::test_support::TempRoot; - -#[test] -fn executable_index_rebuild_replaces_stale_runtime_identity() { - let old = identity(70); - let new = identity(71); - let mut index = DesktopIdentityIndex::default(); - index.index_record(record(old)); - index.records[0].runtime_executable_identity = Some(new); - index.records[0] - .launch_spec - .as_mut() - .expect("runtime launch specification") - .runtime_executable = new; - - index.rebuild_executable_index(); - - assert!(index.records_for_executable(old).is_empty()); - assert_eq!(index.records_for_executable(new).len(), 1); -} - -#[test] -fn portal_discovery_filters_before_applying_the_candidate_limit() { - let root = TempRoot::new("portal-discovery-filter-order"); - for index in 0..300 { - std::fs::write( - root.join(format!("ordinary-library-{index:03}")), - b"fixture", - ) - .expect("write non-portal directory entry"); - } - let portal = root.join("xdg-desktop-portal-example"); - std::fs::write(&portal, b"portal fixture").expect("write portal directory entry"); - - let candidates = portal_candidate_paths(root.path()); - - assert_eq!(candidates, vec![portal]); -} - -#[test] -fn portal_identity_requires_both_system_management_and_executable_file_type() { - let trusted = FileIdentity { - device: 1, - inode: 2, - uid: 0, - mode: 0o100_755, - }; - assert!(portal_identity_is_trusted(trusted)); - assert!(!portal_identity_is_trusted(FileIdentity { - uid: 1_000, - ..trusted - })); - assert!(!portal_identity_is_trusted(FileIdentity { - mode: 0o100_644, - ..trusted - })); -} - -#[test] -fn installed_protected_portal_is_indexed_when_available() { - let installed = [ - "/usr/lib", - "/usr/libexec", - "/usr/local/lib", - "/usr/local/libexec", - ] - .into_iter() - .find_map(|directory| { - portal_candidate_paths(std::path::Path::new(directory)) - .into_iter() - .find_map(|path| { - let evidence = executable_evidence_for_path(&path)?; - portal_identity_is_trusted(evidence.identity).then_some((path, evidence.identity)) - }) - }); - let Some((portal, identity)) = installed else { - // Platforms without an installed portal backend have no system fixture to index - return; - }; - let directory = portal.parent().expect("installed portal parent directory"); - let mut index = DesktopIdentityIndex::default(); - - index.index_trusted_portals_in(directory); - - assert!(index - .trusted_portals - .iter() - .any(|candidate| candidate.identity.same_file(identity))); -} - -fn record(runtime: FileIdentity) -> DesktopRecord { - DesktopRecord { - id: "org.example.App".to_string(), - display_name: "Example App".to_string(), - badge_icon: "example-app".to_string(), - desktop_path: None, - declared_executable_path: Some("/usr/bin/example-app".into()), - declared_executable_identity: Some(runtime), - runtime_executable_path: Some("/usr/bin/example-app".into()), - runtime_executable_identity: Some(runtime), - desktop_identity: None, - desktop_provenance: InstallProvenance::Unknown, - declared_executable_provenance: InstallProvenance::Unknown, - runtime_executable_provenance: InstallProvenance::Unknown, - system_origin: false, - system_association: false, - association_eligible: true, - launch_spec: Some(LaunchSpec { - declared_executable: runtime, - runtime_executable: runtime, - arguments: Vec::new(), - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: false, - }), - names: HashSet::new(), - } -} - -fn identity(inode: u64) -> FileIdentity { - FileIdentity { - device: 1, - inode, - uid: 1_000, - mode: 0o100_755, - } -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs similarity index 99% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs index f8d5b4138..fcae13793 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs @@ -482,5 +482,4 @@ fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { } #[cfg(test)] -#[path = "tests/verification.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/verification.rs rename to crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs From a87aef21105df44cd166bda187a71b3140d69550 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:10:16 -0500 Subject: [PATCH 189/275] refactor(identity): split package provenance engine Summary: split package provenance engine. Scope: identity. --- .../desktop_index/provenance/cache.rs | 168 ++++++ .../identity/desktop_index/provenance/mod.rs | 547 +----------------- .../desktop_index/provenance/process.rs | 124 ++++ .../desktop_index/provenance/query.rs | 190 ++++++ .../identity/desktop_index/provenance/rpm.rs | 106 ++++ .../desktop_index/provenance/tests/cache.rs | 43 ++ .../desktop_index/provenance/tests/mod.rs | 380 +----------- .../desktop_index/provenance/tests/model.rs | 42 ++ .../desktop_index/provenance/tests/process.rs | 104 ++++ .../desktop_index/provenance/tests/query.rs | 140 +++++ .../desktop_index/provenance/tests/rpm.rs | 83 +++ 11 files changed, 1010 insertions(+), 917 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs new file mode 100644 index 000000000..4ed311fe2 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs @@ -0,0 +1,168 @@ +//! Negative-result cache for package ownership lookups + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use super::query::{detect_package_provider, query_package_ownership}; +use super::InstallProvenance; + +const MAX_OWNERSHIP_PATHS: usize = 16_384; +pub(super) const TRANSIENT_NEGATIVE_TTL: Duration = Duration::from_secs(30); +pub(super) const NOT_OWNED_NEGATIVE_TTL: Duration = Duration::from_mins(5); + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum NegativeCause { + NotOwned, + Timeout, + ProviderFailure, + MalformedOutput, + ProcessTermination, +} + +#[derive(Debug, Clone)] +pub(super) enum CachedProvenance { + Known(InstallProvenance), + Negative { + retry_after: Instant, + cause: NegativeCause, + }, +} + +impl CachedProvenance { + pub(super) fn from_lookup(lookup: OwnershipLookup, now: Instant) -> Self { + match lookup { + OwnershipLookup::Known(provenance) => Self::Known(provenance), + OwnershipLookup::Negative(cause) => Self::Negative { + retry_after: now.checked_add(negative_ttl(cause)).unwrap_or(now), + cause, + }, + } + } + + pub(super) fn needs_refresh(&self, now: Instant) -> bool { + let Self::Negative { retry_after, cause } = self else { + return false; + }; + // Keeping the cause live preserves the distinction used to select retry windows + debug_assert!( + !negative_ttl(*cause).is_zero(), + "negative package-provenance results must remain retryable" + ); + now >= *retry_after + } + + pub(super) fn provenance(&self) -> InstallProvenance { + match self { + Self::Known(provenance) => provenance.clone(), + Self::Negative { .. } => InstallProvenance::Unknown, + } + } +} + +fn negative_ttl(cause: NegativeCause) -> Duration { + match cause { + NegativeCause::NotOwned => NOT_OWNED_NEGATIVE_TTL, + NegativeCause::Timeout + | NegativeCause::ProviderFailure + | NegativeCause::MalformedOutput + | NegativeCause::ProcessTermination => TRANSIENT_NEGATIVE_TTL, + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) enum OwnershipLookup { + Known(InstallProvenance), + Negative(NegativeCause), +} + +#[derive(Debug, Default)] +pub(in crate::daemon::notifications::identity) struct PackageOwnershipCache { + provider: OnceLock>, + pub(super) entries: Mutex>, +} + +impl PackageOwnershipCache { + pub(in crate::daemon::notifications::identity) fn resolve_many( + &self, + paths: impl IntoIterator, + ) -> HashMap { + // Dedupe before taking the cache lock so repeated desktop aliases stay cheap + let paths = paths + .into_iter() + .take(MAX_OWNERSHIP_PATHS) + .collect::>(); + let now = Instant::now(); + let missing = self.entries.lock().map_or_else( + |_| paths.iter().cloned().collect::>(), + |entries| { + paths + .iter() + .filter(|path| { + entries + .get(*path) + .is_none_or(|entry| entry.needs_refresh(now)) + }) + .cloned() + .collect::>() + }, + ); + + if !missing.is_empty() { + let resolved = self + .provider + .get_or_init(detect_package_provider) + .as_ref() + .map_or_else( + || { + missing + .iter() + .cloned() + .map(|path| { + ( + path, + OwnershipLookup::Negative(NegativeCause::ProviderFailure), + ) + }) + .collect() + }, + |provider| query_package_ownership(provider, &missing), + ); + let resolved_at = Instant::now(); + if let Ok(mut entries) = self.entries.lock() { + for path in missing { + let lookup = resolved + .get(&path) + .cloned() + .unwrap_or(OwnershipLookup::Negative(NegativeCause::ProviderFailure)); + entries.insert(path, CachedProvenance::from_lookup(lookup, resolved_at)); + } + } + } + + self.entries.lock().map_or_else( + |_| HashMap::new(), + |entries| { + paths + .into_iter() + .map(|path| { + let provenance = entries + .get(&path) + .map_or(InstallProvenance::Unknown, CachedProvenance::provenance); + (path, provenance) + }) + .collect() + }, + ) + } + + pub(in crate::daemon::notifications::identity) fn resolve_one( + &self, + path: &Path, + ) -> InstallProvenance { + self.resolve_many([path.to_path_buf()]) + .remove(path) + .unwrap_or(InstallProvenance::Unknown) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs index ab8df5e57..cd17343fc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs @@ -1,32 +1,11 @@ //! Immutable installation ownership used by desktop attribution -use std::collections::{HashMap, HashSet}; -use std::io::Read; -use std::os::unix::ffi::OsStrExt; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{mpsc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; +mod cache; +mod process; +mod query; +mod rpm; -use rustix::process::{kill_process_group, Pid, Signal}; - -use super::super::executable::executable_evidence_for_path; -use wait_timeout::ChildExt; - -const MAX_OWNERSHIP_PATHS: usize = 16_384; -const MAX_COMMAND_ARGUMENT_BYTES: usize = 192 * 1024; -const MAX_COMMAND_PATHS: usize = 4_096; -const MAX_OWNERSHIP_OUTPUT_BYTES: usize = 8 * 1024 * 1024; -const MAX_PACKAGE_ID_BYTES: usize = 256; -const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); -const PACKAGE_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_millis(50); -const TRANSIENT_NEGATIVE_TTL: Duration = Duration::from_secs(30); -const NOT_OWNED_NEGATIVE_TTL: Duration = Duration::from_mins(5); -const MAX_RPM_QUERY_PATHS: usize = 4_096; -const MAX_RPM_QUERY_WORKERS: usize = 8; -const RPM_TOTAL_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +pub(super) use cache::PackageOwnershipCache; /// System database that established package ownership #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] @@ -36,12 +15,6 @@ pub(in crate::daemon::notifications) enum PackageProvider { Rpm, } -#[derive(Debug, Clone)] -struct PackageProviderCommand { - provider: PackageProvider, - executable: PathBuf, -} - /// Installation source shared by protected desktop and executable files #[derive(Debug, Clone, Default, Eq, Hash, PartialEq)] pub(in crate::daemon::notifications) enum InstallProvenance { @@ -99,515 +72,5 @@ impl InstallProvenance { } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum NegativeCause { - NotOwned, - Timeout, - ProviderFailure, - MalformedOutput, - ProcessTermination, -} - -#[derive(Debug, Clone)] -enum CachedProvenance { - Known(InstallProvenance), - Negative { - retry_after: Instant, - cause: NegativeCause, - }, -} - -impl CachedProvenance { - fn from_lookup(lookup: OwnershipLookup, now: Instant) -> Self { - match lookup { - OwnershipLookup::Known(provenance) => Self::Known(provenance), - OwnershipLookup::Negative(cause) => Self::Negative { - retry_after: now.checked_add(negative_ttl(cause)).unwrap_or(now), - cause, - }, - } - } - - fn needs_refresh(&self, now: Instant) -> bool { - let Self::Negative { retry_after, cause } = self else { - return false; - }; - // Keeping the cause live preserves the distinction used to select retry windows - debug_assert!( - !negative_ttl(*cause).is_zero(), - "negative package-provenance results must remain retryable" - ); - now >= *retry_after - } - - fn provenance(&self) -> InstallProvenance { - match self { - Self::Known(provenance) => provenance.clone(), - Self::Negative { .. } => InstallProvenance::Unknown, - } - } -} - -const fn negative_ttl(cause: NegativeCause) -> Duration { - match cause { - NegativeCause::NotOwned => NOT_OWNED_NEGATIVE_TTL, - NegativeCause::Timeout - | NegativeCause::ProviderFailure - | NegativeCause::MalformedOutput - | NegativeCause::ProcessTermination => TRANSIENT_NEGATIVE_TTL, - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -enum OwnershipLookup { - Known(InstallProvenance), - Negative(NegativeCause), -} - -#[derive(Debug, Default)] -pub(super) struct PackageOwnershipCache { - provider: OnceLock>, - entries: Mutex>, -} - -impl PackageOwnershipCache { - pub(super) fn resolve_many( - &self, - paths: impl IntoIterator, - ) -> HashMap { - // Dedupe before taking the cache lock so repeated desktop aliases stay cheap - let paths = paths - .into_iter() - .take(MAX_OWNERSHIP_PATHS) - .collect::>(); - let now = Instant::now(); - let missing = self.entries.lock().map_or_else( - |_| paths.iter().cloned().collect::>(), - |entries| { - paths - .iter() - .filter(|path| { - entries - .get(*path) - .is_none_or(|entry| entry.needs_refresh(now)) - }) - .cloned() - .collect::>() - }, - ); - - if !missing.is_empty() { - let resolved = self - .provider - .get_or_init(detect_package_provider) - .as_ref() - .map_or_else( - || { - missing - .iter() - .cloned() - .map(|path| { - ( - path, - OwnershipLookup::Negative(NegativeCause::ProviderFailure), - ) - }) - .collect() - }, - |provider| query_package_ownership(provider, &missing), - ); - let resolved_at = Instant::now(); - if let Ok(mut entries) = self.entries.lock() { - for path in missing { - let lookup = resolved - .get(&path) - .cloned() - .unwrap_or(OwnershipLookup::Negative(NegativeCause::ProviderFailure)); - entries.insert(path, CachedProvenance::from_lookup(lookup, resolved_at)); - } - } - } - - self.entries.lock().map_or_else( - |_| HashMap::new(), - |entries| { - paths - .into_iter() - .map(|path| { - let provenance = entries - .get(&path) - .map_or(InstallProvenance::Unknown, CachedProvenance::provenance); - (path, provenance) - }) - .collect() - }, - ) - } - - pub(super) fn resolve_one(&self, path: &Path) -> InstallProvenance { - self.resolve_many([path.to_path_buf()]) - .remove(path) - .unwrap_or(InstallProvenance::Unknown) - } -} - -fn detect_package_provider() -> Option { - [ - ("pacman", PackageProvider::Pacman), - ("dpkg-query", PackageProvider::Dpkg), - ("rpm", PackageProvider::Rpm), - ] - .into_iter() - .find_map(|(program, provider)| { - let executable = unixnotis_core::util::trusted_system_program_path(program)?; - let evidence = executable_evidence_for_path(&executable)?; - // Provider output affects attribution, so user-writable commands are never accepted - (evidence.identity.is_system_managed() && evidence.identity.is_executable_regular()) - .then_some(PackageProviderCommand { - provider, - executable: evidence.canonical_path, - }) - }) -} - -fn query_package_ownership( - provider: &PackageProviderCommand, - paths: &[PathBuf], -) -> HashMap { - match provider.provider { - PackageProvider::Pacman => query_in_chunks(provider, paths, &["-Qo"], parse_pacman_output), - PackageProvider::Dpkg => query_in_chunks(provider, paths, &["--search"], parse_dpkg_output), - // RPM output does not retain each selector, so bounded workers query paths separately - PackageProvider::Rpm => query_rpm_ownership(provider, paths), - } -} - -fn query_in_chunks( - provider: &PackageProviderCommand, - paths: &[PathBuf], - arguments: &[&str], - parser: OwnershipOutputParser, -) -> HashMap { - let mut resolved = HashMap::new(); - let mut remaining = paths; - while remaining.split_first().is_some() { - // A one-path floor preserves progress even if a future chunk policy returns zero - let chunk_len = ownership_chunk_len(remaining).max(1).min(remaining.len()); - let (chunk, next) = remaining.split_at(chunk_len); - let mut command = Command::new(&provider.executable); - command - .args(arguments) - .args(chunk) - .env_clear() - .env("LC_ALL", "C"); - match run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { - Ok(output) => { - let parsed = parser(&output.stdout, chunk, provider.provider); - for path in chunk { - let lookup = parsed.get(path).cloned().map_or_else( - || { - if output.status.success() && output.stdout.is_empty() { - OwnershipLookup::Negative(NegativeCause::NotOwned) - } else if output.status.success() { - OwnershipLookup::Negative(NegativeCause::MalformedOutput) - } else { - OwnershipLookup::Negative(NegativeCause::ProviderFailure) - } - }, - OwnershipLookup::Known, - ); - resolved.insert(path.clone(), lookup); - } - } - Err(error) => { - let cause = error.negative_cause(); - resolved.extend( - chunk - .iter() - .cloned() - .map(|path| (path, OwnershipLookup::Negative(cause))), - ); - } - } - remaining = next; - } - resolved -} - -fn ownership_chunk_len(paths: &[PathBuf]) -> usize { - let mut bytes = 0_usize; - let mut end = 0_usize; - while end < paths.len() && end < MAX_COMMAND_PATHS { - let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); - // The first path always advances so even an oversized selector cannot stall the scan - if end > 0 && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { - break; - } - bytes = bytes.saturating_add(next); - end = end.saturating_add(1); - } - end -} - -type OwnershipOutputParser = - fn(&[u8], &[PathBuf], PackageProvider) -> HashMap; - -fn parse_pacman_output( - output: &[u8], - paths: &[PathBuf], - provider: PackageProvider, -) -> HashMap { - let expected = paths - .iter() - .map(|path| (path.as_os_str().as_bytes(), path)) - .collect::>(); - output - .split(|byte| *byte == b'\n') - .filter_map(|line| { - let marker = b" is owned by "; - let position = line - .windows(marker.len()) - .position(|window| window == marker)?; - let path = expected.get(&line[..position])?; - let package = line.get(position.saturating_add(marker.len())..)?; - let package = package.split(|byte| *byte == b' ').next()?; - package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) - }) - .collect() -} - -fn parse_dpkg_output( - output: &[u8], - paths: &[PathBuf], - provider: PackageProvider, -) -> HashMap { - let expected = paths - .iter() - .map(|path| (path.as_os_str().as_bytes(), path)) - .collect::>(); - output - .split(|byte| *byte == b'\n') - .filter_map(|line| { - let position = line.windows(2).rposition(|window| window == b": ")?; - let package = line.get(..position)?.split(|byte| *byte == b',').next()?; - let path = expected.get(line.get(position.saturating_add(2)..)?)?; - package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) - }) - .collect() -} - -fn query_rpm_ownership( - provider: &PackageProviderCommand, - paths: &[PathBuf], -) -> HashMap { - query_rpm_ownership_with(paths, RPM_TOTAL_QUERY_TIMEOUT, &|path, timeout| { - query_rpm_owner(provider, path, timeout) - }) -} - -fn query_rpm_ownership_with( - paths: &[PathBuf], - total_timeout: Duration, - query: &Query, -) -> HashMap -where - Query: Fn(&Path, Duration) -> OwnershipLookup + Sync, -{ - let bounded_len = paths.len().min(MAX_RPM_QUERY_PATHS); - let bounded = &paths[..bounded_len]; - let next = AtomicUsize::new(0); - let results = Mutex::new(HashMap::with_capacity(bounded_len)); - let deadline = Instant::now() - .checked_add(total_timeout) - .unwrap_or_else(Instant::now); - let worker_count = bounded_len.min(MAX_RPM_QUERY_WORKERS); - - std::thread::scope(|scope| { - let mut workers = Vec::with_capacity(worker_count); - for worker in 0..worker_count { - let spawn = std::thread::Builder::new() - .name(format!("unixnotis-rpm-owner-{worker}")) - .spawn_scoped(scope, || loop { - let path_index = next.fetch_add(1, Ordering::Relaxed); - let Some(path) = bounded.get(path_index) else { - break; - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break; - } - let lookup = query(path, remaining.min(PACKAGE_QUERY_TIMEOUT)); - if let Ok(mut results) = results.lock() { - results.insert(path.clone(), lookup); - } - }); - if let Ok(worker) = spawn { - workers.push(worker); - } - } - for worker in workers { - let _worker_result = worker.join(); - } - }); - - results.into_inner().unwrap_or_default() -} - -fn query_rpm_owner( - provider: &PackageProviderCommand, - path: &Path, - timeout: Duration, -) -> OwnershipLookup { - let mut command = Command::new(&provider.executable); - command - .args(["-qf", "--queryformat", "%{NAME}\n"]) - .arg(path) - .env_clear() - .env("LC_ALL", "C"); - let output = match run_package_query_with_timeout( - &mut command, - MAX_PACKAGE_ID_BYTES.saturating_add(1), - timeout, - ) { - Ok(output) => output, - Err(error) => return OwnershipLookup::Negative(error.negative_cause()), - }; - if !output.status.success() { - return OwnershipLookup::Negative(NegativeCause::ProviderFailure); - } - let package = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); - if package.is_empty() { - return OwnershipLookup::Negative(NegativeCause::NotOwned); - } - package_provenance(provider.provider, package).map_or( - OwnershipLookup::Negative(NegativeCause::MalformedOutput), - OwnershipLookup::Known, - ) -} - -fn package_provenance(provider: PackageProvider, package: &[u8]) -> Option { - if package.is_empty() - || package.len() > MAX_PACKAGE_ID_BYTES - || package - .iter() - .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) - { - return None; - } - Some(InstallProvenance::Package { - provider, - package_id: std::str::from_utf8(package).ok()?.to_string(), - }) -} - -#[derive(Debug)] -struct PackageQueryOutput { - status: ExitStatus, - stdout: Vec, -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum PackageQueryFailure { - Spawn, - Wait, - Timeout, - Reader, - PipeDrainTimeout, - OutputLimit, -} - -impl PackageQueryFailure { - const fn negative_cause(self) -> NegativeCause { - match self { - Self::Timeout | Self::PipeDrainTimeout => NegativeCause::Timeout, - Self::OutputLimit => NegativeCause::MalformedOutput, - Self::Spawn | Self::Wait | Self::Reader => NegativeCause::ProcessTermination, - } - } -} - -fn run_package_query( - command: &mut Command, - output_limit: usize, -) -> Result { - run_package_query_with_timeout(command, output_limit, PACKAGE_QUERY_TIMEOUT) -} - -fn run_package_query_with_timeout( - command: &mut Command, - output_limit: usize, - timeout: Duration, -) -> Result { - // A provider may launch helpers that keep the output pipe open after its leader exits - command.process_group(0); - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|_error| PackageQueryFailure::Spawn)?; - // The child is its new process-group leader because process_group received zero - let process_group = Pid::from_child(&child); - let Some(stdout) = child.stdout.take() else { - terminate_package_query(&mut child, process_group); - return Err(PackageQueryFailure::Reader); - }; - let (reader_tx, reader_rx) = mpsc::sync_channel(1); - let reader = std::thread::Builder::new() - .name("unixnotis-package-output".to_string()) - .spawn(move || { - let limit = u64::try_from(output_limit) - .unwrap_or(u64::MAX) - .saturating_add(1); - let mut output = Vec::new(); - let read_result = stdout.take(limit).read_to_end(&mut output); - let _send_result = reader_tx.send(read_result.map(|_bytes| output)); - }) - .map_err(|_error| { - terminate_package_query(&mut child, process_group); - PackageQueryFailure::Reader - })?; - // The result channel owns completion; dropping the handle avoids every unbounded join path - drop(reader); - - let started = Instant::now(); - let status = match child.wait_timeout(timeout) { - Ok(Some(status)) => status, - Ok(None) => { - terminate_package_query(&mut child, process_group); - return Err(PackageQueryFailure::Timeout); - } - Err(_error) => { - terminate_package_query(&mut child, process_group); - return Err(PackageQueryFailure::Wait); - } - }; - let remaining = timeout.saturating_sub(started.elapsed()); - let drain_timeout = remaining.min(PACKAGE_PIPE_DRAIN_TIMEOUT); - let stdout = match reader_rx.recv_timeout(drain_timeout) { - Ok(Ok(stdout)) => stdout, - Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err(PackageQueryFailure::Reader); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // The leader exited, so only inherited pipe holders remain in its process group - let _kill_result = kill_process_group(process_group, Signal::KILL); - return Err(PackageQueryFailure::PipeDrainTimeout); - } - }; - if stdout.len() > output_limit { - return Err(PackageQueryFailure::OutputLimit); - } - Ok(PackageQueryOutput { status, stdout }) -} - -fn terminate_package_query(child: &mut std::process::Child, process_group: Pid) { - // Group termination closes ordinary inherited pipes while the bounded reap avoids startup hangs - if kill_process_group(process_group, Signal::KILL).is_err() { - let _kill_result = child.kill(); - } - let _wait_result = child.wait_timeout(PACKAGE_PIPE_DRAIN_TIMEOUT); -} - #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs new file mode 100644 index 000000000..73c814caa --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs @@ -0,0 +1,124 @@ +//! Package-manager subprocess supervision + +use std::io::Read; +use std::os::unix::process::CommandExt; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use rustix::process::{kill_process_group, Pid, Signal}; +use wait_timeout::ChildExt; + +use super::cache::NegativeCause; + +const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); +const PACKAGE_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_millis(50); + +#[derive(Debug)] +pub(super) struct PackageQueryOutput { + pub(super) status: ExitStatus, + pub(super) stdout: Vec, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum PackageQueryFailure { + Spawn, + Wait, + Timeout, + Reader, + PipeDrainTimeout, + OutputLimit, +} + +impl PackageQueryFailure { + pub(super) const fn negative_cause(self) -> NegativeCause { + match self { + Self::Timeout | Self::PipeDrainTimeout => NegativeCause::Timeout, + Self::OutputLimit => NegativeCause::MalformedOutput, + Self::Spawn | Self::Wait | Self::Reader => NegativeCause::ProcessTermination, + } + } +} + +pub(super) fn run_package_query( + command: &mut Command, + output_limit: usize, +) -> Result { + run_package_query_with_timeout(command, output_limit, PACKAGE_QUERY_TIMEOUT) +} + +pub(super) fn run_package_query_with_timeout( + command: &mut Command, + output_limit: usize, + timeout: Duration, +) -> Result { + // A provider may launch helpers that keep the output pipe open after its leader exits + command.process_group(0); + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_error| PackageQueryFailure::Spawn)?; + // The child is its new process-group leader because process_group received zero + let process_group = Pid::from_child(&child); + let Some(stdout) = child.stdout.take() else { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Reader); + }; + let (reader_tx, reader_rx) = mpsc::sync_channel(1); + let reader = std::thread::Builder::new() + .name("unixnotis-package-output".to_string()) + .spawn(move || { + let limit = u64::try_from(output_limit) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut output = Vec::new(); + let read_result = stdout.take(limit).read_to_end(&mut output); + let _send_result = reader_tx.send(read_result.map(|_bytes| output)); + }) + .map_err(|_error| { + terminate_package_query(&mut child, process_group); + PackageQueryFailure::Reader + })?; + // The result channel owns completion; dropping the handle avoids every unbounded join path + drop(reader); + + let started = Instant::now(); + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Timeout); + } + Err(_error) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Wait); + } + }; + let remaining = timeout.saturating_sub(started.elapsed()); + let drain_timeout = remaining.min(PACKAGE_PIPE_DRAIN_TIMEOUT); + let stdout = match reader_rx.recv_timeout(drain_timeout) { + Ok(Ok(stdout)) => stdout, + Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(PackageQueryFailure::Reader); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // The leader exited, so only inherited pipe holders remain in its process group + let _kill_result = kill_process_group(process_group, Signal::KILL); + return Err(PackageQueryFailure::PipeDrainTimeout); + } + }; + if stdout.len() > output_limit { + return Err(PackageQueryFailure::OutputLimit); + } + Ok(PackageQueryOutput { status, stdout }) +} + +pub(super) fn terminate_package_query(child: &mut std::process::Child, process_group: Pid) { + // Group termination closes ordinary inherited pipes while the bounded reap avoids startup hangs + if kill_process_group(process_group, Signal::KILL).is_err() { + let _kill_result = child.kill(); + } + let _wait_result = child.wait_timeout(PACKAGE_PIPE_DRAIN_TIMEOUT); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs new file mode 100644 index 000000000..ec9b33fad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs @@ -0,0 +1,190 @@ +//! Package-provider discovery, batching, and output parsing + +use std::collections::HashMap; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; +use std::process::Command; + +use super::super::super::executable::executable_evidence_for_path; +use super::cache::OwnershipLookup; +use super::process::run_package_query; +use super::rpm::query_rpm_ownership; +use super::{InstallProvenance, PackageProvider}; + +pub(super) const MAX_COMMAND_ARGUMENT_BYTES: usize = 192 * 1024; +pub(super) const MAX_COMMAND_PATHS: usize = 4_096; +const MAX_OWNERSHIP_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PACKAGE_ID_BYTES: usize = 256; + +#[derive(Debug, Clone)] +pub(super) struct PackageProviderCommand { + pub(super) provider: PackageProvider, + pub(super) executable: PathBuf, +} + +pub(super) fn detect_package_provider() -> Option { + [ + ("pacman", PackageProvider::Pacman), + ("dpkg-query", PackageProvider::Dpkg), + ("rpm", PackageProvider::Rpm), + ] + .into_iter() + .find_map(|(program, provider)| { + let executable = unixnotis_core::util::trusted_system_program_path(program)?; + let evidence = executable_evidence_for_path(&executable)?; + // Provider output affects attribution, so user-writable commands are never accepted + (evidence.identity.is_system_managed() && evidence.identity.is_executable_regular()) + .then_some(PackageProviderCommand { + provider, + executable: evidence.canonical_path, + }) + }) +} + +pub(super) fn query_package_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + match provider.provider { + PackageProvider::Pacman => query_in_chunks(provider, paths, &["-Qo"], parse_pacman_output), + PackageProvider::Dpkg => query_in_chunks(provider, paths, &["--search"], parse_dpkg_output), + // RPM output does not retain each selector, so bounded workers query paths separately + PackageProvider::Rpm => query_rpm_ownership(provider, paths), + } +} + +fn query_in_chunks( + provider: &PackageProviderCommand, + paths: &[PathBuf], + arguments: &[&str], + parser: OwnershipOutputParser, +) -> HashMap { + let mut resolved = HashMap::new(); + let mut remaining = paths; + while remaining.split_first().is_some() { + // A one-path floor preserves progress even if a future chunk policy returns zero + let chunk_len = ownership_chunk_len(remaining).max(1).min(remaining.len()); + let (chunk, next) = remaining.split_at(chunk_len); + let mut command = Command::new(&provider.executable); + command + .args(arguments) + .args(chunk) + .env_clear() + .env("LC_ALL", "C"); + match run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { + Ok(output) => { + let parsed = parser(&output.stdout, chunk, provider.provider); + for path in chunk { + let lookup = parsed.get(path).cloned().map_or_else( + || { + if output.status.success() && output.stdout.is_empty() { + OwnershipLookup::Negative(super::cache::NegativeCause::NotOwned) + } else if output.status.success() { + OwnershipLookup::Negative( + super::cache::NegativeCause::MalformedOutput, + ) + } else { + OwnershipLookup::Negative( + super::cache::NegativeCause::ProviderFailure, + ) + } + }, + OwnershipLookup::Known, + ); + resolved.insert(path.clone(), lookup); + } + } + Err(error) => { + let cause = error.negative_cause(); + resolved.extend( + chunk + .iter() + .cloned() + .map(|path| (path, OwnershipLookup::Negative(cause))), + ); + } + } + remaining = next; + } + resolved +} + +pub(super) fn ownership_chunk_len(paths: &[PathBuf]) -> usize { + let mut bytes = 0_usize; + let mut end = 0_usize; + while end < paths.len() && end < MAX_COMMAND_PATHS { + let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); + // The first path always advances so even an oversized selector cannot stall the scan + if end > 0 && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { + break; + } + bytes = bytes.saturating_add(next); + end = end.saturating_add(1); + } + end +} + +type OwnershipOutputParser = + fn(&[u8], &[PathBuf], PackageProvider) -> HashMap; + +pub(super) fn parse_pacman_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let marker = b" is owned by "; + let position = line + .windows(marker.len()) + .position(|window| window == marker)?; + let path = expected.get(&line[..position])?; + let package = line.get(position.saturating_add(marker.len())..)?; + let package = package.split(|byte| *byte == b' ').next()?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +pub(super) fn parse_dpkg_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let position = line.windows(2).rposition(|window| window == b": ")?; + let package = line.get(..position)?.split(|byte| *byte == b',').next()?; + let path = expected.get(line.get(position.saturating_add(2)..)?)?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +pub(super) fn package_provenance( + provider: PackageProvider, + package: &[u8], +) -> Option { + if package.is_empty() + || package.len() > MAX_PACKAGE_ID_BYTES + || package + .iter() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return None; + } + Some(InstallProvenance::Package { + provider, + package_id: std::str::from_utf8(package).ok()?.to_string(), + }) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs new file mode 100644 index 000000000..c72fccf56 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs @@ -0,0 +1,106 @@ +//! Bounded RPM ownership queries + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use super::cache::{NegativeCause, OwnershipLookup}; +use super::process::run_package_query_with_timeout; +use super::query::PackageProviderCommand; + +const MAX_RPM_QUERY_PATHS: usize = 4_096; +const MAX_RPM_QUERY_WORKERS: usize = 8; +const RPM_TOTAL_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); +const MAX_PACKAGE_ID_BYTES: usize = 256; + +pub(super) fn query_rpm_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + query_rpm_ownership_with(paths, RPM_TOTAL_QUERY_TIMEOUT, &|path, timeout| { + query_rpm_owner(provider, path, timeout) + }) +} + +pub(super) fn query_rpm_ownership_with( + paths: &[PathBuf], + total_timeout: Duration, + query: &Query, +) -> HashMap +where + Query: Fn(&Path, Duration) -> OwnershipLookup + Sync, +{ + let bounded_len = paths.len().min(MAX_RPM_QUERY_PATHS); + let bounded = &paths[..bounded_len]; + let next = AtomicUsize::new(0); + let results = Mutex::new(HashMap::with_capacity(bounded_len)); + let deadline = Instant::now() + .checked_add(total_timeout) + .unwrap_or_else(Instant::now); + let worker_count = bounded_len.min(MAX_RPM_QUERY_WORKERS); + + std::thread::scope(|scope| { + let mut workers = Vec::with_capacity(worker_count); + for worker in 0..worker_count { + let spawn = std::thread::Builder::new() + .name(format!("unixnotis-rpm-owner-{worker}")) + .spawn_scoped(scope, || loop { + let path_index = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = bounded.get(path_index) else { + break; + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let lookup = query(path, remaining.min(PACKAGE_QUERY_TIMEOUT)); + if let Ok(mut results) = results.lock() { + results.insert(path.clone(), lookup); + } + }); + if let Ok(worker) = spawn { + workers.push(worker); + } + } + for worker in workers { + let _worker_result = worker.join(); + } + }); + + results.into_inner().unwrap_or_default() +} + +pub(super) fn query_rpm_owner( + provider: &PackageProviderCommand, + path: &Path, + timeout: Duration, +) -> OwnershipLookup { + let mut command = std::process::Command::new(&provider.executable); + command + .args(["-qf", "--queryformat", "%{NAME}\n"]) + .arg(path) + .env_clear() + .env("LC_ALL", "C"); + let output = match run_package_query_with_timeout( + &mut command, + MAX_PACKAGE_ID_BYTES.saturating_add(1), + timeout, + ) { + Ok(output) => output, + Err(error) => return OwnershipLookup::Negative(error.negative_cause()), + }; + if !output.status.success() { + return OwnershipLookup::Negative(NegativeCause::ProviderFailure); + } + let package = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); + if package.is_empty() { + return OwnershipLookup::Negative(NegativeCause::NotOwned); + } + super::query::package_provenance(provider.provider, package).map_or( + OwnershipLookup::Negative(NegativeCause::MalformedOutput), + OwnershipLookup::Known, + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs new file mode 100644 index 000000000..a9e5cc020 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; +use std::time::Instant; + +use super::super::cache::{ + CachedProvenance, NegativeCause, OwnershipLookup, PackageOwnershipCache, + NOT_OWNED_NEGATIVE_TTL, TRANSIENT_NEGATIVE_TTL, +}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn transient_ownership_failures_expire_before_confirmed_not_owned_entries() { + let now = Instant::now(); + let transient = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::Timeout), now); + let not_owned = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::NotOwned), now); + + assert!(!transient.needs_refresh(now)); + assert!(transient.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(!not_owned.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(not_owned.needs_refresh(now + NOT_OWNED_NEGATIVE_TTL)); + assert_eq!(transient.provenance(), InstallProvenance::Unknown); +} + +#[test] +fn cached_known_provenance_is_returned_for_every_requested_path() { + let path = PathBuf::from("/usr/bin/example-cache-entry"); + let expected = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-cache-entry".to_string(), + }; + let cache = PackageOwnershipCache::default(); + cache + .entries + .lock() + .expect("package cache should be writable") + .insert(path.clone(), CachedProvenance::Known(expected.clone())); + + let resolved = cache.resolve_many([path.clone()]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get(&path), Some(&expected)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs index d5a41ab49..b28293fad 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs @@ -1,375 +1,5 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use super::{ - ownership_chunk_len, package_provenance, parse_dpkg_output, parse_pacman_output, - query_package_ownership, query_rpm_owner, query_rpm_ownership_with, run_package_query, - run_package_query_with_timeout, CachedProvenance, InstallProvenance, NegativeCause, - OwnershipLookup, PackageOwnershipCache, PackageProvider, PackageProviderCommand, - PackageQueryFailure, MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMAND_PATHS, NOT_OWNED_NEGATIVE_TTL, - TRANSIENT_NEGATIVE_TTL, -}; - -#[test] -fn matching_package_sources_establish_one_installation_owner() { - let desktop = InstallProvenance::Package { - provider: PackageProvider::Pacman, - package_id: "example-app".to_string(), - }; - let executable = desktop.clone(); - - assert!(desktop.same_application_source(&executable)); - assert!( - !desktop.same_application_source(&InstallProvenance::Package { - provider: PackageProvider::Pacman, - package_id: "shared-runtime".to_string(), - }) - ); - assert!(!desktop.same_application_source(&InstallProvenance::Unknown)); -} - -#[test] -fn bundle_and_portal_provenance_require_exact_domain_identity() { - let bundle = InstallProvenance::ImmutableBundle { - bundle_id: "org.example.App".to_string(), - }; - let same_bundle = bundle.clone(); - let other_bundle = InstallProvenance::ImmutableBundle { - bundle_id: "org.example.Other".to_string(), - }; - let portal = InstallProvenance::Portal { - app_id: "org.example.App".to_string(), - }; - let same_portal = portal.clone(); - let other_portal = InstallProvenance::Portal { - app_id: "org.example.Other".to_string(), - }; - - assert!(bundle.same_application_source(&same_bundle)); - assert!(!bundle.same_application_source(&other_bundle)); - assert!(!bundle.same_application_source(&portal)); - assert!(portal.same_application_source(&same_portal)); - assert!(!portal.same_application_source(&other_portal)); -} - -#[test] -fn pacman_output_is_mapped_to_the_exact_queried_path() { - let desktop = PathBuf::from("/usr/share/applications/example.desktop"); - let executable = PathBuf::from("/usr/bin/example"); - let output = b"/usr/bin/example is owned by example-app 2.0-1\n\ -/usr/share/applications/example.desktop is owned by example-app 2.0-1\n"; - - let ownership = parse_pacman_output( - output, - &[desktop.clone(), executable.clone()], - PackageProvider::Pacman, - ); - - for path in [desktop, executable] { - assert_eq!( - ownership.get(&path), - Some(&InstallProvenance::Package { - provider: PackageProvider::Pacman, - package_id: "example-app".to_string(), - }), - "the exact queried file should retain its package owner" - ); - } -} - -#[test] -fn dpkg_output_keeps_architecture_qualified_package_identity() { - let executable = PathBuf::from("/usr/bin/example"); - let ownership = parse_dpkg_output( - b"example-app:amd64: /usr/bin/example\n", - std::slice::from_ref(&executable), - PackageProvider::Dpkg, - ); - - assert_eq!( - ownership.get(&executable), - Some(&InstallProvenance::Package { - provider: PackageProvider::Dpkg, - package_id: "example-app:amd64".to_string(), - }) - ); -} - -#[test] -fn malformed_package_identity_is_rejected() { - assert!(package_provenance(PackageProvider::Pacman, b"").is_none()); - assert!(package_provenance(PackageProvider::Pacman, b"bad package").is_none()); - assert!(package_provenance(PackageProvider::Pacman, b"bad\npackage").is_none()); -} - -#[test] -fn package_query_deadline_stops_a_stalled_provider() { - let mut command = Command::new("/bin/sh"); - command.args(["-c", "sleep 2"]); - let started = Instant::now(); - - let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(20)); - - assert!( - matches!(output, Err(PackageQueryFailure::Timeout)), - "a stalled provider should report its deadline" - ); - assert!( - started.elapsed() < Duration::from_secs(1), - "the package provider deadline should stop a stalled process promptly" - ); -} - -#[test] -fn package_query_rejects_output_beyond_the_declared_limit() { - let mut command = Command::new("/bin/sh"); - command.args(["-c", "printf 12345"]); - - assert!( - run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_err(), - "oversized provider output must fail closed" - ); -} - -#[test] -fn package_query_accepts_successful_output_at_the_exact_limit() { - let mut command = Command::new("/bin/sh"); - command.args(["-c", "printf 1234"]); - - let output = run_package_query(&mut command, 4) - .expect("successful provider output at the exact limit should be retained"); - - assert!(output.status.success()); - assert_eq!(output.stdout, b"1234"); -} - -#[test] -fn package_query_returns_when_descendant_holds_stdout_open() { - let mut command = Command::new("/bin/sh"); - command.args(["-c", "(sleep 2) & exit 0"]); - let started = Instant::now(); - - let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); - - assert!( - matches!(output, Err(PackageQueryFailure::PipeDrainTimeout)), - "an inherited output pipe should report a bounded drain timeout" - ); - assert!( - started.elapsed() < Duration::from_secs(1), - "an inherited output pipe must not block desktop-index construction" - ); -} - -#[test] -fn rpm_bulk_resolution_maps_each_queried_path() { - let paths = [ - PathBuf::from("/usr/bin/example-one"), - PathBuf::from("/usr/bin/example-two"), - PathBuf::from("/usr/share/applications/example.desktop"), - ]; - - let ownership = query_rpm_ownership_with(&paths, Duration::from_secs(1), &|path, _timeout| { - let package_id = path - .file_name() - .and_then(|name| name.to_str()) - .expect("fixture path should have a UTF-8 file name") - .to_string(); - OwnershipLookup::Known(InstallProvenance::Package { - provider: PackageProvider::Rpm, - package_id, - }) - }); - - for path in paths { - let expected = path - .file_name() - .and_then(|name| name.to_str()) - .expect("fixture path should have a UTF-8 file name"); - assert_eq!( - ownership.get(&path), - Some(&OwnershipLookup::Known(InstallProvenance::Package { - provider: PackageProvider::Rpm, - package_id: expected.to_string(), - })), - "each RPM query result must remain bound to its requested path" - ); - } -} - -#[test] -fn transient_ownership_failures_expire_before_confirmed_not_owned_entries() { - let now = Instant::now(); - let transient = - CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::Timeout), now); - let not_owned = - CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::NotOwned), now); - - assert!(!transient.needs_refresh(now)); - assert!(transient.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); - assert!(!not_owned.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); - assert!(not_owned.needs_refresh(now + NOT_OWNED_NEGATIVE_TTL)); - assert_eq!(transient.provenance(), InstallProvenance::Unknown); -} - -#[test] -fn cached_known_provenance_is_returned_for_every_requested_path() { - let path = PathBuf::from("/usr/bin/example-cache-entry"); - let expected = InstallProvenance::Package { - provider: PackageProvider::Pacman, - package_id: "example-cache-entry".to_string(), - }; - let cache = PackageOwnershipCache::default(); - cache - .entries - .lock() - .expect("package cache should be writable") - .insert(path.clone(), CachedProvenance::Known(expected.clone())); - - let resolved = cache.resolve_many([path.clone()]); - - assert_eq!(resolved.len(), 1); - assert_eq!(resolved.get(&path), Some(&expected)); -} - -#[test] -fn short_package_paths_share_one_bounded_provider_query() { - let paths = [ - PathBuf::from("/usr/bin/example-one"), - PathBuf::from("/usr/bin/example-two"), - PathBuf::from("/usr/share/applications/example.desktop"), - ]; - - assert_eq!(ownership_chunk_len(&paths), paths.len()); -} - -#[test] -fn package_query_chunk_never_exceeds_the_path_count_limit() { - let paths = (0..=MAX_COMMAND_PATHS) - .map(|index| PathBuf::from(format!("p{index}"))) - .collect::>(); - - assert_eq!(ownership_chunk_len(&paths), MAX_COMMAND_PATHS); -} - -#[test] -fn oversized_first_package_selector_still_advances_exactly_one_path() { - let paths = [ - PathBuf::from("x".repeat(MAX_COMMAND_ARGUMENT_BYTES.saturating_add(1))), - PathBuf::from("next"), - ]; - - assert_eq!(ownership_chunk_len(&paths), 1); -} - -#[test] -fn package_query_chunk_accepts_the_exact_argument_byte_limit() { - let first_bytes = MAX_COMMAND_ARGUMENT_BYTES.saturating_sub(3); - let paths = [PathBuf::from("x".repeat(first_bytes)), PathBuf::from("y")]; - - assert_eq!(ownership_chunk_len(&paths), 2); -} - -#[test] -fn ownership_query_returns_a_classified_result_for_each_path() { - let provider = PackageProviderCommand { - provider: PackageProvider::Pacman, - executable: PathBuf::from("/bin/echo"), - }; - let paths = [ - PathBuf::from("/usr/bin/example-one"), - PathBuf::from("/usr/bin/example-two"), - ]; - - let resolved = query_package_ownership(&provider, &paths); - - assert_eq!(resolved.len(), paths.len()); - for path in paths { - assert_eq!( - resolved.get(&path), - Some(&OwnershipLookup::Negative(NegativeCause::MalformedOutput)), - "successful but unrecognized provider output must remain a transient failure" - ); - } -} - -#[test] -fn rpm_query_returns_a_classified_result_for_each_path() { - let provider = PackageProviderCommand { - provider: PackageProvider::Rpm, - executable: PathBuf::from("/bin/echo"), - }; - let paths = [ - PathBuf::from("/usr/bin/example-one"), - PathBuf::from("/usr/bin/example-two"), - ]; - - let resolved = query_package_ownership(&provider, &paths); - - assert_eq!(resolved.len(), paths.len()); - for path in paths { - assert!( - resolved.contains_key(&path), - "each RPM selector should receive a classified result" - ); - } -} - -#[test] -fn failed_rpm_process_is_not_reported_as_a_confirmed_unowned_path() { - let provider = PackageProviderCommand { - provider: PackageProvider::Rpm, - executable: PathBuf::from("/bin/false"), - }; - - let result = query_rpm_owner( - &provider, - Path::new("/usr/bin/example"), - Duration::from_secs(1), - ); - - assert_eq!( - result, - OwnershipLookup::Negative(NegativeCause::ProviderFailure) - ); -} - -#[test] -fn timed_out_package_provider_is_terminated_before_returning() { - let serial = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should follow the Unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-package-timeout-{}-{}", - std::process::id(), - serial - )); - fs::create_dir_all(&root).expect("package timeout test root should be created"); - let pid_file = root.join("provider.pid"); - let mut command = Command::new("/bin/sh"); - command - .args([ - "-c", - "printf '%s' \"$$\" > \"$1\"; exec sleep 2", - "unixnotis-package-timeout", - ]) - .arg(&pid_file); - - let result = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); - assert!(matches!(result, Err(PackageQueryFailure::Timeout))); - let pid = fs::read_to_string(&pid_file).expect("provider should publish its process id"); - let process_path = Path::new("/proc").join(pid.trim()); - let reap_deadline = Instant::now() + Duration::from_millis(250); - while process_path.exists() && Instant::now() < reap_deadline { - std::thread::sleep(Duration::from_millis(5)); - } - - assert!( - !process_path.exists(), - "a timed-out provider must not continue after the ownership query returns" - ); - fs::remove_dir_all(root).expect("package timeout test root should be removable"); -} +mod cache; +mod model; +mod process; +mod query; +mod rpm; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs new file mode 100644 index 000000000..f76b7a7b7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs @@ -0,0 +1,42 @@ +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn matching_package_sources_establish_one_installation_owner() { + let desktop = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }; + let executable = desktop.clone(); + + assert!(desktop.same_application_source(&executable)); + assert!( + !desktop.same_application_source(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "shared-runtime".to_string(), + }) + ); + assert!(!desktop.same_application_source(&InstallProvenance::Unknown)); +} +#[test] +fn bundle_and_portal_provenance_require_exact_domain_identity() { + let bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.App".to_string(), + }; + let same_bundle = bundle.clone(); + let other_bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.Other".to_string(), + }; + let portal = InstallProvenance::Portal { + app_id: "org.example.App".to_string(), + }; + let same_portal = portal.clone(); + let other_portal = InstallProvenance::Portal { + app_id: "org.example.Other".to_string(), + }; + + assert!(bundle.same_application_source(&same_bundle)); + assert!(!bundle.same_application_source(&other_bundle)); + assert!(!bundle.same_application_source(&portal)); + assert!(portal.same_application_source(&same_portal)); + assert!(!portal.same_application_source(&other_portal)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs new file mode 100644 index 000000000..c36ce094e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs @@ -0,0 +1,104 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use super::super::process::{ + run_package_query, run_package_query_with_timeout, PackageQueryFailure, +}; + +#[test] +fn package_query_deadline_stops_a_stalled_provider() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 2"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(20)); + + assert!( + matches!(output, Err(PackageQueryFailure::Timeout)), + "a stalled provider should report its deadline" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "the package provider deadline should stop a stalled process promptly" + ); +} + +#[test] +fn package_query_rejects_output_beyond_the_declared_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 12345"]); + + assert!( + run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_err(), + "oversized provider output must fail closed" + ); +} + +#[test] +fn package_query_accepts_successful_output_at_the_exact_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 1234"]); + + let output = run_package_query(&mut command, 4) + .expect("successful provider output at the exact limit should be retained"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"1234"); +} + +#[test] +fn package_query_returns_when_descendant_holds_stdout_open() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "(sleep 2) & exit 0"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + + assert!( + matches!(output, Err(PackageQueryFailure::PipeDrainTimeout)), + "an inherited output pipe should report a bounded drain timeout" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "an inherited output pipe must not block desktop-index construction" + ); +} +#[test] +fn timed_out_package_provider_is_terminated_before_returning() { + let serial = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-package-timeout-{}-{}", + std::process::id(), + serial + )); + fs::create_dir_all(&root).expect("package timeout test root should be created"); + let pid_file = root.join("provider.pid"); + let mut command = Command::new("/bin/sh"); + command + .args([ + "-c", + "printf '%s' \"$$\" > \"$1\"; exec sleep 2", + "unixnotis-package-timeout", + ]) + .arg(&pid_file); + + let result = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + assert!(matches!(result, Err(PackageQueryFailure::Timeout))); + let pid = fs::read_to_string(&pid_file).expect("provider should publish its process id"); + let process_path = Path::new("/proc").join(pid.trim()); + let reap_deadline = Instant::now() + Duration::from_millis(250); + while process_path.exists() && Instant::now() < reap_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + !process_path.exists(), + "a timed-out provider must not continue after the ownership query returns" + ); + fs::remove_dir_all(root).expect("package timeout test root should be removable"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs new file mode 100644 index 000000000..8624735e3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs @@ -0,0 +1,140 @@ +use std::path::PathBuf; + +use super::super::cache::{NegativeCause, OwnershipLookup}; +use super::super::query::{ + ownership_chunk_len, package_provenance, parse_dpkg_output, parse_pacman_output, + query_package_ownership, PackageProviderCommand, MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMAND_PATHS, +}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn pacman_output_is_mapped_to_the_exact_queried_path() { + let desktop = PathBuf::from("/usr/share/applications/example.desktop"); + let executable = PathBuf::from("/usr/bin/example"); + let output = b"/usr/bin/example is owned by example-app 2.0-1\n\ +/usr/share/applications/example.desktop is owned by example-app 2.0-1\n"; + + let ownership = parse_pacman_output( + output, + &[desktop.clone(), executable.clone()], + PackageProvider::Pacman, + ); + + for path in [desktop, executable] { + assert_eq!( + ownership.get(&path), + Some(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }), + "the exact queried file should retain its package owner" + ); + } +} + +#[test] +fn dpkg_output_keeps_architecture_qualified_package_identity() { + let executable = PathBuf::from("/usr/bin/example"); + let ownership = parse_dpkg_output( + b"example-app:amd64: /usr/bin/example\n", + std::slice::from_ref(&executable), + PackageProvider::Dpkg, + ); + + assert_eq!( + ownership.get(&executable), + Some(&InstallProvenance::Package { + provider: PackageProvider::Dpkg, + package_id: "example-app:amd64".to_string(), + }) + ); +} + +#[test] +fn malformed_package_identity_is_rejected() { + assert!(package_provenance(PackageProvider::Pacman, b"").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad package").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad\npackage").is_none()); +} +#[test] +fn short_package_paths_share_one_bounded_provider_query() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + assert_eq!(ownership_chunk_len(&paths), paths.len()); +} + +#[test] +fn package_query_chunk_never_exceeds_the_path_count_limit() { + let paths = (0..=MAX_COMMAND_PATHS) + .map(|index| PathBuf::from(format!("p{index}"))) + .collect::>(); + + assert_eq!(ownership_chunk_len(&paths), MAX_COMMAND_PATHS); +} + +#[test] +fn oversized_first_package_selector_still_advances_exactly_one_path() { + let paths = [ + PathBuf::from("x".repeat(MAX_COMMAND_ARGUMENT_BYTES.saturating_add(1))), + PathBuf::from("next"), + ]; + + assert_eq!(ownership_chunk_len(&paths), 1); +} + +#[test] +fn package_query_chunk_accepts_the_exact_argument_byte_limit() { + let first_bytes = MAX_COMMAND_ARGUMENT_BYTES.saturating_sub(3); + let paths = [PathBuf::from("x".repeat(first_bytes)), PathBuf::from("y")]; + + assert_eq!(ownership_chunk_len(&paths), 2); +} + +#[test] +fn ownership_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Pacman, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert_eq!( + resolved.get(&path), + Some(&OwnershipLookup::Negative(NegativeCause::MalformedOutput)), + "successful but unrecognized provider output must remain a transient failure" + ); + } +} + +#[test] +fn rpm_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert!( + resolved.contains_key(&path), + "each RPM selector should receive a classified result" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs new file mode 100644 index 000000000..d34f10ae8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use super::super::cache::{NegativeCause, OwnershipLookup}; +use super::super::query::{query_package_ownership, PackageProviderCommand}; +use super::super::rpm::{query_rpm_owner, query_rpm_ownership_with}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn rpm_bulk_resolution_maps_each_queried_path() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + let ownership = query_rpm_ownership_with(&paths, Duration::from_secs(1), &|path, _timeout| { + let package_id = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name") + .to_string(); + OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id, + }) + }); + + for path in paths { + let expected = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name"); + assert_eq!( + ownership.get(&path), + Some(&OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id: expected.to_string(), + })), + "each RPM query result must remain bound to its requested path" + ); + } +} +#[test] +fn rpm_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert!( + resolved.contains_key(&path), + "each RPM selector should receive a classified result" + ); + } +} + +#[test] +fn failed_rpm_process_is_not_reported_as_a_confirmed_unowned_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/false"), + }; + + let result = query_rpm_owner( + &provider, + Path::new("/usr/bin/example"), + Duration::from_secs(1), + ); + + assert_eq!( + result, + OwnershipLookup::Negative(NegativeCause::ProviderFailure) + ); +} From adfbf80cbdcd6143c0113d56acc4d8d71cba6484 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:13:54 -0500 Subject: [PATCH 190/275] refactor(identity): split launch verification domains Summary: split launch verification domains. Scope: identity. --- .../desktop_index/verification/authority.rs | 69 +++ .../desktop_index/verification/contract.rs | 279 +++++++++++ .../desktop_index/verification/mod.rs | 441 +----------------- .../desktop_index/verification/payload.rs | 95 ++++ 4 files changed, 458 insertions(+), 426 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs new file mode 100644 index 000000000..01794aeed --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs @@ -0,0 +1,69 @@ +//! Launch-authority classification + +use super::super::model::{ + DesktopIdentityIndex, DesktopRecord, LaunchArgument, LaunchAuthority, LaunchSpec, + LiteralArgument, +}; + +pub(super) fn classify_launch_authority( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> LaunchAuthority { + if spec.arguments.iter().any(is_protected_payload) { + return LaunchAuthority::ProtectedPayload; + } + + if executable_contract_is_dedicated(record, index, spec) { + return LaunchAuthority::DedicatedExecutable; + } + + // Dynamic documents are safe only after the executable establishes the application + if spec.arguments.iter().any(is_dynamic_document_field) { + return LaunchAuthority::DynamicOnly; + } + + LaunchAuthority::Ambiguous +} + +pub(super) fn executable_contract_is_dedicated( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> bool { + record.system_origin + && record.system_association + && spec.declared_executable.is_system_managed() + && spec.declared_executable.is_executable_regular() + && spec.runtime_executable.is_system_managed() + && spec.runtime_executable.is_executable_regular() + && record + .desktop_provenance + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) + && index.records_form_one_application_family(spec.runtime_executable, record.system_origin) + && !spec.arguments.iter().any(is_unprotected_fixed_payload) +} +pub(super) fn is_protected_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(LiteralArgument { + file: Some(_), + value, + }) if !value.starts_with(b"-") + ) +} + +pub(super) const fn is_dynamic_document_field(argument: &LaunchArgument) -> bool { + matches!(argument, LaunchArgument::FieldCode(_)) +} + +pub(super) fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(literal) + if !literal.value.starts_with(b"-") && literal.file.is_none() + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs new file mode 100644 index 000000000..025977cc0 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs @@ -0,0 +1,279 @@ +//! Ordered desktop launch-contract matching + +use std::collections::HashSet; + +use super::super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::super::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, VerifiedLaunch, +}; +use super::payload::literal_file_matches; +use super::MAX_PROCESS_ARGUMENTS; + +pub(super) fn verify_dedicated( + command_line: &CommandLineEvidence, + spec: &LaunchSpec, +) -> LaunchVerification { + let verified_launch = if spec.package_launcher.is_some() { + VerifiedLaunch::PackageLauncherTarget + } else { + VerifiedLaunch::DedicatedExecutable + }; + match command_line.quality { + // Launcher targets require the original desktop contract to match observed runtime argv + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.package_launcher.is_some() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + } + // An empty contract cannot distinguish an ordinary switch from an active payload + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.arguments.is_empty() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine) + } + // A nonempty package-backed contract still contributes identity when argv was rewritten + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable => LaunchVerification::Verified(verified_launch), + CommandLineQuality::Structured => { + let actual = command_line.argv.get(1..).unwrap_or_default(); + if actual.len() <= MAX_PROCESS_ARGUMENTS + && match_ordered_dedicated_contract(spec, actual) + { + LaunchVerification::Verified(verified_launch) + } else { + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + } + } + } +} +pub(super) fn match_ordered_dedicated_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_dedicated_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +pub(super) fn match_dedicated_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + // Only standalone runtime switches are non-authoritative after the fixed contract + return actual[actual_index..] + .iter() + .all(|value| value.starts_with(b"-")); + }; + let next_template = template_index.saturating_add(1); + let matches_expected = match argument { + LaunchArgument::Literal(literal) => { + actual + .get(actual_index) + .is_some_and(|value| value == &literal.value) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(1), + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_dedicated_arguments(template, actual, next_template, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(2), + visited, + )) + } + LaunchArgument::FieldCode(code) => match_dedicated_field( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + }; + if matches_expected { + return true; + } + + // Unknown positional values can select content, so only skip one self-contained option + actual + .get(actual_index) + .is_some_and(|value| value.starts_with(b"-") && value != b"--icon") + && match_dedicated_arguments( + template, + actual, + template_index, + actual_index.saturating_add(1), + visited, + ) +} + +pub(super) fn match_dedicated_field( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_dedicated_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + +pub(super) fn match_ordered_exec_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +pub(super) fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + let matches = if literal.file.is_some() { + actual + .get(actual_index) + .is_some_and(|value| literal_file_matches(literal, value)) + } else { + actual.get(actual_index) == Some(&literal.value) + }; + matches + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(1), + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index, + visited, + ) || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(2), + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +pub(super) fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + +pub(super) fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs index fcae13793..b8ff95a59 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs @@ -1,17 +1,23 @@ -//! Evidence-based launch verification with explicit uncertainty and contradiction +//! Evidence-based desktop launch verification -use std::collections::HashSet; -use std::path::Path; +mod authority; +mod contract; +mod payload; -use super::super::executable::{executable_evidence_for_path, FileIdentity}; -use super::super::sender::{CommandLineEvidence, CommandLineQuality}; +#[cfg(test)] +mod tests; + +use super::super::executable::FileIdentity; +use super::super::sender::CommandLineEvidence; use super::launcher::launcher_binding_is_current; use super::model::{ - DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, - LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, + DesktopIdentityIndex, DesktopRecord, LaunchAuthority, LaunchFailure, LaunchVerification, }; +use authority::classify_launch_authority; +use contract::verify_dedicated; +use payload::{literal_file_identities_are_current, verify_protected_payload}; -const MAX_PROCESS_ARGUMENTS: usize = 256; +pub(super) const MAX_PROCESS_ARGUMENTS: usize = 256; pub(super) fn verify_record_launch( record: &DesktopRecord, @@ -28,7 +34,7 @@ pub(super) fn verify_record_launch( ) } -fn verify_record_launch_with( +pub(super) fn verify_record_launch_with( record: &DesktopRecord, index: &DesktopIdentityIndex, sender_identity: FileIdentity, @@ -66,420 +72,3 @@ fn verify_record_launch_with( } } } - -fn classify_launch_authority( - record: &DesktopRecord, - index: &DesktopIdentityIndex, - spec: &LaunchSpec, -) -> LaunchAuthority { - if spec.arguments.iter().any(is_protected_payload) { - return LaunchAuthority::ProtectedPayload; - } - - if executable_contract_is_dedicated(record, index, spec) { - return LaunchAuthority::DedicatedExecutable; - } - - // Dynamic documents are safe only after the executable establishes the application - if spec.arguments.iter().any(is_dynamic_document_field) { - return LaunchAuthority::DynamicOnly; - } - - LaunchAuthority::Ambiguous -} - -fn executable_contract_is_dedicated( - record: &DesktopRecord, - index: &DesktopIdentityIndex, - spec: &LaunchSpec, -) -> bool { - record.system_origin - && record.system_association - && spec.declared_executable.is_system_managed() - && spec.declared_executable.is_executable_regular() - && spec.runtime_executable.is_system_managed() - && spec.runtime_executable.is_executable_regular() - && record - .desktop_provenance - .same_application_source(&record.declared_executable_provenance) - && record - .desktop_provenance - .same_application_source(&record.runtime_executable_provenance) - && index.records_form_one_application_family(spec.runtime_executable, record.system_origin) - && !spec.arguments.iter().any(is_unprotected_fixed_payload) -} - -fn verify_dedicated(command_line: &CommandLineEvidence, spec: &LaunchSpec) -> LaunchVerification { - let verified_launch = if spec.package_launcher.is_some() { - VerifiedLaunch::PackageLauncherTarget - } else { - VerifiedLaunch::DedicatedExecutable - }; - match command_line.quality { - // Launcher targets require the original desktop contract to match observed runtime argv - CommandLineQuality::RewrittenProcessTitle - | CommandLineQuality::Truncated - | CommandLineQuality::Unavailable - if spec.package_launcher.is_some() => - { - LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) - } - // An empty contract cannot distinguish an ordinary switch from an active payload - CommandLineQuality::RewrittenProcessTitle - | CommandLineQuality::Truncated - | CommandLineQuality::Unavailable - if spec.arguments.is_empty() => - { - LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine) - } - // A nonempty package-backed contract still contributes identity when argv was rewritten - CommandLineQuality::RewrittenProcessTitle - | CommandLineQuality::Truncated - | CommandLineQuality::Unavailable => LaunchVerification::Verified(verified_launch), - CommandLineQuality::Structured => { - let actual = command_line.argv.get(1..).unwrap_or_default(); - if actual.len() <= MAX_PROCESS_ARGUMENTS - && match_ordered_dedicated_contract(spec, actual) - { - LaunchVerification::Verified(verified_launch) - } else { - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) - } - } - } -} - -fn verify_protected_payload( - command_line: &CommandLineEvidence, - spec: &LaunchSpec, -) -> LaunchVerification { - match command_line.quality { - CommandLineQuality::Unavailable | CommandLineQuality::Truncated => { - return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine); - } - CommandLineQuality::RewrittenProcessTitle => { - return LaunchVerification::InsufficientEvidence( - LaunchFailure::UnstructuredCommandLine, - ); - } - CommandLineQuality::Structured => {} - } - - let actual = command_line.argv.get(1..).unwrap_or_default(); - if actual.len() > MAX_PROCESS_ARGUMENTS { - return LaunchVerification::InsufficientEvidence(LaunchFailure::UnstructuredCommandLine); - } - if match_ordered_exec_contract(spec, actual) { - return LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload); - } - - // A protected file in another argv slot is a decoy, not supporting evidence - // Missing or replaced protected files are equally definitive for structured argv - if protected_payload_position_mismatch(spec, actual) { - return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); - } - - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) -} - -fn match_ordered_dedicated_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { - let mut visited = HashSet::new(); - match_dedicated_arguments(&spec.arguments, actual, 0, 0, &mut visited) -} - -fn match_dedicated_arguments( - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - if !visited.insert((template_index, actual_index)) { - return false; - } - let Some(argument) = template.get(template_index) else { - // Only standalone runtime switches are non-authoritative after the fixed contract - return actual[actual_index..] - .iter() - .all(|value| value.starts_with(b"-")); - }; - let next_template = template_index.saturating_add(1); - let matches_expected = match argument { - LaunchArgument::Literal(literal) => { - actual - .get(actual_index) - .is_some_and(|value| value == &literal.value) - && match_dedicated_arguments( - template, - actual, - next_template, - actual_index.saturating_add(1), - visited, - ) - } - LaunchArgument::OptionalIcon { name } => { - match_dedicated_arguments(template, actual, next_template, actual_index, visited) - || (actual - .get(actual_index) - .is_some_and(|value| value == b"--icon") - && actual - .get(actual_index.saturating_add(1)) - .is_some_and(|value| value == name.as_bytes()) - && match_dedicated_arguments( - template, - actual, - next_template, - actual_index.saturating_add(2), - visited, - )) - } - LaunchArgument::FieldCode(code) => match_dedicated_field( - *code, - template, - actual, - template_index, - actual_index, - visited, - ), - }; - if matches_expected { - return true; - } - - // Unknown positional values can select content, so only skip one self-contained option - actual - .get(actual_index) - .is_some_and(|value| value.starts_with(b"-") && value != b"--icon") - && match_dedicated_arguments( - template, - actual, - template_index, - actual_index.saturating_add(1), - visited, - ) -} - -fn match_dedicated_field( - code: FieldCode, - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - let maximum = match code { - FieldCode::File | FieldCode::Url => 1, - FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), - }; - for count in 0..=maximum { - let Some(end) = actual_index.checked_add(count) else { - break; - }; - let Some(values) = actual.get(actual_index..end) else { - break; - }; - if !values.iter().all(|value| field_value_matches(code, value)) { - break; - } - if match_dedicated_arguments( - template, - actual, - template_index.saturating_add(1), - end, - visited, - ) { - return true; - } - } - false -} - -fn match_ordered_exec_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { - let mut visited = HashSet::new(); - match_arguments(&spec.arguments, actual, 0, 0, &mut visited) -} - -fn match_arguments( - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - if !visited.insert((template_index, actual_index)) { - return false; - } - let Some(argument) = template.get(template_index) else { - return actual_index == actual.len(); - }; - match argument { - LaunchArgument::Literal(literal) => { - let matches = if literal.file.is_some() { - actual - .get(actual_index) - .is_some_and(|value| literal_file_matches(literal, value)) - } else { - actual.get(actual_index) == Some(&literal.value) - }; - matches - && match_arguments( - template, - actual, - template_index.saturating_add(1), - actual_index.saturating_add(1), - visited, - ) - } - LaunchArgument::OptionalIcon { name } => { - match_arguments( - template, - actual, - template_index.saturating_add(1), - actual_index, - visited, - ) || (actual - .get(actual_index) - .is_some_and(|value| value == b"--icon") - && actual - .get(actual_index.saturating_add(1)) - .is_some_and(|value| value == name.as_bytes()) - && match_arguments( - template, - actual, - template_index.saturating_add(1), - actual_index.saturating_add(2), - visited, - )) - } - LaunchArgument::FieldCode(code) => match_field_code( - *code, - template, - actual, - template_index, - actual_index, - visited, - ), - } -} - -fn match_field_code( - code: FieldCode, - template: &[LaunchArgument], - actual: &[Vec], - template_index: usize, - actual_index: usize, - visited: &mut HashSet<(usize, usize)>, -) -> bool { - let maximum = match code { - FieldCode::File | FieldCode::Url => 1, - FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), - }; - for count in 0..=maximum { - let Some(end) = actual_index.checked_add(count) else { - break; - }; - let Some(values) = actual.get(actual_index..end) else { - break; - }; - if !values.iter().all(|value| field_value_matches(code, value)) { - break; - } - if match_arguments( - template, - actual, - template_index.saturating_add(1), - end, - visited, - ) { - return true; - } - } - false -} - -fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { - if value.is_empty() || value.starts_with(b"-") { - return false; - } - match code { - FieldCode::File | FieldCode::Files => true, - FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) - .ok() - .is_some_and(|value| url::Url::parse(value).is_ok()), - } -} - -fn protected_payload_position_mismatch(spec: &LaunchSpec, actual: &[Vec]) -> bool { - spec.arguments - .iter() - .enumerate() - .filter_map(|(index, argument)| { - let LaunchArgument::Literal(literal) = argument else { - return None; - }; - is_protected_payload(argument).then_some((index, literal)) - }) - .any(|(template_index, literal)| { - !(0..actual.len()).any(|actual_index| { - let mut visited = HashSet::new(); - match_arguments( - &spec.arguments[..template_index], - &actual[..actual_index], - 0, - 0, - &mut visited, - ) && literal_file_matches(literal, &actual[actual_index]) - }) - }) -} - -fn literal_file_matches(literal: &LiteralArgument, actual: &[u8]) -> bool { - let Some((_expected_path, expected_identity)) = literal.file.as_ref() else { - return false; - }; - let Ok(actual) = std::str::from_utf8(actual) else { - return false; - }; - executable_evidence_for_path(Path::new(actual)) - .is_some_and(|evidence| evidence.identity.same_file(*expected_identity)) -} - -fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { - spec.arguments.iter().all(|argument| { - let LaunchArgument::Literal(LiteralArgument { - file: Some((path, expected)), - .. - }) = argument - else { - return true; - }; - executable_evidence_for_path(path).is_some_and(|evidence| { - evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() - }) - }) -} - -fn is_protected_payload(argument: &LaunchArgument) -> bool { - matches!( - argument, - LaunchArgument::Literal(LiteralArgument { - file: Some(_), - value, - }) if !value.starts_with(b"-") - ) -} - -const fn is_dynamic_document_field(argument: &LaunchArgument) -> bool { - matches!(argument, LaunchArgument::FieldCode(_)) -} - -fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { - matches!( - argument, - LaunchArgument::Literal(literal) - if !literal.value.starts_with(b"-") && literal.file.is_none() - ) -} - -#[cfg(test)] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs new file mode 100644 index 000000000..a88b18116 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs @@ -0,0 +1,95 @@ +//! Protected payload and file-identity verification + +use std::collections::HashSet; +use std::path::Path; + +use super::super::super::executable::executable_evidence_for_path; +use super::super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::super::model::{ + LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, +}; +use super::authority::is_protected_payload; +use super::contract::{match_arguments, match_ordered_exec_contract}; +use super::MAX_PROCESS_ARGUMENTS; + +pub(super) fn verify_protected_payload( + command_line: &CommandLineEvidence, + spec: &LaunchSpec, +) -> LaunchVerification { + match command_line.quality { + CommandLineQuality::Unavailable | CommandLineQuality::Truncated => { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine); + } + CommandLineQuality::RewrittenProcessTitle => { + return LaunchVerification::InsufficientEvidence( + LaunchFailure::UnstructuredCommandLine, + ); + } + CommandLineQuality::Structured => {} + } + + let actual = command_line.argv.get(1..).unwrap_or_default(); + if actual.len() > MAX_PROCESS_ARGUMENTS { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnstructuredCommandLine); + } + if match_ordered_exec_contract(spec, actual) { + return LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload); + } + + // A protected file in another argv slot is a decoy, not supporting evidence + // Missing or replaced protected files are equally definitive for structured argv + if protected_payload_position_mismatch(spec, actual) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); + } + + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) +} +pub(super) fn protected_payload_position_mismatch(spec: &LaunchSpec, actual: &[Vec]) -> bool { + spec.arguments + .iter() + .enumerate() + .filter_map(|(index, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + is_protected_payload(argument).then_some((index, literal)) + }) + .any(|(template_index, literal)| { + !(0..actual.len()).any(|actual_index| { + let mut visited = HashSet::new(); + match_arguments( + &spec.arguments[..template_index], + &actual[..actual_index], + 0, + 0, + &mut visited, + ) && literal_file_matches(literal, &actual[actual_index]) + }) + }) +} + +pub(super) fn literal_file_matches(literal: &LiteralArgument, actual: &[u8]) -> bool { + let Some((_expected_path, expected_identity)) = literal.file.as_ref() else { + return false; + }; + let Ok(actual) = std::str::from_utf8(actual) else { + return false; + }; + executable_evidence_for_path(Path::new(actual)) + .is_some_and(|evidence| evidence.identity.same_file(*expected_identity)) +} + +pub(super) fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} From 0347b5ac75bf0a31b5ebbf14d80b3639ff154b4e Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:13:54 -0500 Subject: [PATCH 191/275] test(identity): add focused launch verification suites Summary: add focused launch verification suites. Scope: identity. --- .../verification/tests/authority.rs | 286 +++++++++++++++++ .../verification/tests/contract.rs | 294 ++++++++++++++++++ .../verification/tests/payload.rs | 169 ++++++++++ .../verification/tests/record.rs | 248 +++++++++++++++ .../verification/tests/support.rs | 45 +++ 5 files changed, 1042 insertions(+) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs new file mode 100644 index 000000000..cd84e6650 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs @@ -0,0 +1,286 @@ +use std::collections::HashSet; +use std::path::Path; + +use super::super::authority::{ + classify_launch_authority, executable_contract_is_dedicated, is_protected_payload, +}; +use super::super::verify_record_launch; +use super::support::{record_for_spec, structured_command, test_package}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchSpec, + LaunchVerification, LiteralArgument, VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; + +fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { + match argument { + LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, + LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), + } +} + +#[test] +fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { + let dynamic = LaunchArgument::FieldCode(FieldCode::Files); + let option = LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }); + let payload = LaunchArgument::Literal(LiteralArgument { + value: b"/usr/share/example/app.bundle".to_vec(), + file: Some(( + "/usr/share/example/app.bundle".into(), + crate::daemon::notifications::identity::FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }, + )), + }); + + assert!(is_dynamic_or_option(&dynamic)); + assert!(is_dynamic_or_option(&option)); + assert!(!is_dynamic_or_option(&payload)); + assert!(!is_protected_payload(&dynamic)); + assert!(is_protected_payload(&payload)); +} +#[test] +fn dynamic_contract_without_shared_provenance_is_not_dedicated() { + for field_code in [FieldCode::Files, FieldCode::Urls] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "Runtime application".to_string(), + badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: None, + desktop_provenance: test_package("runtime-desktop"), + declared_executable_provenance: test_package("runtime"), + runtime_executable_provenance: test_package("runtime"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("single indexed runtime"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a single dynamic record must remain non-authoritative for {field_code:?}" + ); + } +} + +#[test] +fn dedicated_system_application_accepts_dynamic_url_field() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.True".to_string(), + display_name: "True".to_string(), + badge_icon: "true".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("dedicated application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "normal URL arguments must not erase dedicated executable authority" + ); +} + +#[test] +fn dynamic_runtime_requires_matching_immutable_installation_provenance() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "True".to_string(), + badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("runtime-frontend"), + declared_executable_provenance: test_package("shared-runtime"), + runtime_executable_provenance: test_package("shared-runtime"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("runtime application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a package-owned shared runtime must not inherit desktop application authority" + ); +} + +#[test] +fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + for (field_code, actual) in [ + (FieldCode::File, vec!["/usr/bin/true", "/tmp/image.png"]), + ( + FieldCode::Files, + vec!["/usr/bin/true", "/tmp/first.png", "/tmp/second.png"], + ), + ] { + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Viewer", &spec); + record.desktop_provenance = test_package("example-viewer"); + record.declared_executable_provenance = test_package("example-viewer"); + record.runtime_executable_provenance = test_package("example-viewer"); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Viewer") + .into_iter() + .next() + .expect("file application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "immutable application ownership should support {field_code:?}" + ); + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&actual), + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "the ordered {field_code:?} contract should accept matching document arguments" + ); + } +} +#[test] +fn dedicated_authority_accepts_document_fields_but_rejects_unprotected_fixed_payloads() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + for (arguments, expected) in [ + (Vec::new(), true), + (vec![LaunchArgument::FieldCode(FieldCode::Url)], true), + (vec![LaunchArgument::FieldCode(FieldCode::File)], true), + ( + vec![LaunchArgument::Literal(LiteralArgument { + value: b"runtime-selected-payload".to_vec(), + file: None, + })], + false, + ), + ] { + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record_for_spec("org.example.True", &spec)); + let record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("indexed dedicated boundary record"); + + assert_eq!( + executable_contract_is_dedicated(record, &index, &spec), + expected, + "arguments={:?}", + spec.arguments + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs new file mode 100644 index 000000000..b24a49743 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs @@ -0,0 +1,294 @@ +use std::path::Path; + +use super::super::contract::{ + field_value_matches, match_ordered_dedicated_contract, match_ordered_exec_contract, + verify_dedicated, +}; +use super::support::structured_command; +use crate::daemon::notifications::identity::desktop_index::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +#[test] +fn ordered_contract_preserves_repeated_literals_and_field_positions() { + let identity = executable_evidence_for_path(Path::new("/usr/bin/true")) + .expect("system executable") + .identity; + let spec = LaunchSpec { + declared_executable: identity, + runtime_executable: identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"safe".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert!(match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"safe".to_vec(), + b"--mode".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"--mode".to_vec(), + b"safe".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); +} + +#[test] +fn dedicated_contract_does_not_accept_reordered_fixed_options() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--first".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--second".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--first", "--second"]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--second", "--first"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--display=x11", + "--first", + "--tray", + "--second", + "--verbose", + ]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--first", + "/tmp/unexpected-payload", + "--second", + ]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn empty_dedicated_contract_rejects_positional_payload() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "/tmp/attacker-payload"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn empty_contract_with_unstructured_argv_is_not_verified() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + for quality in [ + CommandLineQuality::RewrittenProcessTitle, + CommandLineQuality::Truncated, + CommandLineQuality::Unavailable, + ] { + let command_line = CommandLineEvidence { + argv: Vec::new(), + quality, + }; + + assert_eq!( + verify_dedicated(&command_line, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine), + "an empty contract with {quality:?} argv must stay non-authoritative" + ); + } +} + +#[test] +fn empty_contract_accepts_only_non_positional_switches() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + for arguments in [ + vec!["/usr/bin/true"], + vec!["/usr/bin/true", "--verbose"], + vec!["/usr/bin/true", "--display=x11", "-q"], + ] { + assert_eq!( + verify_dedicated(&structured_command(&arguments), &spec), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "standalone switches should remain compatible: {arguments:?}" + ); + } + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--title", "untrusted-value"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a separate option value is positional without an ordered contract" + ); +} +#[test] +fn optional_icon_contract_preserves_its_flag_and_value_relationship() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::OptionalIcon { + name: "example-icon".to_string(), + }, + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_optional_icon_contract(match_ordered_exec_contract, &spec, "protected"); + assert_optional_icon_contract(match_ordered_dedicated_contract, &spec, "dedicated"); +} + +#[test] +fn field_values_reject_empty_options_and_malformed_urls() { + assert!(!field_value_matches(FieldCode::File, b"")); + assert!(!field_value_matches(FieldCode::Files, b"--runtime-option")); + assert!(field_value_matches(FieldCode::File, b"relative-file")); + assert!(field_value_matches( + FieldCode::Url, + b"https://example.invalid/item" + )); + assert!(!field_value_matches(FieldCode::Urls, b"not a URL")); + assert!(!field_value_matches(FieldCode::Url, &[0xff])); +} + +type ContractMatcher = fn(&LaunchSpec, &[Vec]) -> bool; + +fn assert_optional_icon_contract(matcher: ContractMatcher, spec: &LaunchSpec, label: &str) { + for (actual, expected) in [ + (vec![b"--fixed".to_vec()], true), + ( + vec![ + b"--icon".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + true, + ), + ( + vec![ + b"--badge".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + ( + vec![ + b"--icon".to_vec(), + b"other-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + (vec![b"--icon".to_vec(), b"--fixed".to_vec()], false), + ] { + assert_eq!( + matcher(spec, &actual), + expected, + "{label}: actual={actual:?}" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs new file mode 100644 index 000000000..5139f3ff8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs @@ -0,0 +1,169 @@ +use std::path::Path; + +use super::super::payload::{ + literal_file_identities_are_current, literal_file_matches, verify_protected_payload, +}; +use super::super::MAX_PROCESS_ARGUMENTS; +use super::support::structured_command; +use crate::daemon::notifications::identity::desktop_index::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +#[test] +fn protected_payload_verification_requires_current_file_identity_and_fixed_arguments() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let other = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other system payload"); + let payload_argument = LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + }; + let spec = LaunchSpec { + declared_executable: shell.identity, + runtime_executable: shell.identity, + arguments: vec![ + LaunchArgument::Literal(payload_argument.clone()), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert!(literal_file_matches(&payload_argument, b"/usr/bin/true")); + assert!(!literal_file_matches(&payload_argument, b"/usr/bin/false")); + assert!(!literal_file_matches(&payload_argument, &[0xff])); + assert!(literal_file_identities_are_current(&spec)); + + let mut stale_spec = spec.clone(); + let LaunchArgument::Literal(stale_payload) = &mut stale_spec.arguments[0] else { + panic!("payload fixture should remain literal"); + }; + stale_payload.file = Some(("/usr/bin/true".into(), other.identity)); + assert!(!literal_file_identities_are_current(&stale_spec)); + + let verified = structured_command(&["/usr/bin/sh", "/usr/bin/true", "--fixed"]); + assert_eq!( + verify_protected_payload(&verified, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); + + let wrong_payload = structured_command(&["/usr/bin/sh", "/usr/bin/false", "--fixed"]); + assert_eq!( + verify_protected_payload(&wrong_payload, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) + ); + + let missing_argument = structured_command(&["/usr/bin/sh", "/usr/bin/true"]); + assert_eq!( + verify_protected_payload(&missing_argument, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn trusted_payload_cannot_be_used_as_a_decoy_argument() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let spec = LaunchSpec { + declared_executable: runtime.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let sender = structured_command(&["/usr/bin/sh", "/usr/bin/false", "/usr/bin/true"]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch), + "a protected file after the active payload must not authenticate the runtime" + ); +} + +#[test] +fn variable_width_field_before_protected_payload_does_not_create_false_conflict() { + let payload = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("protected payload"); + let spec = LaunchSpec { + declared_executable: payload.identity, + runtime_executable: payload.identity, + arguments: vec![ + LaunchArgument::FieldCode(FieldCode::Files), + LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some((Path::new("/usr/bin/true").to_path_buf(), payload.identity)), + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let sender = structured_command(&[ + "/usr/bin/true", + "/tmp/one.txt", + "/tmp/two.txt", + "/usr/bin/true", + "--unexpected", + ]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a matched protected payload must not become contradictory because a later option differs" + ); +} +#[test] +fn protected_payload_accepts_exactly_the_bounded_argument_limit() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let mut arguments = vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })]; + arguments.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| { + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }) + })); + let spec = LaunchSpec { + declared_executable: runtime.identity, + runtime_executable: runtime.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut argv = vec![b"/usr/bin/sh".to_vec(), b"/usr/bin/true".to_vec()]; + argv.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| b"--fixed".to_vec())); + let command = CommandLineEvidence { + argv, + quality: CommandLineQuality::Structured, + }; + + assert_eq!(command.argv.len().saturating_sub(1), MAX_PROCESS_ARGUMENTS); + assert_eq!( + verify_protected_payload(&command, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs new file mode 100644 index 000000000..a385bfe41 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs @@ -0,0 +1,248 @@ +use std::collections::HashSet; +use std::path::Path; + +use super::super::authority::classify_launch_authority; +use super::super::{verify_record_launch, verify_record_launch_with}; +use super::support::{record_for_spec, structured_command, test_package}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, + LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, PackageLauncherBinding, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::CommandLineEvidence; + +#[test] +fn package_launcher_target_verifies_with_matching_runtime_and_ordered_contract() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let binding = PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [7; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }; + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(binding), + literal_files_are_system_managed: true, + }; + let package = test_package("example-chat"); + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_path = Some("/usr/bin/false".into()); + record.declared_executable_identity = Some(launcher.identity); + record.runtime_executable_path = Some("/usr/bin/true".into()); + record.runtime_executable_identity = Some(runtime.identity); + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + let verification = verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&[ + "/usr/bin/true", + "--password-store=desktop", + "--display=x11", + "--", + ]), + |_| true, + ); + + assert_eq!( + verification, + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) + ); +} + +#[test] +fn package_launcher_target_requires_current_binding_and_structured_arguments() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [3; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_identity = Some(launcher.identity); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&["/usr/bin/true"]), + |_| false, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged) + ); + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &CommandLineEvidence::default(), + |_| true, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + ); +} + +#[test] +fn shared_launcher_target_does_not_merge_incompatible_application_families() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [9; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let package = test_package("example-suite"); + let mut first = record_for_spec("org.example.First", &spec); + let mut second = record_for_spec("org.example.Second", &spec); + for record in [&mut first, &mut second] { + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package.clone(); + } + let mut index = DesktopIdentityIndex::default(); + index.index_record(first); + index.index_record(second); + let indexed = index + .records_for_id("org.example.First") + .into_iter() + .next() + .expect("first application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "one package-owned runtime shared by unrelated families must remain non-authoritative" + ); +} + +#[test] +fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { + for (wrapper_count, environment_count, expected) in [ + ( + 16, + 0, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 17, + 0, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ( + 0, + 128, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 0, + 129, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: std::iter::repeat_n((b"A".to_vec(), b"1".to_vec()), environment_count) + .collect(), + wrappers: std::iter::repeat_n(LaunchWrapper::Env, wrapper_count).collect(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.True".to_string(), + display_name: "Boundary".to_string(), + badge_icon: "boundary".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: None, + desktop_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("indexed boundary record"); + + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&["/usr/bin/true"]), + ), + expected, + "wrapper_count={wrapper_count}, environment_count={environment_count}" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs new file mode 100644 index 000000000..fa3ed031e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs @@ -0,0 +1,45 @@ +use std::collections::HashSet; + +use crate::daemon::notifications::identity::desktop_index::model::{DesktopRecord, LaunchSpec}; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::InstallProvenance; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +pub(super) fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { + DesktopRecord { + id: id.to_string(), + display_name: "Contract application".to_string(), + badge_icon: "contract".to_string(), + desktop_path: Some(format!("/usr/share/applications/{id}.desktop").into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(spec.declared_executable), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(spec.runtime_executable), + desktop_identity: None, + desktop_provenance: test_package(id), + declared_executable_provenance: test_package(id), + runtime_executable_provenance: test_package(id), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + } +} + +pub(super) fn test_package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} + +pub(super) fn structured_command(arguments: &[&str]) -> CommandLineEvidence { + CommandLineEvidence { + argv: arguments + .iter() + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + quality: CommandLineQuality::Structured, + } +} From 4f3cbb2c08245d8dafb1adf84d60691e842506bb Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:13:54 -0500 Subject: [PATCH 192/275] test(identity): wire focused launch verification suites Summary: wire focused launch verification suites. Scope: identity. --- .../desktop_index/verification/tests/mod.rs | 1012 +---------------- 1 file changed, 5 insertions(+), 1007 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs index c7683daf2..8d0a07ce4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs @@ -1,1007 +1,5 @@ -use std::collections::HashSet; -use std::path::Path; - -use super::{ - classify_launch_authority, executable_contract_is_dedicated, field_value_matches, - is_protected_payload, literal_file_identities_are_current, literal_file_matches, - match_ordered_dedicated_contract, match_ordered_exec_contract, verify_dedicated, - verify_protected_payload, verify_record_launch, verify_record_launch_with, - MAX_PROCESS_ARGUMENTS, -}; -use crate::daemon::notifications::identity::desktop_index::model::{ - DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, - LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, PackageLauncherBinding, - VerifiedLaunch, -}; -use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; -use crate::daemon::notifications::identity::desktop_index::InstallProvenance; -use crate::daemon::notifications::identity::executable::executable_evidence_for_path; -use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; - -fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { - match argument { - LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, - LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), - } -} - -#[test] -fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { - let dynamic = LaunchArgument::FieldCode(FieldCode::Files); - let option = LaunchArgument::Literal(LiteralArgument { - value: b"--fixed".to_vec(), - file: None, - }); - let payload = LaunchArgument::Literal(LiteralArgument { - value: b"/usr/share/example/app.bundle".to_vec(), - file: Some(( - "/usr/share/example/app.bundle".into(), - crate::daemon::notifications::identity::FileIdentity { - device: 1, - inode: 2, - uid: 0, - mode: 0o100_755, - }, - )), - }); - - assert!(is_dynamic_or_option(&dynamic)); - assert!(is_dynamic_or_option(&option)); - assert!(!is_dynamic_or_option(&payload)); - assert!(!is_protected_payload(&dynamic)); - assert!(is_protected_payload(&payload)); -} - -#[test] -fn protected_payload_verification_requires_current_file_identity_and_fixed_arguments() { - let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); - let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); - let other = - executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other system payload"); - let payload_argument = LiteralArgument { - value: b"/usr/bin/true".to_vec(), - file: Some(("/usr/bin/true".into(), payload.identity)), - }; - let spec = LaunchSpec { - declared_executable: shell.identity, - runtime_executable: shell.identity, - arguments: vec![ - LaunchArgument::Literal(payload_argument.clone()), - LaunchArgument::Literal(LiteralArgument { - value: b"--fixed".to_vec(), - file: None, - }), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - assert!(literal_file_matches(&payload_argument, b"/usr/bin/true")); - assert!(!literal_file_matches(&payload_argument, b"/usr/bin/false")); - assert!(!literal_file_matches(&payload_argument, &[0xff])); - assert!(literal_file_identities_are_current(&spec)); - - let mut stale_spec = spec.clone(); - let LaunchArgument::Literal(stale_payload) = &mut stale_spec.arguments[0] else { - panic!("payload fixture should remain literal"); - }; - stale_payload.file = Some(("/usr/bin/true".into(), other.identity)); - assert!(!literal_file_identities_are_current(&stale_spec)); - - let verified = structured_command(&["/usr/bin/sh", "/usr/bin/true", "--fixed"]); - assert_eq!( - verify_protected_payload(&verified, &spec), - LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) - ); - - let wrong_payload = structured_command(&["/usr/bin/sh", "/usr/bin/false", "--fixed"]); - assert_eq!( - verify_protected_payload(&wrong_payload, &spec), - LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) - ); - - let missing_argument = structured_command(&["/usr/bin/sh", "/usr/bin/true"]); - assert_eq!( - verify_protected_payload(&missing_argument, &spec), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) - ); -} - -#[test] -fn trusted_payload_cannot_be_used_as_a_decoy_argument() { - let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); - let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); - let spec = LaunchSpec { - declared_executable: runtime.identity, - runtime_executable: runtime.identity, - arguments: vec![LaunchArgument::Literal(LiteralArgument { - value: b"/usr/bin/true".to_vec(), - file: Some(("/usr/bin/true".into(), payload.identity)), - })], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let sender = structured_command(&["/usr/bin/sh", "/usr/bin/false", "/usr/bin/true"]); - - assert_eq!( - verify_protected_payload(&sender, &spec), - LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch), - "a protected file after the active payload must not authenticate the runtime" - ); -} - -#[test] -fn variable_width_field_before_protected_payload_does_not_create_false_conflict() { - let payload = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("protected payload"); - let spec = LaunchSpec { - declared_executable: payload.identity, - runtime_executable: payload.identity, - arguments: vec![ - LaunchArgument::FieldCode(FieldCode::Files), - LaunchArgument::Literal(LiteralArgument { - value: b"/usr/bin/true".to_vec(), - file: Some((Path::new("/usr/bin/true").to_path_buf(), payload.identity)), - }), - LaunchArgument::Literal(LiteralArgument { - value: b"--fixed".to_vec(), - file: None, - }), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let sender = structured_command(&[ - "/usr/bin/true", - "/tmp/one.txt", - "/tmp/two.txt", - "/usr/bin/true", - "--unexpected", - ]); - - assert_eq!( - verify_protected_payload(&sender, &spec), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), - "a matched protected payload must not become contradictory because a later option differs" - ); -} - -#[test] -fn ordered_contract_preserves_repeated_literals_and_field_positions() { - let identity = executable_evidence_for_path(Path::new("/usr/bin/true")) - .expect("system executable") - .identity; - let spec = LaunchSpec { - declared_executable: identity, - runtime_executable: identity, - arguments: vec![ - LaunchArgument::Literal(LiteralArgument { - value: b"--mode".to_vec(), - file: None, - }), - LaunchArgument::Literal(LiteralArgument { - value: b"safe".to_vec(), - file: None, - }), - LaunchArgument::Literal(LiteralArgument { - value: b"--mode".to_vec(), - file: None, - }), - LaunchArgument::FieldCode(FieldCode::Url), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - assert!(match_ordered_exec_contract( - &spec, - &[ - b"--mode".to_vec(), - b"safe".to_vec(), - b"--mode".to_vec(), - b"https://example.invalid/item".to_vec(), - ], - )); - assert!(!match_ordered_exec_contract( - &spec, - &[ - b"--mode".to_vec(), - b"--mode".to_vec(), - b"safe".to_vec(), - b"https://example.invalid/item".to_vec(), - ], - )); -} - -#[test] -fn dedicated_contract_does_not_accept_reordered_fixed_options() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![ - LaunchArgument::Literal(LiteralArgument { - value: b"--first".to_vec(), - file: None, - }), - LaunchArgument::Literal(LiteralArgument { - value: b"--second".to_vec(), - file: None, - }), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - assert_eq!( - verify_dedicated( - &structured_command(&["/usr/bin/true", "--first", "--second"]), - &spec, - ), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) - ); - assert_eq!( - verify_dedicated( - &structured_command(&["/usr/bin/true", "--second", "--first"]), - &spec, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) - ); - assert_eq!( - verify_dedicated( - &structured_command(&[ - "/usr/bin/true", - "--display=x11", - "--first", - "--tray", - "--second", - "--verbose", - ]), - &spec, - ), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) - ); - assert_eq!( - verify_dedicated( - &structured_command(&[ - "/usr/bin/true", - "--first", - "/tmp/unexpected-payload", - "--second", - ]), - &spec, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) - ); -} - -#[test] -fn empty_dedicated_contract_rejects_positional_payload() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: Vec::new(), - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - assert_eq!( - verify_dedicated( - &structured_command(&["/usr/bin/true", "/tmp/attacker-payload"]), - &spec, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) - ); -} - -#[test] -fn empty_contract_with_unstructured_argv_is_not_verified() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: Vec::new(), - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - for quality in [ - CommandLineQuality::RewrittenProcessTitle, - CommandLineQuality::Truncated, - CommandLineQuality::Unavailable, - ] { - let command_line = CommandLineEvidence { - argv: Vec::new(), - quality, - }; - - assert_eq!( - verify_dedicated(&command_line, &spec), - LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine), - "an empty contract with {quality:?} argv must stay non-authoritative" - ); - } -} - -#[test] -fn empty_contract_accepts_only_non_positional_switches() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: Vec::new(), - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - for arguments in [ - vec!["/usr/bin/true"], - vec!["/usr/bin/true", "--verbose"], - vec!["/usr/bin/true", "--display=x11", "-q"], - ] { - assert_eq!( - verify_dedicated(&structured_command(&arguments), &spec), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - "standalone switches should remain compatible: {arguments:?}" - ); - } - assert_eq!( - verify_dedicated( - &structured_command(&["/usr/bin/true", "--title", "untrusted-value"]), - &spec, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), - "a separate option value is positional without an ordered contract" - ); -} - -#[test] -fn dynamic_contract_without_shared_provenance_is_not_dedicated() { - for field_code in [FieldCode::Files, FieldCode::Urls] { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![LaunchArgument::FieldCode(field_code)], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let record = DesktopRecord { - id: "org.example.Runtime".to_string(), - display_name: "Runtime application".to_string(), - badge_icon: "runtime".to_string(), - desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), - declared_executable_path: Some("/usr/bin/true".into()), - declared_executable_identity: Some(executable.identity), - runtime_executable_path: Some("/usr/bin/true".into()), - runtime_executable_identity: Some(executable.identity), - desktop_identity: None, - desktop_provenance: test_package("runtime-desktop"), - declared_executable_provenance: test_package("runtime"), - runtime_executable_provenance: test_package("runtime"), - system_origin: true, - system_association: true, - association_eligible: true, - launch_spec: Some(spec.clone()), - names: HashSet::new(), - }; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Runtime") - .into_iter() - .next() - .expect("single indexed runtime"); - - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DynamicOnly, - "a single dynamic record must remain non-authoritative for {field_code:?}" - ); - } -} - -#[test] -fn dedicated_system_application_accepts_dynamic_url_field() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![ - LaunchArgument::Literal(LiteralArgument { - value: b"--".to_vec(), - file: None, - }), - LaunchArgument::FieldCode(FieldCode::Url), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let record = DesktopRecord { - id: "org.example.True".to_string(), - display_name: "True".to_string(), - badge_icon: "true".to_string(), - desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), - declared_executable_path: Some("/usr/bin/true".into()), - declared_executable_identity: Some(executable.identity), - runtime_executable_path: Some("/usr/bin/true".into()), - runtime_executable_identity: Some(executable.identity), - desktop_identity: Some(executable.identity), - desktop_provenance: test_package("true"), - declared_executable_provenance: test_package("true"), - runtime_executable_provenance: test_package("true"), - system_origin: true, - system_association: true, - association_eligible: true, - launch_spec: Some(spec.clone()), - names: HashSet::from(["true".to_string()]), - }; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.True") - .into_iter() - .next() - .expect("dedicated application record"); - - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DedicatedExecutable, - "normal URL arguments must not erase dedicated executable authority" - ); -} - -#[test] -fn dynamic_runtime_requires_matching_immutable_installation_provenance() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let record = DesktopRecord { - id: "org.example.Runtime".to_string(), - display_name: "True".to_string(), - badge_icon: "runtime".to_string(), - desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), - declared_executable_path: Some("/usr/bin/true".into()), - declared_executable_identity: Some(executable.identity), - runtime_executable_path: Some("/usr/bin/true".into()), - runtime_executable_identity: Some(executable.identity), - desktop_identity: Some(executable.identity), - desktop_provenance: test_package("runtime-frontend"), - declared_executable_provenance: test_package("shared-runtime"), - runtime_executable_provenance: test_package("shared-runtime"), - system_origin: true, - system_association: true, - association_eligible: true, - launch_spec: Some(spec.clone()), - names: HashSet::from(["true".to_string()]), - }; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Runtime") - .into_iter() - .next() - .expect("runtime application record"); - - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DynamicOnly, - "a package-owned shared runtime must not inherit desktop application authority" - ); -} - -#[test] -fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - for (field_code, actual) in [ - (FieldCode::File, vec!["/usr/bin/true", "/tmp/image.png"]), - ( - FieldCode::Files, - vec!["/usr/bin/true", "/tmp/first.png", "/tmp/second.png"], - ), - ] { - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![LaunchArgument::FieldCode(field_code)], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let mut record = record_for_spec("org.example.Viewer", &spec); - record.desktop_provenance = test_package("example-viewer"); - record.declared_executable_provenance = test_package("example-viewer"); - record.runtime_executable_provenance = test_package("example-viewer"); - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Viewer") - .into_iter() - .next() - .expect("file application record"); - - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DedicatedExecutable, - "immutable application ownership should support {field_code:?}" - ); - assert_eq!( - verify_record_launch( - indexed, - &index, - executable.identity, - &structured_command(&actual), - ), - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - "the ordered {field_code:?} contract should accept matching document arguments" - ); - } -} - -#[test] -fn package_launcher_target_verifies_with_matching_runtime_and_ordered_contract() { - let launcher = - executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); - let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); - let binding = PackageLauncherBinding { - launcher_path: "/usr/bin/false".into(), - launcher_identity: launcher.identity, - launcher_digest: [7; 32], - target_path: "/usr/bin/true".into(), - target_identity: runtime.identity, - }; - let spec = LaunchSpec { - declared_executable: launcher.identity, - runtime_executable: runtime.identity, - arguments: vec![ - LaunchArgument::Literal(LiteralArgument { - value: b"--".to_vec(), - file: None, - }), - LaunchArgument::FieldCode(FieldCode::Url), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: Some(binding), - literal_files_are_system_managed: true, - }; - let package = test_package("example-chat"); - let mut record = record_for_spec("org.example.Chat", &spec); - record.declared_executable_path = Some("/usr/bin/false".into()); - record.declared_executable_identity = Some(launcher.identity); - record.runtime_executable_path = Some("/usr/bin/true".into()); - record.runtime_executable_identity = Some(runtime.identity); - record.desktop_provenance = package.clone(); - record.declared_executable_provenance = package.clone(); - record.runtime_executable_provenance = package; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Chat") - .into_iter() - .next() - .expect("launcher-backed application record"); - - let verification = verify_record_launch_with( - indexed, - &index, - runtime.identity, - &structured_command(&[ - "/usr/bin/true", - "--password-store=desktop", - "--display=x11", - "--", - ]), - |_| true, - ); - - assert_eq!( - verification, - LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) - ); -} - -#[test] -fn package_launcher_target_requires_current_binding_and_structured_arguments() { - let launcher = - executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); - let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); - let spec = LaunchSpec { - declared_executable: launcher.identity, - runtime_executable: runtime.identity, - arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: Some(PackageLauncherBinding { - launcher_path: "/usr/bin/false".into(), - launcher_identity: launcher.identity, - launcher_digest: [3; 32], - target_path: "/usr/bin/true".into(), - target_identity: runtime.identity, - }), - literal_files_are_system_managed: true, - }; - let mut record = record_for_spec("org.example.Chat", &spec); - record.declared_executable_identity = Some(launcher.identity); - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.Chat") - .into_iter() - .next() - .expect("launcher-backed application record"); - - assert_eq!( - verify_record_launch_with( - indexed, - &index, - runtime.identity, - &structured_command(&["/usr/bin/true"]), - |_| false, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged) - ); - assert_eq!( - verify_record_launch_with( - indexed, - &index, - runtime.identity, - &CommandLineEvidence::default(), - |_| true, - ), - LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) - ); -} - -#[test] -fn shared_launcher_target_does_not_merge_incompatible_application_families() { - let launcher = - executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); - let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); - let spec = LaunchSpec { - declared_executable: launcher.identity, - runtime_executable: runtime.identity, - arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: Some(PackageLauncherBinding { - launcher_path: "/usr/bin/false".into(), - launcher_identity: launcher.identity, - launcher_digest: [9; 32], - target_path: "/usr/bin/true".into(), - target_identity: runtime.identity, - }), - literal_files_are_system_managed: true, - }; - let package = test_package("example-suite"); - let mut first = record_for_spec("org.example.First", &spec); - let mut second = record_for_spec("org.example.Second", &spec); - for record in [&mut first, &mut second] { - record.desktop_provenance = package.clone(); - record.declared_executable_provenance = package.clone(); - record.runtime_executable_provenance = package.clone(); - } - let mut index = DesktopIdentityIndex::default(); - index.index_record(first); - index.index_record(second); - let indexed = index - .records_for_id("org.example.First") - .into_iter() - .next() - .expect("first application record"); - - assert_eq!( - classify_launch_authority(indexed, &index, &spec), - LaunchAuthority::DynamicOnly, - "one package-owned runtime shared by unrelated families must remain non-authoritative" - ); -} - -#[test] -fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { - for (wrapper_count, environment_count, expected) in [ - ( - 16, - 0, - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - ), - ( - 17, - 0, - LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), - ), - ( - 0, - 128, - LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), - ), - ( - 0, - 129, - LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), - ), - ] { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: Vec::new(), - environment: std::iter::repeat_n((b"A".to_vec(), b"1".to_vec()), environment_count) - .collect(), - wrappers: std::iter::repeat_n(LaunchWrapper::Env, wrapper_count).collect(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let record = DesktopRecord { - id: "org.example.True".to_string(), - display_name: "Boundary".to_string(), - badge_icon: "boundary".to_string(), - desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), - declared_executable_path: Some("/usr/bin/true".into()), - declared_executable_identity: Some(executable.identity), - runtime_executable_path: Some("/usr/bin/true".into()), - runtime_executable_identity: Some(executable.identity), - desktop_identity: None, - desktop_provenance: test_package("true"), - declared_executable_provenance: test_package("true"), - runtime_executable_provenance: test_package("true"), - system_origin: true, - system_association: true, - association_eligible: true, - launch_spec: Some(spec), - names: HashSet::new(), - }; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record); - let indexed = index - .records_for_id("org.example.True") - .into_iter() - .next() - .expect("indexed boundary record"); - - assert_eq!( - verify_record_launch( - indexed, - &index, - executable.identity, - &structured_command(&["/usr/bin/true"]), - ), - expected, - "wrapper_count={wrapper_count}, environment_count={environment_count}" - ); - } -} - -#[test] -fn dedicated_authority_accepts_document_fields_but_rejects_unprotected_fixed_payloads() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - for (arguments, expected) in [ - (Vec::new(), true), - (vec![LaunchArgument::FieldCode(FieldCode::Url)], true), - (vec![LaunchArgument::FieldCode(FieldCode::File)], true), - ( - vec![LaunchArgument::Literal(LiteralArgument { - value: b"runtime-selected-payload".to_vec(), - file: None, - })], - false, - ), - ] { - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments, - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let mut index = DesktopIdentityIndex::default(); - index.index_record(record_for_spec("org.example.True", &spec)); - let record = index - .records_for_id("org.example.True") - .into_iter() - .next() - .expect("indexed dedicated boundary record"); - - assert_eq!( - executable_contract_is_dedicated(record, &index, &spec), - expected, - "arguments={:?}", - spec.arguments - ); - } -} - -#[test] -fn protected_payload_accepts_exactly_the_bounded_argument_limit() { - let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); - let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); - let mut arguments = vec![LaunchArgument::Literal(LiteralArgument { - value: b"/usr/bin/true".to_vec(), - file: Some(("/usr/bin/true".into(), payload.identity)), - })]; - arguments.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| { - LaunchArgument::Literal(LiteralArgument { - value: b"--fixed".to_vec(), - file: None, - }) - })); - let spec = LaunchSpec { - declared_executable: runtime.identity, - runtime_executable: runtime.identity, - arguments, - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - let mut argv = vec![b"/usr/bin/sh".to_vec(), b"/usr/bin/true".to_vec()]; - argv.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| b"--fixed".to_vec())); - let command = CommandLineEvidence { - argv, - quality: CommandLineQuality::Structured, - }; - - assert_eq!(command.argv.len().saturating_sub(1), MAX_PROCESS_ARGUMENTS); - assert_eq!( - verify_protected_payload(&command, &spec), - LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) - ); -} - -#[test] -fn optional_icon_contract_preserves_its_flag_and_value_relationship() { - let executable = - executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); - let spec = LaunchSpec { - declared_executable: executable.identity, - runtime_executable: executable.identity, - arguments: vec![ - LaunchArgument::OptionalIcon { - name: "example-icon".to_string(), - }, - LaunchArgument::Literal(LiteralArgument { - value: b"--fixed".to_vec(), - file: None, - }), - ], - environment: Vec::new(), - wrappers: Vec::new(), - package_launcher: None, - literal_files_are_system_managed: true, - }; - - assert_optional_icon_contract(match_ordered_exec_contract, &spec, "protected"); - assert_optional_icon_contract(match_ordered_dedicated_contract, &spec, "dedicated"); -} - -#[test] -fn field_values_reject_empty_options_and_malformed_urls() { - assert!(!field_value_matches(FieldCode::File, b"")); - assert!(!field_value_matches(FieldCode::Files, b"--runtime-option")); - assert!(field_value_matches(FieldCode::File, b"relative-file")); - assert!(field_value_matches( - FieldCode::Url, - b"https://example.invalid/item" - )); - assert!(!field_value_matches(FieldCode::Urls, b"not a URL")); - assert!(!field_value_matches(FieldCode::Url, &[0xff])); -} - -type ContractMatcher = fn(&LaunchSpec, &[Vec]) -> bool; - -fn assert_optional_icon_contract(matcher: ContractMatcher, spec: &LaunchSpec, label: &str) { - for (actual, expected) in [ - (vec![b"--fixed".to_vec()], true), - ( - vec![ - b"--icon".to_vec(), - b"example-icon".to_vec(), - b"--fixed".to_vec(), - ], - true, - ), - ( - vec![ - b"--badge".to_vec(), - b"example-icon".to_vec(), - b"--fixed".to_vec(), - ], - false, - ), - ( - vec![ - b"--icon".to_vec(), - b"other-icon".to_vec(), - b"--fixed".to_vec(), - ], - false, - ), - (vec![b"--icon".to_vec(), b"--fixed".to_vec()], false), - ] { - assert_eq!( - matcher(spec, &actual), - expected, - "{label}: actual={actual:?}" - ); - } -} - -fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { - DesktopRecord { - id: id.to_string(), - display_name: "Contract application".to_string(), - badge_icon: "contract".to_string(), - desktop_path: Some(format!("/usr/share/applications/{id}.desktop").into()), - declared_executable_path: Some("/usr/bin/true".into()), - declared_executable_identity: Some(spec.declared_executable), - runtime_executable_path: Some("/usr/bin/true".into()), - runtime_executable_identity: Some(spec.runtime_executable), - desktop_identity: None, - desktop_provenance: test_package(id), - declared_executable_provenance: test_package(id), - runtime_executable_provenance: test_package(id), - system_origin: true, - system_association: true, - association_eligible: true, - launch_spec: Some(spec.clone()), - names: HashSet::new(), - } -} - -fn test_package(package_id: &str) -> InstallProvenance { - InstallProvenance::Package { - provider: PackageProvider::Pacman, - package_id: package_id.to_string(), - } -} - -fn structured_command(arguments: &[&str]) -> CommandLineEvidence { - CommandLineEvidence { - argv: arguments - .iter() - .map(|argument| argument.as_bytes().to_vec()) - .collect(), - quality: CommandLineQuality::Structured, - } -} +mod authority; +mod contract; +mod payload; +mod record; +mod support; From b89202763821679ef2cdf62de1a0ceedd7127cf8 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 17:31:45 -0500 Subject: [PATCH 193/275] refactor(identity): remove obsolete test seams Summary: remove obsolete test seams. Scope: identity. --- crates/unixnotis-center/src/media/mpris/discovery.rs | 12 ------------ .../src/media/mpris/tests/discovery.rs | 10 ++++------ .../identity/desktop_index/index/lookup.rs | 11 ----------- .../identity/desktop_index/index/tests/families.rs | 1 - .../identity/desktop_index/index/tests/lookup.rs | 1 - .../identity/desktop_index/index/tests/mod.rs | 2 -- .../identity/desktop_index/provenance/cache.rs | 2 +- .../identity/resolver/tests/pipeline/provenance.rs | 8 ++------ 8 files changed, 7 insertions(+), 40 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index 1b3cc2f42..963b757dd 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -175,15 +175,3 @@ pub(super) fn select_player_names( pub(super) const fn owner_capacity_exceeded(owner_count: usize, capacity: usize) -> bool { owner_count > capacity } - -#[cfg_attr( - not(test), - expect(dead_code, reason = "capacity helper is exercised by discovery tests") -)] -pub(super) const fn should_skip_for_owner_capacity( - owner_count: usize, - capacity: usize, - owner_is_tracked: bool, -) -> bool { - owner_count >= capacity && !owner_is_tracked -} diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index 0a69c36a4..3d690dd55 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -7,7 +7,6 @@ use zbus::fdo::DBusProxy; use super::super::discovery::{ is_discoverable_player, owner_capacity_exceeded, refresh_players, select_player_names, - should_skip_for_owner_capacity, }; use super::super::player::build_player_state; use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; @@ -135,11 +134,10 @@ fn discovery_owner_capacity_rejects_only_values_above_the_limit() { } #[test] -fn discovery_capacity_keeps_aliases_but_rejects_new_owners_at_the_limit() { - assert!(!should_skip_for_owner_capacity(31, 32, false)); - assert!(!should_skip_for_owner_capacity(32, 32, true)); - assert!(should_skip_for_owner_capacity(32, 32, false)); - assert!(should_skip_for_owner_capacity(33, 32, false)); +fn discovery_owner_capacity_applies_only_above_the_limit() { + assert!(!owner_capacity_exceeded(31, 32)); + assert!(!owner_capacity_exceeded(32, 32)); + assert!(owner_capacity_exceeded(33, 32)); } #[tokio::test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs index aa55d1c6b..7c851e975 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs @@ -86,17 +86,6 @@ impl DesktopIdentityIndex { .any(|candidate| std::ptr::eq(*candidate, record)) } - #[cfg_attr( - not(test), - expect(dead_code, reason = "async wrapper remains for test seams") - )] - pub(in crate::daemon::notifications::identity) async fn install_provenance_for_path_async( - &self, - path: PathBuf, - ) -> super::super::provenance::InstallProvenance { - self.install_provenance_for_path(path) - } - pub(in crate::daemon::notifications::identity) fn install_provenance_for_path( &self, path: PathBuf, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs deleted file mode 100644 index 887f201f5..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/families.rs +++ /dev/null @@ -1 +0,0 @@ -//! Desktop application-family test module diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs deleted file mode 100644 index b9ea2eed6..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/lookup.rs +++ /dev/null @@ -1 +0,0 @@ -//! Desktop index lookup test module diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs index 82f2f0a55..90a97a18b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs @@ -1,4 +1,2 @@ -mod families; -mod lookup; mod mutation; mod trusted; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs index 4ed311fe2..5bcb2d8ec 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs @@ -61,7 +61,7 @@ impl CachedProvenance { } } -fn negative_ttl(cause: NegativeCause) -> Duration { +const fn negative_ttl(cause: NegativeCause) -> Duration { match cause { NegativeCause::NotOwned => NOT_OWNED_NEGATIVE_TTL, NegativeCause::Timeout diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index 9995670e2..73928022f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -94,12 +94,8 @@ async fn recognized_helper_is_reresolved_with_live_package_provenance() { let app_evidence = executable_evidence_for_path(&app_path).expect("read the application executable identity"); let ownership_index = DesktopIdentityIndex::default(); - let helper_provenance = ownership_index - .install_provenance_for_path_async(helper_path.clone()) - .await; - let app_provenance = ownership_index - .install_provenance_for_path_async(app_path.clone()) - .await; + let helper_provenance = ownership_index.install_provenance_for_path(helper_path.clone()); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); assert!(helper_provenance.is_known()); assert!(helper_provenance.same_application_source(&app_provenance)); From 90dbe6789cd59d7311755a69120e2bab9db58e7e Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 18:56:54 -0500 Subject: [PATCH 194/275] fix(identity): align attribution and provenance deadlines Summary: align attribution and provenance deadlines. Scope: identity. --- .../src/daemon/notifications/identity/mod.rs | 3 +- .../notifications/identity/resolver/mod.rs | 2 +- .../identity/resolver/pipeline.rs | 160 ++++++++---- .../identity/resolver/tests/mod.rs | 5 +- .../resolver/tests/pipeline/provenance.rs | 241 ++++++++++++++++++ .../src/daemon/notifications/server/flow.rs | 21 +- 6 files changed, 367 insertions(+), 65 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index acbde72f9..9b09fc557 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -10,7 +10,8 @@ mod sender_cache; pub use desktop_index::DesktopIndexSnapshot; pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; -pub(in crate::daemon) use resolver::{resolve_attribution_owned, unknown_reply_denied, AppClaim}; +pub(in crate::daemon) use resolver::resolve_attribution_owned; +pub(in crate::daemon::notifications) use resolver::resolve_attribution_with_deadline; pub(in crate::daemon) use sender::resolve_sender_metadata; pub(super) use sender::SenderMetadata; pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs index 2e9aebcff..26c59352b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs @@ -11,7 +11,7 @@ mod validation; pub(in crate::daemon) use model::{AppClaim, AttributionResolution}; pub(in crate::daemon) use pipeline::resolve_attribution_owned; -pub(in crate::daemon) use resolution::unknown_reply_denied; +pub(in crate::daemon::notifications) use pipeline::resolve_attribution_with_deadline; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index aa4b315fc..ac371fd37 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -1,7 +1,10 @@ //! Ordered attribution pipeline and candidate orchestration +use std::future::Future; use std::sync::{Arc, OnceLock}; +use std::time::Duration; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tracing::warn; use unixnotis_core::{AttributionStatus, InteractionPolicies, RecordTrust}; use super::super::desktop_index::{ @@ -24,13 +27,18 @@ use super::validation::validate_desktop_id; const ATTRIBUTION_WORKER_SLOTS: usize = 8; +// The ingress deadline covers the one-second package query, its bounded pipe +// drain, and the procfs/index work around that query +pub(in crate::daemon::notifications) const ATTRIBUTION_TIMEOUT: Duration = + Duration::from_millis(1_500); + fn attribution_worker_pool() -> Arc { static POOL: OnceLock> = OnceLock::new(); Arc::clone(POOL.get_or_init(|| Arc::new(Semaphore::new(ATTRIBUTION_WORKER_SLOTS)))) } -fn try_attribution_worker() -> Option { - attribution_worker_pool().try_acquire_owned().ok() +fn try_attribution_worker_from(pool: &Arc) -> Option { + Arc::clone(pool).try_acquire_owned().ok() } /// Production entry point that moves procfs and filesystem work off Tokio workers @@ -40,7 +48,71 @@ pub(in crate::daemon) async fn resolve_attribution_owned( sender: SenderMetadata, index: Arc, ) -> AttributionResolution { - let Some(initial_permit) = try_attribution_worker() else { + resolve_attribution_owned_with( + reported_name, + desktop_entry, + sender, + index, + enrich_sender_install_provenance_blocking, + ) + .await +} + +pub(in crate::daemon::notifications) async fn resolve_attribution_with_deadline( + reported_name: String, + desktop_entry: Option, + sender: &SenderMetadata, + resolution: F, +) -> AttributionResolution +where + F: Future, +{ + tokio::time::timeout(ATTRIBUTION_TIMEOUT, resolution) + .await + .ok() + .unwrap_or_else(|| { + warn!("notification attribution timed out and failed closed"); + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + unknown_reply_denied(claim, sender, "attribution timed out") + }) +} + +pub(super) async fn resolve_attribution_owned_with( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, + enrich: F, +) -> AttributionResolution +where + F: FnOnce(&mut SenderMetadata, &DesktopIdentityIndex) + Send + 'static, +{ + resolve_attribution_owned_with_pool( + reported_name, + desktop_entry, + sender, + index, + attribution_worker_pool(), + enrich, + ) + .await +} + +pub(super) async fn resolve_attribution_owned_with_pool( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, + worker_pool: Arc, + enrich: F, +) -> AttributionResolution +where + F: FnOnce(&mut SenderMetadata, &DesktopIdentityIndex) + Send + 'static, +{ + let Some(initial_permit) = try_attribution_worker_from(&worker_pool) else { let claim = AppClaim { reported_name: &reported_name, desktop_entry: desktop_entry.as_deref(), @@ -48,55 +120,49 @@ pub(in crate::daemon) async fn resolve_attribution_owned( return unknown_reply_denied(claim, &sender, "attribution worker capacity exhausted"); }; let fallback_sender = sender.clone(); - let result = tokio::time::timeout( - std::time::Duration::from_millis(500), - tokio::task::spawn_blocking({ - let reported_name = reported_name.clone(); - let desktop_entry = desktop_entry.clone(); - let index = Arc::clone(&index); - let sender = sender.clone(); - move || { - // The permit lives inside the blocking closure so timeout cancellation - // cannot release capacity while procfs work is still running - let _permit = initial_permit; - let sender = refresh_sender_security_evidence(&sender); - let claim = AppClaim { - reported_name: &reported_name, - desktop_entry: desktop_entry.as_deref(), - }; - let resolution = resolve_with_evidence(claim, &sender, &index); - let needs = needs_sender_provenance( - resolution.attribution.status, - resolution.attribution.interactions, - claim_has_index_candidate(claim, &index), - ); - if !needs { - return resolution; - } - - let mut sender = sender; - enrich_sender_install_provenance_blocking(&mut sender, &index); - resolve_with_evidence(claim, &sender, &index) - } - }), - ) - .await; - let resolution = match result { - Ok(Ok(resolution)) => resolution, - Ok(Err(_)) => { + // The server owns the single wall-clock deadline for this operation + // This layer only limits concurrent blocking attribution work + let result = tokio::task::spawn_blocking({ + let reported_name = reported_name.clone(); + let desktop_entry = desktop_entry.clone(); + let index = Arc::clone(&index); + let sender = sender.clone(); + move || { + // The permit stays in the closure until every blocking operation exits + let _permit = initial_permit; + let sender = refresh_sender_security_evidence(&sender); let claim = AppClaim { reported_name: &reported_name, desktop_entry: desktop_entry.as_deref(), }; - return unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped"); - } - Err(_) => { - let claim = AppClaim { - reported_name: &reported_name, - desktop_entry: desktop_entry.as_deref(), - }; - return unknown_reply_denied(claim, &fallback_sender, "attribution timed out"); + // The first pass can decide that package ownership is unnecessary + let initial = resolve_with_evidence(claim, &sender, &index); + let needs = needs_sender_provenance( + initial.attribution.status, + initial.attribution.interactions, + claim_has_index_candidate(claim, &index), + ); + if !needs { + return initial; + } + + let mut sender = sender; + // Enrichment is blocking and remains inside the same worker slot + enrich(&mut sender, &index); + // Missing or failed provenance must not erase useful safe attribution + if !sender.install_provenance.is_known() { + return initial; + } + resolve_with_evidence(claim, &sender, &index) } + }) + .await; + let Ok(resolution) = result else { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped"); }; resolution } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs index bb4be7cfa..fa8fb2358 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -12,8 +12,9 @@ use super::candidates::{resolve_unverified_candidates, strongest_verified_result use super::evidence::verify_record_sender; use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; use super::pipeline::{ - claim_has_index_candidate, needs_sender_provenance, resolve_with_evidence, - should_return_initial_resolution, + claim_has_index_candidate, needs_sender_provenance, resolve_attribution_owned_with, + resolve_attribution_owned_with_pool, resolve_attribution_with_deadline, resolve_with_evidence, + should_return_initial_resolution, ATTRIBUTION_TIMEOUT, }; use super::sender_context::enrich_sender_install_provenance; use super::AppClaim; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index 73928022f..3f33a89dd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -1,5 +1,9 @@ //! Async provenance enrichment in the resolver pipeline +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; + use super::super::*; #[test] @@ -125,3 +129,240 @@ async fn recognized_helper_is_reresolved_with_live_package_provenance() { .diagnostic_detail .contains("same installed application package")); } + +#[tokio::test] +async fn slow_valid_provenance_is_not_cut_off_by_a_short_inner_deadline() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance.clone(); + let index = Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())); + + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender(&helper_path.display().to_string(), helper_evidence.identity), + index, + move |sender, _| { + // Package ownership can exceed the old 500 ms inner deadline + std::thread::sleep(Duration::from_millis(650)); + sender.install_provenance = app_provenance; + }, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); +} + +#[tokio::test] +async fn failed_provenance_keeps_the_initial_safe_resolution() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance.clone(); + let index = Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())); + let mut sender = sender(&helper_path.display().to_string(), helper_evidence.identity); + sender.install_provenance = app_provenance; + + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + |sender, _| { + // Model a provider failure without granting a stronger result + sender.install_provenance = InstallProvenance::Unknown; + }, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); +} + +#[tokio::test] +async fn ingress_deadline_fails_closed_after_the_real_resolver_exceeds_budget() { + let (index, sender) = same_package_helper_fixture(); + let slow_resolution = async { + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender.clone(), + index, + move |_, _| { + std::thread::sleep(ATTRIBUTION_TIMEOUT + Duration::from_millis(250)); + }, + ) + .await; + // Keep the injected production future beyond the outer ingress budget + tokio::time::sleep(ATTRIBUTION_TIMEOUT + Duration::from_millis(250)).await; + resolution + }; + let resolution = resolve_attribution_with_deadline( + "Example App".to_string(), + Some("org.example.App".to_string()), + &sender, + slow_resolution, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); + assert!(resolution + .attribution + .diagnostic_detail + .contains("attribution timed out")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn cancelled_attribution_keeps_worker_permits_until_blocking_jobs_exit() { + let (index, sender) = same_package_helper_fixture(); + let worker_pool = Arc::new(tokio::sync::Semaphore::new(8)); + let started = Arc::new(AtomicUsize::new(0)); + let finished = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Barrier::new(9)); + let mut tasks = Vec::with_capacity(8); + + for _ in 0..8 { + let index = Arc::clone(&index); + let sender = sender.clone(); + let worker_pool = Arc::clone(&worker_pool); + let started = Arc::clone(&started); + let finished = Arc::clone(&finished); + let release = Arc::clone(&release); + tasks.push(tokio::spawn(resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + worker_pool, + move |_, _| { + started.fetch_add(1, Ordering::AcqRel); + release.wait(); + finished.fetch_add(1, Ordering::Release); + }, + ))); + } + + tokio::time::timeout(Duration::from_secs(2), async { + while started.load(Ordering::Acquire) != 8 { + tokio::task::yield_now().await; + } + }) + .await + .expect("all attribution workers should enter blocking enrichment"); + + for task in &tasks { + task.abort(); + } + + let blocked = resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender.clone(), + Arc::clone(&index), + Arc::clone(&worker_pool), + |_, _| {}, + ) + .await; + assert!(blocked + .attribution + .diagnostic_detail + .contains("attribution worker capacity exhausted")); + + release.wait(); + tokio::time::timeout(Duration::from_secs(2), async { + while finished.load(Ordering::Acquire) != 8 { + tokio::task::yield_now().await; + } + }) + .await + .expect("worker permits should be released after blocking jobs exit"); + + let available = resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + worker_pool, + |_, _| {}, + ) + .await; + assert!(!available + .attribution + .diagnostic_detail + .contains("attribution worker capacity exhausted")); +} + +fn same_package_helper_fixture() -> (Arc, SenderMetadata) { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance; + + ( + Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())), + sender(&helper_path.display().to_string(), helper_evidence.identity), + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index ea6d01c53..dd33bd1b5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -8,7 +8,7 @@ use zbus::zvariant::OwnedValue; use crate::daemon::notifications::identity::resolve_sender_metadata; use crate::daemon::notifications::identity::{ - resolve_attribution_owned, unknown_reply_denied, AppClaim, SenderMetadata, + resolve_attribution_owned, resolve_attribution_with_deadline, SenderMetadata, }; use crate::daemon::notifications::ingress::payload::{ build_notification, owned_to_string, resolve_expiration, NotificationInput, @@ -35,7 +35,6 @@ struct WireNotification { } const SENDER_METADATA_TIMEOUT: Duration = Duration::from_millis(100); -const ATTRIBUTION_TIMEOUT: Duration = Duration::from_millis(500); impl NotificationServer { #[expect( @@ -132,8 +131,11 @@ impl NotificationServer { }; let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); let desktop_identity_index = self.state.desktop_identity_index.load_full(); - let resolution = tokio::time::timeout( - ATTRIBUTION_TIMEOUT, + // This is the only attribution deadline, including package enrichment + let resolution = resolve_attribution_with_deadline( + input.app_name.clone(), + desktop_entry.clone(), + &sender, resolve_attribution_owned( input.app_name.clone(), desktop_entry.clone(), @@ -141,16 +143,7 @@ impl NotificationServer { desktop_identity_index, ), ) - .await - .ok() - .unwrap_or_else(|| { - warn!("notification attribution timed out and failed closed"); - let claim = AppClaim { - reported_name: &input.app_name, - desktop_entry: desktop_entry.as_deref(), - }; - unknown_reply_denied(claim, &sender, "attribution timed out") - }); + .await; if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict From b63e8cab01a9184090b233f3c9d77a267f88d3f4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 20:38:30 -0500 Subject: [PATCH 195/275] fix(ui): preserve recycled rows and application refresh state Summary: preserve recycled rows and application refresh state. Scope: ui. --- crates/noticenterctl/src/cli/command.rs | 2 + crates/noticenterctl/src/cli/tests/args.rs | 7 ++ crates/noticenterctl/src/dbus/client.rs | 7 ++ crates/noticenterctl/src/dbus/commands.rs | 3 + .../noticenterctl/src/dbus/tests/commands.rs | 4 ++ .../noticenterctl/src/dbus/tests/support.rs | 5 ++ crates/unixnotis-center/src/ui/events.rs | 2 +- .../src/ui/icons/decode/tests/svg.rs | 2 +- .../notifications/row/notification/build.rs | 4 +- .../ui/notifications/row/notification/mod.rs | 4 +- .../row/notification/reply/binding.rs | 12 ++++ .../row/notification/update/actions.rs | 5 +- .../row/notification/update/mod.rs | 2 +- .../row/notification/update/row.rs | 65 +++++++++++++++-- .../row/notification/update/tests/mod.rs | 2 +- .../row/notification/update/tests/state.rs | 65 ++++++++++++++++- .../src/ui/notifications/store/lifecycle.rs | 24 +++++-- .../ui/notifications/store/tests/lifecycle.rs | 71 +++++++++---------- .../ui/notifications/view/tests/widgets.rs | 2 +- .../src/ui/notifications/view/widgets.rs | 4 +- crates/unixnotis-core/assets/panel.css | 9 ++- crates/unixnotis-core/assets/popup.css | 11 +-- crates/unixnotis-core/src/control/proxy.rs | 2 + .../src/daemon/control/server.rs | 18 ++++- .../identity/desktop_index/refresh.rs | 58 ++++++++++++--- .../identity/desktop_index/tests/refresh.rs | 12 +++- .../src/ui/entry/builders/common.rs | 2 +- .../src/ui/entry/builders/communication.rs | 2 +- .../src/ui/entry/builders/layout.rs | 4 +- .../src/ui/entry/builders/tests/common.rs | 6 +- .../src/ui/entry/builders/utility.rs | 2 +- .../src/ui/state/tests/constructor.rs | 2 +- .../src/ui/state/tests/mutation.rs | 2 +- crates/unixnotis-ui/src/presentation/build.rs | 12 ++-- .../src/presentation/default_activation.rs | 4 +- .../presentation/tests/default_activation.rs | 2 +- .../src/presentation/tests/presentation.rs | 6 +- 37 files changed, 347 insertions(+), 99 deletions(-) diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index 95c76a001..e2758e493 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -17,6 +17,8 @@ pub enum Command { }, // Close the panel if it is visible ClosePanel, + // Rebuild the daemon's desktop application index immediately + RefreshApplications, // Set or toggle Do Not Disturb mode Dnd { #[arg(value_enum)] diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs index 0189c498c..0f20bc168 100644 --- a/crates/noticenterctl/src/cli/tests/args.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -18,6 +18,13 @@ fn parses_open_panel_debug_default() { } } +#[test] +fn parses_refresh_applications() { + let args = Args::try_parse_from(["noticenterctl", "refresh-applications"]) + .expect("refresh command should parse"); + assert!(matches!(args.command, Command::RefreshApplications)); +} + #[test] fn parses_open_panel_debug_value() { // Verifies explicit debug values map to the requested verbosity diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index 67a068ee2..89d5095d3 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -25,6 +25,9 @@ pub trait ControlClient { // Ask the panel to close fn close_panel(&self) -> ControlFuture<'_, ()>; + // Rebuild desktop application records without restarting the daemon + fn refresh_applications(&self) -> ControlFuture<'_, ()>; + // Remove every notification from both active and history areas fn clear_all(&self) -> ControlFuture<'_, ()>; @@ -92,6 +95,10 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::close_panel(self))) } + fn refresh_applications(&self) -> ControlFuture<'_, ()> { + Box::pin(run_control_call(ControlProxy::refresh_applications(self))) + } + fn clear_all(&self) -> ControlFuture<'_, ()> { // Ask the daemon to clear everything it is holding Box::pin(run_control_call(ControlProxy::clear_all(self))) diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index 11e67d764..da52d3c32 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -43,6 +43,9 @@ pub(super) async fn handle_command_with_debug_logs( // Explicit close avoids accidental toggles when the panel is hidden client.close_panel().await?; } + Command::RefreshApplications => { + client.refresh_applications().await?; + } Command::Clear | Command::ClearAll => { // Clear keeps legacy behavior: remove active notifications and saved history client.clear_all().await?; diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs index 7c19d9f3a..8de5a8d16 100644 --- a/crates/noticenterctl/src/dbus/tests/commands.rs +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -32,6 +32,10 @@ async fn panel_commands_dispatch_to_matching_control_calls() { (Command::TogglePanel, RecordedCall::TogglePanel), (Command::OpenPanel { debug: None }, RecordedCall::OpenPanel), (Command::ClosePanel, RecordedCall::ClosePanel), + ( + Command::RefreshApplications, + RecordedCall::RefreshApplications, + ), ]; for (command, expected) in cases { diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs index 164ea948e..f8f6b553f 100644 --- a/crates/noticenterctl/src/dbus/tests/support.rs +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -12,6 +12,7 @@ pub(super) enum RecordedCall { OpenPanel, OpenPanelDebug(PanelDebugLevel), ClosePanel, + RefreshApplications, ClearAll, ClearActive, ClearHistory, @@ -82,6 +83,10 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::ClosePanel, ()) } + fn refresh_applications(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::RefreshApplications, ()) + } + fn clear_all(&self) -> ControlFuture<'_, ()> { self.record(RecordedCall::ClearAll, ()) } diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 3d3c1c182..562050947 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -16,7 +16,7 @@ impl UiState { UiEvent::Disconnected => { debug!("UnixNotis control service disconnected"); // Old rows and state must not survive into a later daemon generation - self.list.seed(Vec::new(), Vec::new()); + self.list.clear_for_disconnect(); self.update_state(unixnotis_core::ControlState::default()); self.refresh_counts(); } diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index ff984a811..50dee7881 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -188,7 +188,7 @@ fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect_err("oversized child dimensions must fail"); - assert!(error.contains("renderer returned")); + assert!(error.contains("renderer")); } #[test] diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 50adb21d6..7261230b5 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -129,7 +129,7 @@ pub(in crate::ui::notifications) fn build_notification_row( summary_label.set_wrap(true); summary_label.set_wrap_mode(WrapMode::WordChar); summary_label.set_ellipsize(EllipsizeMode::End); - summary_label.set_lines(1); + summary_label.set_lines(2); summary_label.set_max_width_chars(88); summary_label.add_css_class("unixnotis-panel-summary"); @@ -140,7 +140,7 @@ pub(in crate::ui::notifications) fn build_notification_row( body_label.set_wrap(true); body_label.set_wrap_mode(WrapMode::WordChar); body_label.set_ellipsize(EllipsizeMode::End); - body_label.set_lines(3); + body_label.set_lines(5); body_label.set_max_width_chars(112); body_label.add_css_class("unixnotis-panel-body"); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 3ec06b1d9..1920e3c0a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -16,4 +16,6 @@ mod update; // Re-export them here so callers do not need to know the internal file split pub(in crate::ui::notifications) use self::build::build_notification_row; pub(in crate::ui::notifications) use self::state::NotificationRowWidgets; -pub(in crate::ui::notifications) use self::update::update_notification_row; +pub(in crate::ui::notifications) use self::update::{ + clear_notification_row, update_notification_row, +}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs index 3e369025f..5e1d749a8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs @@ -1,6 +1,7 @@ //! Notification binding and action-button behavior for inline replies use std::rc::Rc; +use std::rc::Weak; use gtk::prelude::*; use unixnotis_core::{InlineReplyPolicy, NotificationView}; @@ -68,6 +69,17 @@ fn reset_reply_form(widgets: &InlineReplyWidgets) { widgets.revealer.set_reveal_child(false); } +impl InlineReplyWidgets { + pub(in super::super) fn reset_for_recycle(&self) { + // A row can be unbound without a replacement notification + invalidate_reply_attempt(&self.state); + self.state.bound_id.set(0); + self.state.bound_generation.set(0); + *self.bound_snapshot.borrow_mut() = Weak::new(); + reset_reply_form(self); + } +} + pub(in super::super) fn connect_inline_reply_button( button: >k::Button, widgets: &InlineReplyWidgets, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 181d2ebd7..27360c5b9 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -43,6 +43,9 @@ pub(super) fn update_actions( ) { let presentation = NotificationPresentation::from_view(notification); configure_inline_reply(&row.inline_reply, notification, is_active); + let has_actions = visible_action_count_from(&presentation, is_active) > 0; + // Recycled rows may have hidden this container before the current bind + row.actions_box.set_visible(has_actions); let action_signature = action_signature(&presentation, is_active); // Fast path skips button rebuilding when the action set is unchanged { @@ -79,7 +82,7 @@ pub(super) fn update_actions( if !is_active { return; } - if visible_action_count_from(&presentation, is_active) == 0 { + if !has_actions { return; } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs index cbf0c0572..c18dc9928 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs @@ -7,7 +7,7 @@ mod row; mod thumbnail; mod visual; -pub(in crate::ui::notifications) use row::update_notification_row; +pub(in crate::ui::notifications) use row::{clear_notification_row, update_notification_row}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index c53b85132..cc9bedfb1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -17,6 +17,63 @@ use super::metadata::update_metadata_labels; use super::thumbnail::notification_has_thumbnail; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; +pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { + // Clear every visible lane before a recycled row can be painted again + row.default_activation.set_target(None); + row.notify_key.set(unixnotis_core::NotificationKey { + id: 0, + generation: 0, + }); + row.action_cache_key.set(unixnotis_core::NotificationKey { + id: 0, + generation: 0, + }); + row.action_cache.borrow_mut().clear(); + *row.reply_cache.borrow_mut() = ( + unixnotis_core::InlineReply::default(), + unixnotis_core::InlineReplyPolicy::Deny, + false, + ); + row.icon_sig.borrow_mut().take(); + row.inline_reply.reset_for_recycle(); + + for widget in [ + row.card.upcast_ref::(), + row.card_plate.upcast_ref::(), + row.header.upcast_ref::(), + row.meta_top.upcast_ref::(), + row.footer.upcast_ref::(), + row.actions_box.upcast_ref::(), + row.thumbnail.upcast_ref::(), + row.popup_status.upcast_ref::(), + row.stack_middle.upcast_ref::(), + row.stack_back.upcast_ref::(), + ] { + widget.set_visible(false); + } + row.icon.clear(); + row.thumbnail.clear(); + for label in [ + &row.app_label, + &row.secondary_claim, + &row.trust_chip, + &row.summary_label, + &row.body_label, + &row.popup_status, + &row.meta_label, + &row.time_badge, + &row.footer_left, + &row.footer_right, + ] { + label.set_text(""); + label.set_visible(false); + } + row.app_label.set_tooltip_text(None); + while let Some(child) = row.actions_box.first_child() { + row.actions_box.remove(&child); + } +} + pub(in crate::ui::notifications) fn update_notification_row( row: &NotificationRowWidgets, data: &RowData, @@ -27,11 +84,7 @@ pub(in crate::ui::notifications) fn update_notification_row( .set_reduced_motion(data.presentation.reduced_motion); // Model changes may briefly update a recycled row without notification data let Some(notification_snapshot) = data.notification.as_ref() else { - row.default_activation.set_target(None); - row.notify_key.set(unixnotis_core::NotificationKey { - id: 0, - generation: 0, - }); + clear_notification_row(row); return; }; let notification = notification_snapshot.as_ref(); @@ -124,4 +177,6 @@ pub(in crate::ui::notifications) fn update_notification_row( icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); } set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); + set_widget_visible_if_changed(&row.card_plate, true); + set_widget_visible_if_changed(&row.card, true); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs index 53594a22d..db3a8c1a0 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -11,5 +11,5 @@ pub(super) use super::labels::optional_label_state; pub(super) use super::metadata::{ notification_meta_label, relative_time_badge, relative_time_badge_at, }; -pub(super) use super::row::update_notification_row; +pub(super) use super::row::{clear_notification_row, update_notification_row}; pub(super) use super::thumbnail::notification_has_thumbnail; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index b7a9c9fa0..2c330fa32 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -12,7 +12,7 @@ use super::super::super::test_support::{ child_count, notification_row, notification_row_with_receiver, row_data, sample_notification, RowFlags, }; -use super::update_notification_row; +use super::{clear_notification_row, update_notification_row}; #[test] fn icon_signature_changes_when_trust_presentation_changes() { @@ -52,6 +52,65 @@ fn close_control_ignores_unbound_rows_and_keeps_the_bound_generation() { )); } +#[gtk::test] +fn clearing_a_recycled_row_removes_old_content_and_controls() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(row.summary_label.text().as_str(), "summary"); + assert!(row.card.get_visible()); + + clear_notification_row(&row); + + assert!(row.summary_label.text().is_empty()); + assert!(row.body_label.text().is_empty()); + assert!(row.app_label.text().is_empty()); + assert!(!row.card.get_visible()); + assert!(!row.header.get_visible()); + assert!(row.action_cache.borrow().is_empty()); + assert_eq!(row.notify_key.get().id, 0); +} + +#[gtk::test] +fn rebinding_after_clear_restores_wrapper_and_actions() { + let (_root, row) = notification_row(); + let first = row_data( + Rc::new(sample_notification()), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let mut second_notification = sample_notification(); + second_notification.id = 2; + second_notification.generation = 2; + second_notification.summary = "second summary".to_string(); + second_notification.actions = vec![unixnotis_core::Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let second = row_data( + Rc::new(second_notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &first, &IconResolver::new(), &command_tx); + clear_notification_row(&row); + update_notification_row(&row, &second, &IconResolver::new(), &command_tx); + + assert!(row.card_plate.get_visible()); + assert!(row.card.get_visible()); + assert_eq!(row.summary_label.text().as_str(), "second summary"); + assert!(row.actions_box.get_visible()); + assert!(child_count(&row.actions_box) > 0); +} + #[gtk::test] fn update_notification_row_applies_state_classes_and_text() { let (_root, row) = notification_row(); @@ -149,8 +208,8 @@ fn panel_text_limits_keep_compact_rows_content_driven() { let close = descendant_with_class(root.upcast_ref(), "unixnotis-panel-close") .expect("panel close button"); - assert_eq!(row.summary_label.lines(), 1); - assert_eq!(row.body_label.lines(), 3); + assert_eq!(row.summary_label.lines(), 2); + assert_eq!(row.body_label.lines(), 5); assert_eq!(close.parent().as_ref(), Some(row.header.upcast_ref())); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 102d3b806..9cc7e4371 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -2,7 +2,7 @@ //! //! These helpers own the base storage lifecycle so mutation code can stay focused on updates -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -13,6 +13,13 @@ use super::item::{RowData, RowItem, RowPresentation}; use super::types::{NotificationEntry, NotificationList}; impl NotificationList { + pub fn clear_for_disconnect(&mut self) { + // Preserve group preferences while the old daemon generation is absent + let previous_expansion = std::mem::take(&mut self.group_expanded); + self.seed(Vec::new(), Vec::new()); + self.group_expanded = previous_expansion; + } + pub fn apply_limits(&mut self, max_active: usize, max_entries: usize) { let mut changed = false; if self.max_active != max_active { @@ -35,7 +42,7 @@ impl NotificationList { self.entries.clear(); self.active_order.clear(); self.history_order.clear(); - clear_seed_group_expansion(&mut self.group_expanded); + let previous_expansion = std::mem::take(&mut self.group_expanded); self.group_headers.clear(); self.group_order.clear(); self.group_order_scratch.clear(); @@ -54,6 +61,15 @@ impl NotificationList { self.insert_entry(notification, false); } self.trim_to_limits(); + // Preserve the user's open groups when the daemon sends a new seed + self.group_expanded = previous_expansion + .into_iter() + .filter(|(key, _)| { + self.entries + .values() + .any(|entry| entry.app_key.as_ref() == key.as_ref()) + }) + .collect(); debug!( active = self.active_order.len(), @@ -146,10 +162,6 @@ fn now_millis() -> i64 { .unwrap_or(0) } -fn clear_seed_group_expansion(group_expanded: &mut HashMap, bool>) { - group_expanded.clear(); -} - fn drain_order_over_limit(order: &mut VecDeque, max_entries: usize) -> Vec { if max_entries == 0 { // A zero limit means the section is disabled, so every id must leave storage diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs index dccd51623..672227ec8 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs @@ -1,8 +1,8 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; -use super::{clear_seed_group_expansion, drain_order_over_limit}; +use super::drain_order_over_limit; use crate::ui::notifications::test_support as support; @@ -10,36 +10,6 @@ fn ordered_ids(ids: &[u32]) -> VecDeque { ids.iter().copied().collect() } -#[test] -fn seed_group_state_reset_clears_expanded_groups() { - let mut group_expanded = HashMap::from([(Rc::::from("Crash Reporting System"), true)]); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - -#[test] -fn seed_group_state_reset_clears_collapsed_groups_too() { - let mut group_expanded = HashMap::from([ - (Rc::::from("Crash Reporting System"), false), - (Rc::::from("notify-send"), true), - ]); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - -#[test] -fn seed_group_state_reset_accepts_empty_state() { - let mut group_expanded = HashMap::new(); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - #[test] fn drain_order_over_limit_removes_oldest_ids_from_back() { let mut order = ordered_ids(&[4, 3, 2, 1]); @@ -91,10 +61,10 @@ fn drain_order_over_limit_zero_capacity_accepts_empty_order() { } #[gtk::test] -fn seed_replaces_existing_state_and_requests_rebuild() { +fn seed_preserves_existing_group_expansion_for_surviving_groups() { let mut list = support::make_list(); - let stale_key = Rc::::from("crash reporting system"); - list.group_expanded.insert(stale_key, true); + list.group_expanded + .insert(Rc::::from("test:terminal"), true); list.seed( vec![ @@ -104,13 +74,42 @@ fn seed_replaces_existing_state_and_requests_rebuild() { vec![support::notification(3, "History")], ); - assert!(list.group_expanded.is_empty()); + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); assert_eq!(list.total_count(), 3); assert_eq!(list.active_order, ordered_ids(&[2, 1])); assert_eq!(list.history_order, ordered_ids(&[3])); assert!(list.needs_rebuild()); } +#[gtk::test] +fn disconnect_reset_keeps_group_expansion_until_reconnect_seed() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + Vec::new(), + ); + list.group_expanded + .insert(Rc::::from("test:terminal"), true); + + list.clear_for_disconnect(); + + assert!(list.entries.is_empty()); + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); + + list.seed( + vec![ + support::notification(3, "Terminal"), + support::notification(4, "Terminal"), + ], + Vec::new(), + ); + + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); +} + #[gtk::test] fn seed_trims_to_current_limits() { let mut list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index fd3652f02..eaf2977c0 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -176,7 +176,7 @@ fn unbind_disconnects_row_item_update_handler() { RowPresentation::default(), )); - assert!(contains_label_text( + assert!(!contains_label_text( &widgets.root.clone().upcast::(), "summary 1" )); diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 4cb8ffc6f..186492c43 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -16,7 +16,7 @@ use crate::control::{UiCommand, UiEvent}; use super::item::{RowData, RowItem, RowKind}; use super::row::group::{build_group_row, update_group_row, GroupRowWidgets}; use super::row::notification::{ - build_notification_row, update_notification_row, NotificationRowWidgets, + build_notification_row, clear_notification_row, update_notification_row, NotificationRowWidgets, }; use crate::ui::icons::IconResolver; @@ -95,7 +95,7 @@ impl RowWidgets { pub(super) fn unbind(&self) { self.disconnect(); if let Some(notification) = &self.notification { - notification.default_activation.set_target(None); + clear_notification_row(notification); } } diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 5dc96cd93..fc114cd91 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -778,14 +778,14 @@ entry selection { border-color: alpha(#ffffff, 0.14); } -.unixnotis-panel-card:hover { +.unixnotis-panel-card.unixnotis-default-action:hover { border-color: alpha(@unixnotis-accent, 0.22); box-shadow: 0 14px 28px -20px @unixnotis-shadow-strong, inset 0 0 0 1px alpha(#ffffff, 0.05); } -.unixnotis-panel-card.active:hover { +.unixnotis-panel-card.active.unixnotis-default-action:hover { border-color: alpha(@unixnotis-accent, 0.28); box-shadow: 0 14px 28px -20px @unixnotis-shadow-strong, @@ -797,6 +797,11 @@ entry selection { box-shadow: inset 0 0 0 1px alpha(#ffffff, 0.05); } +.unixnotis-panel-card.unixnotis-default-action:focus-visible { + outline: none; + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.25); +} + /* * Premium glowing scrollbars */ diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index e3d7186cd..cd3e8d375 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -15,16 +15,17 @@ min-height: var(--unixnotis-popup-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.70); - opacity: 0; + opacity: 0.58; transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-popup-card:hover .unixnotis-popup-close, -.unixnotis-popup-close:focus-visible { +.unixnotis-popup-close:focus-visible, +.unixnotis-popup-close:hover { opacity: 1; } -.unixnotis-popup-close:hover { +.unixnotis-popup-card .unixnotis-popup-close:hover { background: alpha(#fb7185, 0.16); border-color: alpha(#fb7185, 0.45); color: #fb7185; @@ -57,7 +58,7 @@ inset 0 1px 0 alpha(#ffffff, 0.035); } -.unixnotis-popup-card.unixnotis-popup-default-action:focus-visible { +.unixnotis-popup-card.unixnotis-default-action:focus-visible { border-color: alpha(@unixnotis-accent, 0.48); outline: none; box-shadow: @@ -92,7 +93,7 @@ .unixnotis-popup-app-name { color: alpha(@unixnotis-text, 0.68); font-weight: 600; - font-size: 11px; + font-size: 12px; } .unixnotis-popup-time { diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 2e7305034..5b5f9e7ba 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -46,6 +46,8 @@ trait Control { ) -> zbus::Result>; /// Open the control center panel fn open_panel(&self) -> zbus::Result<()>; + /// Rebuild the desktop application index immediately + fn refresh_applications(&self) -> zbus::Result<()>; /// Open the control center panel with debug logging fn open_panel_debug(&self, level: PanelDebugLevel) -> zbus::Result<()>; /// Close the control center panel diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 7dbe6bb9d..35ce34136 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -9,7 +9,7 @@ use unixnotis_core::{ use zbus::message::Header; use zbus::{interface, SignalContext}; -use crate::daemon::{auth, to_fdo_error, DaemonState}; +use crate::daemon::{auth, to_fdo_error, DaemonState, DesktopIdentityIndex}; /// D-Bus server for com.unixnotis.Control pub struct ControlServer { @@ -153,6 +153,22 @@ impl ControlServer { .await } + async fn refresh_applications( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "RefreshApplications") + .await?; + // Build off the async runtime, then publish one immutable replacement snapshot + let snapshot = tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot) + .await + .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?; + self.state + .desktop_identity_index + .store(Arc::new(snapshot.index)); + Ok(()) + } + async fn open_panel_debug( &self, level: PanelDebugLevel, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs index b73cb17b0..04333fe2d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs @@ -18,6 +18,7 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(500); const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(5); const REFRESH_SIGNAL_CAPACITY: usize = 1; const MAX_WATCHED_DIRECTORIES: usize = 4_096; +const FALLBACK_REBUILD_INTERVAL: Duration = Duration::from_secs(90); pub fn spawn_desktop_index_refresh( index: Arc>, @@ -30,24 +31,47 @@ pub fn spawn_desktop_index_refresh( }) .context("create desktop application watcher")?; - let active_watches = add_watch_directories( - &mut file_monitor, - watched_directories - .into_iter() - .take(MAX_WATCHED_DIRECTORIES), - ); - if active_watches.is_empty() { - warn!("no desktop application directory is available for refresh watching"); + let requested_watches = watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES) + .collect::>(); + let active_watches = + add_watch_directories(&mut file_monitor, requested_watches.iter().cloned()); + let mut watch_coverage_incomplete = + has_incomplete_watch_coverage(requested_watches.len(), active_watches.len()); + if watch_coverage_incomplete { + warn!( + requested = requested_watches.len(), + active = active_watches.len(), + "desktop application watch coverage is incomplete; periodic rebuilds enabled" + ); } Ok(tokio::spawn(async move { // The watcher must stay owned by this task for kernel watches to remain registered let mut file_monitor = file_monitor; let mut active_watches = active_watches; + let mut fallback_tick = watch_coverage_incomplete.then(|| { + // Missing watches include an empty set so a newly created directory is discovered + tokio::time::interval_at( + tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, + FALLBACK_REBUILD_INTERVAL, + ) + }); let mut last_rebuild = Instant::now() .checked_sub(MIN_REBUILD_INTERVAL) .unwrap_or_else(Instant::now); - while refresh_rx.recv().await.is_some() { + loop { + let refresh_requested = match fallback_tick.as_mut() { + Some(tick) => tokio::select! { + signal = refresh_rx.recv() => signal, + _ = tick.tick() => Some(()), + }, + None => refresh_rx.recv().await, + }; + if refresh_requested.is_none() { + break; + } tokio::time::sleep(REFRESH_DEBOUNCE).await; // Drain events that arrived during the debounce window before one complete rebuild while refresh_rx.try_recv().is_ok() {} @@ -69,6 +93,17 @@ pub fn spawn_desktop_index_refresh( remove_stale_watches(&mut file_monitor, &active_watches, &requested); active_watches.retain(|directory| requested.contains(directory)); active_watches.extend(added); + watch_coverage_incomplete = + has_incomplete_watch_coverage(requested.len(), active_watches.len()); + if watch_coverage_incomplete && fallback_tick.is_none() { + // Continue polling after a rebuild if the watcher still misses paths + fallback_tick = Some(tokio::time::interval_at( + tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, + FALLBACK_REBUILD_INTERVAL, + )); + } else if !watch_coverage_incomplete { + fallback_tick = None; + } last_rebuild = Instant::now(); debug!("desktop application identity index refreshed"); } @@ -80,6 +115,11 @@ pub fn spawn_desktop_index_refresh( })) } +const fn has_incomplete_watch_coverage(requested: usize, active: usize) -> bool { + // An empty watch set can become valid later when an application directory appears + requested == 0 || requested != active +} + const fn rebuild_delay(elapsed: Duration) -> Duration { MIN_REBUILD_INTERVAL.saturating_sub(elapsed) } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs index 4b93ad147..6a35478b2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs @@ -5,7 +5,9 @@ use notify::{Event, EventKind}; use std::time::Duration; -use super::{queue_refresh_event, rebuild_delay, relevant_desktop_event}; +use super::{ + has_incomplete_watch_coverage, queue_refresh_event, rebuild_delay, relevant_desktop_event, +}; use crate::test_support::TempRoot; #[test] @@ -79,3 +81,11 @@ fn rebuild_delay_enforces_the_minimum_interval_without_oversleeping() { assert_eq!(rebuild_delay(Duration::from_secs(5)), Duration::ZERO); assert_eq!(rebuild_delay(Duration::from_secs(8)), Duration::ZERO); } + +#[test] +fn incomplete_watch_coverage_requires_periodic_rebuilds() { + assert!(has_incomplete_watch_coverage(2, 1)); + assert!(has_incomplete_watch_coverage(1, 0)); + assert!(has_incomplete_watch_coverage(0, 0)); + assert!(!has_incomplete_watch_coverage(2, 2)); +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index d9ee5e066..60b830eda 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -88,7 +88,7 @@ pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeade trailing.add_css_class("unixnotis-popup-trailing"); trailing.set_halign(Align::End); trailing.set_valign(Align::Start); - trailing.set_margin_end(26); + trailing.set_margin_end(24); let time = gtk::Label::new(Some(&view.timestamp_label)); time.set_single_line_mode(true); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs index 557c31609..a67c9e1d6 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs @@ -18,7 +18,7 @@ pub(super) fn build_communication_popup( view, PopupLayout { css_class: "unixnotis-popup-communication-content", - body_lines: 3, + body_lines: 5, show_reply_note: true, }, ) diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs index 7000c88be..6206cd320 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -11,7 +11,7 @@ use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const POPUP_IDENTITY_SIZE: i32 = 38; +const POPUP_IDENTITY_SIZE: i32 = 34; pub(super) struct PopupLayout { pub(super) css_class: &'static str, @@ -28,7 +28,7 @@ pub(super) fn build_popup_grid( let grid = gtk::Grid::new(); grid.add_css_class(layout.css_class); grid.add_css_class("unixnotis-popup-content-grid"); - grid.set_column_spacing(10); + grid.set_column_spacing(8); grid.set_row_spacing(4); grid.set_hexpand(true); grid.set_accessible_role(gtk::AccessibleRole::Group); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 2a4f580d0..9ddc3f92f 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -35,14 +35,14 @@ fn title_and_body_builders_keep_text_classes_and_line_limits() { let mut view = view_model(); let title = build_title_label(&view).expect("visible title"); - let body = build_body_label(&view, 3).expect("visible body"); + let body = build_body_label(&view, 5).expect("visible body"); assert_eq!(title.text().as_str(), "Primary title"); assert!(title.has_css_class("unixnotis-popup-summary")); assert_eq!(title.lines(), 2); assert_eq!(body.text().as_str(), "Supporting body"); assert!(body.has_css_class("unixnotis-popup-body")); - assert_eq!(body.lines(), 3); + assert_eq!(body.lines(), 5); view.title.clear(); view.body = None; @@ -86,7 +86,7 @@ fn close_button_and_identity_header_keep_their_interaction_contracts() { Some("Dismiss notification") ); assert!(header.identity.hexpands()); - assert_eq!(header.trailing.margin_end(), 26); + assert_eq!(header.trailing.margin_end(), 24); assert_eq!(header.trailing.orientation(), gtk::Orientation::Vertical); assert!(header .trailing diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs index b58a1777c..f05a6fd23 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -18,7 +18,7 @@ pub(super) fn build_utility_popup( view, PopupLayout { css_class: "unixnotis-popup-utility-content", - body_lines: 2, + body_lines: 4, show_reply_note: false, }, ) diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 1932d3249..3c3cc68ac 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -250,7 +250,7 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { )); assert!(visible_descendant_has_text( root.upcast_ref(), - "App label: Signal" + "Identity could not be verified" )); } diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 6a5100282..8add3787e 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -152,7 +152,7 @@ fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { .expect("popup content should start with the identity grid"); assert!(grid.has_css_class("unixnotis-popup-content-grid")); - assert_eq!(grid.column_spacing(), 10); + assert_eq!(grid.column_spacing(), 8); assert_eq!(grid.row_spacing(), 4); assert_eq!( grid.property::("accessible-role"), diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 5d958db05..26c270cf4 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -169,10 +169,14 @@ fn identity_presentation( "Unknown application".to_string(), visible_claim(&claimed_name).map(|claim| format!("Claimed app: {claim}")), ), - AttributionStatus::Unresolved => ( - "Unknown application".to_string(), - visible_claim(&claimed_name).map(|claim| format!("App label: {claim}")), - ), + AttributionStatus::Unresolved => { + // A claim can help people recognize a message, but never supplies trusted branding + let claim = visible_claim(&claimed_name); + ( + claim.unwrap_or("Unknown application").to_string(), + claim.map(|_| "Identity could not be verified".to_string()), + ) + } }; let badge = match level { TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, diff --git a/crates/unixnotis-ui/src/presentation/default_activation.rs b/crates/unixnotis-ui/src/presentation/default_activation.rs index 64935534e..7fba60a96 100644 --- a/crates/unixnotis-ui/src/presentation/default_activation.rs +++ b/crates/unixnotis-ui/src/presentation/default_activation.rs @@ -42,9 +42,9 @@ impl DefaultActionBinding { "" })]); if enabled { - root.add_css_class("unixnotis-popup-default-action"); + root.add_css_class("unixnotis-default-action"); } else { - root.remove_css_class("unixnotis-popup-default-action"); + root.remove_css_class("unixnotis-default-action"); } } } diff --git a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs index c0b9a81f2..69b8bd513 100644 --- a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs @@ -38,7 +38,7 @@ fn binding_replaces_and_clears_the_current_generation() { binding.set_target(None); assert!(binding.target.borrow().is_none()); assert!(!card.is_focusable()); - assert!(!card.has_css_class("unixnotis-popup-default-action")); + assert!(!card.has_css_class("unixnotis-default-action")); } #[gtk::test] diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 291894bed..c7dcaab36 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -211,7 +211,7 @@ fn trusted_relay_claim_never_becomes_the_primary_application_identity() { } #[test] -fn unknown_claim_stays_secondary_and_unverified() { +fn unknown_claim_is_primary_but_remains_unverified() { let mut view = notification(); view.attribution = NotificationAttribution::unresolved( "Local helper", @@ -223,10 +223,10 @@ fn unknown_claim_stays_secondary_and_unverified() { let presentation = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!(presentation.trust.level, TrustLevel::Unresolved); - assert_eq!(presentation.identity.primary_label, "Unknown application"); + assert_eq!(presentation.identity.primary_label, "Local helper"); assert_eq!( presentation.identity.secondary_claim.as_deref(), - Some("App label: Local helper") + Some("Identity could not be verified") ); } From 27f0c0566b5e28b18f2ed3f474c72ea3ea92b0a4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 22:35:54 -0500 Subject: [PATCH 196/275] fix(identity): persist and reconstruct degraded watchers Summary: persist and reconstruct degraded watchers. Scope: identity. --- .../src/ui/icons/decode/tests/svg.rs | 5 +- crates/unixnotis-core/assets/popup.css | 6 +- .../unixnotis-core/src/embedded/tests/css.rs | 2 +- .../src/daemon/control/server.rs | 17 +- .../identity/desktop_index/mod.rs | 1 + .../identity/desktop_index/refresh.rs | 331 +++++++++++++----- .../identity/desktop_index/tests/refresh.rs | 212 ++++++++++- .../src/daemon/notifications/identity/mod.rs | 1 + .../src/daemon/state/model.rs | 15 +- crates/unixnotis-daemon/src/runtime/daemon.rs | 5 +- .../src/ui/entry/builders/common.rs | 2 +- .../src/ui/entry/builders/tests/common.rs | 2 +- 12 files changed, 490 insertions(+), 109 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index 50dee7881..cbdec78d4 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -188,7 +188,10 @@ fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect_err("oversized child dimensions must fail"); - assert!(error.contains("renderer")); + assert!( + error.contains("renderer returned oversized image"), + "unexpected error: {error}" + ); } #[test] diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index cd3e8d375..1900ba87e 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -135,9 +135,9 @@ } .unixnotis-identity-avatar { - min-width: 38px; - min-height: 38px; - border-radius: 11px; + min-width: 34px; + min-height: 34px; + border-radius: 10px; background: alpha(#ffffff, 0.07); color: alpha(#ffffff, 0.92); } diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index d7be44597..6d8665eac 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -153,7 +153,7 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { // Default popups must not restore the old raw provenance body row assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 38px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 34px")); assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); } diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 35ce34136..b19d56dca 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -9,7 +9,7 @@ use unixnotis_core::{ use zbus::message::Header; use zbus::{interface, SignalContext}; -use crate::daemon::{auth, to_fdo_error, DaemonState, DesktopIdentityIndex}; +use crate::daemon::{auth, to_fdo_error, DaemonState}; /// D-Bus server for com.unixnotis.Control pub struct ControlServer { @@ -159,14 +159,13 @@ impl ControlServer { ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "RefreshApplications") .await?; - // Build off the async runtime, then publish one immutable replacement snapshot - let snapshot = tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot) - .await - .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?; - self.state - .desktop_identity_index - .store(Arc::new(snapshot.index)); - Ok(()) + if self.state.request_desktop_index_refresh() { + // The worker owns rebuild timing, watcher replacement, and publication + return Ok(()); + } + Err(zbus::fdo::Error::Failed( + "desktop application refresh worker is unavailable".to_string(), + )) } async fn open_panel_debug( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs index cd6eb1c6c..6415e44b9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -19,6 +19,7 @@ pub(super) use model::{LaunchFailure, LaunchVerification, VerifiedLaunch}; pub(super) use names::{normalize_desktop_id, normalize_name}; pub(super) use provenance::InstallProvenance; pub use refresh::spawn_desktop_index_refresh; +pub use refresh::DesktopIndexRefreshHandle; pub use scan::DesktopIndexSnapshot; pub(in crate::daemon::notifications::identity) fn verify_record_launch( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs index 04333fe2d..117e14663 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs @@ -2,13 +2,14 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use arc_swap::ArcSwap; use notify::event::{CreateKind, RemoveKind}; -use notify::{Event, EventKind, RecursiveMode, Watcher}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::mpsc; use tracing::{debug, warn}; @@ -20,119 +21,285 @@ const REFRESH_SIGNAL_CAPACITY: usize = 1; const MAX_WATCHED_DIRECTORIES: usize = 4_096; const FALLBACK_REBUILD_INTERVAL: Duration = Duration::from_secs(90); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefreshTrigger { + Filesystem, + Fallback, + Manual, + WatchError, + RecoveryVerification, +} + +#[derive(Debug, Default)] +struct WatcherHealth { + degraded: AtomicBool, + installed: AtomicBool, +} + +impl WatcherHealth { + fn is_degraded(&self) -> bool { + self.degraded.load(Ordering::Acquire) + } + + fn set_installed(&self, installed: bool) { + self.installed.store(installed, Ordering::Release); + } + + /// Returns true only for the first error from an installed watcher + fn record_error(&self) -> bool { + let first_error = !self.degraded.swap(true, Ordering::AcqRel); + first_error && self.installed.load(Ordering::Acquire) + } + + fn accepts_events(&self) -> bool { + self.installed.load(Ordering::Acquire) + } +} + +struct WatcherInstance { + monitor: W, + active_watches: HashSet, + health: Arc, +} + +type DesktopWatcherInstance = WatcherInstance; + +#[derive(Clone)] +pub struct DesktopIndexRefreshHandle { + refresh_tx: mpsc::Sender, +} + +impl DesktopIndexRefreshHandle { + pub(crate) fn request_manual(&self) -> bool { + match self.refresh_tx.try_send(RefreshTrigger::Manual) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + } + } +} + pub fn spawn_desktop_index_refresh( index: Arc>, watched_directories: Vec, -) -> Result> { - let (refresh_tx, mut refresh_rx) = mpsc::channel(REFRESH_SIGNAL_CAPACITY); - let mut file_monitor = - notify::recommended_watcher(move |event: notify::Result| { - queue_refresh_event(event, &refresh_tx); - }) - .context("create desktop application watcher")?; - +) -> Result { + let (refresh_tx, refresh_rx) = mpsc::channel(REFRESH_SIGNAL_CAPACITY); let requested_watches = watched_directories .into_iter() .take(MAX_WATCHED_DIRECTORIES) .collect::>(); - let active_watches = - add_watch_directories(&mut file_monitor, requested_watches.iter().cloned()); - let mut watch_coverage_incomplete = - has_incomplete_watch_coverage(requested_watches.len(), active_watches.len()); + let watcher = create_watcher_instance(refresh_tx.clone(), &requested_watches)?; + watcher.health.set_installed(true); + let watch_coverage_incomplete = + has_incomplete_watch_coverage(&requested_watches, &watcher.active_watches); if watch_coverage_incomplete { warn!( requested = requested_watches.len(), - active = active_watches.len(), + active = watcher.active_watches.len(), "desktop application watch coverage is incomplete; periodic rebuilds enabled" ); } + let worker_refresh_tx = refresh_tx.clone(); - Ok(tokio::spawn(async move { - // The watcher must stay owned by this task for kernel watches to remain registered - let mut file_monitor = file_monitor; - let mut active_watches = active_watches; - let mut fallback_tick = watch_coverage_incomplete.then(|| { - // Missing watches include an empty set so a newly created directory is discovered - tokio::time::interval_at( - tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, - FALLBACK_REBUILD_INTERVAL, - ) - }); - let mut last_rebuild = Instant::now() - .checked_sub(MIN_REBUILD_INTERVAL) - .unwrap_or_else(Instant::now); - loop { - let refresh_requested = match fallback_tick.as_mut() { - Some(tick) => tokio::select! { - signal = refresh_rx.recv() => signal, - _ = tick.tick() => Some(()), - }, - None => refresh_rx.recv().await, - }; - if refresh_requested.is_none() { - break; - } - tokio::time::sleep(REFRESH_DEBOUNCE).await; - // Drain events that arrived during the debounce window before one complete rebuild - while refresh_rx.try_recv().is_ok() {} - - // Sustained user filesystem activity cannot trigger continuous complete rescans - let remaining = rebuild_delay(last_rebuild.elapsed()); - tokio::time::sleep(remaining).await; - match tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot).await { - Ok(rebuilt) => { - let requested = rebuilt - .watched_directories - .into_iter() - .take(MAX_WATCHED_DIRECTORIES) - .collect::>(); - // Add replacement watches before publishing the new immutable index - let additions = requested.difference(&active_watches).cloned(); - let added = add_watch_directories(&mut file_monitor, additions); - index.store(Arc::new(rebuilt.index)); - remove_stale_watches(&mut file_monitor, &active_watches, &requested); - active_watches.retain(|directory| requested.contains(directory)); - active_watches.extend(added); - watch_coverage_incomplete = - has_incomplete_watch_coverage(requested.len(), active_watches.len()); - if watch_coverage_incomplete && fallback_tick.is_none() { - // Continue polling after a rebuild if the watcher still misses paths - fallback_tick = Some(tokio::time::interval_at( - tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, - FALLBACK_REBUILD_INTERVAL, - )); - } else if !watch_coverage_incomplete { - fallback_tick = None; + tokio::spawn(run_refresh_worker( + index, + refresh_rx, + watcher, + worker_refresh_tx, + watch_coverage_incomplete, + )); + + Ok(DesktopIndexRefreshHandle { refresh_tx }) +} + +async fn run_refresh_worker( + index: Arc>, + mut refresh_rx: mpsc::Receiver, + mut watcher: DesktopWatcherInstance, + worker_refresh_tx: mpsc::Sender, + mut watch_coverage_incomplete: bool, +) { + // The watcher stays owned by this task for kernel watches to remain registered + let mut fallback_tick = + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()) + .then(fallback_interval); + let mut last_rebuild = Instant::now() + .checked_sub(MIN_REBUILD_INTERVAL) + .unwrap_or_else(Instant::now); + loop { + let refresh_trigger = match fallback_tick.as_mut() { + Some(tick) => tokio::select! { + signal = refresh_rx.recv() => signal, + _ = tick.tick() => Some(RefreshTrigger::Fallback), + }, + None => refresh_rx.recv().await, + }; + let Some(refresh_trigger) = refresh_trigger else { + break; + }; + update_fallback_timer( + &mut fallback_tick, + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()), + ); + debug!(?refresh_trigger, "desktop application refresh requested"); + tokio::time::sleep(REFRESH_DEBOUNCE).await; + // Drain events that arrived during the debounce window before one complete rebuild + while refresh_rx.try_recv().is_ok() {} + + // Sustained user filesystem activity cannot trigger continuous complete rescans + let remaining = rebuild_delay(last_rebuild.elapsed()); + tokio::time::sleep(remaining).await; + match tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot).await { + Ok(rebuilt) => { + let requested = rebuilt + .watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES) + .collect::>(); + let mut watcher_recovered = false; + if watcher.health.is_degraded() { + match create_watcher_instance(worker_refresh_tx.clone(), &requested) { + Ok(candidate) => { + watcher_recovered = + install_healthy_replacement(&mut watcher, candidate, &requested); + if watcher_recovered { + debug!( + watched = watcher.active_watches.len(), + "desktop application watcher reconstructed" + ); + queue_recovery_verification(&worker_refresh_tx); + } else { + warn!( + requested = requested.len(), + "replacement desktop watcher was not healthy; retaining degraded watcher and periodic fallback" + ); + } + } + Err(error) => { + warn!(?error, "failed to construct replacement desktop watcher"); + } } - last_rebuild = Instant::now(); - debug!("desktop application identity index refreshed"); } - Err(error) => { - warn!(?error, "desktop application identity index rebuild failed"); + + if !watcher_recovered { + let additions = requested.difference(&watcher.active_watches).cloned(); + let added = add_watch_directories(&mut watcher.monitor, additions); + watcher.active_watches.extend(added); } + index.store(Arc::new(rebuilt.index)); + if !watcher_recovered { + remove_stale_watches(&mut watcher.monitor, &watcher.active_watches, &requested); + watcher + .active_watches + .retain(|directory| requested.contains(directory)); + } + watch_coverage_incomplete = + has_incomplete_watch_coverage(&requested, &watcher.active_watches); + update_fallback_timer( + &mut fallback_tick, + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()), + ); + last_rebuild = Instant::now(); + debug!("desktop application identity index refreshed"); + } + Err(error) => { + warn!(?error, "desktop application identity index rebuild failed"); } } - })) + } +} + +fn create_watcher_instance( + refresh_tx: mpsc::Sender, + requested: &HashSet, +) -> Result { + let health = Arc::new(WatcherHealth::default()); + let callback_health = Arc::clone(&health); + let mut monitor = notify::recommended_watcher(move |event: notify::Result| { + queue_refresh_event(event, &refresh_tx, &callback_health); + }) + .context("create desktop application watcher")?; + let active_watches = add_watch_directories(&mut monitor, requested.iter().cloned()); + if !registration_is_complete(requested, &active_watches) { + health.degraded.store(true, Ordering::Release); + } + Ok(WatcherInstance { + monitor, + active_watches, + health, + }) +} + +fn has_incomplete_watch_coverage(requested: &HashSet, active: &HashSet) -> bool { + requested.is_empty() || requested != active +} + +fn registration_is_complete(requested: &HashSet, active: &HashSet) -> bool { + requested == active +} + +const fn fallback_required(watch_coverage_incomplete: bool, watcher_degraded: bool) -> bool { + watch_coverage_incomplete || watcher_degraded } -const fn has_incomplete_watch_coverage(requested: usize, active: usize) -> bool { - // An empty watch set can become valid later when an application directory appears - requested == 0 || requested != active +fn fallback_interval() -> tokio::time::Interval { + tokio::time::interval_at( + tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, + FALLBACK_REBUILD_INTERVAL, + ) +} + +fn update_fallback_timer(fallback_tick: &mut Option, required: bool) { + match (required, fallback_tick.is_some()) { + (true, false) => *fallback_tick = Some(fallback_interval()), + (false, true) => *fallback_tick = None, + _ => {} + } } const fn rebuild_delay(elapsed: Duration) -> Duration { MIN_REBUILD_INTERVAL.saturating_sub(elapsed) } -fn queue_refresh_event(event: notify::Result, refresh_tx: &mpsc::Sender<()>) { +fn queue_refresh_event( + event: notify::Result, + refresh_tx: &mpsc::Sender, + health: &WatcherHealth, +) { match event { - Ok(event) if relevant_desktop_event(&event) => { + Ok(event) if health.accepts_events() && relevant_desktop_event(&event) => { // A single pending signal coalesces filesystem bursts without blocking the watcher - let _ = refresh_tx.try_send(()); + let _ = refresh_tx.try_send(RefreshTrigger::Filesystem); } Ok(_) => {} - Err(error) => warn!(?error, "desktop application watcher reported an error"), + Err(error) => { + warn!(?error, "desktop application watcher reported an error"); + // Setup errors mark only the candidate; installed errors wake the worker once + if health.record_error() { + let _ = refresh_tx.try_send(RefreshTrigger::WatchError); + } + } + } +} + +fn queue_recovery_verification(refresh_tx: &mpsc::Sender) { + let _ = refresh_tx.try_send(RefreshTrigger::RecoveryVerification); +} + +fn install_healthy_replacement( + current: &mut WatcherInstance, + candidate: WatcherInstance, + requested: &HashSet, +) -> bool { + if !registration_is_complete(requested, &candidate.active_watches) + || candidate.health.is_degraded() + { + return false; } + // The candidate may receive events before the old instance is dropped + candidate.health.set_installed(true); + current.health.set_installed(false); + *current = candidate; + true } fn relevant_desktop_event(event: &Event) -> bool { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs index 6a35478b2..763bf6aaf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; use std::fs; +use std::path::PathBuf; +use std::sync::Arc; use notify::event::{CreateKind, RemoveKind}; use notify::{Event, EventKind}; @@ -6,7 +9,9 @@ use notify::{Event, EventKind}; use std::time::Duration; use super::{ - has_incomplete_watch_coverage, queue_refresh_event, rebuild_delay, relevant_desktop_event, + fallback_required, has_incomplete_watch_coverage, install_healthy_replacement, + queue_refresh_event, rebuild_delay, registration_is_complete, relevant_desktop_event, + DesktopIndexRefreshHandle, RefreshTrigger, WatcherHealth, WatcherInstance, }; use crate::test_support::TempRoot; @@ -27,19 +32,23 @@ fn unrelated_regular_file_changes_do_not_request_an_index_refresh() { #[test] fn relevant_event_is_queued_for_the_async_refresh_loop() { let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); let event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); - queue_refresh_event(Ok(event), &refresh_tx); + queue_refresh_event(Ok(event), &refresh_tx, &health); - assert_eq!(refresh_rx.try_recv(), Ok(())); + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Filesystem)); } #[test] fn unrelated_event_is_not_queued_for_the_async_refresh_loop() { let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); let event = Event::new(EventKind::Any).add_path("notes.txt".into()); - queue_refresh_event(Ok(event), &refresh_tx); + queue_refresh_event(Ok(event), &refresh_tx, &health); assert_eq!( refresh_rx.try_recv(), @@ -47,6 +56,60 @@ fn unrelated_event_is_not_queued_for_the_async_refresh_loop() { ); } +#[test] +fn watcher_errors_request_fallback_refreshes() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + + queue_refresh_event( + Err(notify::Error::generic("watcher failure")), + &refresh_tx, + &health, + ); + + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::WatchError)); + assert!(health.is_degraded()); +} + +#[test] +fn watcher_error_is_retained_when_trigger_channel_is_full() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + let filesystem_event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + queue_refresh_event(Ok(filesystem_event), &refresh_tx, &health); + queue_refresh_event( + Err(notify::Error::generic("watcher failure")), + &refresh_tx, + &health, + ); + + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Filesystem)); + assert_eq!( + refresh_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); + assert!(health.is_degraded()); +} + +#[test] +fn degraded_watcher_keeps_fallback_after_rebuild_with_full_coverage() { + assert!(fallback_required(true, false)); + assert!(fallback_required(false, true)); + assert!(!fallback_required(false, false)); +} + +#[test] +fn manual_refresh_is_enqueued_without_running_a_second_worker() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let handle = DesktopIndexRefreshHandle { refresh_tx }; + + assert!(handle.request_manual()); + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Manual)); +} + #[test] fn existing_directory_changes_request_watch_set_refresh() { let root = TempRoot::new("desktop-refresh-directory"); @@ -84,8 +147,141 @@ fn rebuild_delay_enforces_the_minimum_interval_without_oversleeping() { #[test] fn incomplete_watch_coverage_requires_periodic_rebuilds() { - assert!(has_incomplete_watch_coverage(2, 1)); - assert!(has_incomplete_watch_coverage(1, 0)); - assert!(has_incomplete_watch_coverage(0, 0)); - assert!(!has_incomplete_watch_coverage(2, 2)); + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let one_active = HashSet::from([PathBuf::from("/apps/a")]); + let empty = HashSet::new(); + + assert!(has_incomplete_watch_coverage(&requested, &one_active)); + assert!(has_incomplete_watch_coverage(&requested, &empty)); + assert!(has_incomplete_watch_coverage(&empty, &empty)); + assert!(!has_incomplete_watch_coverage(&requested, &requested)); +} + +#[test] +fn equal_watch_counts_do_not_imply_complete_coverage() { + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let active = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/c")]); + + assert!(has_incomplete_watch_coverage(&requested, &active)); + assert!(!registration_is_complete(&requested, &active)); +} + +#[test] +fn setup_errors_mark_a_candidate_without_waking_the_worker() { + let health = WatcherHealth::default(); + + assert!(!health.record_error()); + assert!(health.is_degraded()); +} + +#[test] +fn installed_watcher_error_wakes_the_worker_once() { + let health = WatcherHealth::default(); + health.set_installed(true); + + assert!(health.record_error()); + assert!(!health.record_error()); + assert!(health.is_degraded()); +} + +#[test] +fn partial_replacement_is_rejected() { + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate = WatcherInstance { + monitor: (), + active_watches: HashSet::from([PathBuf::from("/apps/a")]), + health: Arc::new(WatcherHealth::default()), + }; + + assert!(!install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(current.health.accepts_events()); + assert!(Arc::ptr_eq(¤t.health, &old_health)); +} + +#[test] +fn degraded_replacement_is_rejected() { + let requested = HashSet::from([PathBuf::from("/apps/a")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate_health = Arc::new(WatcherHealth::default()); + candidate_health.record_error(); + let candidate = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: candidate_health, + }; + + assert!(!install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(current.health.accepts_events()); + assert!(Arc::ptr_eq(¤t.health, &old_health)); +} + +#[test] +fn healthy_replacement_transfers_event_ownership() { + let requested = HashSet::from([PathBuf::from("/apps/a")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + old_health.record_error(); + let candidate_health = Arc::new(WatcherHealth::default()); + let candidate_health_for_assertion = Arc::clone(&candidate_health); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: candidate_health, + }; + + assert!(install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(!current.health.is_degraded()); + assert!(current.health.accepts_events()); + assert!(!old_health.accepts_events()); + old_health.record_error(); + assert!(!current.health.is_degraded()); + assert!(Arc::ptr_eq( + ¤t.health, + &candidate_health_for_assertion + )); +} + +#[test] +fn empty_registration_needs_fallback_but_can_be_replaced() { + let empty = HashSet::new(); + assert!(registration_is_complete(&empty, &empty)); + assert!(has_incomplete_watch_coverage(&empty, &empty)); +} + +#[test] +fn successful_replacement_queues_recovery_verification() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + super::queue_recovery_verification(&tx); + + assert_eq!(rx.try_recv(), Ok(RefreshTrigger::RecoveryVerification)); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 9b09fc557..5da97d671 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -7,6 +7,7 @@ mod resolver; mod sender; mod sender_cache; +pub use desktop_index::DesktopIndexRefreshHandle; pub use desktop_index::DesktopIndexSnapshot; pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 0349e16ff..e073e3329 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -12,7 +12,7 @@ use crate::sound::SoundSettings; use crate::store::NotificationStore; use crate::daemon::events::DaemonEventPublisher; -use crate::daemon::notifications::identity::DesktopIdentityIndex; +use crate::daemon::notifications::identity::{DesktopIdentityIndex, DesktopIndexRefreshHandle}; use crate::daemon::notifications::NotificationBurstState; use crate::daemon::notifications::SenderMetadataCache; @@ -61,6 +61,8 @@ pub struct DaemonState { pub(in crate::daemon) sender_metadata_cache: SenderMetadataCache, // Readers load one immutable snapshot while filesystem refresh swaps the complete index pub(crate) desktop_identity_index: Arc>, + // The refresh worker owns watcher replacement and atomic index publication + pub(in crate::daemon::state) desktop_index_refresh: OnceLock, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, // Normal startup supplies None; private-bus protocol tests can inject one unique owner @@ -111,6 +113,7 @@ impl DaemonState { notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), sender_metadata_cache: SenderMetadataCache::new(), desktop_identity_index, + desktop_index_refresh: OnceLock::new(), trial_mode, preauthorized_control_owner, }) @@ -119,4 +122,14 @@ impl DaemonState { pub(crate) const fn connection(&self) -> &Connection { &self.connection } + + pub(crate) fn set_desktop_index_refresh(&self, handle: DesktopIndexRefreshHandle) { + let _ = self.desktop_index_refresh.set(handle); + } + + pub(crate) fn request_desktop_index_refresh(&self) -> bool { + self.desktop_index_refresh + .get() + .is_some_and(DesktopIndexRefreshHandle::request_manual) + } } diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index cbb745e69..d1a017c90 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -44,11 +44,12 @@ pub(super) async fn run_daemon( desktop_identity_index, preauthorized_control_owner, ); - if let Err(error) = spawn_desktop_index_refresh( + match spawn_desktop_index_refresh( state.desktop_identity_index.clone(), watched_desktop_directories, ) { - warn!(?error, "desktop application refresh watcher is unavailable"); + Ok(handle) => state.set_desktop_index_refresh(handle), + Err(error) => warn!(?error, "desktop application refresh watcher is unavailable"), } let scheduler = ExpirationScheduler::start(state.clone()); state.set_scheduler(scheduler.clone()); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 60b830eda..337ed0edd 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -88,7 +88,7 @@ pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeade trailing.add_css_class("unixnotis-popup-trailing"); trailing.set_halign(Align::End); trailing.set_valign(Align::Start); - trailing.set_margin_end(24); + trailing.set_margin_end(30); let time = gtk::Label::new(Some(&view.timestamp_label)); time.set_single_line_mode(true); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 9ddc3f92f..38cbf42e2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -86,7 +86,7 @@ fn close_button_and_identity_header_keep_their_interaction_contracts() { Some("Dismiss notification") ); assert!(header.identity.hexpands()); - assert_eq!(header.trailing.margin_end(), 24); + assert_eq!(header.trailing.margin_end(), 30); assert_eq!(header.trailing.orientation(), gtk::Orientation::Vertical); assert!(header .trailing From d6739668aea79b2f6b6b7a44337f033978ae0507 Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 1 Aug 2026 23:50:03 -0500 Subject: [PATCH 197/275] feat(ui): materialize and render bounded conversation avatars Summary: materialize and render bounded conversation avatars. Scope: ui. --- .../src/ui/icons/decode/tests/svg.rs | 17 +- .../src/ui/icons/resolution.rs | 25 +- .../unixnotis-center/src/ui/icons/resolver.rs | 4 + crates/unixnotis-center/src/ui/icons/theme.rs | 6 +- .../row/notification/update/row.rs | 15 +- .../notification/update/tests/thumbnail.rs | 21 +- .../row/notification/update/thumbnail.rs | 8 + crates/unixnotis-core/assets/popup.css | 4 + .../unixnotis-core/src/model/image/hints.rs | 2 + crates/unixnotis-core/src/model/image/mod.rs | 2 +- .../unixnotis-core/src/model/image/model.rs | 14 + .../src/model/image/projection.rs | 2 + .../src/model/image/tests/projection.rs | 4 + crates/unixnotis-core/src/model/mod.rs | 2 +- .../src/model/tests/notification.rs | 2 + .../identity/desktop_index/model.rs | 10 + .../identity/desktop_index/record.rs | 16 + .../identity/desktop_index/tests/parsing.rs | 16 + .../src/daemon/notifications/identity/mod.rs | 4 +- .../identity/resolver/resolution.rs | 10 +- .../identity/resolver/tests/resolution.rs | 22 ++ .../daemon/notifications/identity/sender.rs | 177 ++++++----- .../notifications/identity/tests/sender.rs | 88 ++++++ .../identity/tests/sender_cache.rs | 5 +- .../daemon/notifications/ingress/payload.rs | 256 +++++++++++++++- .../notifications/ingress/tests/payload.rs | 287 +++++++++++++++++- .../src/daemon/notifications/server/avatar.rs | 47 +++ .../src/daemon/notifications/server/flow.rs | 43 ++- .../src/daemon/notifications/server/mod.rs | 1 + .../notifications/server/tests/avatar.rs | 48 +++ .../src/ui/entry/builders/common.rs | 11 +- .../src/ui/entry/builders/tests/common.rs | 38 +++ crates/unixnotis-popups/src/ui/icon_state.rs | 22 +- .../unixnotis-popups/src/ui/icons/content.rs | 5 +- crates/unixnotis-popups/src/ui/icons/mod.rs | 2 +- 35 files changed, 1114 insertions(+), 122 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index cbdec78d4..a7a9671eb 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -8,16 +8,11 @@ use super::super::model::RasterImage; use super::super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; use super::super::svg::{ checked_rgba_len, decode_svg_bytes_with_renderer, decompress_svgz_with_limit, is_gzip_payload, + resolve_svg_renderer, }; fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { - let current_exe = - std::env::current_exe().map_err(|error| format!("resolve test executable: {error}"))?; - let renderer = current_exe - .parent() - .and_then(std::path::Path::parent) - .map(|directory| directory.join("unixnotis-svg-renderer")) - .ok_or_else(|| "resolve renderer directory".to_string())?; + let renderer = resolve_svg_renderer()?; decode_svg_bytes_with_renderer(bytes, target, &renderer) } @@ -180,7 +175,7 @@ fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { let renderer = directory.path().join("bad-renderer"); std::fs::write( &renderer, - "#!/bin/sh\nprintf '\\377\\377\\377\\377\\377\\377\\377\\377'\n", + "#!/bin/sh\n# Consume the complete request before returning malformed dimensions\ndd bs=1 count=8 iflag=fullblock of=/dev/null 2>/dev/null || exit 1\ncat >/dev/null\nprintf '\\377\\377\\377\\377\\377\\377\\377\\377'\n", ) .expect("write renderer fixture"); std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) @@ -198,7 +193,11 @@ fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { fn renderer_deadline_terminates_a_slow_child() { let directory = tempfile::tempdir().expect("create renderer fixture directory"); let renderer = directory.path().join("slow-renderer"); - std::fs::write(&renderer, "#!/bin/sh\nsleep 2\n").expect("write renderer fixture"); + std::fs::write( + &renderer, + "#!/bin/sh\n# Consume the request so the parent can finish writing before the timeout\ncat >/dev/null\nsleep 2\n", + ) + .expect("write renderer fixture"); std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) .expect("make renderer executable"); diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index a990f92f2..2bfdd9ba3 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -11,12 +11,33 @@ use super::cache::{ }; use super::resolver::IconResolverInner; use super::theme::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_source, - IconSource, + collect_icon_candidates, file_path_from_hint, image_data_texture, image_data_texture_for_data, + resolve_icon_source, IconSource, }; use super::types::{IconDecodeRequest, IconResolution}; impl IconResolverInner { + pub(super) fn apply_conversation_avatar( + &self, + image: >k::Image, + notification: &NotificationView, + ) { + // The daemon has already decoded and bounded this sender-provided raster + if matches!( + notification.image.visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ) { + if let Some(texture) = + image_data_texture_for_data(¬ification.image.conversation_avatar) + { + image.set_paintable(Some(&texture)); + image.set_visible(true); + return; + } + } + image.set_visible(false); + } + pub(super) fn apply_badge( &self, image: >k::Image, diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index 5abfb74ad..24860a703 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -68,6 +68,10 @@ impl IconResolver { self.inner.apply_badge(image, notification, size, scale); } + pub fn apply_conversation_avatar(&self, image: >k::Image, notification: &NotificationView) { + self.inner.apply_conversation_avatar(image, notification); + } + pub fn clear_missing_cache(&self) { // Theme reloads must retry names that were previously unavailable self.inner.clear_missing_cache(); diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index 1dab35845..4d24955e4 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -9,7 +9,7 @@ use gio::prelude::FileExt; use gtk::gdk; use gtk::prelude::*; use gtk::{IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::{NotificationImage, NotificationView}; +use unixnotis_core::{ImageData, NotificationImage, NotificationView}; pub(super) enum IconSource { Paintable(IconPaintable), @@ -131,8 +131,10 @@ pub(super) fn image_data_texture(image: &NotificationImage) -> Option Option { // The standard image-data payload for notifications is typically 8 bits per channel // If it's not 8, the byte layout is ambiguous for this path, so reject it if data.bits_per_sample != 8 { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index cc9bedfb1..6907419cf 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -14,7 +14,7 @@ use super::super::state::{IconSignature, NotificationRowWidgets}; use super::actions::{update_actions, visible_action_count}; use super::labels::update_notification_text; use super::metadata::update_metadata_labels; -use super::thumbnail::notification_has_thumbnail; +use super::thumbnail::{notification_has_conversation_avatar, notification_has_thumbnail}; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { @@ -107,8 +107,11 @@ pub(in crate::ui::notifications) fn update_notification_row( row.default_activation.set_target(default_target); let show_identity = !data.collapsed_group_preview && !data.expanded; let has_actions = visible_action_count(notification, data.is_active) > 0; + let has_content_thumbnail = notification_has_thumbnail(notification); + // The daemon has already assigned the visual role after attribution and safe decoding + let has_conversation_avatar = notification_has_conversation_avatar(notification); let has_thumbnail = - data.presentation.show_thumbnail && notification_has_thumbnail(notification); + data.presentation.show_thumbnail && (has_content_thumbnail || has_conversation_avatar); apply_visual_state(row, data, notification, has_actions, has_thumbnail); update_notification_text( @@ -173,8 +176,12 @@ pub(in crate::ui::notifications) fn update_notification_row( set_widget_visible_if_changed(&row.close_button, true); if has_thumbnail { // Reapply visible thumbnails so config reloads cannot leave stale previews - let scale = row.card.scale_factor(); - icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); + if has_conversation_avatar && !has_content_thumbnail { + icon_resolver.apply_conversation_avatar(&row.thumbnail, notification); + } else { + let scale = row.card.scale_factor(); + icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); + } } set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); set_widget_visible_if_changed(&row.card_plate, true); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index 51e6fdcd5..350d5f0e1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -3,13 +3,14 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::hooks; +use unixnotis_core::{hooks, ImageData}; use crate::ui::icons::IconResolver; use super::super::super::test_support::{ notification_row, row_data, sample_notification, RowFlags, }; +use super::super::thumbnail::notification_has_conversation_avatar; use super::{notification_has_thumbnail, update_notification_row}; #[test] @@ -21,6 +22,24 @@ fn notification_thumbnail_only_uses_real_image_sources() { assert!(notification_has_thumbnail(¬ification)); } +#[test] +fn conversation_avatar_is_a_separate_thumbnail_source() { + let mut notification = sample_notification(); + notification.image.visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.conversation_avatar = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + + assert!(notification_has_conversation_avatar(¬ification)); + assert!(!notification_has_thumbnail(¬ification)); +} + #[gtk::test] fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index 9db5e4432..f010ee535 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -9,3 +9,11 @@ pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> boo .thumbnail == ThumbnailKind::Content } + +pub(super) const fn notification_has_conversation_avatar(notification: &NotificationView) -> bool { + // Avatars are presentation-only raster data and never count as message content + matches!( + notification.image.visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ) +} diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 1900ba87e..d394e9709 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -134,6 +134,10 @@ color: inherit; } +.unixnotis-popup-conversation-avatar { + border-radius: 8px; +} + .unixnotis-identity-avatar { min-width: 34px; min-height: 34px; diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 3046be668..232d589c3 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -43,6 +43,8 @@ impl NotificationImage { Self { has_image_data: image_data.is_some(), image_data: image_data.unwrap_or_default(), + visual_role: super::NotificationVisualRole::None, + conversation_avatar: ImageData::default(), image_path, icon_name, } diff --git a/crates/unixnotis-core/src/model/image/mod.rs b/crates/unixnotis-core/src/model/image/mod.rs index 4076853a7..e18675a85 100644 --- a/crates/unixnotis-core/src/model/image/mod.rs +++ b/crates/unixnotis-core/src/model/image/mod.rs @@ -6,7 +6,7 @@ mod normalize; mod projection; mod rgb; -pub use model::{ImageData, NotificationImage}; +pub use model::{ImageData, NotificationImage, NotificationVisualRole}; pub(super) use model::{ MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, MAX_IMAGE_PATH_BYTES, }; diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index dec13586c..253c8a690 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -5,6 +5,7 @@ //! RGB expansion live in focused files under `model/image` use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; /// Raw image data payload from notification hints @@ -19,11 +20,24 @@ pub struct ImageData { pub data: Vec, } +/// Presentation role selected by the daemon after attribution and payload checks +#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum NotificationVisualRole { + #[default] + None = 0, + ConversationAvatar = 1, +} + /// Image information derived from standard hints and `app_icon` #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct NotificationImage { pub has_image_data: bool, pub image_data: ImageData, + // Conversation avatars are decoded before leaving the daemon + pub visual_role: NotificationVisualRole, + pub conversation_avatar: ImageData, + // image_path remains reserved for message content media pub image_path: String, pub icon_name: String, } diff --git a/crates/unixnotis-core/src/model/image/projection.rs b/crates/unixnotis-core/src/model/image/projection.rs index 80bc09f71..06ca65e87 100644 --- a/crates/unixnotis-core/src/model/image/projection.rs +++ b/crates/unixnotis-core/src/model/image/projection.rs @@ -11,6 +11,8 @@ impl NotificationImage { Self { has_image_data: false, image_data: ImageData::default(), + visual_role: self.visual_role, + conversation_avatar: self.conversation_avatar.clone(), image_path: self.image_path.clone(), icon_name: self.icon_name.clone(), } diff --git a/crates/unixnotis-core/src/model/image/tests/projection.rs b/crates/unixnotis-core/src/model/image/tests/projection.rs index aeee0bc7b..70d29f5bb 100644 --- a/crates/unixnotis-core/src/model/image/tests/projection.rs +++ b/crates/unixnotis-core/src/model/image/tests/projection.rs @@ -13,6 +13,8 @@ fn listing_projection_removes_raw_image_bytes_but_keeps_identifiers() { channels: 4, data: vec![9, 8, 7, 6], }, + visual_role: super::super::NotificationVisualRole::None, + conversation_avatar: ImageData::default(), image_path: "/tmp/icon.png".to_string(), icon_name: "icon-name".to_string(), }; @@ -38,6 +40,8 @@ fn history_projection_drops_raw_data_only_when_alternate_identifier_exists() { channels: 4, data: vec![1, 2, 3, 4], }, + visual_role: super::super::NotificationVisualRole::None, + conversation_avatar: ImageData::default(), image_path: String::new(), icon_name: "app-icon".to_string(), }; diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index cec489ecc..54d1c202f 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -17,7 +17,7 @@ pub use diagnostics::{ AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, RecordTrust, }; -pub use image::{ImageData, NotificationImage}; +pub use image::{ImageData, NotificationImage, NotificationVisualRole}; pub use interaction::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; pub use notification::{Notification, NotificationKey, NotificationView}; pub use reply::InlineReply; diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index f675c7a17..3b9151366 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -68,6 +68,8 @@ fn image_with_raw_bytes() -> NotificationImage { channels: 4, data: vec![1, 2, 3, 4], }, + visual_role: crate::NotificationVisualRole::None, + conversation_avatar: ImageData::default(), image_path: "/tmp/icon.png".to_string(), icon_name: "mail".to_string(), } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index 5c8589471..f1b395f1a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -154,11 +154,21 @@ pub struct DesktopIdentityIndex { pub(super) by_identity: HashMap<(u64, u64), Vec>, pub(super) by_name: HashMap>, pub(super) system_brand_names: HashSet, + pub(super) communication_desktop_ids: HashSet, pub(in crate::daemon::notifications::identity) trusted_relays: Vec, pub(in crate::daemon::notifications::identity) trusted_portals: Vec, pub(super) package_ownership: Arc, } +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications) fn desktop_id_has_communication_role( + &self, + desktop_id: &str, + ) -> bool { + self.communication_desktop_ids.contains(desktop_id) && self.by_id.contains_key(desktop_id) + } +} + #[derive(Debug, Clone)] pub(in crate::daemon::notifications::identity) struct ExecutableIdentity { pub(in crate::daemon::notifications::identity) path: PathBuf, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs index 0dd4bdf67..322cada44 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -27,6 +27,9 @@ impl DesktopIdentityIndex { return; } let display_name = desktop.display_name().to_string(); + if desktop_categories_are_communication(&desktop) { + self.communication_desktop_ids.insert(id.clone()); + } // Wrapper normalization finds the application executable instead of indexing env itself let parsed_launch = build_launch_spec(&desktop, path); let declared_executable_path = parsed_launch @@ -152,6 +155,19 @@ impl DesktopIdentityIndex { } } +fn desktop_categories_are_communication(desktop: &gio::DesktopAppInfo) -> bool { + desktop + .string("Categories") + .is_some_and(|categories| categories.split(';').any(is_communication_category)) +} + +fn is_communication_category(category: &str) -> bool { + matches!( + category.to_ascii_lowercase().as_str(), + "chat" | "instantmessaging" | "email" | "telephony" + ) +} + fn discard_untrusted_launcher_binding(record: &mut DesktopRecord) { let Some(spec) = record.launch_spec.as_mut() else { return; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index b920fbf55..d88e36345 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -88,3 +88,19 @@ fn generic_name_is_an_association_alias_but_not_a_protected_brand() { assert!(index.claim_matches_system_app("Example Browser")); assert!(!index.claim_matches_system_app("Web Browser")); } + +#[test] +fn desktop_categories_mark_conversation_capable_applications() { + let root = TempRoot::new("desktop-communication-category"); + let path = root.join("org.example.Messages.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Messages\nCategories=Network;InstantMessaging;\nExec=/usr/bin/true\n", + ) + .expect("desktop entry with communication category"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + + assert!(index.desktop_id_has_communication_role("org.example.messages")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 5da97d671..55d493abb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -13,6 +13,8 @@ pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; pub(in crate::daemon) use resolver::resolve_attribution_owned; pub(in crate::daemon::notifications) use resolver::resolve_attribution_with_deadline; -pub(in crate::daemon) use sender::resolve_sender_metadata; pub(super) use sender::SenderMetadata; +pub(in crate::daemon) use sender::{ + resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, +}; pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index 0f6a0ed1b..020d5be13 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -10,7 +10,7 @@ use super::super::desktop_index::{ VerifiedLaunch, }; use super::super::policy::inline_reply_policy; -use super::super::sender::SenderMetadata; +use super::super::sender::{SenderMetadata, SenderMetadataStatus}; use super::diagnostics::{launch_failure_label, with_diagnostics}; use super::model::VerifiedDesktopRecord; use super::{AppClaim, AttributionResolution}; @@ -20,6 +20,14 @@ pub(in crate::daemon) fn unknown_reply_denied( sender: &SenderMetadata, reason: &str, ) -> AttributionResolution { + let reason = match sender.status { + SenderMetadataStatus::CredentialLookupTimedOut => { + "sender metadata: credential lookup timed out" + } + SenderMetadataStatus::CredentialLookupFailed => "sender metadata: credential lookup failed", + SenderMetadataStatus::MissingSenderName => "sender metadata: sender name missing", + SenderMetadataStatus::Complete | SenderMetadataStatus::ProcessEvidenceUnavailable => reason, + }; let detail = sender.sender_executable.as_deref().map_or_else( || reason.to_string(), |path| format!("{reason}; source {path}"), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs index 3348b42d4..bbd67e3ef 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -4,6 +4,7 @@ use super::super::resolution::{ resolution_for_record, sender_claim_group_key, unknown_reply_denied, }; use super::*; +use crate::daemon::notifications::identity::sender::SenderMetadataStatus; use unixnotis_core::ApplicationActionPolicy; #[test] @@ -101,3 +102,24 @@ fn missing_sender_reply_resolution_is_unresolved_and_noninteractive() { .diagnostic_detail .contains("sender metadata unavailable")); } + +#[test] +fn sender_credential_timeout_is_preserved_in_diagnostics() { + let metadata = SenderMetadata { + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + }; + let resolution = unknown_reply_denied( + AppClaim { + reported_name: "Signal", + desktop_entry: None, + }, + &metadata, + "sender metadata unavailable", + ); + + assert!(resolution + .attribution + .diagnostic_detail + .contains("credential lookup timed out")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index c7827944f..cd610a244 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -4,6 +4,7 @@ //! notification delivery use std::fs::File; +use std::future::Future; use std::io::Read; use zbus::fdo::DBusProxy; @@ -17,6 +18,8 @@ use crate::daemon::notifications::identity::desktop_index::InstallProvenance; const MAX_PROCESS_CMDLINE_BYTES: u64 = 128 * 1024; const MAX_PROCESS_ARGUMENTS: usize = 256; const MAX_PROCESS_ANCESTORS: usize = 8; +pub(in crate::daemon) const SENDER_CREDENTIAL_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(500); #[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] pub(in crate::daemon::notifications) enum CommandLineQuality { @@ -33,6 +36,16 @@ pub(in crate::daemon::notifications) struct CommandLineEvidence { pub(in crate::daemon::notifications) quality: CommandLineQuality, } +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon) enum SenderMetadataStatus { + Complete, + MissingSenderName, + CredentialLookupFailed, + CredentialLookupTimedOut, + #[default] + ProcessEvidenceUnavailable, +} + /// Stable executable evidence for one same-user process ancestor #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::daemon::notifications) struct ProcessLineageEvidence { @@ -63,6 +76,38 @@ pub(in crate::daemon) struct SenderMetadata { pub(in crate::daemon::notifications) command_line: CommandLineEvidence, // Ancestors remain supporting evidence and never grant actions by themselves pub(in crate::daemon::notifications) ancestors: Vec, + // The stage that failed remains visible to diagnostics instead of becoming generic unknown + pub(in crate::daemon::notifications) status: SenderMetadataStatus, +} + +fn metadata_with_status( + sender_name: Option, + status: SenderMetadataStatus, +) -> SenderMetadata { + SenderMetadata { + sender_name, + status, + ..SenderMetadata::default() + } +} + +fn metadata_from_credentials( + sender_name: Option, + process_id: Option, + user_id: Option, +) -> SenderMetadata { + let status = if user_id.is_some() && process_id.is_some() { + SenderMetadataStatus::ProcessEvidenceUnavailable + } else { + SenderMetadataStatus::CredentialLookupFailed + }; + SenderMetadata { + sender_name, + sender_pid: process_id, + sender_uid: user_id, + status, + ..SenderMetadata::default() + } } pub(in crate::daemon) async fn resolve_sender_metadata( @@ -73,17 +118,7 @@ pub(in crate::daemon) async fn resolve_sender_metadata( // Sender lookup failures are non-fatal and should degrade to "unknown" let sender_name = header.sender().map(|sender| sender.as_str().to_string()); let Some(sender_name_str) = sender_name.as_deref() else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_uid: None, - sender_executable: None, - sender_executable_identity: None, - install_provenance: InstallProvenance::Unknown, - command_line: CommandLineEvidence::default(), - ancestors: Vec::new(), - }; + return metadata_with_status(sender_name, SenderMetadataStatus::MissingSenderName); }; // Unique names are stable for one bus connection and safe cache identities @@ -93,82 +128,62 @@ pub(in crate::daemon) async fn resolve_sender_metadata( let cache_key = sender_name_str.to_string(); let Ok(bus_name) = zbus::names::BusName::try_from(sender_name_str) else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_uid: None, - sender_executable: None, - sender_executable_identity: None, - install_provenance: InstallProvenance::Unknown, - command_line: CommandLineEvidence::default(), - ancestors: Vec::new(), - }; + return metadata_with_status(sender_name, SenderMetadataStatus::CredentialLookupFailed); }; let Ok(proxy) = DBusProxy::new(connection).await else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_uid: None, - sender_executable: None, - sender_executable_identity: None, - install_provenance: InstallProvenance::Unknown, - command_line: CommandLineEvidence::default(), - ancestors: Vec::new(), - }; + return metadata_with_status(sender_name, SenderMetadataStatus::CredentialLookupFailed); }; - // PID and executable come from the bus owner, not caller-provided payload fields - let connection_user_id = proxy.get_connection_unix_user(bus_name.clone()).await.ok(); - let connection_process_id = proxy.get_connection_unix_process_id(bus_name).await.ok(); - let (sender_start_time, process_evidence) = connection_process_id.map_or((None, None), |pid| { - let start_before = read_process_start_time(pid); - let executable = executable_evidence_for_pid(pid); - let command_line = read_process_cmdline(pid, executable.as_ref()); - let evidence = (executable, command_line); - let start_after = read_process_start_time(pid); - stable_process_evidence(start_before, Some(evidence), start_after) - }); - let (executable_evidence, command_line) = - process_evidence.unwrap_or_else(|| (None, CommandLineEvidence::default())); - let sender_executable = executable_evidence - .as_ref() - .map(|evidence| evidence.canonical_path.display().to_string()); - let sender_executable_identity = executable_evidence.map(|evidence| evidence.identity); - let stable_uid = connection_process_id - .zip(connection_user_id) - .and_then(|(pid, uid)| (read_process_real_uid(pid) == Some(uid)).then_some(uid)); - let ancestors = connection_process_id - .zip(sender_start_time) - .zip(stable_uid) - .map_or_else(Vec::new, |((pid, _start_time), uid)| { - collect_process_lineage(pid, uid) - }); - - let metadata = SenderMetadata { - sender_name, - sender_pid: connection_process_id, - sender_start_time, - sender_uid: stable_uid, - sender_executable, - sender_executable_identity, - install_provenance: InstallProvenance::Unknown, - command_line, - ancestors, - }; - // Failed lookups remain retryable instead of becoming persistent unknown identities - if metadata.sender_start_time.is_some() && metadata.sender_executable_identity.is_some() { + // Credentials are the only asynchronous pre-attribution work + let (connection_user_id, connection_process_id) = resolve_connection_credentials( + proxy.get_connection_unix_user(bus_name.clone()), + proxy.get_connection_unix_process_id(bus_name), + ) + .await; + let metadata = + metadata_from_credentials(sender_name, connection_process_id, connection_user_id); + // Credentials remain cached while process evidence is refreshed inside the worker + if metadata.sender_pid.is_some() && metadata.sender_uid.is_some() { cache.insert(cache_key, metadata.clone()); } metadata } +async fn resolve_connection_credentials( + user_id: U, + process_id: P, +) -> (Option, Option) +where + U: Future>, + P: Future>, +{ + let (user_id, process_id) = tokio::join!(user_id, process_id); + (user_id.ok(), process_id.ok()) +} + pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> SenderMetadata { let mut refreshed = metadata.clone(); - let (Some(pid), Some(expected_start)) = (metadata.sender_pid, metadata.sender_start_time) - else { + let Some(pid) = metadata.sender_pid else { + return refreshed; + }; + if metadata.sender_uid.is_none() + && matches!( + metadata.status, + SenderMetadataStatus::CredentialLookupFailed + | SenderMetadataStatus::CredentialLookupTimedOut + | SenderMetadataStatus::MissingSenderName + ) + { + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; + return refreshed; + } + // Fresh credential metadata has no expected lifetime yet; capture it in this worker + let expected_start = metadata + .sender_start_time + .or_else(|| read_process_start_time(pid)); + let Some(expected_start) = expected_start else { + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; return refreshed; }; @@ -185,6 +200,7 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen refreshed.sender_executable_identity = None; refreshed.command_line = CommandLineEvidence::default(); refreshed.ancestors.clear(); + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; return refreshed; } @@ -198,6 +214,7 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen refreshed.sender_executable_identity = None; refreshed.command_line = CommandLineEvidence::default(); refreshed.ancestors.clear(); + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; return refreshed; } @@ -209,6 +226,12 @@ pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> Sen refreshed.ancestors = metadata .sender_uid .map_or_else(Vec::new, |uid| collect_process_lineage(pid, uid)); + refreshed.sender_start_time = Some(expected_start); + refreshed.status = if refreshed.sender_executable_identity.is_some() { + SenderMetadataStatus::Complete + } else { + SenderMetadataStatus::ProcessEvidenceUnavailable + }; refreshed } @@ -370,6 +393,10 @@ fn classify_command_line( } } +#[cfg_attr( + not(test), + expect(dead_code, reason = "kept as a focused process-lifetime test seam") +)] fn stable_process_evidence( start_before: Option, evidence: Option, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 7989bed71..bd1af302c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -1,5 +1,70 @@ use super::*; +#[tokio::test] +async fn credential_reads_run_concurrently_within_the_supported_deadline() { + let started = std::time::Instant::now(); + let (uid, pid) = resolve_connection_credentials( + async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok::(1_000) + }, + async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok::(42) + }, + ) + .await; + + assert!(started.elapsed() < std::time::Duration::from_millis(350)); + assert_eq!((uid, pid), (Some(1_000), Some(42))); +} + +#[tokio::test] +async fn failed_credential_read_is_returned_without_process_evidence() { + let (uid, pid) = resolve_connection_credentials( + async { Err::(zbus::Error::Failure("uid unavailable".into())) }, + async { Ok::(42) }, + ) + .await; + + assert_eq!(uid, None); + assert_eq!(pid, Some(42)); +} + +#[test] +fn credential_metadata_keeps_sender_identity_and_failure_stage() { + let metadata = metadata_from_credentials(Some(":1.42".to_string()), Some(42), Some(1_000)); + + assert_eq!(metadata.sender_name.as_deref(), Some(":1.42")); + assert_eq!(metadata.sender_pid, Some(42)); + assert_eq!(metadata.sender_uid, Some(1_000)); + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); + assert_eq!( + metadata.status, + SenderMetadataStatus::ProcessEvidenceUnavailable + ); + + let failed = metadata_from_credentials(Some(":1.43".to_string()), Some(43), None); + assert_eq!(failed.status, SenderMetadataStatus::CredentialLookupFailed); + assert_eq!(failed.install_provenance, InstallProvenance::Unknown); +} + +#[test] +fn status_metadata_preserves_sender_name_and_failure_status() { + let metadata = metadata_with_status( + Some(":1.99".to_string()), + SenderMetadataStatus::CredentialLookupTimedOut, + ); + + assert_eq!(metadata.sender_name.as_deref(), Some(":1.99")); + assert_eq!( + metadata.status, + SenderMetadataStatus::CredentialLookupTimedOut + ); + assert!(metadata.sender_pid.is_none()); + assert!(metadata.sender_uid.is_none()); +} + #[cfg(target_os = "linux")] #[test] fn parse_process_start_time_handles_spaces_in_comm() { @@ -125,6 +190,29 @@ fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { assert!(refreshed.command_line.argv.is_empty()); } +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_rejects_a_sender_uid_that_changed() { + let pid = std::process::id(); + let start_time = read_process_start_time(pid).expect("current process start time"); + let uid = read_process_real_uid(pid).expect("current process uid"); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(start_time), + sender_uid: Some(uid.wrapping_add(1)), + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert_eq!( + refreshed.status, + SenderMetadataStatus::ProcessEvidenceUnavailable + ); + assert!(refreshed.sender_executable_identity.is_none()); + assert!(refreshed.command_line.argv.is_empty()); +} + #[test] fn rewritten_process_title_is_kept_as_unstructured_evidence() { let executable = super::super::executable::ExecutableEvidence { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index 386de1bf8..66ec1fc93 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -1,5 +1,7 @@ use super::{SenderMetadataCache, MAX_CACHED_SENDERS}; -use crate::daemon::notifications::identity::sender::{CommandLineEvidence, SenderMetadata}; +use crate::daemon::notifications::identity::sender::{ + CommandLineEvidence, SenderMetadata, SenderMetadataStatus, +}; fn metadata(sender: &str, pid: u32) -> SenderMetadata { SenderMetadata { @@ -13,6 +15,7 @@ fn metadata(sender: &str, pid: u32) -> SenderMetadata { crate::daemon::notifications::identity::desktop_index::InstallProvenance::default(), command_line: CommandLineEvidence::default(), ancestors: Vec::new(), + status: SenderMetadataStatus::Complete, } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index 4daa8432b..836f999a1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -4,15 +4,21 @@ use std::cmp::Ordering; use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use rustix::fs::{openat2, Mode, OFlags, ResolveFlags, CWD}; use unixnotis_core::{ - util, Action, AttributionDiagnostics, Config, ImageData, InlineReply, InlineReplyPolicy, - Notification, NotificationAttribution, NotificationImage, Urgency, + decode_image_asset_contents, util, Action, AssetPolicy, AttributionDiagnostics, Config, + IdentityAssurance, ImageData, InlineReply, InlineReplyPolicy, Notification, + NotificationAttribution, NotificationImage, NotificationVisualRole, Urgency, + DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, + DEFAULT_ICON_ASSET_MAX_WIDTH, }; use zbus::zvariant::{OwnedValue, Value}; -use super::super::identity::SenderMetadata; +use super::super::identity::{DesktopIdentityIndex, SenderMetadata}; use super::limits::{ MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, @@ -27,6 +33,7 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) actions: Vec, pub(in crate::daemon::notifications) hints: HashMap, pub(in crate::daemon::notifications) image_data: Option, + pub(in crate::daemon::notifications) conversation_avatar: Option, pub(in crate::daemon::notifications) sender: SenderMetadata, pub(in crate::daemon::notifications) attribution: NotificationAttribution, pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, @@ -45,6 +52,7 @@ pub(in crate::daemon::notifications) fn build_notification( actions, hints, image_data, + conversation_avatar, sender, attribution, attribution_diagnostics, @@ -78,6 +86,14 @@ pub(in crate::daemon::notifications) fn build_notification( image.has_image_data = true; image.image_data = image_data; } + // Only positive application association may expose a decoded sender avatar + if may_materialize_host_avatar(&attribution) { + if let Some(avatar) = conversation_avatar { + // The avatar is already decoded and bounded before this model is stored + image.visual_role = NotificationVisualRole::ConversationAvatar; + image.conversation_avatar = avatar; + } + } // Only verified senders may name host files for decoding. Untrusted, // conflicting, relay, and portal-associated senders are stripped of @@ -139,6 +155,240 @@ pub(in crate::daemon::notifications) fn build_notification( } } +// Keep sender-provided avatar work separate from the normal application badge path +const MAX_CONVERSATION_AVATAR_BYTES: u64 = 2_097_152; +const MAX_CONVERSATION_AVATAR_DIMENSION: u32 = 256; +const MAX_STORED_AVATAR_DIMENSION: u32 = 64; +const MAX_STORED_AVATAR_PIXELS: usize = + (MAX_STORED_AVATAR_DIMENSION as usize) * (MAX_STORED_AVATAR_DIMENSION as usize); +pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration = + Duration::from_millis(500); + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum SenderVisualRole { + None, + ConversationAvatar, +} + +pub(in crate::daemon::notifications) const fn may_materialize_host_avatar( + attribution: &NotificationAttribution, +) -> bool { + matches!( + attribution.assurance, + IdentityAssurance::Authenticated + | IdentityAssurance::SystemAssociated + | IdentityAssurance::UserAssociated + ) +} + +pub(in crate::daemon::notifications) fn sender_visual_role( + attribution: &NotificationAttribution, + index: &DesktopIdentityIndex, + hints: &HashMap, + actions: &[String], +) -> SenderVisualRole { + if !may_materialize_host_avatar(attribution) { + return SenderVisualRole::None; + } + // Inline reply is a stronger communication signal than a caller label + if actions + .chunks_exact(2) + .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) + { + return SenderVisualRole::ConversationAvatar; + } + // Categories are protocol metadata and remain only a presentation hint + let explicit_metadata = + hints + .get("category") + .and_then(owned_to_string) + .is_some_and(|category| { + let category = category.to_ascii_lowercase(); + ["im", "chat", "message", "email", "mail"] + .iter() + .any(|marker| category.split('.').any(|part| part == *marker)) + }); + // The index rejects empty and unknown IDs, so no separate string check is needed here + let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); + if explicit_metadata || desktop_metadata { + SenderVisualRole::ConversationAvatar + } else { + SenderVisualRole::None + } +} + +pub(in crate::daemon::notifications) fn materialize_conversation_avatar( + app_icon: &str, +) -> Option { + // Decode while the daemon still controls the file read and parser limits + let path = local_avatar_path(app_icon)?; + // Nonblocking and no-follow flags prevent special files and final-component symlinks from + // turning the bounded worker into a blocking host-file reader + let descriptor = openat2( + CWD, + &path, + OFlags::RDONLY + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + ResolveFlags::NO_MAGICLINKS, + ) + .ok()?; + let mut file = std::fs::File::from(descriptor); + let metadata = file.metadata().ok()?; + if !metadata.is_file() { + return None; + } + if !avatar_file_size_allowed(metadata.len()) { + return None; + } + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_CONVERSATION_AVATAR_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + if !avatar_buffer_size_allowed(bytes.len()) { + return None; + } + // The small policy keeps contact art from becoming an unbounded texture + let policy = AssetPolicy { + max_bytes: MAX_CONVERSATION_AVATAR_BYTES, + max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(MAX_CONVERSATION_AVATAR_DIMENSION), + max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(MAX_CONVERSATION_AVATAR_DIMENSION), + max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS.min(65_536), + allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, + }; + let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; + let (width, height, rgba) = downsample_avatar(decoded.width, decoded.height, decoded.rgba)?; + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let rowstride = width.checked_mul(4)?; + let expected = usize::try_from(rowstride) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + if rgba.len() != expected { + return None; + } + Some(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) +} + +fn downsample_avatar(width: u32, height: u32, rgba: Vec) -> Option<(u32, u32, Vec)> { + if width == 0 || height == 0 { + return None; + } + let source_pixels = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + let source_bytes = source_pixels.checked_mul(4)?; + if rgba.len() != source_bytes { + return None; + } + + let (target_width, target_height) = if width >= height { + ( + MAX_STORED_AVATAR_DIMENSION.min(width), + width_to_height(width, height, MAX_STORED_AVATAR_DIMENSION), + ) + } else { + ( + height_to_width(width, height, MAX_STORED_AVATAR_DIMENSION), + MAX_STORED_AVATAR_DIMENSION.min(height), + ) + }; + let target_pixels = usize::try_from(target_width) + .ok()? + .checked_mul(usize::try_from(target_height).ok()?)?; + if target_pixels > MAX_STORED_AVATAR_PIXELS { + return None; + } + if target_width == width && target_height == height { + return Some((width, height, rgba)); + } + + let mut output = vec![0u8; target_pixels.checked_mul(4)?]; + for target_y in 0..target_height { + let source_y = u32::try_from( + usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(height).ok()?)? + / usize::try_from(target_height).ok()?, + ) + .ok()?; + for target_x in 0..target_width { + let source_x = u32::try_from( + usize::try_from(target_x) + .ok()? + .checked_mul(usize::try_from(width).ok()?)? + / usize::try_from(target_width).ok()?, + ) + .ok()?; + let source_index = usize::try_from(source_y) + .ok()? + .checked_mul(usize::try_from(width).ok()?)? + .checked_add(usize::try_from(source_x).ok()?)? + .checked_mul(4)?; + let target_index = usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(target_width).ok()?)? + .checked_add(usize::try_from(target_x).ok()?)? + .checked_mul(4)?; + output[target_index..target_index + 4] + .copy_from_slice(&rgba[source_index..source_index + 4]); + } + } + Some((target_width, target_height, output)) +} + +fn width_to_height(width: u32, height: u32, target_width: u32) -> u32 { + if width <= target_width { + return height; + } + u64::from(height) + .saturating_mul(u64::from(target_width)) + .checked_div(u64::from(width)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +fn height_to_width(width: u32, height: u32, target_height: u32) -> u32 { + if height <= target_height { + return width; + } + u64::from(width) + .saturating_mul(u64::from(target_height)) + .checked_div(u64::from(height)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +const fn avatar_file_size_allowed(size: u64) -> bool { + size <= MAX_CONVERSATION_AVATAR_BYTES +} + +const fn avatar_buffer_size_allowed(size: usize) -> bool { + size <= MAX_CONVERSATION_AVATAR_BYTES as usize +} + +fn local_avatar_path(value: &str) -> Option { + if value.starts_with('/') { + return Some(PathBuf::from(value)); + } + let path = value.strip_prefix("file://")?; + let path = path.strip_prefix("localhost/").unwrap_or(path); + path.starts_with('/').then(|| Path::new(path).to_path_buf()) +} + pub(in crate::daemon::notifications) fn resolve_expiration( config: &Config, notification: &Notification, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index 63befff72..a532e8131 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -1,14 +1,18 @@ use std::collections::HashMap; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use zbus::zvariant::OwnedValue; use super::{ - build_notification, owned_to_string, parse_actions, parse_urgency_hint, resolve_expiration, - sanitize_hints_for_storage, string_to_owned_value, NotificationInput, SenderMetadata, - MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES, + avatar_buffer_size_allowed, avatar_file_size_allowed, build_notification, + materialize_conversation_avatar, may_materialize_host_avatar, owned_to_string, parse_actions, + parse_urgency_hint, resolve_expiration, sanitize_hints_for_storage, sender_visual_role, + string_to_owned_value, NotificationInput, SenderMetadata, SenderVisualRole, MAX_ACTIONS, + MAX_BODY_BYTES, MAX_CONVERSATION_AVATAR_BYTES, MAX_SUMMARY_BYTES, +}; +use unixnotis_core::{ + AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationImage, Urgency, }; -use unixnotis_core::{AttributionReason, Config, NotificationImage, Urgency}; #[test] fn build_notification_clamps_summary_and_body_sizes() { @@ -23,6 +27,7 @@ fn build_notification_clamps_summary_and_body_sizes() { actions: Vec::new(), hints: HashMap::::new(), image_data: None, + conversation_avatar: None, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -51,6 +56,7 @@ fn build_notification_strips_display_spoofing_controls() { actions: vec!["default".to_string(), "Open\u{202E}".to_string()], hints: HashMap::::new(), image_data: None, + conversation_avatar: None, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -95,6 +101,7 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints, image_data: None, + conversation_avatar: None, sender: SenderMetadata { sender_executable: Some("/usr/bin/messages".to_string()), ..SenderMetadata::default() @@ -130,6 +137,7 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( actions: vec!["inline-reply".to_string(), "Password".to_string()], hints: HashMap::new(), image_data: None, + conversation_avatar: None, sender: SenderMetadata { sender_name: Some(":1.hostile".to_string()), sender_executable: Some("/usr/bin/unknown-client".to_string()), @@ -170,6 +178,7 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints: HashMap::new(), image_data: None, + conversation_avatar: None, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::unresolved( "Messages", @@ -211,6 +220,7 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { actions: vec!["default".to_string(), "Open".to_string()], hints, image_data: None, + conversation_avatar: None, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::default(), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), @@ -222,6 +232,273 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { assert!(notification.inline_reply.placeholder.is_empty()); } +#[test] +fn conversation_avatar_never_changes_badge_or_unresolved_identity() { + let avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Signal".to_string(), + app_icon: "/tmp/contact.png".to_string(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + conversation_avatar: Some(avatar), + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.attribution.display_name, "Unknown application"); + assert_eq!( + notification.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!( + notification.image.visual_role, + unixnotis_core::NotificationVisualRole::None + ); +} + +#[test] +fn verified_sender_keeps_explicit_message_image_path() { + let mut hints = HashMap::new(); + hints.insert( + "image-path".to_string(), + string_to_owned_value("/tmp/message-image.png").expect("image path"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints, + image_data: None, + conversation_avatar: None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.image.image_path, "/tmp/message-image.png"); +} + +#[test] +fn associated_sender_role_accepts_inline_reply_and_message_categories() { + let attribution = unixnotis_core::NotificationAttribution::recognized( + "Messages", + "Messages", + "org.example.Messages", + "messages", + unixnotis_core::AttributionReason::ExactUserExecutable, + "associated executable", + "recognized:system-app:org.example.Messages:sender".to_string(), + ); + let index = super::super::super::identity::DesktopIdentityIndex::default(); + + assert_eq!( + sender_visual_role( + &attribution, + &index, + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + ), + SenderVisualRole::ConversationAvatar + ); + + let mut hints = HashMap::new(); + hints.insert( + "category".to_string(), + string_to_owned_value("im.received").expect("category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &hints, &[]), + SenderVisualRole::ConversationAvatar + ); + + let mut exact = HashMap::new(); + exact.insert( + "category".to_string(), + string_to_owned_value("im").expect("exact category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &exact, &[]), + SenderVisualRole::ConversationAvatar + ); + + let mut unrelated = HashMap::new(); + unrelated.insert( + "category".to_string(), + string_to_owned_value("other").expect("unrelated category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &unrelated, &[]), + SenderVisualRole::None + ); + assert_eq!( + sender_visual_role(&attribution, &index, &HashMap::new(), &[]), + SenderVisualRole::None + ); +} + +#[test] +fn portal_association_cannot_start_host_avatar_materialization() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Portal app", + "Portal app", + "org.example.PortalApp", + "portal-app", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal supplied app id", + "recognized:portal:org.example.PortalApp".to_string(), + ); + assert!(!may_materialize_host_avatar(&attribution)); + assert_eq!( + sender_visual_role( + &attribution, + &super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + ), + SenderVisualRole::None + ); +} + +#[test] +fn large_avatar_is_downsampled_to_the_storage_bound() { + let source = vec![255_u8; 256 * 128 * 4]; + let (width, height, data) = super::downsample_avatar(256, 128, source).expect("downsample"); + assert_eq!((width, height), (64, 32)); + assert_eq!(data.len(), 64 * 32 * 4); +} + +#[test] +fn avatar_downsampling_rejects_zero_dimensions_and_keeps_exact_size_images() { + assert!(super::downsample_avatar(0, 1, Vec::new()).is_none()); + assert!(super::downsample_avatar(1, 0, Vec::new()).is_none()); + + let source = vec![7_u8; 64 * 64 * 4]; + let source_ptr = source.as_ptr(); + let (width, height, data) = super::downsample_avatar(64, 64, source).expect("exact bound"); + assert_eq!((width, height), (64, 64)); + assert_eq!(data.as_ptr(), source_ptr); +} + +#[test] +fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { + // Keep the source height unchanged after scaling so the early-return guard + // must compare both dimensions rather than accepting one matching value + let mut horizontal = vec![0_u8; 128 * 4]; + for x in 0..128 { + horizontal[x * 4] = u8::try_from(x).expect("horizontal fixture value"); + } + let (width, height, data) = + super::downsample_avatar(128, 1, horizontal).expect("horizontal downsample"); + assert_eq!((width, height), (64, 1)); + assert_eq!(data[4], 2); + + let mut vertical = vec![0_u8; 64 * 128 * 4]; + for y in 0..128 { + vertical[y * 64 * 4] = u8::try_from(y).expect("vertical fixture value"); + } + let (width, height, data) = + super::downsample_avatar(64, 128, vertical).expect("vertical downsample"); + assert_eq!((width, height), (32, 64)); + assert_eq!(data[32 * 4], 2); +} + +#[cfg(target_os = "linux")] +#[test] +fn fifo_avatar_path_is_rejected_without_opening_a_blocking_reader() { + let directory = std::env::temp_dir().join(format!( + "unixnotis-avatar-fifo-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + std::fs::create_dir(&directory).expect("create temporary directory"); + let path = directory.join("avatar.fifo"); + let path_string = path.to_string_lossy().into_owned(); + let status = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo available"); + assert!(status.success()); + assert!(materialize_conversation_avatar(&path_string).is_none()); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(directory); +} + +#[test] +fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { + // This is a tiny 1x1 RGBA PNG used only to exercise the real decoder + let png = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, + 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unixnotis-avatar-{suffix}.png")); + std::fs::write(&path, png).expect("write avatar fixture"); + + let avatar = materialize_conversation_avatar(path.to_str().expect("utf8 fixture path")); + let _ = std::fs::remove_file(&path); + + let avatar = avatar.expect("valid avatar should decode"); + assert_eq!((avatar.width, avatar.height), (1, 1)); + assert_eq!(avatar.channels, 4); + assert_eq!(avatar.data.len(), 4); +} + +#[test] +fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { + assert!(avatar_file_size_allowed(MAX_CONVERSATION_AVATAR_BYTES)); + assert!(!avatar_file_size_allowed(MAX_CONVERSATION_AVATAR_BYTES + 1)); + assert!(avatar_buffer_size_allowed( + MAX_CONVERSATION_AVATAR_BYTES as usize + )); + assert!(!avatar_buffer_size_allowed( + MAX_CONVERSATION_AVATAR_BYTES as usize + 1 + )); +} + +#[test] +fn relative_or_missing_avatar_path_is_rejected() { + assert!(materialize_conversation_avatar("avatar.png").is_none()); + assert!(materialize_conversation_avatar("/path/that/does/not/exist.png").is_none()); +} + #[test] fn parse_actions_caps_pairs() { let mut raw = Vec::new(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs new file mode 100644 index 000000000..1abcd552b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs @@ -0,0 +1,47 @@ +//! Bounded worker support for sender-provided conversation artwork + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Semaphore; + +const AVATAR_WORKER_SLOTS: usize = 4; + +fn avatar_worker_pool() -> Arc { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + Arc::clone(POOL.get_or_init(|| Arc::new(Semaphore::new(AVATAR_WORKER_SLOTS)))) +} + +pub(super) async fn run_avatar_worker(work: F, deadline: Duration) -> Option +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + run_avatar_worker_with_pool(avatar_worker_pool(), work, deadline).await +} + +pub(super) async fn run_avatar_worker_with_pool( + pool: Arc, + work: F, + deadline: Duration, +) -> Option +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + // try_acquire makes overload fail closed instead of queuing unbounded work + let permit = pool.try_acquire_owned().ok()?; + let task = tokio::task::spawn_blocking(move || { + // Keep this permit in the blocking closure so timeout cancellation cannot release it early + let _permit = permit; + work() + }); + tokio::time::timeout(deadline, task) + .await + .ok() + .and_then(Result::ok) +} + +#[cfg(test)] +#[path = "tests/avatar.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index dd33bd1b5..386718308 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,21 +1,23 @@ use std::collections::HashMap; -use std::time::Duration; - use tracing::{debug, warn}; use unixnotis_core::{ImageData, Notification, NotificationKey}; use zbus::message::Header; use zbus::zvariant::OwnedValue; -use crate::daemon::notifications::identity::resolve_sender_metadata; use crate::daemon::notifications::identity::{ resolve_attribution_owned, resolve_attribution_with_deadline, SenderMetadata, }; +use crate::daemon::notifications::identity::{ + resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, +}; use crate::daemon::notifications::ingress::payload::{ - build_notification, owned_to_string, resolve_expiration, NotificationInput, + build_notification, materialize_conversation_avatar, owned_to_string, resolve_expiration, + sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; +use super::avatar::run_avatar_worker; use super::wire_hints::WireHints; use super::NotificationServer; @@ -34,8 +36,6 @@ struct WireNotification { expire_timeout: i32, } -const SENDER_METADATA_TIMEOUT: Duration = Duration::from_millis(100); - impl NotificationServer { #[expect( clippy::too_many_arguments, @@ -115,7 +115,7 @@ impl NotificationServer { ) -> Notification { // Sender metadata helps with ownership checks and diagnostics let sender = if let Ok(sender) = tokio::time::timeout( - SENDER_METADATA_TIMEOUT, + SENDER_CREDENTIAL_TIMEOUT, resolve_sender_metadata( &self.state.sender_metadata_cache, self.state.connection(), @@ -126,8 +126,11 @@ impl NotificationServer { { sender } else { - warn!("notification sender metadata timed out and failed closed"); - SenderMetadata::default() + warn!("notification sender credentials timed out and failed closed"); + SenderMetadata { + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + } }; let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); let desktop_identity_index = self.state.desktop_identity_index.load_full(); @@ -140,10 +143,29 @@ impl NotificationServer { input.app_name.clone(), desktop_entry.clone(), sender.clone(), - desktop_identity_index, + std::sync::Arc::clone(&desktop_identity_index), ), ) .await; + let conversation_avatar = if matches!( + sender_visual_role( + &resolution.attribution, + &desktop_identity_index, + &input.hints, + &input.actions, + ), + SenderVisualRole::ConversationAvatar + ) { + let app_icon = input.app_icon.clone(); + run_avatar_worker( + move || materialize_conversation_avatar(&app_icon), + CONVERSATION_AVATAR_TIMEOUT, + ) + .await + .flatten() + } else { + None + }; if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -178,6 +200,7 @@ impl NotificationServer { actions: input.actions, hints: input.hints, image_data: input.image_data, + conversation_avatar, sender, attribution: resolution.attribution, attribution_diagnostics: resolution.diagnostics, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index 2b793d264..b0ff0684d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -1,5 +1,6 @@ //! Freedesktop notification D-Bus server and request handling +mod avatar; mod capabilities; mod close; mod flow; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs new file mode 100644 index 000000000..e9fe135f3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs @@ -0,0 +1,48 @@ +//! Tests for bounded conversation-avatar work + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; + +use tokio::sync::Semaphore; + +use super::super::avatar::{run_avatar_worker, run_avatar_worker_with_pool}; + +#[tokio::test] +async fn public_avatar_worker_runs_a_completed_job() { + assert_eq!( + run_avatar_worker(|| 7_u8, Duration::from_secs(1)).await, + Some(7) + ); +} + +#[tokio::test] +async fn avatar_worker_capacity_fails_closed_without_queueing() { + let pool = Arc::new(Semaphore::new(1)); + let release = Arc::new(AtomicBool::new(false)); + let held_release = Arc::clone(&release); + + let first = run_avatar_worker_with_pool( + Arc::clone(&pool), + move || { + while !held_release.load(Ordering::Acquire) { + std::thread::yield_now(); + } + 1_u8 + }, + Duration::from_millis(10), + ); + assert_eq!(first.await, None); + + let second = + run_avatar_worker_with_pool(Arc::clone(&pool), || 2_u8, Duration::from_millis(10)).await; + assert_eq!(second, None); + + release.store(true, Ordering::Release); + tokio::time::sleep(Duration::from_millis(20)).await; + + let recovered = run_avatar_worker_with_pool(pool, || 3_u8, Duration::from_millis(100)).await; + assert_eq!(recovered, Some(3)); +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 337ed0edd..a168ee4fd 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -32,8 +32,15 @@ pub(super) fn build_identity_avatar( view: &PopupEntryViewModel, size: i32, ) -> IdentityAvatar { - let icon_size = (size - 14).max(18); - let icon = build_semantic_badge(view.badge, icon_size) + let has_conversation_avatar = notification.image.visual_role + == unixnotis_core::NotificationVisualRole::ConversationAvatar; + let icon_size = if has_conversation_avatar { + size + } else { + (size - 14).max(18) + }; + let icon = UiState::build_conversation_avatar_widget(notification, icon_size) + .or_else(|| build_semantic_badge(view.badge, icon_size)) .or_else(|| state.build_app_icon_widget(notification, icon_size)) .unwrap_or_else(|| gtk::Image::from_icon_name("application-x-executable-symbolic")); icon.set_pixel_size(icon_size); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 38cbf42e2..2bac0f9a2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -333,6 +333,44 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { assert!(icon.vexpands()); } +#[gtk::test] +fn communication_identity_avatar_prefers_materialized_conversation_image() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register conversation avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.inline_reply.available = true; + notification.image.visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.conversation_avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar should contain one image"); + + assert_eq!(icon.pixel_size(), 36); + assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); +} + fn view_model() -> PopupEntryViewModel { PopupEntryViewModel::for_notification_at(¬ification(), 1_000) } diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index a88f349d1..bdb8c6c45 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -12,8 +12,8 @@ use tracing::debug; use unixnotis_core::NotificationView; use super::icons::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, - IconDecodePool, IconDecodeResult, + collect_icon_candidates, file_path_from_hint, image_data_texture, image_data_texture_for_data, + resolve_icon_image, IconDecodePool, IconDecodeResult, }; use super::state::IconCacheEntry; use super::UiState; @@ -27,6 +27,24 @@ const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 64; const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); impl UiState { + pub(super) fn build_conversation_avatar_widget( + notification: &NotificationView, + size: i32, + ) -> Option { + // Conversation art is safe to render here because the daemon sent pixels, not a path + if notification.image.visual_role + != unixnotis_core::NotificationVisualRole::ConversationAvatar + { + return None; + } + + let texture = image_data_texture_for_data(¬ification.image.conversation_avatar)?; + let widget = gtk::Image::from_paintable(Some(&texture)); + set_popup_icon_size(&widget, size); + widget.add_css_class("unixnotis-popup-conversation-avatar"); + Some(widget) + } + pub(super) fn build_content_image_widget( &self, notification: &NotificationView, diff --git a/crates/unixnotis-popups/src/ui/icons/content.rs b/crates/unixnotis-popups/src/ui/icons/content.rs index 439094f44..54b0a2fc7 100644 --- a/crates/unixnotis-popups/src/ui/icons/content.rs +++ b/crates/unixnotis-popups/src/ui/icons/content.rs @@ -10,7 +10,10 @@ pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option Option { // GTK memory textures need positive dimensions and eight-bit channels if data.bits_per_sample != 8 || data.rowstride < 0 || data.width <= 0 || data.height <= 0 { return None; diff --git a/crates/unixnotis-popups/src/ui/icons/mod.rs b/crates/unixnotis-popups/src/ui/icons/mod.rs index b79165c1c..b788e0a7e 100644 --- a/crates/unixnotis-popups/src/ui/icons/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/mod.rs @@ -6,6 +6,6 @@ mod decode; mod resolver; pub(super) use cache::{IconDecodePool, IconDecodeResult, TextureCache}; -pub(super) use content::image_data_texture; +pub(super) use content::{image_data_texture, image_data_texture_for_data}; pub(super) use decode::{decode_icon_file, RasterIcon}; pub(super) use resolver::{collect_icon_candidates, file_path_from_hint, resolve_icon_image}; From dd5ac97b4e90cfff7c2dde02eeffb974b3417d2f Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 01:46:54 -0500 Subject: [PATCH 198/275] fix(media): restore native local artwork defaults Summary: restore native local artwork defaults. Scope: media. --- .../src/media/mpris/admission.rs | 4 +- .../src/media/mpris/player.rs | 21 ++++++---- .../src/media/mpris/tests/admission.rs | 22 ++++++++++ .../src/media/mpris/tests/player.rs | 5 +-- .../src/config/loading/diagnostics.rs | 28 +++++++++++++ .../src/config/loading/io/load.rs | 4 +- .../src/config/loading/tests/diagnostics.rs | 12 ++++++ .../src/config/media/defaults.rs | 5 +-- .../src/config/media/tests/effective.rs | 18 +++++++- .../unixnotis-core/src/config/media/types.rs | 4 +- crates/unixnotis-core/src/config/types.rs | 2 +- .../src/config/validation/schema.rs | 35 ++++++++++++++++ .../src/config/validation/tests/schema.rs | 41 +++++++++++++++++++ 13 files changed, 181 insertions(+), 20 deletions(-) diff --git a/crates/unixnotis-center/src/media/mpris/admission.rs b/crates/unixnotis-center/src/media/mpris/admission.rs index f00defd04..af23653c7 100644 --- a/crates/unixnotis-center/src/media/mpris/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/admission.rs @@ -67,8 +67,8 @@ pub(super) fn local_art_allowed( } MediaLocalArtPolicy::AllAdmitted => { // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. - // Only native players (non-browser) may name host files for local artwork. - browser_family.is_none() + // Only native players with a stable owner descriptor may name host files + browser_family.is_none() && owner_executable_is_allowed } } } diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index 1d890f713..5d6749701 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -138,13 +138,20 @@ pub(super) async fn build_player_state_for_owner( config.remote_art_policy, ); #[cfg(target_os = "linux")] - let owner_executable_is_allowed = owner.process_fd.as_ref().is_some_and(|process_fd| { - executable_allowed_from_pidfd( - process_fd, - owner.pid, - &config.local_art_executable_allowlist, - ) - }); + let owner_executable_is_allowed = match config.local_art_policy { + unixnotis_core::MediaLocalArtPolicy::ExactExecutableOnly => { + owner.process_fd.as_ref().is_some_and(|process_fd| { + executable_allowed_from_pidfd( + process_fd, + owner.pid, + &config.local_art_executable_allowlist, + ) + }) + } + // The all-admitted policy still requires a stable broker-provided process descriptor + unixnotis_core::MediaLocalArtPolicy::AllAdmitted => owner.process_fd.is_some(), + unixnotis_core::MediaLocalArtPolicy::Disabled => false, + }; #[cfg(not(target_os = "linux"))] let owner_executable_is_allowed = false; let local_art_allowed = local_art_allowed( diff --git a/crates/unixnotis-center/src/media/mpris/tests/admission.rs b/crates/unixnotis-center/src/media/mpris/tests/admission.rs index c98c5a44e..aea847025 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/admission.rs @@ -152,3 +152,25 @@ fn local_art_admission_requires_verified_executable_evidence() { MediaLocalArtPolicy::ExactExecutableOnly, )); } + +#[test] +fn all_admitted_native_art_still_requires_a_stable_owner() { + assert!(local_art_allowed( + None, + Some("/usr/bin/player"), + true, + MediaLocalArtPolicy::AllAdmitted, + )); + assert!(!local_art_allowed( + None, + Some("/usr/bin/player"), + false, + MediaLocalArtPolicy::AllAdmitted, + )); + assert!(!local_art_allowed( + Some("chromium"), + Some("/usr/bin/chromium"), + true, + MediaLocalArtPolicy::AllAdmitted, + )); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index d22d38717..bc0ecc3d8 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -67,7 +67,7 @@ fn player_timeout_state_clear_releases_a_quarantine() { async fn player_state_uses_live_identity_owner_and_process_details() { let fixture = MprisFixture::start().await; - // Test with default config (empty allowlist, ExactExecutableOnly policy) + // Native players use bounded local artwork by default let state = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) .await .expect("probe test MPRIS player") @@ -77,8 +77,7 @@ async fn player_state_uses_live_identity_owner_and_process_details() { assert_eq!(state.identity, TEST_PLAYER_IDENTITY); assert_eq!(state.owner_pid, Some(std::process::id())); assert!(state.remote_art_allowed); - // With empty allowlist and ExactExecutableOnly policy, local art should be disabled - assert!(!state.local_art_allowed); + assert!(state.local_art_allowed); assert_eq!( state.unique_owner.as_deref(), fixture.server.unique_name().map(|name| name.as_str()) diff --git a/crates/unixnotis-core/src/config/loading/diagnostics.rs b/crates/unixnotis-core/src/config/loading/diagnostics.rs index 775f55e19..c8da6ff85 100644 --- a/crates/unixnotis-core/src/config/loading/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/diagnostics.rs @@ -77,6 +77,34 @@ pub(super) fn migrated_field_diagnostic(path: String) -> ConfigDiagnostic { } } +pub(super) fn empty_exact_media_policy_diagnostic(contents: &str) -> Option { + let document = contents.parse::().ok()?; + let root = document.as_table()?; + let version = root.get("config_version").and_then(Value::as_integer)?; + if u32::try_from(version).ok()? != CURRENT_CONFIG_VERSION { + return None; + } + let media = root.get("media").and_then(Value::as_table)?; + let exact = media + .get("local_art_policy") + .and_then(Value::as_str) + .is_some_and(|value| value == "exact_executable_only"); + let empty = media + .get("local_art_executable_allowlist") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty); + (exact && empty).then(|| ConfigDiagnostic { + code: "config.media.empty-exact-allowlist", + kind: ConfigDiagnosticKind::Warning, + path: Some("media.local_art_policy".to_string()), + message: + "Exact local artwork policy has an empty executable allowlist; artwork is disabled" + .to_string(), + original: Some("exact_executable_only".to_string()), + effective: None, + }) +} + pub(super) fn unknown_key_diagnostic(path: String) -> ConfigDiagnostic { ConfigDiagnostic { code: "config.unknown-key", diff --git a/crates/unixnotis-core/src/config/loading/io/load.rs b/crates/unixnotis-core/src/config/loading/io/load.rs index ee7cdcde1..2dc33084b 100644 --- a/crates/unixnotis-core/src/config/loading/io/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/load.rs @@ -11,7 +11,8 @@ use crate::config::schema::deserialize_config_with_migrations; use crate::{log_config_diagnostics, Config, ConfigLoadReport}; use super::super::diagnostics::{ - adjustment_diagnostics, migrated_field_diagnostic, migration_diagnostic, unknown_key_diagnostic, + adjustment_diagnostics, empty_exact_media_policy_diagnostic, migrated_field_diagnostic, + migration_diagnostic, unknown_key_diagnostic, }; use super::ConfigError; @@ -62,6 +63,7 @@ impl Config { let mut diagnostics = migration_diagnostic(contents) .into_iter() .collect::>(); + diagnostics.extend(empty_exact_media_policy_diagnostic(contents)); diagnostics.extend(migrated_paths.into_iter().map(migrated_field_diagnostic)); diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); let before_runtime = config.clone(); diff --git a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs index ecf28c823..561f02aca 100644 --- a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs @@ -41,6 +41,18 @@ fn current_schema_produces_no_migration_diagnostic() { assert!(migration_diagnostic(&input).is_none()); } +#[test] +fn current_empty_exact_media_policy_emits_a_warning() { + let report = Config::parse_with_report( + "config_version = 4\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", + ) + .expect("current config should parse"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "config.media.empty-exact-allowlist" + && diagnostic.kind == ConfigDiagnosticKind::Warning + })); +} + #[test] fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { let mut before = Config::default(); diff --git a/crates/unixnotis-core/src/config/media/defaults.rs b/crates/unixnotis-core/src/config/media/defaults.rs index 2f45c2ea6..f103a020e 100644 --- a/crates/unixnotis-core/src/config/media/defaults.rs +++ b/crates/unixnotis-core/src/config/media/defaults.rs @@ -45,9 +45,8 @@ impl Default for MediaConfig { denylist: vec!["playerctld".to_string()], // Browsers stay opt-in because webpage metadata can choose artwork URLs remote_art_policy: MediaRemoteArtPolicy::NativeOnly, - // Local artwork requires exact executable allowlist match to prevent - // untrusted MPRIS services from directing the renderer to arbitrary host files - local_art_policy: MediaLocalArtPolicy::ExactExecutableOnly, + // Native players regain the normal cover-art behavior; browser local paths remain denied + local_art_policy: MediaLocalArtPolicy::AllAdmitted, local_art_executable_allowlist: Vec::new(), } } diff --git a/crates/unixnotis-core/src/config/media/tests/effective.rs b/crates/unixnotis-core/src/config/media/tests/effective.rs index f0d74fc59..4e75cbe25 100644 --- a/crates/unixnotis-core/src/config/media/tests/effective.rs +++ b/crates/unixnotis-core/src/config/media/tests/effective.rs @@ -1,7 +1,23 @@ use crate::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, }; +#[test] +fn native_local_art_is_enabled_by_default() { + assert_eq!( + MediaConfig::default().local_art_policy, + MediaLocalArtPolicy::AllAdmitted + ); +} + +#[test] +fn stock_media_serialization_names_native_art_policy_and_omits_empty_allowlist() { + let serialized = toml::to_string(&MediaConfig::default()).expect("serialize media config"); + assert!(serialized.contains("local_art_policy = \"all_admitted\"")); + assert!(!serialized.contains("local_art_executable_allowlist")); +} + #[test] fn preset_defaults_stay_stable() { let mut config = MediaConfig { diff --git a/crates/unixnotis-core/src/config/media/types.rs b/crates/unixnotis-core/src/config/media/types.rs index 23a1f1b90..dd42c93d9 100644 --- a/crates/unixnotis-core/src/config/media/types.rs +++ b/crates/unixnotis-core/src/config/media/types.rs @@ -75,7 +75,7 @@ pub struct MediaConfig { /// Controls which players may use local file paths for artwork pub local_art_policy: MediaLocalArtPolicy, /// Exact executable paths allowed for local artwork (device/inode verified) - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub local_art_executable_allowlist: Vec, } @@ -97,9 +97,9 @@ pub enum MediaLocalArtPolicy { /// Disable local artwork fetches for every player Disabled, /// Allow local artwork only for players whose executable matches the allowlist - #[default] ExactExecutableOnly, /// Allow local artwork for all admitted players + #[default] AllAdmitted, } diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index 5b5a36385..87e1f33e0 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -12,7 +12,7 @@ use super::rules::RuleConfig; use super::theme::ThemeConfig; use super::widgets::WidgetsConfig; -pub const CURRENT_CONFIG_VERSION: u32 = 3; +pub const CURRENT_CONFIG_VERSION: u32 = 4; /// Top-level configuration loaded from config.toml #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index d99879e8e..3d4b80908 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -114,10 +114,16 @@ fn migrate_document(document: &mut toml::Value) -> Result { let result = migrate_legacy_layout(root); migrate_legacy_commands(root)?; + migrate_media_art_policy(root, version); result } 2 => { migrate_legacy_commands(root)?; + migrate_media_art_policy(root, version); + MigrationResult::default() + } + 3 => { + migrate_media_art_policy(root, version); MigrationResult::default() } CURRENT_CONFIG_VERSION => MigrationResult::default(), @@ -130,6 +136,35 @@ fn migrate_document(document: &mut toml::Value) -> Result Result<(), String> { let Some(widgets) = root.get_mut("widgets").and_then(toml::Value::as_table_mut) else { return Ok(()); diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index e79a100fe..5b972d78e 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -107,6 +107,47 @@ fn version_three_accepts_structured_direct_commands_without_inference() { ); } +#[test] +fn old_media_defaults_restore_native_artwork() { + let (config, _) = deserialize_config( + "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", + ) + .expect("old media config should migrate"); + assert_eq!( + config.media.local_art_policy, + crate::MediaLocalArtPolicy::AllAdmitted + ); + assert!(config.media.local_art_executable_allowlist.is_empty()); +} + +#[test] +fn old_explicit_allowlist_remains_exact() { + let (config, _) = deserialize_config( + "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\nlocal_art_executable_allowlist = [\"/usr/bin/player\"]\n", + ) + .expect("old explicit media config should migrate"); + assert_eq!( + config.media.local_art_policy, + crate::MediaLocalArtPolicy::ExactExecutableOnly + ); + assert_eq!( + config.media.local_art_executable_allowlist, + ["/usr/bin/player"] + ); +} + +#[test] +fn current_explicit_empty_exact_policy_is_preserved() { + let (config, _) = deserialize_config( + "config_version = 4\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", + ) + .expect("current media config should preserve explicit policy"); + assert_eq!( + config.media.local_art_policy, + crate::MediaLocalArtPolicy::ExactExecutableOnly + ); +} + #[test] fn future_schema_is_rejected_instead_of_guessed() { let error = deserialize_config("config_version = 999\n").expect_err("reject future config"); From 42938d067064f2ce1855cfaecd6a6e44e20eb223 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 02:00:44 -0500 Subject: [PATCH 199/275] fix(notifications): materialize sender visuals safely Summary: materialize sender visuals safely. Scope: notifications. --- crates/unixnotis-center/src/ui/icons/cache.rs | 4 +- .../src/ui/icons/resolution.rs | 32 +--- .../src/ui/icons/tests/resolution.rs | 74 +------ .../src/ui/icons/tests/theme.rs | 2 +- crates/unixnotis-center/src/ui/icons/theme.rs | 21 +- .../notification/update/tests/thumbnail.rs | 25 ++- .../row/notification/update/thumbnail.rs | 2 +- .../unixnotis-core/src/model/attribution.rs | 14 ++ .../unixnotis-core/src/model/image/hints.rs | 140 ++------------ crates/unixnotis-core/src/model/image/mod.rs | 4 +- .../unixnotis-core/src/model/image/model.rs | 21 +- .../src/model/image/projection.rs | 23 +-- .../src/model/image/tests/hints.rs | 181 ++++-------------- .../src/model/image/tests/model.rs | 18 +- .../src/model/image/tests/projection.rs | 60 +++--- .../unixnotis-core/src/model/notification.rs | 6 +- .../src/model/tests/attribution.rs | 40 ++++ .../src/model/tests/notification.rs | 21 +- .../daemon/notifications/ingress/payload.rs | 99 ++++++---- .../notifications/ingress/tests/payload.rs | 138 ++++++++++--- .../src/daemon/notifications/server/flow.rs | 74 ++++--- .../notifications/server/tests/ingress.rs | 12 +- .../notifications/server/wire_hints/decode.rs | 11 +- .../notifications/server/wire_hints/mod.rs | 17 +- .../src/ui/entry/builders/common.rs | 2 +- .../src/ui/entry/builders/layout.rs | 2 +- .../src/ui/entry/builders/mod.rs | 3 +- .../src/ui/entry/builders/tests/common.rs | 5 +- .../ui/entry/presentation/tests/view_model.rs | 59 ++++-- crates/unixnotis-popups/src/ui/icon_state.rs | 16 +- .../unixnotis-popups/src/ui/icons/content.rs | 4 +- .../src/ui/icons/tests/content.rs | 5 +- .../src/ui/icons/tests/resolver/candidates.rs | 3 +- .../src/ui/icons/tests/resolver/support.rs | 2 +- .../src/ui/state/tests/constructor.rs | 2 +- .../src/ui/state/tests/mutation.rs | 7 +- crates/unixnotis-ui/src/presentation/build.rs | 45 +---- .../src/presentation/tests/presentation.rs | 108 ++--------- 38 files changed, 530 insertions(+), 772 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index 9e873407f..930d00751 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -59,10 +59,10 @@ pub(super) fn icon_key_for_image( size: i32, scale: i32, ) -> Option { - if !image.has_image_data { + if image.content_image.data.is_empty() { return None; } - let data = &image.image_data; + let data = &image.content_image; if data.data.is_empty() { return None; } diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index 2bfdd9ba3..23d988867 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -11,8 +11,8 @@ use super::cache::{ }; use super::resolver::IconResolverInner; use super::theme::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, image_data_texture_for_data, - resolve_icon_source, IconSource, + collect_icon_candidates, image_data_texture, image_data_texture_for_data, resolve_icon_source, + IconSource, }; use super::types::{IconDecodeRequest, IconResolution}; @@ -24,12 +24,11 @@ impl IconResolverInner { ) { // The daemon has already decoded and bounded this sender-provided raster if matches!( - notification.image.visual_role, + notification.image.sender_visual_role, unixnotis_core::NotificationVisualRole::ConversationAvatar + | unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon ) { - if let Some(texture) = - image_data_texture_for_data(¬ification.image.conversation_avatar) - { + if let Some(texture) = image_data_texture_for_data(¬ification.image.sender_visual) { image.set_paintable(Some(&texture)); image.set_visible(true); return; @@ -86,27 +85,6 @@ impl IconResolverInner { } } - if let Some(path) = file_path_from_hint(&image.image_path) { - // File paths use asynchronous decoding so disk I/O stays off GTK - if let Some(key) = icon_key_for_path(&path, size, scale) { - if let Some(paintable) = self.cache.borrow_mut().get(&key) { - return Some(IconResolution::Ready { key, paintable }); - } - return Some(IconResolution::Async { - request: IconDecodeRequest { - key, - path, - size, - scale, - }, - }); - } - } - - if let Some(resolution) = self.resolve_icon_name(&image.icon_name, size, scale) { - return Some(resolution); - } - let candidates = collect_icon_candidates(notification); for candidate in &candidates { if let Some(icons) = self.desktop_index.icons_for(candidate) { diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index 9ce75b170..6e8213b97 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -1,19 +1,13 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::fs; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use gtk::prelude::*; -use image::codecs::png::PngEncoder; -use image::{ExtendedColorType, ImageEncoder}; use unixnotis_core::{NotificationImage, NotificationView}; use unixnotis_ui::icons::DesktopIconIndex; use super::{icon_name_is_usable, IconResolverInner}; -use crate::ui::icons::cache::{set_image_key, IconCache}; +use crate::ui::icons::cache::IconCache; use crate::ui::icons::decode::{IconUpdate, IconWorker}; use crate::ui::icons::missing::MissingIconCache; -use crate::ui::icons::types::IconResolution; #[test] fn empty_icon_name_is_not_resolved() { @@ -35,46 +29,19 @@ fn resolver_inner(update_tx: async_channel::Sender) -> IconResolverI } } -fn test_png() -> Vec { - let mut bytes = Vec::new(); - PngEncoder::new(&mut bytes) - .write_image(&[1, 2, 3, 255], 1, 1, ExtendedColorType::Rgba8) - .expect("encode icon PNG"); - bytes -} - -fn wait_for_update(receiver: &async_channel::Receiver) -> IconUpdate { - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match receiver.try_recv() { - Ok(update) => return update, - Err(async_channel::TryRecvError::Closed) => panic!("icon update channel closed"), - Err(async_channel::TryRecvError::Empty) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(async_channel::TryRecvError::Empty) => panic!("icon worker did not respond"), - } - } -} - #[gtk::test] -fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "unixnotis-center-resolution-{}-{stamp}.png", - std::process::id() - )); - fs::write(&path, test_png()).expect("write icon fixture"); - let (update_tx, update_rx) = async_channel::bounded(4); +fn sender_paths_are_not_resolved_by_client_icon_lookup() { + let (update_tx, _update_rx) = async_channel::bounded(1); let resolver = resolver_inner(update_tx); let notification = NotificationView { id: 1, generation: 1, app_name: "Icon test".to_string(), - attribution: unixnotis_core::NotificationAttribution::default(), + attribution: unixnotis_core::NotificationAttribution { + // Keep the daemon-owned fallback empty so this test isolates sender paths + badge_icon: String::new(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: String::new(), body: String::new(), actions: Vec::new(), @@ -84,40 +51,19 @@ fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { category: String::new(), is_transient: false, received_at_unix_seconds: 0, - image: NotificationImage { - image_path: path.to_string_lossy().into_owned(), - ..NotificationImage::default() - }, + image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; - let resolution = resolver - .resolve_icon(¬ification, 16, 1) - .expect("file icon should resolve"); - let IconResolution::Async { request } = resolution else { - panic!("file icon should use the worker"); - }; - let image = gtk::Image::new(); - image.set_visible(false); - set_image_key(&image, request.key.clone()); - resolver.enqueue(request, &image); - - resolver.handle_update(wait_for_update(&update_rx)); - - assert!(image.get_visible()); - assert!(image.paintable().is_some()); - assert!(resolver.inflight.borrow().is_empty()); - fs::remove_file(path).expect("remove icon fixture"); + assert!(resolver.resolve_icon(¬ification, 16, 1).is_none()); } #[gtk::test] fn standard_theme_icon_name_resolves_through_the_resolver() { let (update_tx, _update_rx) = async_channel::bounded(1); let resolver = resolver_inner(update_tx); - let resolution = resolver .resolve_icon_name("folder", 24, 1) .or_else(|| resolver.resolve_icon_name("folder-symbolic", 24, 1)); - assert!(resolution.is_some()); } diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 5a7cc6309..2812a7b35 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -47,7 +47,7 @@ fn badge_candidates_exclude_caller_content_icon() { ..unixnotis_core::NotificationAttribution::default() }, NotificationImage { - icon_name: "caller-content-icon".to_string(), + badge_icon: "caller-content-icon".to_string(), ..NotificationImage::default() }, ); diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index 4d24955e4..f69255baf 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -37,23 +37,6 @@ pub(super) fn resolve_icon_source(name: &str, size: i32, scale: i32) -> Option Option { - // Accept raw absolute paths and file:// URIs, decoding percent escapes when present - if path.starts_with('/') { - return Some(PathBuf::from(path)); - } - if path.starts_with("file://") { - // gio::File handles URI decoding and local filesystem resolution - let file = gio::File::for_uri(path); - // Only accept native filesystem paths to avoid non-local URIs - if !file.is_native() { - return None; - } - return file.path(); - } - None -} - fn worker_decodes_theme_path(path: &Path) -> bool { path.extension() .and_then(|extension| extension.to_str()) @@ -127,11 +110,11 @@ fn is_missing_icon(path: &Path) -> bool { pub(super) fn image_data_texture(image: &NotificationImage) -> Option { // Only proceed if the notification actually carried image-data (not just a name/path hint) - if !image.has_image_data { + if image.content_image.data.is_empty() { return None; } - image_data_texture_for_data(&image.image_data) + image_data_texture_for_data(&image.content_image) } pub(super) fn image_data_texture_for_data(data: &ImageData) -> Option { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index 350d5f0e1..d754588d5 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -18,15 +18,24 @@ fn notification_thumbnail_only_uses_real_image_sources() { let mut notification = sample_notification(); assert!(!notification_has_thumbnail(¬ification)); - notification.image.image_path = "/tmp/demo.png".to_string(); + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + }; assert!(notification_has_thumbnail(¬ification)); } #[test] fn conversation_avatar_is_a_separate_thumbnail_source() { let mut notification = sample_notification(); - notification.image.visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; - notification.image.conversation_avatar = ImageData { + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { width: 1, height: 1, rowstride: 4, @@ -68,7 +77,15 @@ fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { fn update_notification_row_shows_thumbnail_when_config_and_image_allow_it() { let (_root, row) = notification_row(); let mut notification = sample_notification(); - notification.image.image_path = "/tmp/demo.png".to_string(); + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + }; let data = row_data( Rc::new(notification), RowFlags { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index f010ee535..e7c5a50b0 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -13,7 +13,7 @@ pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> boo pub(super) const fn notification_has_conversation_avatar(notification: &NotificationView) -> bool { // Avatars are presentation-only raster data and never count as message content matches!( - notification.image.visual_role, + notification.image.sender_visual_role, unixnotis_core::NotificationVisualRole::ConversationAvatar ) } diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index 35efcb884..92eb12467 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -313,6 +313,20 @@ impl NotificationAttribution { self.interactions.action_buttons } + /// Host visuals require a positively associated executable and one-click local authority + #[must_use] + pub const fn may_read_sender_host_visual(&self) -> bool { + matches!( + self.assurance, + IdentityAssurance::Authenticated + | IdentityAssurance::SystemAssociated + | IdentityAssurance::UserAssociated + ) && matches!( + self.default_activation_policy(), + ApplicationActionPolicy::Allow + ) + } + /// Whether this attribution has kernel or broker-backed identity evidence #[must_use] pub const fn is_verified(&self) -> bool { diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 232d589c3..56a97b77b 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -4,15 +4,15 @@ use std::collections::HashMap; use zbus::zvariant::{Array, OwnedValue, Structure, Value}; -use crate::util; - -use super::{ - ImageData, NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_PATH_BYTES, -}; +use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; impl NotificationImage { - pub fn from_hints(app_name: &str, app_icon: &str, hints: &HashMap) -> Self { - // Content-image hints stay separate from the application identity icon + pub fn from_hints( + _app_name: &str, + _app_icon: &str, + hints: &HashMap, + ) -> Self { + // Embedded pixels are already detached from the sender's filesystem let image_data = hints .get("image-data") .and_then(Self::parse_image_data) @@ -20,33 +20,11 @@ impl NotificationImage { .or_else(|| hints.get("icon_data").and_then(Self::parse_image_data)); let image_data = image_data.filter(Self::is_image_data_usable); - let image_path = hints - .get("image-path") - .and_then(owned_to_string) - .or_else(|| hints.get("image_path").and_then(owned_to_string)) - .map(|path| normalize_image_path(&path)) - .unwrap_or_default(); - - // Desktop-entry values map to icon theme names after the suffix is removed - let desktop_entry = hints - .get("desktop-entry") - .and_then(owned_to_string) - .map(|entry| strip_desktop_suffix(&entry)); - let app_icon_path = normalize_app_icon_path(app_icon); - let icon_name = bound_icon_name(&resolve_icon_name( - app_name, - app_icon, - app_icon_path.as_ref(), - desktop_entry, - )); - Self { - has_image_data: image_data.is_some(), - image_data: image_data.unwrap_or_default(), - visual_role: super::NotificationVisualRole::None, - conversation_avatar: ImageData::default(), - image_path, - icon_name, + badge_icon: String::new(), + sender_visual_role: super::NotificationVisualRole::None, + sender_visual: ImageData::default(), + content_image: image_data.unwrap_or_default(), } } @@ -91,99 +69,3 @@ impl NotificationImage { Some(bytes) } } - -fn resolve_icon_name( - app_name: &str, - app_icon: &str, - app_icon_path: Option<&String>, - desktop_entry: Option, -) -> String { - if app_icon_path.is_some() { - return String::new(); - } - if !app_icon.is_empty() && !app_icon.starts_with("file://") { - return strip_desktop_suffix(app_icon); - } - if let Some(desktop_entry) = desktop_entry { - return desktop_entry; - } - if !app_name.is_empty() { - return app_name.to_string(); - } - String::new() -} - -fn normalize_app_icon_path(app_icon: &str) -> Option { - // Normalize the incoming icon path first so later checks operate on a cleaned, - // bounded value rather than raw metadata input - let path = normalize_image_path(app_icon); - - // Only accept paths that are already absolute filesystem paths or valid file URIs - // Relative paths are rejected because app icons need to resolve unambiguously - if path.starts_with('/') || path.starts_with("file://") { - Some(path) - } else { - None - } -} - -fn normalize_image_path(value: &str) -> String { - // Sanitize display-facing metadata and enforce the maximum byte length before - // doing any URI-specific normalization - let bounded = sanitize_metadata_string(value, MAX_IMAGE_PATH_BYTES); - - // File URIs get normalized into the accepted form when possible. Invalid or - // unsupported file URI shapes fall back to an empty string - if bounded.starts_with("file://") { - return normalize_file_uri(&bounded).unwrap_or_default(); - } - - // Non-file URI values are returned after sanitization/truncation only - bounded -} - -fn normalize_file_uri(value: &str) -> Option { - // This function only handles file:// URIs; anything else is rejected immediately - let stripped = value.strip_prefix("file://")?; - - // A file URI with an absolute path is already in the expected form - if stripped.starts_with('/') { - return Some(value.to_string()); - } - - // Convert localhost-based file URIs into the canonical absolute-path form - stripped - .strip_prefix("localhost/") - .map(|path| format!("file:///{path}")) -} - -fn bound_icon_name(value: &str) -> String { - // Icon names use the same metadata sanitization path, but with the icon-name - // byte limit instead of the image-path byte limit - sanitize_metadata_string(value, MAX_ICON_NAME_BYTES) -} - -fn sanitize_metadata_string(value: &str, max_bytes: usize) -> String { - // Remove inline display control/problematic characters before trimming and - // applying the final UTF-8-safe byte limit - let cleaned = util::sanitize_inline_display_text(value); - util::truncate_utf8_bytes(cleaned.trim(), max_bytes) -} - -pub(in crate::model) fn owned_to_string(value: &OwnedValue) -> Option { - // Clone the owned D-Bus value first, then attempt to extract it as a String - // Any clone or conversion failure is represented as None - value - .try_clone() - .ok() - .and_then(|owned| String::try_from(owned).ok()) -} - -pub(in crate::model) fn strip_desktop_suffix(value: &str) -> String { - // Desktop entries may include ".desktop"; icon themes usually omit it - if let Some(stripped) = value.strip_suffix(".desktop") { - stripped.to_string() - } else { - value.to_string() - } -} diff --git a/crates/unixnotis-core/src/model/image/mod.rs b/crates/unixnotis-core/src/model/image/mod.rs index e18675a85..e8bceccca 100644 --- a/crates/unixnotis-core/src/model/image/mod.rs +++ b/crates/unixnotis-core/src/model/image/mod.rs @@ -7,9 +7,7 @@ mod projection; mod rgb; pub use model::{ImageData, NotificationImage, NotificationVisualRole}; -pub(super) use model::{ - MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, MAX_IMAGE_PATH_BYTES, -}; +pub(super) use model::{MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index 253c8a690..b4fb181c9 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -27,23 +27,22 @@ pub enum NotificationVisualRole { #[default] None = 0, ConversationAvatar = 1, + ApplicationProvidedIcon = 2, + ContentImage = 3, } -/// Image information derived from standard hints and `app_icon` +/// Pixel visuals retained after daemon-side validation #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct NotificationImage { - pub has_image_data: bool, - pub image_data: ImageData, - // Conversation avatars are decoded before leaving the daemon - pub visual_role: NotificationVisualRole, - pub conversation_avatar: ImageData, - // image_path remains reserved for message content media - pub image_path: String, - pub icon_name: String, + /// Desktop-index-selected identity icon + pub badge_icon: String, + /// Safely decoded sender-provided visual + pub sender_visual_role: NotificationVisualRole, + pub sender_visual: ImageData, + /// Safely decoded message content image + pub content_image: ImageData, } // Bound untrusted image payloads to keep daemon/UI memory predictable under floods pub(in crate::model) const MAX_IMAGE_BYTES: usize = 256 * 1024; pub(in crate::model) const MAX_IMAGE_DIMENSION: i32 = 256; -pub(in crate::model) const MAX_IMAGE_PATH_BYTES: usize = 1024; -pub(in crate::model) const MAX_ICON_NAME_BYTES: usize = 256; diff --git a/crates/unixnotis-core/src/model/image/projection.rs b/crates/unixnotis-core/src/model/image/projection.rs index 06ca65e87..a7bf0e864 100644 --- a/crates/unixnotis-core/src/model/image/projection.rs +++ b/crates/unixnotis-core/src/model/image/projection.rs @@ -1,32 +1,17 @@ //! Lightweight notification image projections -use super::{ImageData, NotificationImage}; +use super::NotificationImage; impl NotificationImage { #[must_use] pub fn for_listing(&self) -> Self { - if self.image_data.data.is_empty() { - return self.clone(); - } - Self { - has_image_data: false, - image_data: ImageData::default(), - visual_role: self.visual_role, - conversation_avatar: self.conversation_avatar.clone(), - image_path: self.image_path.clone(), - icon_name: self.icon_name.clone(), - } + // All retained images are already bounded daemon-owned pixels + self.clone() } #[must_use] pub fn for_history(&self) -> Self { - if self.has_image_data && (!self.image_path.is_empty() || !self.icon_name.is_empty()) { - let mut trimmed = self.clone(); - // History rows can use a path or theme name, so raw bytes are dropped - trimmed.has_image_data = false; - trimmed.image_data = ImageData::default(); - return trimmed; - } + // History receives pixels only; sender paths never cross this boundary self.clone() } } diff --git a/crates/unixnotis-core/src/model/image/tests/hints.rs b/crates/unixnotis-core/src/model/image/tests/hints.rs index 94c5b9bd7..6d5adf79a 100644 --- a/crates/unixnotis-core/src/model/image/tests/hints.rs +++ b/crates/unixnotis-core/src/model/image/tests/hints.rs @@ -1,11 +1,10 @@ -use super::super::hints::{owned_to_string, strip_desktop_suffix}; -use super::super::{NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_PATH_BYTES}; +use super::super::{ImageData, NotificationImage, NotificationVisualRole, MAX_IMAGE_BYTES}; use super::{image_data_value, string_value}; use std::collections::HashMap; use zbus::zvariant::{OwnedValue, Structure, Value}; #[test] -fn from_hints_prefers_valid_image_data_over_image_path_and_icon() { +fn embedded_image_data_is_retained_as_content() { let mut hints = HashMap::new(); hints.insert( "image-data".to_string(), @@ -13,164 +12,58 @@ fn from_hints_prefers_valid_image_data_over_image_path_and_icon() { ); hints.insert("image-path".to_string(), string_value("/tmp/icon.png")); - let image = NotificationImage::from_hints("App", "fallback-icon", &hints); - - assert!(image.has_image_data); - assert_eq!(image.image_data.data, vec![1, 2, 3, 4]); - assert_eq!(image.image_path, "/tmp/icon.png"); - assert_eq!(image.icon_name, "fallback-icon"); -} - -#[test] -fn from_hints_never_promotes_an_app_icon_path_to_content_media() { - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - image_data_value(0, 1, 4, true, 8, 4, vec![1, 2, 3, 4]), - ); - - let image = NotificationImage::from_hints("App", "/tmp/app-icon.png", &hints); - - assert!(!image.has_image_data); - assert!(image.image_path.is_empty()); - assert!(image.icon_name.is_empty()); -} - -#[test] -fn from_hints_never_promotes_an_app_icon_name_to_content_media() { - let image = NotificationImage::from_hints("Signal", "signal-desktop", &HashMap::new()); - - assert!(!image.has_image_data); - assert!(image.image_path.is_empty()); - assert_eq!(image.icon_name, "signal-desktop"); -} - -#[test] -fn from_hints_uses_desktop_entry_before_app_name_for_icon_name() { - let mut hints = HashMap::new(); - hints.insert( - "desktop-entry".to_string(), - string_value("org.example.App.desktop"), - ); - - let image = NotificationImage::from_hints("Fallback App", "", &hints); - - assert_eq!(image.icon_name, "org.example.App"); -} - -#[test] -fn from_hints_uses_app_name_when_no_icon_hints_exist() { - let hints = HashMap::new(); - - let image = NotificationImage::from_hints("Fallback App", "", &hints); - - assert_eq!(image.icon_name, "Fallback App"); - assert!(image.image_path.is_empty()); - assert!(!image.has_image_data); -} - -#[test] -fn from_hints_bounds_image_path_and_icon_name_without_splitting_utf8() { - let mut hints = HashMap::new(); - let long_path = format!("/tmp/{}{}", "a".repeat(MAX_IMAGE_PATH_BYTES), "é"); - let long_icon = format!("{}{}", "b".repeat(MAX_ICON_NAME_BYTES), "é"); - hints.insert("image-path".to_string(), string_value(&long_path)); - - let image = NotificationImage::from_hints("App", &long_icon, &hints); - - assert!(image.image_path.len() <= MAX_IMAGE_PATH_BYTES); - assert!(image.image_path.is_char_boundary(image.image_path.len())); - assert!(image.icon_name.len() <= MAX_ICON_NAME_BYTES); - assert!(image.icon_name.is_char_boundary(image.icon_name.len())); + let image = NotificationImage::from_hints("App", "/tmp/app.png", &hints); + assert_eq!(image.sender_visual_role, NotificationVisualRole::None); + assert_eq!(image.content_image.data, vec![1, 2, 3, 4]); + assert!(image.badge_icon.is_empty()); } #[test] -fn from_hints_truncates_image_path_at_previous_utf8_boundary() { +fn app_icon_and_image_path_never_become_retained_host_paths() { let mut hints = HashMap::new(); - let prefix = format!("/{}", "a".repeat(MAX_IMAGE_PATH_BYTES - 2)); - hints.insert( - "image-path".to_string(), - string_value(&format!("{prefix}é-tail")), - ); - - let image = NotificationImage::from_hints("App", "", &hints); - - assert_eq!(image.image_path, prefix); - assert_eq!(image.image_path.len(), MAX_IMAGE_PATH_BYTES - 1); - assert!(image.image_path.is_char_boundary(image.image_path.len())); -} - -#[test] -fn from_hints_normalizes_localhost_file_uri_and_ignores_remote_file_uri_path() { - let mut hints = HashMap::new(); - hints.insert( - "image-path".to_string(), - string_value("file://localhost/tmp/icon%20name.png"), - ); - - let image = NotificationImage::from_hints("App", "file://example.com/tmp/app.png", &hints); - - assert_eq!(image.image_path, "file:///tmp/icon%20name.png"); - assert_eq!(image.icon_name, "App"); + hints.insert("image-path".to_string(), string_value("/tmp/icon.png")); + let image = NotificationImage::from_hints("App", "/tmp/app.png", &hints); + assert!(image.sender_visual.data.is_empty()); + assert!(image.content_image.data.is_empty()); } #[test] -fn parse_image_data_accepts_legacy_hint_aliases_and_rejects_wrong_field_count() { - let parsed = NotificationImage::parse_image_data(&image_data_value( - 1, - 1, - 4, - true, - 8, - 4, - vec![1, 2, 3, 4], - )) - .expect("valid image-data should parse"); - assert_eq!(parsed.width, 1); - assert_eq!(parsed.channels, 4); - +fn parse_image_data_rejects_wrong_structure() { let wrong = Structure::from((1_i32, 1_i32)); - let wrong: OwnedValue = Value::from(wrong) - .try_into() - .expect("wrong structure should convert"); + let wrong: OwnedValue = Value::from(wrong).try_into().expect("structure conversion"); assert!(NotificationImage::parse_image_data(&wrong).is_none()); } #[test] -fn array_to_bytes_rejects_empty_large_and_non_byte_arrays() { - let empty = Value::from(Vec::::new()); - assert!(NotificationImage::array_to_bytes(&empty).is_none()); - - let too_large = Value::from(vec![0_u8; super::super::MAX_IMAGE_BYTES + 1]); - assert!(NotificationImage::array_to_bytes(&too_large).is_none()); - - let exact_limit = Value::from(vec![0_u8; super::super::MAX_IMAGE_BYTES]); +fn parse_image_data_accepts_legacy_aliases() { + let value = image_data_value(1, 1, 4, true, 8, 4, vec![1, 2, 3, 4]); + let parsed = NotificationImage::parse_image_data(&value).expect("valid image data"); assert_eq!( - NotificationImage::array_to_bytes(&exact_limit) - .expect("exact limit should be accepted") - .len(), - super::super::MAX_IMAGE_BYTES + parsed, + ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + } ); +} - let wrong_type = Value::from(vec![1_u32]); - assert!(NotificationImage::array_to_bytes(&wrong_type).is_none()); +#[test] +fn parse_image_data_enforces_the_exact_raw_byte_boundary() { + let accepted = image_data_value(1, 1, 4, true, 8, 4, vec![0; MAX_IMAGE_BYTES]); + assert!(NotificationImage::parse_image_data(&accepted).is_some()); - let bytes = Value::from(vec![1_u8, 2, 3]); - assert_eq!( - NotificationImage::array_to_bytes(&bytes), - Some(vec![1, 2, 3]) - ); + let rejected = image_data_value(1, 1, 4, true, 8, 4, vec![0; MAX_IMAGE_BYTES + 1]); + assert!(NotificationImage::parse_image_data(&rejected).is_none()); } #[test] -fn owned_string_and_desktop_suffix_helpers_match_hint_expectations() { - assert_eq!( - owned_to_string(&string_value("org.example.App.desktop")).as_deref(), - Some("org.example.App.desktop") - ); - assert_eq!( - strip_desktop_suffix("org.example.App.desktop"), - "org.example.App" - ); - assert_eq!(strip_desktop_suffix("org.example.App"), "org.example.App"); +fn array_to_bytes_rejects_empty_payloads() { + let value = Value::from(Vec::::new()); + + assert!(NotificationImage::array_to_bytes(&value).is_none()); } diff --git a/crates/unixnotis-core/src/model/image/tests/model.rs b/crates/unixnotis-core/src/model/image/tests/model.rs index ef4a6a55b..c73f81312 100644 --- a/crates/unixnotis-core/src/model/image/tests/model.rs +++ b/crates/unixnotis-core/src/model/image/tests/model.rs @@ -1,20 +1,16 @@ use super::super::{ - ImageData, NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, - MAX_IMAGE_PATH_BYTES, + ImageData, NotificationImage, NotificationVisualRole, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, }; #[test] fn image_models_default_to_empty_bounded_payloads() { - let data = ImageData::default(); let image = NotificationImage::default(); - - assert_eq!(data.width, 0); - assert!(data.data.is_empty()); - assert!(!image.has_image_data); - assert!(image.image_path.is_empty()); - assert!(image.icon_name.is_empty()); + assert!(image.badge_icon.is_empty()); + assert_eq!(image.sender_visual_role, NotificationVisualRole::None); + assert!(image.sender_visual.data.is_empty()); + assert!(image.content_image.data.is_empty()); assert_eq!(MAX_IMAGE_BYTES, 256 * 1024); assert_eq!(MAX_IMAGE_DIMENSION, 256); - assert_eq!(MAX_IMAGE_PATH_BYTES, 1024); - assert_eq!(MAX_ICON_NAME_BYTES, 256); + assert_eq!(NotificationImage::retained_byte_limit(), MAX_IMAGE_BYTES); + assert_eq!(ImageData::default().width, 0); } diff --git a/crates/unixnotis-core/src/model/image/tests/projection.rs b/crates/unixnotis-core/src/model/image/tests/projection.rs index 70d29f5bb..18a924f37 100644 --- a/crates/unixnotis-core/src/model/image/tests/projection.rs +++ b/crates/unixnotis-core/src/model/image/tests/projection.rs @@ -1,55 +1,39 @@ -use super::super::{ImageData, NotificationImage}; +use super::super::{ImageData, NotificationImage, NotificationVisualRole}; -#[test] -fn listing_projection_removes_raw_image_bytes_but_keeps_identifiers() { - let image = NotificationImage { - has_image_data: true, - image_data: ImageData { +fn image() -> NotificationImage { + NotificationImage { + badge_icon: "mail".to_string(), + sender_visual_role: NotificationVisualRole::ConversationAvatar, + sender_visual: ImageData { width: 1, height: 1, rowstride: 4, has_alpha: true, bits_per_sample: 8, channels: 4, - data: vec![9, 8, 7, 6], + data: vec![1, 2, 3, 4], }, - visual_role: super::super::NotificationVisualRole::None, - conversation_avatar: ImageData::default(), - image_path: "/tmp/icon.png".to_string(), - icon_name: "icon-name".to_string(), - }; - - let listing = image.for_listing(); - - assert!(!listing.has_image_data); - assert!(listing.image_data.data.is_empty()); - assert_eq!(listing.image_path, "/tmp/icon.png"); - assert_eq!(listing.icon_name, "icon-name"); -} - -#[test] -fn history_projection_drops_raw_data_only_when_alternate_identifier_exists() { - let with_icon = NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 1, height: 1, rowstride: 4, has_alpha: true, bits_per_sample: 8, channels: 4, - data: vec![1, 2, 3, 4], + data: vec![4, 3, 2, 1], }, - visual_role: super::super::NotificationVisualRole::None, - conversation_avatar: ImageData::default(), - image_path: String::new(), - icon_name: "app-icon".to_string(), - }; - let without_icon = NotificationImage { - icon_name: String::new(), - ..with_icon.clone() - }; + } +} + +#[test] +fn listing_projection_keeps_bounded_daemon_owned_pixels() { + let listing = image().for_listing(); + assert_eq!(listing, image()); +} - assert!(!with_icon.for_history().has_image_data); - assert!(without_icon.for_history().has_image_data); +#[test] +fn history_projection_keeps_safe_pixels_without_paths() { + let history = image().for_history(); + assert_eq!(history.sender_visual.data, vec![1, 2, 3, 4]); + assert_eq!(history.content_image.data, vec![4, 3, 2, 1]); } diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 81599db55..d61fbf70e 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -132,10 +132,10 @@ impl Notification { /// Create a history entry with heavyweight hint data stripped out #[must_use] pub fn to_history(&self) -> Self { - // History entries should never retain raw image-data blobs + // History entries keep only bounded daemon-owned image roles let mut image = self.image.clone(); - image.has_image_data = false; - image.image_data = Default::default(); + image.content_image = Default::default(); + image.sender_visual = Default::default(); Self { id: self.id, generation: self.generation, diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index d66cbe48d..0c5559a70 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -271,6 +271,7 @@ fn authenticated_and_native_policies_keep_action_surfaces_separate() { InlineReplyPolicy::Deny, "same-user native association cannot protect credential-like reply text" ); + assert!(native.may_read_sender_host_visual()); } #[test] @@ -291,6 +292,7 @@ fn portal_and_unassociated_policies_never_allow_silent_actions() { ApplicationActionPolicy::Confirm, "an app id without unforgeable provenance must not activate silently" ); + assert!(!portal.may_read_sender_host_visual()); for attribution in [ NotificationAttribution::recognized( @@ -325,3 +327,41 @@ fn portal_and_unassociated_policies_never_allow_silent_actions() { ); } } + +#[test] +fn host_visuals_require_both_positive_assurance_and_allowed_activation() { + let mut authenticated = NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example", + AttributionReason::ExactSystemExecutable, + "", + "verified:example".to_string(), + ); + authenticated.interactions = InteractionPolicies::DENY; + + assert!(!authenticated.may_read_sender_host_visual()); +} + +#[test] +fn verification_status_is_not_inferred_from_display_fields() { + let verified = NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example", + AttributionReason::ExactSystemExecutable, + "", + "verified:example".to_string(), + ); + let unresolved = NotificationAttribution::unresolved( + "Example", + AttributionReason::MissingSenderEvidence, + "", + "unknown:example".to_string(), + ); + + assert!(verified.is_verified()); + assert!(!unresolved.is_verified()); +} diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 3b9151366..c49c47a36 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -58,8 +58,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { fn image_with_raw_bytes() -> NotificationImage { NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 1, height: 1, rowstride: 4, @@ -68,10 +67,9 @@ fn image_with_raw_bytes() -> NotificationImage { channels: 4, data: vec![1, 2, 3, 4], }, - visual_role: crate::NotificationVisualRole::None, - conversation_avatar: ImageData::default(), - image_path: "/tmp/icon.png".to_string(), - icon_name: "mail".to_string(), + sender_visual_role: crate::NotificationVisualRole::None, + sender_visual: ImageData::default(), + badge_icon: "mail".to_string(), } } @@ -92,7 +90,7 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { assert_eq!(view.urgency, Urgency::Critical.as_u8()); assert!(view.is_transient); assert_eq!(view.received_at_unix_seconds, 1_700_000_000); - assert!(view.image.has_image_data); + assert!(!view.image.content_image.data.is_empty()); } #[test] @@ -280,10 +278,8 @@ fn list_view_strips_raw_image_bytes_but_keeps_icon_identifiers() { let view = notification.to_list_view(); // List rows should avoid carrying raw image buffers across D-Bus - assert!(!view.image.has_image_data); - assert!(view.image.image_data.data.is_empty()); - assert_eq!(view.image.image_path, "/tmp/icon.png"); - assert_eq!(view.image.icon_name, "mail"); + assert!(!view.image.content_image.data.is_empty()); + assert_eq!(view.image.badge_icon, "mail"); assert!(view.is_transient); } @@ -295,8 +291,7 @@ fn history_projection_drops_raw_hints_and_image_bytes() { // History entries should stay lightweight and avoid retaining raw D-Bus hints assert!(history.hints.is_empty()); - assert!(!history.image.has_image_data); - assert!(history.image.image_data.data.is_empty()); + assert!(history.image.content_image.data.is_empty()); assert_eq!(history.sender_name.as_deref(), Some(":1.42")); assert_eq!(history.sender_pid, Some(1234)); assert_eq!(history.sender_start_time, Some(9000)); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs index 836f999a1..2ea7e3853 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs @@ -11,10 +11,9 @@ use std::time::{Duration, Instant}; use rustix::fs::{openat2, Mode, OFlags, ResolveFlags, CWD}; use unixnotis_core::{ decode_image_asset_contents, util, Action, AssetPolicy, AttributionDiagnostics, Config, - IdentityAssurance, ImageData, InlineReply, InlineReplyPolicy, Notification, - NotificationAttribution, NotificationImage, NotificationVisualRole, Urgency, - DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, - DEFAULT_ICON_ASSET_MAX_WIDTH, + ImageData, InlineReply, InlineReplyPolicy, Notification, NotificationAttribution, + NotificationImage, NotificationVisualRole, Urgency, DEFAULT_ICON_ASSET_EXTENSIONS, + DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; use zbus::zvariant::{OwnedValue, Value}; @@ -33,7 +32,8 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) actions: Vec, pub(in crate::daemon::notifications) hints: HashMap, pub(in crate::daemon::notifications) image_data: Option, - pub(in crate::daemon::notifications) conversation_avatar: Option, + pub(in crate::daemon::notifications) sender_visual: Option, + pub(in crate::daemon::notifications) sender_visual_role: SenderVisualRole, pub(in crate::daemon::notifications) sender: SenderMetadata, pub(in crate::daemon::notifications) attribution: NotificationAttribution, pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, @@ -52,7 +52,8 @@ pub(in crate::daemon::notifications) fn build_notification( actions, hints, image_data, - conversation_avatar, + sender_visual, + sender_visual_role, sender, attribution, attribution_diagnostics, @@ -81,26 +82,27 @@ pub(in crate::daemon::notifications) fn build_notification( .and_then(|value| bool::try_from(value).ok()) .unwrap_or(false); let mut image = NotificationImage::from_hints(&app_name, &app_icon, &hints); + // Badge identity comes only from attribution selected by the daemon + image.badge_icon.clone_from(&attribution.badge_icon); if let Some(image_data) = image_data { - // The wire decoder already normalized this bounded image without dynamic byte expansion - image.has_image_data = true; - image.image_data = image_data; + // Embedded content pixels are already detached from the sender's filesystem + image.content_image = image_data; } // Only positive application association may expose a decoded sender avatar - if may_materialize_host_avatar(&attribution) { - if let Some(avatar) = conversation_avatar { + if may_read_sender_host_visual(&attribution) { + if let Some(avatar) = sender_visual { // The avatar is already decoded and bounded before this model is stored - image.visual_role = NotificationVisualRole::ConversationAvatar; - image.conversation_avatar = avatar; + image.sender_visual_role = match sender_visual_role { + SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, + SenderVisualRole::ApplicationProvidedIcon => { + NotificationVisualRole::ApplicationProvidedIcon + } + SenderVisualRole::None => NotificationVisualRole::None, + }; + image.sender_visual = avatar; } } - // Only verified senders may name host files for decoding. Untrusted, - // conflicting, relay, and portal-associated senders are stripped of - // host file paths to prevent parser delegation attacks (UNX-4-003). - if !attribution.is_verified() { - image.image_path = String::new(); - } let actions = parse_actions(actions); // Protocol metadata is parsed independently from the daemon's interaction decision let inline_reply = parse_inline_reply(&actions, &hints); @@ -119,7 +121,12 @@ pub(in crate::daemon::notifications) fn build_notification( } else { util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) }, - app_icon: util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), + // Absolute sender paths are materialized into pixels and never cross into clients + app_icon: if local_avatar_path(&app_icon).is_some() { + String::new() + } else { + util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES) + }, attribution, attribution_diagnostics, // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid @@ -157,10 +164,7 @@ pub(in crate::daemon::notifications) fn build_notification( // Keep sender-provided avatar work separate from the normal application badge path const MAX_CONVERSATION_AVATAR_BYTES: u64 = 2_097_152; -const MAX_CONVERSATION_AVATAR_DIMENSION: u32 = 256; const MAX_STORED_AVATAR_DIMENSION: u32 = 64; -const MAX_STORED_AVATAR_PIXELS: usize = - (MAX_STORED_AVATAR_DIMENSION as usize) * (MAX_STORED_AVATAR_DIMENSION as usize); pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration = Duration::from_millis(500); @@ -168,17 +172,13 @@ pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration pub(in crate::daemon::notifications) enum SenderVisualRole { None, ConversationAvatar, + ApplicationProvidedIcon, } -pub(in crate::daemon::notifications) const fn may_materialize_host_avatar( +pub(in crate::daemon::notifications) const fn may_read_sender_host_visual( attribution: &NotificationAttribution, ) -> bool { - matches!( - attribution.assurance, - IdentityAssurance::Authenticated - | IdentityAssurance::SystemAssociated - | IdentityAssurance::UserAssociated - ) + attribution.may_read_sender_host_visual() } pub(in crate::daemon::notifications) fn sender_visual_role( @@ -186,8 +186,9 @@ pub(in crate::daemon::notifications) fn sender_visual_role( index: &DesktopIdentityIndex, hints: &HashMap, actions: &[String], + app_icon: &str, ) -> SenderVisualRole { - if !may_materialize_host_avatar(attribution) { + if !may_read_sender_host_visual(attribution) { return SenderVisualRole::None; } // Inline reply is a stronger communication signal than a caller label @@ -212,13 +213,16 @@ pub(in crate::daemon::notifications) fn sender_visual_role( let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); if explicit_metadata || desktop_metadata { SenderVisualRole::ConversationAvatar + } else if local_avatar_path(app_icon).is_some() { + SenderVisualRole::ApplicationProvidedIcon } else { SenderVisualRole::None } } -pub(in crate::daemon::notifications) fn materialize_conversation_avatar( +pub(in crate::daemon::notifications) fn materialize_sender_visual( app_icon: &str, + max_dimension: u32, ) -> Option { // Decode while the daemon still controls the file read and parser limits let path = local_avatar_path(app_icon)?; @@ -252,15 +256,18 @@ pub(in crate::daemon::notifications) fn materialize_conversation_avatar( return None; } // The small policy keeps contact art from becoming an unbounded texture + let max_dimension = max_dimension.min(MAX_STORED_AVATAR_DIMENSION * 8); let policy = AssetPolicy { max_bytes: MAX_CONVERSATION_AVATAR_BYTES, - max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(MAX_CONVERSATION_AVATAR_DIMENSION), - max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(MAX_CONVERSATION_AVATAR_DIMENSION), - max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS.min(65_536), + max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(max_dimension), + max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(max_dimension), + max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS + .min(u64::from(max_dimension).checked_mul(u64::from(max_dimension))?), allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, }; let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; - let (width, height, rgba) = downsample_avatar(decoded.width, decoded.height, decoded.rgba)?; + let (width, height, rgba) = + downsample_avatar(decoded.width, decoded.height, decoded.rgba, max_dimension)?; let width = i32::try_from(width).ok()?; let height = i32::try_from(height).ok()?; let rowstride = width.checked_mul(4)?; @@ -281,7 +288,12 @@ pub(in crate::daemon::notifications) fn materialize_conversation_avatar( }) } -fn downsample_avatar(width: u32, height: u32, rgba: Vec) -> Option<(u32, u32, Vec)> { +fn downsample_avatar( + width: u32, + height: u32, + rgba: Vec, + max_dimension: u32, +) -> Option<(u32, u32, Vec)> { if width == 0 || height == 0 { return None; } @@ -295,19 +307,22 @@ fn downsample_avatar(width: u32, height: u32, rgba: Vec) -> Option<(u32, u32 let (target_width, target_height) = if width >= height { ( - MAX_STORED_AVATAR_DIMENSION.min(width), - width_to_height(width, height, MAX_STORED_AVATAR_DIMENSION), + max_dimension.min(width), + width_to_height(width, height, max_dimension), ) } else { ( - height_to_width(width, height, MAX_STORED_AVATAR_DIMENSION), - MAX_STORED_AVATAR_DIMENSION.min(height), + height_to_width(width, height, max_dimension), + max_dimension.min(height), ) }; let target_pixels = usize::try_from(target_width) .ok()? .checked_mul(usize::try_from(target_height).ok()?)?; - if target_pixels > MAX_STORED_AVATAR_PIXELS { + let max_pixels = usize::try_from(max_dimension) + .ok()? + .checked_mul(usize::try_from(max_dimension).ok()?)?; + if target_pixels > max_pixels { return None; } if target_width == width && target_height == height { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs index a532e8131..e264644d0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs @@ -5,7 +5,7 @@ use zbus::zvariant::OwnedValue; use super::{ avatar_buffer_size_allowed, avatar_file_size_allowed, build_notification, - materialize_conversation_avatar, may_materialize_host_avatar, owned_to_string, parse_actions, + materialize_sender_visual, may_read_sender_host_visual, owned_to_string, parse_actions, parse_urgency_hint, resolve_expiration, sanitize_hints_for_storage, sender_visual_role, string_to_owned_value, NotificationInput, SenderMetadata, SenderVisualRole, MAX_ACTIONS, MAX_BODY_BYTES, MAX_CONVERSATION_AVATAR_BYTES, MAX_SUMMARY_BYTES, @@ -27,7 +27,8 @@ fn build_notification_clamps_summary_and_body_sizes() { actions: Vec::new(), hints: HashMap::::new(), image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -56,7 +57,8 @@ fn build_notification_strips_display_spoofing_controls() { actions: vec!["default".to_string(), "Open\u{202E}".to_string()], hints: HashMap::::new(), image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { sender_name: Some(":1.test".to_string()), sender_pid: Some(42), @@ -101,7 +103,8 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints, image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { sender_executable: Some("/usr/bin/messages".to_string()), ..SenderMetadata::default() @@ -137,7 +140,8 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( actions: vec!["inline-reply".to_string(), "Password".to_string()], hints: HashMap::new(), image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { sender_name: Some(":1.hostile".to_string()), sender_executable: Some("/usr/bin/unknown-client".to_string()), @@ -178,7 +182,8 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints: HashMap::new(), image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::unresolved( "Messages", @@ -220,7 +225,8 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { actions: vec!["default".to_string(), "Open".to_string()], hints, image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::default(), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), @@ -251,7 +257,8 @@ fn conversation_avatar_never_changes_badge_or_unresolved_identity() { actions: Vec::new(), hints: HashMap::new(), image_data: None, - conversation_avatar: Some(avatar), + sender_visual: Some(avatar), + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::default(), attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), @@ -265,13 +272,13 @@ fn conversation_avatar_never_changes_badge_or_unresolved_identity() { "application-x-executable-symbolic" ); assert_eq!( - notification.image.visual_role, + notification.image.sender_visual_role, unixnotis_core::NotificationVisualRole::None ); } #[test] -fn verified_sender_keeps_explicit_message_image_path() { +fn sender_image_path_is_not_retained_in_notification_model() { let mut hints = HashMap::new(); hints.insert( "image-path".to_string(), @@ -286,7 +293,8 @@ fn verified_sender_keeps_explicit_message_image_path() { actions: Vec::new(), hints, image_data: None, - conversation_avatar: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), attribution: unixnotis_core::NotificationAttribution::verified( "Messages", @@ -302,16 +310,19 @@ fn verified_sender_keeps_explicit_message_image_path() { expire_timeout: 0, }); - assert_eq!(notification.image.image_path, "/tmp/message-image.png"); + assert!(notification.image.content_image.data.is_empty()); + assert!(!notification.hints.contains_key("image-path")); } #[test] fn associated_sender_role_accepts_inline_reply_and_message_categories() { - let attribution = unixnotis_core::NotificationAttribution::recognized( + let attribution = unixnotis_core::NotificationAttribution::associated( "Messages", "Messages", "org.example.Messages", "messages", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, unixnotis_core::AttributionReason::ExactUserExecutable, "associated executable", "recognized:system-app:org.example.Messages:sender".to_string(), @@ -324,6 +335,7 @@ fn associated_sender_role_accepts_inline_reply_and_message_categories() { &index, &HashMap::new(), &["inline-reply".to_string(), "Reply".to_string()], + "", ), SenderVisualRole::ConversationAvatar ); @@ -334,7 +346,7 @@ fn associated_sender_role_accepts_inline_reply_and_message_categories() { string_to_owned_value("im.received").expect("category value"), ); assert_eq!( - sender_visual_role(&attribution, &index, &hints, &[]), + sender_visual_role(&attribution, &index, &hints, &[], ""), SenderVisualRole::ConversationAvatar ); @@ -344,7 +356,7 @@ fn associated_sender_role_accepts_inline_reply_and_message_categories() { string_to_owned_value("im").expect("exact category value"), ); assert_eq!( - sender_visual_role(&attribution, &index, &exact, &[]), + sender_visual_role(&attribution, &index, &exact, &[], ""), SenderVisualRole::ConversationAvatar ); @@ -354,15 +366,39 @@ fn associated_sender_role_accepts_inline_reply_and_message_categories() { string_to_owned_value("other").expect("unrelated category value"), ); assert_eq!( - sender_visual_role(&attribution, &index, &unrelated, &[]), + sender_visual_role(&attribution, &index, &unrelated, &[], ""), SenderVisualRole::None ); assert_eq!( - sender_visual_role(&attribution, &index, &HashMap::new(), &[]), + sender_visual_role(&attribution, &index, &HashMap::new(), &[], ""), SenderVisualRole::None ); } +#[test] +fn associated_noncommunication_path_is_a_small_application_visual() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.Player:sender".to_string(), + ); + let role = sender_visual_role( + &attribution, + &super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &[], + "/tmp/application-icon.png", + ); + + assert_eq!(role, SenderVisualRole::ApplicationProvidedIcon); +} + #[test] fn portal_association_cannot_start_host_avatar_materialization() { let attribution = unixnotis_core::NotificationAttribution::associated( @@ -376,34 +412,80 @@ fn portal_association_cannot_start_host_avatar_materialization() { "portal supplied app id", "recognized:portal:org.example.PortalApp".to_string(), ); - assert!(!may_materialize_host_avatar(&attribution)); + assert!(!may_read_sender_host_visual(&attribution)); assert_eq!( sender_visual_role( &attribution, &super::super::super::identity::DesktopIdentityIndex::default(), &HashMap::new(), &["inline-reply".to_string(), "Reply".to_string()], + "", ), SenderVisualRole::None ); } +#[test] +fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { + let icon = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example player".to_string(), + app_icon: "example-player".to_string(), + summary: "Track".to_string(), + body: "Artist".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + sender_visual: Some(icon), + sender_visual_role: SenderVisualRole::ApplicationProvidedIcon, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected local association", + "associated:system-app:org.example.Player".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ); + assert_eq!(notification.image.badge_icon, "example-player"); +} + #[test] fn large_avatar_is_downsampled_to_the_storage_bound() { let source = vec![255_u8; 256 * 128 * 4]; - let (width, height, data) = super::downsample_avatar(256, 128, source).expect("downsample"); + let (width, height, data) = super::downsample_avatar(256, 128, source, 64).expect("downsample"); assert_eq!((width, height), (64, 32)); assert_eq!(data.len(), 64 * 32 * 4); } #[test] fn avatar_downsampling_rejects_zero_dimensions_and_keeps_exact_size_images() { - assert!(super::downsample_avatar(0, 1, Vec::new()).is_none()); - assert!(super::downsample_avatar(1, 0, Vec::new()).is_none()); + assert!(super::downsample_avatar(0, 1, Vec::new(), 64).is_none()); + assert!(super::downsample_avatar(1, 0, Vec::new(), 64).is_none()); let source = vec![7_u8; 64 * 64 * 4]; let source_ptr = source.as_ptr(); - let (width, height, data) = super::downsample_avatar(64, 64, source).expect("exact bound"); + let (width, height, data) = super::downsample_avatar(64, 64, source, 64).expect("exact bound"); assert_eq!((width, height), (64, 64)); assert_eq!(data.as_ptr(), source_ptr); } @@ -417,7 +499,7 @@ fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { horizontal[x * 4] = u8::try_from(x).expect("horizontal fixture value"); } let (width, height, data) = - super::downsample_avatar(128, 1, horizontal).expect("horizontal downsample"); + super::downsample_avatar(128, 1, horizontal, 64).expect("horizontal downsample"); assert_eq!((width, height), (64, 1)); assert_eq!(data[4], 2); @@ -426,7 +508,7 @@ fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { vertical[y * 64 * 4] = u8::try_from(y).expect("vertical fixture value"); } let (width, height, data) = - super::downsample_avatar(64, 128, vertical).expect("vertical downsample"); + super::downsample_avatar(64, 128, vertical, 64).expect("vertical downsample"); assert_eq!((width, height), (32, 64)); assert_eq!(data[32 * 4], 2); } @@ -450,7 +532,7 @@ fn fifo_avatar_path_is_rejected_without_opening_a_blocking_reader() { .status() .expect("mkfifo available"); assert!(status.success()); - assert!(materialize_conversation_avatar(&path_string).is_none()); + assert!(materialize_sender_visual(&path_string, 64).is_none()); let _ = std::fs::remove_file(path); let _ = std::fs::remove_dir(directory); } @@ -472,7 +554,7 @@ fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { let path = std::env::temp_dir().join(format!("unixnotis-avatar-{suffix}.png")); std::fs::write(&path, png).expect("write avatar fixture"); - let avatar = materialize_conversation_avatar(path.to_str().expect("utf8 fixture path")); + let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); let _ = std::fs::remove_file(&path); let avatar = avatar.expect("valid avatar should decode"); @@ -495,8 +577,8 @@ fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { #[test] fn relative_or_missing_avatar_path_is_rejected() { - assert!(materialize_conversation_avatar("avatar.png").is_none()); - assert!(materialize_conversation_avatar("/path/that/does/not/exist.png").is_none()); + assert!(materialize_sender_visual("avatar.png", 64).is_none()); + assert!(materialize_sender_visual("/path/that/does/not/exist.png", 64).is_none()); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 386718308..57374914f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -11,7 +11,7 @@ use crate::daemon::notifications::identity::{ resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, }; use crate::daemon::notifications::ingress::payload::{ - build_notification, materialize_conversation_avatar, owned_to_string, resolve_expiration, + build_notification, materialize_sender_visual, owned_to_string, resolve_expiration, sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; @@ -33,6 +33,7 @@ struct WireNotification { actions: Vec, hints: HashMap, image_data: Option, + image_path: Option, expire_timeout: i32, } @@ -60,7 +61,7 @@ impl NotificationServer { replaces_id, expire_timeout, ); - let (hints, image_data) = hints.into_parts(); + let (hints, image_data, image_path) = hints.into_parts(); let notification = self .notification_from_wire( WireNotification { @@ -71,6 +72,7 @@ impl NotificationServer { actions, hints, image_data, + image_path, expire_timeout, }, header, @@ -147,25 +149,17 @@ impl NotificationServer { ), ) .await; - let conversation_avatar = if matches!( - sender_visual_role( - &resolution.attribution, - &desktop_identity_index, - &input.hints, - &input.actions, - ), - SenderVisualRole::ConversationAvatar - ) { - let app_icon = input.app_icon.clone(); - run_avatar_worker( - move || materialize_conversation_avatar(&app_icon), - CONVERSATION_AVATAR_TIMEOUT, - ) - .await - .flatten() - } else { - None - }; + let sender_visual_role = sender_visual_role( + &resolution.attribution, + &desktop_identity_index, + &input.hints, + &input.actions, + &input.app_icon, + ); + let sender_visual = + materialize_sender_visual_for_role(sender_visual_role, input.app_icon.clone()).await; + let materialized_content = + materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -199,8 +193,9 @@ impl NotificationServer { body: input.body, actions: input.actions, hints: input.hints, - image_data: input.image_data, - conversation_avatar, + image_data: input.image_data.or(materialized_content), + sender_visual, + sender_visual_role, sender, attribution: resolution.attribution, attribution_diagnostics: resolution.diagnostics, @@ -329,6 +324,39 @@ impl NotificationServer { } } +async fn materialize_sender_visual_for_role( + role: SenderVisualRole, + app_icon: String, +) -> Option { + if matches!(role, SenderVisualRole::None) { + return None; + } + run_avatar_worker( + move || materialize_sender_visual(&app_icon, 64), + CONVERSATION_AVATAR_TIMEOUT, + ) + .await + .flatten() +} + +async fn materialize_content_visual( + attribution: &unixnotis_core::NotificationAttribution, + image_path: Option<&str>, +) -> Option { + if !crate::daemon::notifications::ingress::payload::may_read_sender_host_visual(attribution) { + return None; + } + let path = image_path + .filter(|path| !path.trim().is_empty()) + .map(str::to_owned)?; + run_avatar_worker( + move || materialize_sender_visual(&path, 512), + CONVERSATION_AVATAR_TIMEOUT, + ) + .await + .flatten() +} + #[cfg(test)] #[path = "tests/flow.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index 3733a5390..1a6154702 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -138,7 +138,7 @@ async fn native_image_above_retained_limit_keeps_the_text_notification() { .expect("notification should be retained"); assert_eq!(active.summary, "summary"); - assert!(!active.image.has_image_data); + assert!(active.image.content_image.data.is_empty()); } #[tokio::test] @@ -155,8 +155,8 @@ async fn native_image_within_retained_limit_reaches_the_notification_model() { .active_notification_view(id) .expect("notification should be retained"); - assert!(active.image.has_image_data); - assert_eq!(active.image.image_data.data.len(), 128 * 128 * 4); + assert!(!active.image.content_image.data.is_empty()); + assert_eq!(active.image.content_image.data.len(), 128 * 128 * 4); } #[tokio::test] @@ -249,7 +249,7 @@ async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order .active_notification_view(standard_id) .expect("standard image notification") .image - .image_data + .content_image .data, [1, 0, 0, 255] ); @@ -258,7 +258,7 @@ async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order .active_notification_view(legacy_id) .expect("legacy image notification") .image - .image_data + .content_image .data, [2, 0, 0, 255] ); @@ -267,7 +267,7 @@ async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order .active_notification_view(icon_id) .expect("legacy icon notification") .image - .image_data + .content_image .data, [3, 0, 0, 255] ); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs index c0c261e59..bac640e11 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs @@ -36,6 +36,7 @@ impl<'de> Visitor<'de> for WireHintsVisitor { let mut standard_image = None; let mut legacy_image = None; let mut legacy_icon = None; + let mut image_path = None; while let Some(key) = map.next_key::()? { let Some(kind) = HintKind::for_key(&key) else { @@ -46,7 +47,14 @@ impl<'de> Visitor<'de> for WireHintsVisitor { let decoded = map.next_value_seed(HintVariantSeed { kind })?; match decoded { DecodedHint::Text(text) => { - values.insert(key, owned_string(&text).map_err(A::Error::custom)?); + let value = owned_string(&text).map_err(A::Error::custom)?; + if matches!(key.as_str(), "image-path" | "image_path") { + image_path = value + .try_clone() + .ok() + .and_then(|owned| String::try_from(owned).ok()); + } + values.insert(key, value); } DecodedHint::Bool(value) => { values.insert(key, OwnedValue::from(value)); @@ -70,6 +78,7 @@ impl<'de> Visitor<'de> for WireHintsVisitor { Ok(WireHints { values, image_data: standard_image.or(legacy_image).or(legacy_icon), + image_path, }) } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs index 560379384..8a4cd6f9a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs @@ -13,20 +13,33 @@ use zbus::zvariant::{OwnedValue, Signature, Type}; pub(super) struct WireHints { values: HashMap, image_data: Option, + image_path: Option, } impl WireHints { - pub(super) fn into_parts(self) -> (HashMap, Option) { - (self.values, self.image_data) + pub(super) fn into_parts( + self, + ) -> ( + HashMap, + Option, + Option, + ) { + (self.values, self.image_data, self.image_path) } } impl From> for WireHints { fn from(values: HashMap) -> Self { // Internal tests and helpers may still supply an already-decoded hint map + let image_path = values + .get("image-path") + .or_else(|| values.get("image_path")) + .and_then(|value| value.try_clone().ok()) + .and_then(|value| String::try_from(value).ok()); Self { values, image_data: None, + image_path, } } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index a168ee4fd..aecdafa8a 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -32,7 +32,7 @@ pub(super) fn build_identity_avatar( view: &PopupEntryViewModel, size: i32, ) -> IdentityAvatar { - let has_conversation_avatar = notification.image.visual_role + let has_conversation_avatar = notification.image.sender_visual_role == unixnotis_core::NotificationVisualRole::ConversationAvatar; let icon_size = if has_conversation_avatar { size diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs index 6206cd320..f157978a2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -54,7 +54,7 @@ pub(super) fn build_popup_grid( if let Some(body) = build_body_label(view, layout.body_lines) { message.append(&body); } - let has_image = append_thumbnail(state, notification, view, &message); + let has_image = append_thumbnail(notification, view, &message); if layout.show_reply_note { if let Some(note) = build_reply_note(view) { message.append(¬e); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index c27c83889..9b9d55ca0 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -39,7 +39,6 @@ pub(super) fn build_popup_content( } pub(super) fn append_thumbnail( - state: &UiState, notification: &NotificationView, view: &PopupEntryViewModel, content: >k::Box, @@ -47,7 +46,7 @@ pub(super) fn append_thumbnail( if view.thumbnail != super::presentation::ThumbnailKind::Content { return false; } - let Some(image) = state.build_content_image_widget(notification) else { + let Some(image) = UiState::build_content_image_widget(notification) else { return false; }; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 2bac0f9a2..96773e668 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -348,8 +348,9 @@ fn communication_identity_avatar_prefers_materialized_conversation_image() { let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); let mut notification = notification(); notification.inline_reply.available = true; - notification.image.visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; - notification.image.conversation_avatar = unixnotis_core::ImageData { + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = unixnotis_core::ImageData { width: 1, height: 1, rowstride: 4, diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 3e5e4677b..494ca217b 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -133,10 +133,10 @@ fn user_associated_attribution_hides_application_directed_actions() { fn communication_avatar_is_not_suppressed_as_decoration() { let mut view = notification(); view.category = "im.received".to_string(); - view.image.has_image_data = true; - view.image.image_data = ImageData { + view.image.content_image = ImageData { width: 64, height: 64, + data: vec![0; 64 * 64 * 4], ..ImageData::default() }; @@ -147,14 +147,22 @@ fn communication_avatar_is_not_suppressed_as_decoration() { } #[test] -fn thumbnail_requires_real_image_data_or_a_nonempty_path() { +fn thumbnail_requires_real_image_data() { let mut view = notification(); assert_eq!( PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, ThumbnailKind::None ); - view.image.image_path = "/tmp/content.png".to_string(); + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; assert_eq!( PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, ThumbnailKind::Content @@ -165,11 +173,11 @@ fn thumbnail_requires_real_image_data_or_a_nonempty_path() { fn app_icon_name_never_suppresses_real_content_image_data() { let mut icon_match = notification(); icon_match.attribution.badge_icon = "example".to_string(); - icon_match.image.has_image_data = true; - icon_match.image.icon_name = "example".to_string(); - icon_match.image.image_data = ImageData { + icon_match.image.badge_icon = "example".to_string(); + icon_match.image.content_image = ImageData { width: 160, height: 90, + data: vec![0; 160 * 90 * 4], ..ImageData::default() }; assert_eq!( @@ -179,14 +187,22 @@ fn app_icon_name_never_suppresses_real_content_image_data() { let mut path_match = notification(); path_match.attribution.badge_icon = "example".to_string(); - path_match.image.image_path = "example".to_string(); + path_match.image.content_image = ImageData::default(); assert_eq!( PopupEntryViewModel::for_notification_at(&path_match, 1_000).thumbnail, ThumbnailKind::None ); let mut no_match = path_match; - no_match.image.image_path = "different".to_string(); + no_match.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; assert_eq!( PopupEntryViewModel::for_notification_at(&no_match, 1_000).thumbnail, ThumbnailKind::Content @@ -194,31 +210,38 @@ fn app_icon_name_never_suppresses_real_content_image_data() { } #[test] -fn image_dimensions_alone_never_prove_badge_duplication() { +fn invalid_image_dimensions_do_not_create_thumbnail_content() { let mut view = notification(); - view.image.has_image_data = true; - for (width, height) in [(0, 0), (64, 64), (96, 72), (128, 128), (129, 129)] { - view.image.image_data = ImageData { + view.image.content_image = ImageData { width, height, + data: if width > 0 && height > 0 { + vec![0; 4] + } else { + Vec::new() + }, ..ImageData::default() }; + let expected = if width > 0 && height > 0 { + ThumbnailKind::Content + } else { + ThumbnailKind::None + }; assert_eq!( PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, - ThumbnailKind::Content, - "{width}x{height} should remain real notification content" + expected ); } } #[test] -fn square_path_content_is_not_mistaken_for_embedded_icon_data() { +fn square_content_is_rendered_as_notification_content() { let mut view = notification(); - view.image.image_path = "/tmp/content.png".to_string(); - view.image.image_data = ImageData { + view.image.content_image = ImageData { width: 64, height: 64, + data: vec![0; 64 * 64 * 4], ..ImageData::default() }; diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index bdb8c6c45..c2280f6c9 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -32,13 +32,15 @@ impl UiState { size: i32, ) -> Option { // Conversation art is safe to render here because the daemon sent pixels, not a path - if notification.image.visual_role - != unixnotis_core::NotificationVisualRole::ConversationAvatar - { + if !matches!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + | unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ) { return None; } - let texture = image_data_texture_for_data(¬ification.image.conversation_avatar)?; + let texture = image_data_texture_for_data(¬ification.image.sender_visual)?; let widget = gtk::Image::from_paintable(Some(&texture)); set_popup_icon_size(&widget, size); widget.add_css_class("unixnotis-popup-conversation-avatar"); @@ -46,7 +48,6 @@ impl UiState { } pub(super) fn build_content_image_widget( - &self, notification: &NotificationView, ) -> Option { if let Some(texture) = image_data_texture(¬ification.image) { @@ -55,10 +56,7 @@ impl UiState { return Some(widget); } - if notification.image.image_path.trim().is_empty() { - return None; - } - self.resolve_icon_widget(¬ification.image.image_path, POPUP_CONTENT_THUMBNAIL_SIZE) + None } pub(super) fn build_app_icon_widget( diff --git a/crates/unixnotis-popups/src/ui/icons/content.rs b/crates/unixnotis-popups/src/ui/icons/content.rs index 54b0a2fc7..46f45e52b 100644 --- a/crates/unixnotis-popups/src/ui/icons/content.rs +++ b/crates/unixnotis-popups/src/ui/icons/content.rs @@ -6,11 +6,11 @@ use unixnotis_core::{ImageData, NotificationImage}; pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option { // Content images stay separate from the authenticated application badge - if !image.has_image_data { + if image.content_image.data.is_empty() { return None; } - image_data_texture_for_data(&image.image_data) + image_data_texture_for_data(&image.content_image) } pub(in crate::ui) fn image_data_texture_for_data(data: &ImageData) -> Option { diff --git a/crates/unixnotis-popups/src/ui/icons/tests/content.rs b/crates/unixnotis-popups/src/ui/icons/tests/content.rs index 8ecffd5f9..f4828fd40 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/content.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/content.rs @@ -2,8 +2,7 @@ use super::*; fn image_data(channels: i32, rowstride: i32, data: Vec) -> NotificationImage { NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 2, height: 1, rowstride, @@ -20,7 +19,7 @@ fn image_data(channels: i32, rowstride: i32, data: Vec) -> NotificationImage fn rgb_content_image_expands_to_opaque_rgba() { let image = image_data(3, 8, vec![1, 2, 3, 4, 5, 6, 90, 91]); - let (bytes, stride) = expand_rgb_to_rgba(&image.image_data).expect("valid RGB data"); + let (bytes, stride) = expand_rgb_to_rgba(&image.content_image).expect("valid RGB data"); assert_eq!(stride, 8); assert_eq!(bytes, vec![1, 2, 3, 255, 4, 5, 6, 255]); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index e246e5b96..a2fed6488 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -26,7 +26,8 @@ fn collect_icon_candidates_dedupes_empty_and_repeated_values() { #[test] fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { let mut notification = notification("authenticated-app", "trusted-badge"); - notification.image.icon_name = "caller-controlled-content".to_string(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; let candidates = collect_icon_candidates(¬ification); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index 850e17281..ae4718239 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -20,7 +20,7 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView is_transient: false, received_at_unix_seconds: 0, image: NotificationImage { - icon_name: icon_name.to_string(), + badge_icon: icon_name.to_string(), ..NotificationImage::default() }, popup_decision: unixnotis_core::PopupDecisionRecord::default(), diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 3c3cc68ac..67ca4d059 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -352,7 +352,7 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), }; - notification.image.icon_name = "signal-desktop".to_string(); + notification.image.badge_icon = "signal-desktop".to_string(); let root = state.build_popup_root(¬ification); diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 8add3787e..74d58ec69 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -89,8 +89,7 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { let mut content = notification(8, 1, "content"); content.category = "image.photo".to_string(); content.image = NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 2, height: 1, rowstride: 8, @@ -102,7 +101,7 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { ..NotificationImage::default() }; // Image categories retain real content even when the thumbnail is compact - assert!(state.build_content_image_widget(&content).is_some()); + assert!(UiState::build_content_image_widget(&content).is_some()); let content_root = state.build_popup_root(&content); assert!(content_root.has_css_class(hooks::popup_card::HAS_IMAGE)); assert!(descendant_has_class( @@ -114,7 +113,7 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { missing_content.attribution.badge_icon.clear(); missing_content.attribution.desktop_id.clear(); // Empty content and badge sources must not create placeholder image widgets - assert!(state.build_content_image_widget(&missing_content).is_none()); + assert!(UiState::build_content_image_widget(&missing_content).is_none()); assert!(state.build_app_icon_widget(&missing_content, 20).is_none()); let missing_root = state.build_popup_root(&missing_content); assert!(!missing_root.has_css_class(hooks::popup_card::HAS_IMAGE)); diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 26c270cf4..6cd16bba2 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -307,11 +307,7 @@ fn action_view(action: &Action, policy: ApplicationActionPolicy) -> ActionView { } fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { - let has_content = - notification.image.has_image_data || !notification.image.image_path.trim().is_empty(); - if !has_content { - return ThumbnailKind::None; - } + let has_content = !notification.image.content_image.data.is_empty(); let category_is_media = ["image", "media", "photo"].iter().any(|category| { notification .category @@ -320,49 +316,12 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { .unwrap_or_default() .eq_ignore_ascii_case(category) }); - let identity_is_verified = matches!( - notification.attribution.assurance, - IdentityAssurance::Authenticated - ); - if !identity_is_verified { - // Untrusted senders need an explicit media category before large imagery is shown - return if category_is_media { - ThumbnailKind::Content - } else { - ThumbnailKind::None - }; - } - if notification.image.has_image_data - || category_is_media - || !image_path_matches_authenticated_badge(notification) - { + if category_is_media || has_content { return ThumbnailKind::Content; } ThumbnailKind::None } -fn image_path_matches_authenticated_badge(notification: &NotificationView) -> bool { - let badge = notification.attribution.badge_icon.trim(); - if badge.is_empty() { - return false; - } - let image_path = notification.image.image_path.trim(); - if image_path == badge { - return true; - } - - // Canonical identity handles symlink aliases without treating dimensions as evidence - let badge_path = std::path::Path::new(badge); - let image_path = std::path::Path::new(image_path); - if !badge_path.is_absolute() || !image_path.is_absolute() { - return false; - } - let Some(badge_path) = std::fs::canonicalize(badge_path).ok() else { - return false; - }; - std::fs::canonicalize(image_path).is_ok_and(|path| path == badge_path) -} - fn relative_time_label(received_at: i64, now: i64) -> String { if received_at <= 0 { return "now".to_string(); diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index c7dcaab36..05716e13a 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -24,10 +24,10 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { label: "Open".to_string(), }, ]; - view.image.has_image_data = true; - view.image.image_data = ImageData { + view.image.content_image = ImageData { width: 64, height: 64, + data: vec![0; 64 * 64 * 4], ..ImageData::default() }; @@ -191,7 +191,7 @@ fn trusted_relay_claim_never_becomes_the_primary_application_identity() { "Sent via /usr/bin/notify-send", "relay:notify-send:signal".to_string(), ); - view.image.icon_name = "signal-desktop".to_string(); + view.image.badge_icon = "signal-desktop".to_string(); let presentation = NotificationPresentation::from_view_at(&view, 1_000); @@ -353,7 +353,6 @@ fn untrusted_non_media_notification_cannot_render_content_art() { "Sent via /usr/bin/notify-send", "relay:notify-send:signal".to_string(), ); - view.image.image_path = "/tmp/signal-logo.png".to_string(); assert_eq!( NotificationPresentation::from_view_at(&view, 1_000) @@ -489,12 +488,20 @@ fn empty_and_generic_claims_never_create_secondary_identity_copy() { } #[test] -fn verified_media_category_or_pixel_data_can_override_duplicate_badge_suppression() { +fn media_category_or_pixel_data_can_select_content() { for (has_image_data, category) in [(false, "image.received"), (true, "")] { let mut view = notification(); - view.attribution.badge_icon = "same-icon".to_string(); - view.image.image_path = "same-icon".to_string(); - view.image.has_image_data = has_image_data; + if has_image_data { + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + } view.category = category.to_string(); assert_eq!( @@ -507,91 +514,6 @@ fn verified_media_category_or_pixel_data_can_override_duplicate_badge_suppressio } } -#[test] -fn verified_plain_image_path_suppresses_only_duplicate_badging() { - let mut view = notification(); - view.attribution.badge_icon = "same-icon".to_string(); - view.image.image_path = "same-icon".to_string(); - assert_eq!( - NotificationPresentation::from_view_at(&view, 1_000) - .media - .thumbnail, - ThumbnailKind::None, - "the authenticated badge must not be repeated as content" - ); - - view.image.image_path = "different-content".to_string(); - assert_eq!( - NotificationPresentation::from_view_at(&view, 1_000) - .media - .thumbnail, - ThumbnailKind::Content, - "a distinct explicit content path should remain visible" - ); - - view.attribution.badge_icon.clear(); - assert_eq!( - NotificationPresentation::from_view_at(&view, 1_000) - .media - .thumbnail, - ThumbnailKind::Content, - "a missing badge cannot make explicit content look duplicated" - ); - - for (badge, image_path) in [ - ("relative-badge", "/absolute/content.png"), - ("/absolute/badge.png", "relative-content"), - ] { - view.attribution.badge_icon = badge.to_string(); - view.image.image_path = image_path.to_string(); - assert_eq!( - NotificationPresentation::from_view_at(&view, 1_000) - .media - .thumbnail, - ThumbnailKind::Content, - "mixed absolute and symbolic sources cannot establish duplicate identity" - ); - } - - let fixture = std::fs::canonicalize("Cargo.toml").expect("resolve package manifest fixture"); - view.attribution.badge_icon = "Cargo.toml".to_string(); - view.image.image_path = fixture.to_string_lossy().into_owned(); - assert_eq!( - NotificationPresentation::from_view_at(&view, 1_000) - .media - .thumbnail, - ThumbnailKind::Content, - "a relative badge name must not alias an absolute content path" - ); -} - -#[cfg(unix)] -#[test] -fn verified_badge_symlink_is_suppressed_by_canonical_file_identity() { - use std::os::unix::fs::symlink; - - let root = std::env::temp_dir().join(format!( - "unixnotis-presentation-badge-{}", - std::process::id() - )); - std::fs::create_dir_all(&root).expect("create badge fixture directory"); - let badge = root.join("badge.svg"); - let alias = root.join("badge-alias.svg"); - std::fs::write(&badge, b"").expect("write badge fixture"); - let _ = std::fs::remove_file(&alias); - symlink(&badge, &alias).expect("create badge alias"); - - let mut view = notification(); - view.attribution.badge_icon = badge.to_string_lossy().into_owned(); - view.image.image_path = alias.to_string_lossy().into_owned(); - let presentation = NotificationPresentation::from_view_at(&view, 1_000); - - assert_eq!(presentation.media.thumbnail, ThumbnailKind::None); - std::fs::remove_file(&alias).expect("remove badge alias"); - std::fs::remove_file(&badge).expect("remove badge fixture"); - std::fs::remove_dir(&root).expect("remove badge fixture directory"); -} - #[test] fn shared_model_keeps_user_association_unverified_and_noninteractive() { let mut view = notification(); From 07cdd74057dcaae4622f35345df3dd8532f3bc63 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 03:44:35 -0500 Subject: [PATCH 200/275] refactor(daemon): split notification payload responsibilities Summary: split notification payload responsibilities. Scope: daemon. --- .../daemon/notifications/ingress/payload.rs | 546 ------------------ .../notifications/ingress/payload/build.rs | 183 ++++++ .../ingress/payload/expiration.rs | 31 + .../notifications/ingress/payload/mod.rs | 17 + .../notifications/ingress/payload/sanitize.rs | 98 ++++ .../notifications/ingress/payload/visuals.rs | 299 ++++++++++ .../src/daemon/notifications/server/flow.rs | 3 +- 7 files changed, 630 insertions(+), 547 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs deleted file mode 100644 index 2ea7e3853..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload.rs +++ /dev/null @@ -1,546 +0,0 @@ -//! Payload construction and sanitization for notifications -//! -//! This module turns raw D-Bus values into bounded internal model values - -use std::cmp::Ordering; -use std::collections::HashMap; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use rustix::fs::{openat2, Mode, OFlags, ResolveFlags, CWD}; -use unixnotis_core::{ - decode_image_asset_contents, util, Action, AssetPolicy, AttributionDiagnostics, Config, - ImageData, InlineReply, InlineReplyPolicy, Notification, NotificationAttribution, - NotificationImage, NotificationVisualRole, Urgency, DEFAULT_ICON_ASSET_EXTENSIONS, - DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, -}; -use zbus::zvariant::{OwnedValue, Value}; - -use super::super::identity::{DesktopIdentityIndex, SenderMetadata}; -use super::limits::{ - MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, - MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, - MAX_HINT_STRING_BYTES, MAX_SUMMARY_BYTES, -}; - -pub(in crate::daemon::notifications) struct NotificationInput { - pub(in crate::daemon::notifications) app_name: String, - pub(in crate::daemon::notifications) app_icon: String, - pub(in crate::daemon::notifications) summary: String, - pub(in crate::daemon::notifications) body: String, - pub(in crate::daemon::notifications) actions: Vec, - pub(in crate::daemon::notifications) hints: HashMap, - pub(in crate::daemon::notifications) image_data: Option, - pub(in crate::daemon::notifications) sender_visual: Option, - pub(in crate::daemon::notifications) sender_visual_role: SenderVisualRole, - pub(in crate::daemon::notifications) sender: SenderMetadata, - pub(in crate::daemon::notifications) attribution: NotificationAttribution, - pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, - pub(in crate::daemon::notifications) inline_reply_policy: InlineReplyPolicy, - pub(in crate::daemon::notifications) expire_timeout: i32, -} - -pub(in crate::daemon::notifications) fn build_notification( - input: NotificationInput, -) -> Notification { - let NotificationInput { - app_name, - app_icon, - summary, - body, - actions, - hints, - image_data, - sender_visual, - sender_visual_role, - sender, - attribution, - attribution_diagnostics, - inline_reply_policy, - expire_timeout, - } = input; - - // Read shared hint data first - let urgency = Urgency::from_hint(hints.get("urgency")); - let category = hints - .get("category") - .and_then(owned_to_string) - .map(|value| { - // Category stays on one line - util::truncate_utf8_bytes( - &util::sanitize_inline_display_text(&value), - MAX_CATEGORY_BYTES, - ) - }); - let is_transient = hints - .get("transient") - .and_then(|value| bool::try_from(value).ok()) - .unwrap_or(false); - let is_resident = hints - .get("resident") - .and_then(|value| bool::try_from(value).ok()) - .unwrap_or(false); - let mut image = NotificationImage::from_hints(&app_name, &app_icon, &hints); - // Badge identity comes only from attribution selected by the daemon - image.badge_icon.clone_from(&attribution.badge_icon); - if let Some(image_data) = image_data { - // Embedded content pixels are already detached from the sender's filesystem - image.content_image = image_data; - } - // Only positive application association may expose a decoded sender avatar - if may_read_sender_host_visual(&attribution) { - if let Some(avatar) = sender_visual { - // The avatar is already decoded and bounded before this model is stored - image.sender_visual_role = match sender_visual_role { - SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, - SenderVisualRole::ApplicationProvidedIcon => { - NotificationVisualRole::ApplicationProvidedIcon - } - SenderVisualRole::None => NotificationVisualRole::None, - }; - image.sender_visual = avatar; - } - } - - let actions = parse_actions(actions); - // Protocol metadata is parsed independently from the daemon's interaction decision - let inline_reply = parse_inline_reply(&actions, &hints); - // Clean text before storing it - let app_name = util::sanitize_inline_display_text(&app_name); - let summary = util::sanitize_display_text(&summary); - let body = util::sanitize_display_text(&body); - - Notification { - id: 0, - // The store assigns a process-wide generation during the commit - generation: 0, - app_name: if app_name.is_empty() { - // Keep explicit fallback text for empty callers - "Unknown".to_string() - } else { - util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) - }, - // Absolute sender paths are materialized into pixels and never cross into clients - app_icon: if local_avatar_path(&app_icon).is_some() { - String::new() - } else { - util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES) - }, - attribution, - attribution_diagnostics, - // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid - // Fold very long unbroken runs so renderer width remains bounded - summary: util::fold_text_for_layout( - &util::truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), - util::MAX_DISPLAY_TOKEN_WIDTH, - ), - // Apply the same order for body so renderer sees consistent text constraints - // Body can be much larger, so apply the same run-folding protection here - body: util::fold_text_for_layout( - &util::truncate_utf8_bytes(&body, MAX_BODY_BYTES), - util::MAX_DISPLAY_TOKEN_WIDTH, - ), - actions, - inline_reply, - inline_reply_policy, - // Keep only needed hints - hints: sanitize_hints_for_storage(hints), - urgency, - category, - is_transient, - is_resident, - suppress_popup: false, - suppress_sound: false, - image, - expire_timeout, - received_at: chrono::Utc::now(), - sender_name: sender.sender_name, - sender_pid: sender.sender_pid, - sender_start_time: sender.sender_start_time, - sender_executable: sender.sender_executable, - } -} - -// Keep sender-provided avatar work separate from the normal application badge path -const MAX_CONVERSATION_AVATAR_BYTES: u64 = 2_097_152; -const MAX_STORED_AVATAR_DIMENSION: u32 = 64; -pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration = - Duration::from_millis(500); - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(in crate::daemon::notifications) enum SenderVisualRole { - None, - ConversationAvatar, - ApplicationProvidedIcon, -} - -pub(in crate::daemon::notifications) const fn may_read_sender_host_visual( - attribution: &NotificationAttribution, -) -> bool { - attribution.may_read_sender_host_visual() -} - -pub(in crate::daemon::notifications) fn sender_visual_role( - attribution: &NotificationAttribution, - index: &DesktopIdentityIndex, - hints: &HashMap, - actions: &[String], - app_icon: &str, -) -> SenderVisualRole { - if !may_read_sender_host_visual(attribution) { - return SenderVisualRole::None; - } - // Inline reply is a stronger communication signal than a caller label - if actions - .chunks_exact(2) - .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) - { - return SenderVisualRole::ConversationAvatar; - } - // Categories are protocol metadata and remain only a presentation hint - let explicit_metadata = - hints - .get("category") - .and_then(owned_to_string) - .is_some_and(|category| { - let category = category.to_ascii_lowercase(); - ["im", "chat", "message", "email", "mail"] - .iter() - .any(|marker| category.split('.').any(|part| part == *marker)) - }); - // The index rejects empty and unknown IDs, so no separate string check is needed here - let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); - if explicit_metadata || desktop_metadata { - SenderVisualRole::ConversationAvatar - } else if local_avatar_path(app_icon).is_some() { - SenderVisualRole::ApplicationProvidedIcon - } else { - SenderVisualRole::None - } -} - -pub(in crate::daemon::notifications) fn materialize_sender_visual( - app_icon: &str, - max_dimension: u32, -) -> Option { - // Decode while the daemon still controls the file read and parser limits - let path = local_avatar_path(app_icon)?; - // Nonblocking and no-follow flags prevent special files and final-component symlinks from - // turning the bounded worker into a blocking host-file reader - let descriptor = openat2( - CWD, - &path, - OFlags::RDONLY - .union(OFlags::NONBLOCK) - .union(OFlags::CLOEXEC) - .union(OFlags::NOFOLLOW), - Mode::empty(), - ResolveFlags::NO_MAGICLINKS, - ) - .ok()?; - let mut file = std::fs::File::from(descriptor); - let metadata = file.metadata().ok()?; - if !metadata.is_file() { - return None; - } - if !avatar_file_size_allowed(metadata.len()) { - return None; - } - let mut bytes = Vec::new(); - file.by_ref() - .take(MAX_CONVERSATION_AVATAR_BYTES.saturating_add(1)) - .read_to_end(&mut bytes) - .ok()?; - if !avatar_buffer_size_allowed(bytes.len()) { - return None; - } - // The small policy keeps contact art from becoming an unbounded texture - let max_dimension = max_dimension.min(MAX_STORED_AVATAR_DIMENSION * 8); - let policy = AssetPolicy { - max_bytes: MAX_CONVERSATION_AVATAR_BYTES, - max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(max_dimension), - max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(max_dimension), - max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS - .min(u64::from(max_dimension).checked_mul(u64::from(max_dimension))?), - allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, - }; - let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; - let (width, height, rgba) = - downsample_avatar(decoded.width, decoded.height, decoded.rgba, max_dimension)?; - let width = i32::try_from(width).ok()?; - let height = i32::try_from(height).ok()?; - let rowstride = width.checked_mul(4)?; - let expected = usize::try_from(rowstride) - .ok()? - .checked_mul(usize::try_from(height).ok()?)?; - if rgba.len() != expected { - return None; - } - Some(ImageData { - width, - height, - rowstride, - has_alpha: true, - bits_per_sample: 8, - channels: 4, - data: rgba, - }) -} - -fn downsample_avatar( - width: u32, - height: u32, - rgba: Vec, - max_dimension: u32, -) -> Option<(u32, u32, Vec)> { - if width == 0 || height == 0 { - return None; - } - let source_pixels = usize::try_from(width) - .ok()? - .checked_mul(usize::try_from(height).ok()?)?; - let source_bytes = source_pixels.checked_mul(4)?; - if rgba.len() != source_bytes { - return None; - } - - let (target_width, target_height) = if width >= height { - ( - max_dimension.min(width), - width_to_height(width, height, max_dimension), - ) - } else { - ( - height_to_width(width, height, max_dimension), - max_dimension.min(height), - ) - }; - let target_pixels = usize::try_from(target_width) - .ok()? - .checked_mul(usize::try_from(target_height).ok()?)?; - let max_pixels = usize::try_from(max_dimension) - .ok()? - .checked_mul(usize::try_from(max_dimension).ok()?)?; - if target_pixels > max_pixels { - return None; - } - if target_width == width && target_height == height { - return Some((width, height, rgba)); - } - - let mut output = vec![0u8; target_pixels.checked_mul(4)?]; - for target_y in 0..target_height { - let source_y = u32::try_from( - usize::try_from(target_y) - .ok()? - .checked_mul(usize::try_from(height).ok()?)? - / usize::try_from(target_height).ok()?, - ) - .ok()?; - for target_x in 0..target_width { - let source_x = u32::try_from( - usize::try_from(target_x) - .ok()? - .checked_mul(usize::try_from(width).ok()?)? - / usize::try_from(target_width).ok()?, - ) - .ok()?; - let source_index = usize::try_from(source_y) - .ok()? - .checked_mul(usize::try_from(width).ok()?)? - .checked_add(usize::try_from(source_x).ok()?)? - .checked_mul(4)?; - let target_index = usize::try_from(target_y) - .ok()? - .checked_mul(usize::try_from(target_width).ok()?)? - .checked_add(usize::try_from(target_x).ok()?)? - .checked_mul(4)?; - output[target_index..target_index + 4] - .copy_from_slice(&rgba[source_index..source_index + 4]); - } - } - Some((target_width, target_height, output)) -} - -fn width_to_height(width: u32, height: u32, target_width: u32) -> u32 { - if width <= target_width { - return height; - } - u64::from(height) - .saturating_mul(u64::from(target_width)) - .checked_div(u64::from(width)) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(1) - .max(1) -} - -fn height_to_width(width: u32, height: u32, target_height: u32) -> u32 { - if height <= target_height { - return width; - } - u64::from(width) - .saturating_mul(u64::from(target_height)) - .checked_div(u64::from(height)) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(1) - .max(1) -} - -const fn avatar_file_size_allowed(size: u64) -> bool { - size <= MAX_CONVERSATION_AVATAR_BYTES -} - -const fn avatar_buffer_size_allowed(size: usize) -> bool { - size <= MAX_CONVERSATION_AVATAR_BYTES as usize -} - -fn local_avatar_path(value: &str) -> Option { - if value.starts_with('/') { - return Some(PathBuf::from(value)); - } - let path = value.strip_prefix("file://")?; - let path = path.strip_prefix("localhost/").unwrap_or(path); - path.starts_with('/').then(|| Path::new(path).to_path_buf()) -} - -pub(in crate::daemon::notifications) fn resolve_expiration( - config: &Config, - notification: &Notification, -) -> Option { - // Resident notifications never auto-expire - if notification.is_resident { - return None; - } - - let timeout_ms = match notification.expire_timeout.cmp(&0) { - // Explicit timeout=0 disables auto-expiration - Ordering::Equal => return None, - // Positive values are caller-provided milliseconds - Ordering::Greater => notification.expire_timeout as u64, - // Negative values request defaults by urgency - Ordering::Less => match notification.urgency { - Urgency::Critical => config.popups.critical_timeout_ms?, - _ => config.popups.default_timeout_ms, - }, - }; - - if timeout_ms == 0 { - return None; - } - - Some(Instant::now() + Duration::from_millis(timeout_ms)) -} - -fn parse_inline_reply(actions: &[Action], hints: &HashMap) -> InlineReply { - let Some(action) = actions.iter().find(|action| action.key == "inline-reply") else { - // Reply hints without the protocol action cannot create a text control - return InlineReply::default(); - }; - - InlineReply { - available: true, - label: action.label.clone(), - placeholder: reply_hint_text(hints, "x-kde-reply-placeholder-text"), - submit_label: reply_hint_text(hints, "x-kde-reply-submit-button-text"), - submit_icon: reply_hint_text(hints, "x-kde-reply-submit-button-icon-name"), - } -} - -fn reply_hint_text(hints: &HashMap, key: &str) -> String { - let Some(value) = hints.get(key).and_then(owned_to_string) else { - return String::new(); - }; - // Reply controls are single-line GTK widgets, so layout controls are removed here - let clean = util::sanitize_inline_display_text(&value); - util::truncate_utf8_bytes(&clean, MAX_HINT_STRING_BYTES) -} - -fn parse_actions(raw: Vec) -> Vec { - // Actions come in key and label pairs - let action_capacity = (raw.len() / 2).min(MAX_ACTIONS); - let mut actions = Vec::with_capacity(action_capacity); - let mut iter = raw.into_iter(); - - // The protocol sends actions as [key, label, key, label, ...] - while let Some(key) = iter.next() { - if let Some(label) = iter.next() { - if actions.len() >= MAX_ACTIONS { - // Hard stop keeps button rows bounded even when sender floods action pairs - break; - } - actions.push(Action { - // Key is protocol data - key: util::truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), - // Label is shown to the user - label: util::truncate_utf8_bytes( - &util::sanitize_inline_display_text(&label), - MAX_ACTION_LABEL_BYTES, - ), - }); - } - } - actions -} - -fn sanitize_hints_for_storage(hints: HashMap) -> HashMap { - // Pre-sizing avoids rehash churn on adversarial hint fanout - let mut sanitized = HashMap::with_capacity(hints.len().min(MAX_HINT_ENTRIES)); - - for (key, value) in hints { - if sanitized.len() >= MAX_HINT_ENTRIES { - break; - } - - let key = util::truncate_utf8_bytes(key.trim(), MAX_HINT_KEY_BYTES); - if key.is_empty() { - continue; - } - - let value = match key.as_str() { - // Keep only hints that matter for daemon behavior and rendering - "sound-name" | "sound-file" | "category" => owned_to_string(&value).and_then(|text| { - // Keep hint text small - let bounded = util::truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); - string_to_owned_value(&bounded) - }), - "transient" | "resident" | "suppress-sound" => { - bool::try_from(&value).ok().map(OwnedValue::from) - } - "urgency" => parse_urgency_hint(&value).map(OwnedValue::from), - _ => None, - }; - - if let Some(value) = value { - sanitized.insert(key, value); - } - } - - sanitized -} - -fn string_to_owned_value(value: &str) -> Option { - OwnedValue::try_from(Value::from(value)).ok() -} - -fn parse_urgency_hint(value: &OwnedValue) -> Option { - // Accept both byte and integer variants from mixed clients - if let Ok(raw) = u8::try_from(value) { - return Some(u32::from(raw).min(2)); - } - if let Ok(raw) = u32::try_from(value) { - return Some(raw.min(2)); - } - None -} - -pub(in crate::daemon::notifications) fn owned_to_string(value: &OwnedValue) -> Option { - value - .try_clone() - .ok() - .and_then(|owned| String::try_from(owned).ok()) -} - -#[cfg(test)] -#[path = "tests/payload.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs new file mode 100644 index 000000000..68c6a3d84 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -0,0 +1,183 @@ +//! Convert bounded wire fields into a stored notification + +use std::collections::HashMap; + +use unixnotis_core::{ + util, Action, AttributionDiagnostics, ImageData, InlineReply, InlineReplyPolicy, Notification, + NotificationAttribution, NotificationImage, NotificationVisualRole, Urgency, +}; +use zbus::zvariant::OwnedValue; + +use super::super::super::identity::SenderMetadata; +use super::super::limits::{ + MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_SUMMARY_BYTES, +}; +use super::sanitize::parse_actions; +use super::visuals::{may_read_sender_host_visual, SenderVisualRole}; +use super::{owned_to_string, sanitize_hints_for_storage}; + +pub(in crate::daemon::notifications) struct NotificationInput { + pub(in crate::daemon::notifications) app_name: String, + pub(in crate::daemon::notifications) app_icon: String, + pub(in crate::daemon::notifications) summary: String, + pub(in crate::daemon::notifications) body: String, + pub(in crate::daemon::notifications) actions: Vec, + pub(in crate::daemon::notifications) hints: HashMap, + pub(in crate::daemon::notifications) image_data: Option, + pub(in crate::daemon::notifications) sender_visual: Option, + pub(in crate::daemon::notifications) sender_visual_role: SenderVisualRole, + pub(in crate::daemon::notifications) sender: SenderMetadata, + pub(in crate::daemon::notifications) attribution: NotificationAttribution, + pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, + pub(in crate::daemon::notifications) inline_reply_policy: InlineReplyPolicy, + pub(in crate::daemon::notifications) expire_timeout: i32, +} + +pub(in crate::daemon::notifications) fn build_notification( + input: NotificationInput, +) -> Notification { + let NotificationInput { + app_name, + app_icon, + summary, + body, + actions, + hints, + image_data, + sender_visual, + sender_visual_role, + sender, + attribution, + attribution_diagnostics, + inline_reply_policy, + expire_timeout, + } = input; + + let urgency = Urgency::from_hint(hints.get("urgency")); + let category = hints + .get("category") + .and_then(owned_to_string) + .map(|value| { + util::truncate_utf8_bytes( + &util::sanitize_inline_display_text(&value), + MAX_CATEGORY_BYTES, + ) + }); + let is_transient = hints + .get("transient") + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false); + let is_resident = hints + .get("resident") + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false); + let image = build_image( + &app_name, + &app_icon, + &hints, + image_data, + sender_visual, + sender_visual_role, + &attribution, + ); + + let actions = parse_actions(actions); + let inline_reply = parse_inline_reply(&actions, &hints); + let app_name = util::sanitize_inline_display_text(&app_name); + let summary = util::sanitize_display_text(&summary); + let body = util::sanitize_display_text(&body); + + Notification { + id: 0, + generation: 0, + app_name: if app_name.is_empty() { + "Unknown".to_string() + } else { + util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) + }, + app_icon: if super::visuals::local_avatar_path(&app_icon).is_some() { + String::new() + } else { + util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES) + }, + attribution, + attribution_diagnostics, + summary: util::fold_text_for_layout( + &util::truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), + util::MAX_DISPLAY_TOKEN_WIDTH, + ), + body: util::fold_text_for_layout( + &util::truncate_utf8_bytes(&body, MAX_BODY_BYTES), + util::MAX_DISPLAY_TOKEN_WIDTH, + ), + actions, + inline_reply, + inline_reply_policy, + hints: sanitize_hints_for_storage(hints), + urgency, + category, + is_transient, + is_resident, + suppress_popup: false, + suppress_sound: false, + image, + expire_timeout, + received_at: chrono::Utc::now(), + sender_name: sender.sender_name, + sender_pid: sender.sender_pid, + sender_start_time: sender.sender_start_time, + sender_executable: sender.sender_executable, + } +} + +fn build_image( + app_name: &str, + app_icon: &str, + hints: &HashMap, + image_data: Option, + sender_visual: Option, + sender_visual_role: SenderVisualRole, + attribution: &NotificationAttribution, +) -> NotificationImage { + // Keep daemon-selected badge identity separate from sender-provided pixels + let mut image = NotificationImage::from_hints(app_name, app_icon, hints); + image.badge_icon.clone_from(&attribution.badge_icon); + if let Some(image_data) = image_data.and_then(NotificationImage::normalize_image_data) { + image.content_image = image_data; + } + if may_read_sender_host_visual(attribution) { + if let Some(visual) = sender_visual.and_then(NotificationImage::normalize_image_data) { + image.sender_visual_role = match sender_visual_role { + SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, + SenderVisualRole::ApplicationProvidedIcon => { + NotificationVisualRole::ApplicationProvidedIcon + } + SenderVisualRole::None => NotificationVisualRole::None, + }; + image.sender_visual = visual; + } + } + image +} + +fn parse_inline_reply(actions: &[Action], hints: &HashMap) -> InlineReply { + let Some(action) = actions.iter().find(|action| action.key == "inline-reply") else { + return InlineReply::default(); + }; + + InlineReply { + available: true, + label: action.label.clone(), + placeholder: reply_hint_text(hints, "x-kde-reply-placeholder-text"), + submit_label: reply_hint_text(hints, "x-kde-reply-submit-button-text"), + submit_icon: reply_hint_text(hints, "x-kde-reply-submit-button-icon-name"), + } +} + +fn reply_hint_text(hints: &HashMap, key: &str) -> String { + let Some(value) = hints.get(key).and_then(owned_to_string) else { + return String::new(); + }; + let clean = util::sanitize_inline_display_text(&value); + util::truncate_utf8_bytes(&clean, super::super::limits::MAX_HINT_STRING_BYTES) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs new file mode 100644 index 000000000..d84a7d040 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs @@ -0,0 +1,31 @@ +//! Expiration policy for stored notifications + +use std::cmp::Ordering; +use std::time::{Duration, Instant}; + +use unixnotis_core::{Config, Notification, Urgency}; + +pub(in crate::daemon::notifications) fn resolve_expiration( + config: &Config, + notification: &Notification, +) -> Option { + // Resident notifications stay visible until the sender or user closes them + if notification.is_resident { + return None; + } + + // Zero is an explicit request to disable the expiration timer + let timeout_ms = match notification.expire_timeout.cmp(&0) { + Ordering::Equal => return None, + // Positive values are already bounded at the wire boundary + Ordering::Greater => notification.expire_timeout as u64, + // Negative values select the configured urgency default + Ordering::Less => match notification.urgency { + Urgency::Critical => config.popups.critical_timeout_ms?, + _ => config.popups.default_timeout_ms, + }, + }; + + // Avoid allocating a timer deadline when the configured default is disabled + (timeout_ms != 0).then(|| Instant::now() + Duration::from_millis(timeout_ms)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs new file mode 100644 index 000000000..20cb0aad6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -0,0 +1,17 @@ +//! Bounded notification payload construction + +mod build; +mod expiration; +mod sanitize; +mod visuals; + +pub(in crate::daemon::notifications) use build::{build_notification, NotificationInput}; +pub(in crate::daemon::notifications) use expiration::resolve_expiration; +pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; +pub(in crate::daemon::notifications) use visuals::{ + materialize_sender_visual, may_read_sender_host_visual, sender_visual_role, SenderVisualRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_CONTENT_DIMENSION, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs new file mode 100644 index 000000000..67fc2a871 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs @@ -0,0 +1,98 @@ +//! Bounded hint and action sanitization + +use std::collections::HashMap; + +use unixnotis_core::util; +use zbus::zvariant::{OwnedValue, Value}; + +use super::super::limits::{ + MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_HINT_ENTRIES, + MAX_HINT_KEY_BYTES, MAX_HINT_STRING_BYTES, +}; + +pub(in crate::daemon::notifications) fn sanitize_hints_for_storage( + hints: HashMap, +) -> HashMap { + let mut sanitized = HashMap::with_capacity(hints.len().min(MAX_HINT_ENTRIES)); + + for (key, value) in hints { + // Stop before retaining more hint entries than the model can expose + if sanitized.len() >= MAX_HINT_ENTRIES { + break; + } + + let key = util::truncate_utf8_bytes(key.trim(), MAX_HINT_KEY_BYTES); + if key.is_empty() { + continue; + } + + // Only hints with a defined daemon or presentation meaning survive storage + let value = match key.as_str() { + "sound-name" | "sound-file" | "category" => owned_to_string(&value).and_then(|text| { + let bounded = util::truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); + string_to_owned_value(&bounded) + }), + "transient" | "resident" | "suppress-sound" => { + bool::try_from(&value).ok().map(OwnedValue::from) + } + "urgency" => parse_urgency_hint(&value).map(OwnedValue::from), + _ => None, + }; + + // Unknown values are intentionally dropped instead of being echoed to clients + if let Some(value) = value { + sanitized.insert(key, value); + } + } + + sanitized +} + +pub(in crate::daemon::notifications) fn string_to_owned_value(value: &str) -> Option { + OwnedValue::try_from(Value::from(value)).ok() +} + +pub(in crate::daemon::notifications) fn parse_urgency_hint(value: &OwnedValue) -> Option { + if let Ok(raw) = u8::try_from(value) { + return Some(u32::from(raw).min(2)); + } + if let Ok(raw) = u32::try_from(value) { + return Some(raw.min(2)); + } + None +} + +pub(in crate::daemon::notifications) fn owned_to_string(value: &OwnedValue) -> Option { + value + .try_clone() + .ok() + .and_then(|owned| String::try_from(owned).ok()) +} + +pub(in crate::daemon::notifications) fn parse_actions( + raw: Vec, +) -> Vec { + // The wire format is a flat key/label sequence, so incomplete pairs are ignored + let action_capacity = (raw.len() / 2).min(MAX_ACTIONS); + let mut actions = Vec::with_capacity(action_capacity); + let mut iter = raw.into_iter(); + + while let Some(key) = iter.next() { + let Some(label) = iter.next() else { + break; + }; + // Stop before creating more action state than the UI can render + if actions.len() >= MAX_ACTIONS { + break; + } + actions.push(unixnotis_core::Action { + key: util::truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), + label: util::truncate_utf8_bytes( + &util::sanitize_inline_display_text(&label), + MAX_ACTION_LABEL_BYTES, + ), + }); + } + + actions +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs new file mode 100644 index 000000000..caa846261 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -0,0 +1,299 @@ +//! Sender-provided visual materialization + +use std::collections::HashMap; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; + +use std::os::unix::ffi::OsStrExt; + +use rustix::fs::{openat2, Mode, OFlags, ResolveFlags, CWD}; +use unixnotis_core::{ + decode_image_asset_contents, AssetPolicy, ImageData, NotificationAttribution, + DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, + DEFAULT_ICON_ASSET_MAX_WIDTH, +}; +use url::Url; +use zbus::zvariant::OwnedValue; + +use crate::daemon::notifications::identity::DesktopIdentityIndex; + +use super::owned_to_string; + +pub(in crate::daemon::notifications::ingress) const MAX_SENDER_VISUAL_BYTES: u64 = 2_097_152; +const MAX_STORED_AVATAR_DIMENSION: u32 = 64; +pub(in crate::daemon::notifications::ingress) const MAX_DECODE_DIMENSION: u32 = + MAX_STORED_AVATAR_DIMENSION * 8; +pub(in crate::daemon::notifications) const MAX_STORED_CONTENT_DIMENSION: u32 = 256; + +pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration = + Duration::from_millis(500); + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum SenderVisualRole { + None, + ConversationAvatar, + ApplicationProvidedIcon, +} + +pub(in crate::daemon::notifications) const fn may_read_sender_host_visual( + attribution: &NotificationAttribution, +) -> bool { + attribution.may_read_sender_host_visual() +} + +pub(in crate::daemon::notifications) fn sender_visual_role( + attribution: &NotificationAttribution, + index: &DesktopIdentityIndex, + hints: &HashMap, + actions: &[String], + app_icon: &str, +) -> SenderVisualRole { + // Sender paths are never opened until attribution grants positive local evidence + if !may_read_sender_host_visual(attribution) { + return SenderVisualRole::None; + } + + // An advertised inline reply is an explicit communication signal + if actions + .chunks_exact(2) + .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) + { + return SenderVisualRole::ConversationAvatar; + } + + // Category hints remain presentation input, not identity proof + let explicit_metadata = + hints + .get("category") + .and_then(owned_to_string) + .is_some_and(|category| { + let category = category.to_ascii_lowercase(); + ["im", "chat", "message", "email", "mail"] + .iter() + .any(|marker| category.split('.').any(|part| part == *marker)) + }); + // Desktop categories cover clients that omit the optional wire category + let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); + if explicit_metadata || desktop_metadata { + SenderVisualRole::ConversationAvatar + } else if local_avatar_path(app_icon).is_some() { + SenderVisualRole::ApplicationProvidedIcon + } else { + SenderVisualRole::None + } +} + +pub(in crate::daemon::notifications) fn materialize_sender_visual( + app_icon: &str, + max_dimension: u32, +) -> Option { + // Convert the sender value to a local path before touching the filesystem + let path = local_avatar_path(app_icon)?; + let descriptor = openat2( + CWD, + &path, + OFlags::RDONLY + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + ResolveFlags::NO_MAGICLINKS, + ) + .ok()?; + let mut file = std::fs::File::from(descriptor); + // Metadata is taken from the opened descriptor, not from a second path lookup + let metadata = file.metadata().ok()?; + if !sender_visual_file_allowed(metadata.is_file(), metadata.len()) { + return None; + } + + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_SENDER_VISUAL_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + if !avatar_buffer_size_allowed(bytes.len()) { + return None; + } + + // Keep the decoder bound independent from the UI-requested size + let max_dimension = bounded_decode_dimension(max_dimension); + let policy = AssetPolicy { + max_bytes: MAX_SENDER_VISUAL_BYTES, + max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(max_dimension), + max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(max_dimension), + max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS + .min(u64::from(max_dimension).checked_mul(u64::from(max_dimension))?), + allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, + }; + let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; + let (width, height, rgba) = + downsample_avatar(decoded.width, decoded.height, decoded.rgba, max_dimension)?; + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let rowstride = width.checked_mul(4)?; + let expected = usize::try_from(rowstride) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + (rgba.len() == expected).then_some(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) +} + +pub(in crate::daemon::notifications::ingress) fn downsample_avatar( + width: u32, + height: u32, + rgba: Vec, + max_dimension: u32, +) -> Option<(u32, u32, Vec)> { + if width == 0 || height == 0 { + return None; + } + let source_pixels = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + if rgba.len() != source_pixels.checked_mul(4)? { + return None; + } + + let (target_width, target_height) = if width >= height { + ( + max_dimension.min(width), + width_to_height(width, height, max_dimension), + ) + } else { + ( + height_to_width(width, height, max_dimension), + max_dimension.min(height), + ) + }; + let target_pixels = usize::try_from(target_width) + .ok()? + .checked_mul(usize::try_from(target_height).ok()?)?; + let max_pixels = usize::try_from(max_dimension) + .ok()? + .checked_mul(usize::try_from(max_dimension).ok()?)?; + if target_pixels > max_pixels { + return None; + } + if target_width == width && target_height == height { + return Some((width, height, rgba)); + } + + let mut output = vec![0u8; target_pixels.checked_mul(4)?]; + for target_y in 0..target_height { + let source_y = usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(height).ok()?)? + / usize::try_from(target_height).ok()?; + for target_x in 0..target_width { + let source_x = usize::try_from(target_x) + .ok()? + .checked_mul(usize::try_from(width).ok()?)? + / usize::try_from(target_width).ok()?; + let source_index = source_y + .checked_mul(usize::try_from(width).ok()?)? + .checked_add(source_x)? + .checked_mul(4)?; + let target_index = usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(target_width).ok()?)? + .checked_add(usize::try_from(target_x).ok()?)? + .checked_mul(4)?; + output[target_index..target_index + 4] + .copy_from_slice(&rgba[source_index..source_index + 4]); + } + } + Some((target_width, target_height, output)) +} + +fn width_to_height(width: u32, height: u32, target_width: u32) -> u32 { + if width <= target_width { + return height; + } + u64::from(height) + .saturating_mul(u64::from(target_width)) + .checked_div(u64::from(width)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +fn height_to_width(width: u32, height: u32, target_height: u32) -> u32 { + if height <= target_height { + return width; + } + u64::from(width) + .saturating_mul(u64::from(target_height)) + .checked_div(u64::from(height)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +pub(in crate::daemon::notifications::ingress) const fn avatar_file_size_allowed(size: u64) -> bool { + size <= MAX_SENDER_VISUAL_BYTES +} + +pub(in crate::daemon::notifications::ingress) const fn avatar_buffer_size_allowed( + size: usize, +) -> bool { + size <= MAX_SENDER_VISUAL_BYTES as usize +} + +pub(in crate::daemon::notifications::ingress) const fn sender_visual_file_allowed( + is_regular: bool, + size: u64, +) -> bool { + is_regular && avatar_file_size_allowed(size) +} + +pub(in crate::daemon::notifications::ingress) fn bounded_decode_dimension(requested: u32) -> u32 { + std::cmp::min(requested, MAX_DECODE_DIMENSION) +} + +pub(in crate::daemon::notifications::ingress) fn local_avatar_path(value: &str) -> Option { + if value.starts_with('/') { + return Some(PathBuf::from(value)); + } + if !valid_percent_escapes(value) { + return None; + } + let url = Url::parse(value).ok()?; + if url.scheme() != "file" || url.query().is_some() || url.fragment().is_some() { + return None; + } + match url.host_str() { + None | Some("" | "localhost") => {} + Some(_) => return None, + } + let path = url.to_file_path().ok()?; + (!path.as_os_str().as_bytes().contains(&0)).then_some(path) +} + +pub(in crate::daemon::notifications::ingress) fn valid_percent_escapes(value: &str) -> bool { + // Url accepts some malformed percent text literally, so reject it before parsing + let mut bytes = value.as_bytes().iter().copied(); + while let Some(byte) = bytes.next() { + if byte != b'%' { + continue; + } + let Some(first) = bytes.next() else { + return false; + }; + let Some(second) = bytes.next() else { + return false; + }; + if !first.is_ascii_hexdigit() || !second.is_ascii_hexdigit() { + return false; + } + } + true +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 57374914f..171c8250e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -13,6 +13,7 @@ use crate::daemon::notifications::identity::{ use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, owned_to_string, resolve_expiration, sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, + MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; @@ -350,7 +351,7 @@ async fn materialize_content_visual( .filter(|path| !path.trim().is_empty()) .map(str::to_owned)?; run_avatar_worker( - move || materialize_sender_visual(&path, 512), + move || materialize_sender_visual(&path, MAX_STORED_CONTENT_DIMENSION), CONVERSATION_AVATAR_TIMEOUT, ) .await From c14077b6d8e5f9f00cfe34bea67379f1e16ae939 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 03:44:35 -0500 Subject: [PATCH 201/275] test(daemon): mirror notification payload responsibilities Summary: mirror notification payload responsibilities. Scope: daemon. --- .../ingress/payload/tests/build.rs | 330 ++++++++ .../ingress/payload/tests/expiration.rs | 96 +++ .../ingress/payload/tests/mod.rs | 28 + .../ingress/payload/tests/sanitize.rs | 92 +++ .../ingress/payload/tests/visuals.rs | 331 ++++++++ .../notifications/ingress/tests/payload.rs | 770 ------------------ 6 files changed, 877 insertions(+), 770 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs new file mode 100644 index 000000000..47a754735 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -0,0 +1,330 @@ +use super::*; +#[test] +fn build_notification_clamps_summary_and_body_sizes() { + let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); + let body = "B".repeat(MAX_BODY_BYTES + 512); + + let notification = build_notification(NotificationInput { + app_name: "app".to_string(), + app_icon: "icon".to_string(), + summary, + body, + actions: Vec::new(), + hints: HashMap::::new(), + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.summary.len() <= MAX_SUMMARY_BYTES); + assert!(notification.body.len() <= MAX_BODY_BYTES); +} + +#[test] +fn build_notification_rejects_content_pixels_above_retained_limit() { + let notification = build_notification(NotificationInput { + app_name: "Example viewer".to_string(), + app_icon: "example-viewer".to_string(), + summary: "Image".to_string(), + body: "Attachment".to_string(), + actions: Vec::new(), + hints: HashMap::::new(), + image_data: Some(unixnotis_core::ImageData { + width: 512, + height: 512, + rowstride: 512 * 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![0; 512 * 512 * 4], + }), + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.image.content_image.data.is_empty()); +} + +#[test] +fn build_notification_strips_display_spoofing_controls() { + let notification = build_notification(NotificationInput { + app_name: "mail\u{202E}exe\nfake".to_string(), + app_icon: "icon".to_string(), + summary: "safe\u{202E}spoof".to_string(), + body: "line1\nline2\u{2066}tail".to_string(), + actions: vec!["default".to_string(), "Open\u{202E}".to_string()], + hints: HashMap::::new(), + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.app_name, "mailexe fake"); + assert_eq!(notification.summary, "safespoof"); + assert_eq!(notification.body, "line1\nline2tail"); + assert_eq!(notification.actions[0].label, "Open"); +} + +#[test] +fn build_notification_collects_inline_reply_action_and_kde_labels() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Write a reply").expect("placeholder value"), + ); + hints.insert( + "x-kde-reply-submit-button-text".to_string(), + string_to_owned_value("Send now").expect("submit label value"), + ); + hints.insert( + "x-kde-reply-submit-button-icon-name".to_string(), + string_to_owned_value("mail-send-symbolic").expect("submit icon value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints, + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_executable: Some("/usr/bin/messages".to_string()), + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable /usr/bin/messages", + "system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!(notification.inline_reply.label, "Reply"); + assert_eq!(notification.inline_reply.placeholder, "Write a reply"); + assert_eq!(notification.inline_reply.submit_label, "Send now"); + assert_eq!(notification.inline_reply.submit_icon, "mail-send-symbolic"); +} + +#[test] +fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy() { + let notification = build_notification(NotificationInput { + app_name: "Password Manager".to_string(), + app_icon: "password-manager".to_string(), + summary: "Sign in".to_string(), + body: "Enter the account password".to_string(), + actions: vec!["inline-reply".to_string(), "Password".to_string()], + hints: HashMap::new(), + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.hostile".to_string()), + sender_executable: Some("/usr/bin/unknown-client".to_string()), + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::conflict( + "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, + "source /usr/bin/unknown-client", + "executable:1:2".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); + let view = notification.to_view(); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!( + view.attribution.status, + unixnotis_core::AttributionStatus::Conflict + ); +} + +#[test] +fn build_notification_keeps_unknown_sender_reply_policy_denied() { + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints: HashMap::new(), + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Messages", + AttributionReason::MissingSenderEvidence, + "", + "unknown:messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); + let view = notification.to_view(); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!( + view.attribution.status, + unixnotis_core::AttributionStatus::Unresolved + ); +} + +#[test] +fn build_notification_ignores_reply_hints_without_explicit_action() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Decoy reply").expect("placeholder value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["default".to_string(), "Open".to_string()], + hints, + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(!notification.inline_reply.available); + assert!(notification.inline_reply.placeholder.is_empty()); +} + +#[test] +fn conversation_avatar_never_changes_badge_or_unresolved_identity() { + let avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Signal".to_string(), + app_icon: "/tmp/contact.png".to_string(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + sender_visual: Some(avatar), + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.attribution.display_name, "Unknown application"); + assert_eq!( + notification.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); +} + +#[test] +fn sender_image_path_is_not_retained_in_notification_model() { + let mut hints = HashMap::new(); + hints.insert( + "image-path".to_string(), + string_to_owned_value("/tmp/message-image.png").expect("image path"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints, + image_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.image.content_image.data.is_empty()); + assert!(!notification.hints.contains_key("image-path")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs new file mode 100644 index 000000000..ac1b3fb96 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs @@ -0,0 +1,96 @@ +use super::*; +#[test] +fn resolve_expiration_respects_protocol_and_config_rules() { + let mut config = Config::default(); + config.popups.default_timeout_ms = 5_000; + config.popups.critical_timeout_ms = Some(9_000); + + let mut notification = unixnotis_core::Notification { + id: 1, + generation: 1, + app_name: "app".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + hints: HashMap::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: -1, + received_at: chrono::Utc::now(), + sender_name: None, + sender_pid: None, + sender_start_time: None, + sender_executable: None, + }; + + assert!(resolve_expiration(&config, ¬ification).is_some()); + + notification.urgency = Urgency::Critical; + assert!(resolve_expiration(&config, ¬ification).is_some()); + + notification.expire_timeout = 0; + assert!(resolve_expiration(&config, ¬ification).is_none()); + + notification.expire_timeout = 100; + notification.is_resident = true; + assert!(resolve_expiration(&config, ¬ification).is_none()); + + notification.is_resident = false; + let before = Instant::now(); + let deadline = resolve_expiration(&config, ¬ification).expect("explicit timeout"); + assert!(deadline > before); + assert!(deadline <= Instant::now() + Duration::from_millis(500)); + + notification.expire_timeout = -1; + notification.urgency = Urgency::Critical; + config.popups.critical_timeout_ms = None; + assert!(resolve_expiration(&config, ¬ification).is_none()); +} + +#[test] +fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_is_zero() { + let mut config = Config::default(); + config.popups.default_timeout_ms = 0; + let mut notification = unixnotis_core::Notification { + id: 1, + generation: 1, + app_name: "app".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + hints: HashMap::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 25, + received_at: chrono::Utc::now(), + sender_name: None, + sender_pid: None, + sender_start_time: None, + sender_executable: None, + }; + + assert!(resolve_expiration(&config, ¬ification).is_some()); + + notification.expire_timeout = -1; + assert!(resolve_expiration(&config, ¬ification).is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs new file mode 100644 index 000000000..b3de3986a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use zbus::zvariant::OwnedValue; + +pub(super) use super::super::super::identity::SenderMetadata; +pub(super) use super::super::limits::{MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES}; +pub(super) use super::build::{build_notification, NotificationInput}; +pub(super) use super::expiration::resolve_expiration; +pub(super) use super::sanitize::{ + owned_to_string, parse_actions, parse_urgency_hint, sanitize_hints_for_storage, + string_to_owned_value, +}; +pub(super) use super::visuals::{ + avatar_buffer_size_allowed, avatar_file_size_allowed, bounded_decode_dimension, + materialize_sender_visual, may_read_sender_host_visual, sender_visual_file_allowed, + MAX_SENDER_VISUAL_BYTES, +}; +pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; + +pub(super) use unixnotis_core::{ + AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationImage, Urgency, +}; + +mod build; +mod expiration; +mod sanitize; +mod visuals; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs new file mode 100644 index 000000000..afb603e91 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs @@ -0,0 +1,92 @@ +use super::*; +#[test] +fn parse_actions_caps_pairs() { + let mut raw = Vec::new(); + for idx in 0..(MAX_ACTIONS + 10) { + raw.push(format!("key-{idx}")); + raw.push(format!("label-{idx}")); + } + + let actions = parse_actions(raw); + assert_eq!(actions.len(), MAX_ACTIONS); +} + +#[test] +fn parse_actions_ignores_dangling_key_without_label() { + let actions = parse_actions(vec![ + "default".to_string(), + "Open".to_string(), + "orphan-key".to_string(), + ]); + + // D-Bus action arrays are pairs; a trailing key cannot produce a safe button + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[0].label, "Open"); +} + +#[test] +fn parse_actions_reserves_capacity_for_complete_pairs_only() { + let actions = parse_actions(vec![ + "default".to_string(), + "Open".to_string(), + "dismiss".to_string(), + "Dismiss".to_string(), + ]); + + assert_eq!(actions.len(), 2); + assert_eq!(actions.capacity(), 2); +} + +#[test] +fn sanitize_hints_drops_untrusted_and_bounds_strings() { + let mut hints = HashMap::::new(); + hints.insert("transient".to_string(), OwnedValue::from(true)); + hints.insert("urgency".to_string(), OwnedValue::from(9u32)); + hints.insert( + "sound-name".to_string(), + string_to_owned_value(&"n".repeat(5000)).expect("sound-name"), + ); + hints.insert("image-data".to_string(), OwnedValue::from(123u32)); + hints.insert( + "x-custom".to_string(), + string_to_owned_value("custom").expect("custom"), + ); + + let sanitized = sanitize_hints_for_storage(hints); + assert_eq!(sanitized.len(), 3); + assert!(sanitized.contains_key("transient")); + assert!(sanitized.contains_key("sound-name")); + assert_eq!( + u32::try_from(sanitized.get("urgency").expect("urgency")), + Ok(2) + ); + + let sound_name = owned_to_string( + sanitized + .get("sound-name") + .expect("sound-name should remain"), + ) + .expect("sound-name should be string"); + assert!(sound_name.len() <= 2048); +} + +#[test] +fn parse_urgency_hint_accepts_byte_and_integer_values_with_cap() { + assert_eq!(parse_urgency_hint(&OwnedValue::from(0u8)), Some(0)); + assert_eq!(parse_urgency_hint(&OwnedValue::from(1u32)), Some(1)); + assert_eq!(parse_urgency_hint(&OwnedValue::from(99u32)), Some(2)); + assert_eq!( + parse_urgency_hint(&string_to_owned_value("high").expect("string")), + None + ); +} + +#[test] +fn owned_to_string_accepts_only_string_values() { + assert_eq!( + owned_to_string(&string_to_owned_value("sound").expect("string")).as_deref(), + Some("sound") + ); + assert_eq!(owned_to_string(&OwnedValue::from(7u32)), None); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs new file mode 100644 index 000000000..d1069bc7d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -0,0 +1,331 @@ +use super::super::visuals::{ + downsample_avatar, local_avatar_path, valid_percent_escapes, MAX_DECODE_DIMENSION, +}; +use super::*; +#[test] +fn associated_sender_role_accepts_inline_reply_and_message_categories() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Messages", + "Messages", + "org.example.Messages", + "messages", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactUserExecutable, + "associated executable", + "recognized:system-app:org.example.Messages:sender".to_string(), + ); + let index = super::super::super::super::identity::DesktopIdentityIndex::default(); + + assert_eq!( + sender_visual_role( + &attribution, + &index, + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + "", + ), + SenderVisualRole::ConversationAvatar + ); + + let mut hints = HashMap::new(); + hints.insert( + "category".to_string(), + string_to_owned_value("im.received").expect("category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &hints, &[], ""), + SenderVisualRole::ConversationAvatar + ); + + let mut exact = HashMap::new(); + exact.insert( + "category".to_string(), + string_to_owned_value("im").expect("exact category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &exact, &[], ""), + SenderVisualRole::ConversationAvatar + ); + + let mut unrelated = HashMap::new(); + unrelated.insert( + "category".to_string(), + string_to_owned_value("other").expect("unrelated category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &unrelated, &[], ""), + SenderVisualRole::None + ); + assert_eq!( + sender_visual_role(&attribution, &index, &HashMap::new(), &[], ""), + SenderVisualRole::None + ); +} + +#[test] +fn associated_noncommunication_path_is_a_small_application_visual() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.Player:sender".to_string(), + ); + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &[], + "/tmp/application-icon.png", + ); + + assert_eq!(role, SenderVisualRole::ApplicationProvidedIcon); +} + +#[test] +fn portal_association_cannot_start_host_avatar_materialization() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Portal app", + "Portal app", + "org.example.PortalApp", + "portal-app", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal supplied app id", + "recognized:portal:org.example.PortalApp".to_string(), + ); + assert!(!may_read_sender_host_visual(&attribution)); + assert_eq!( + sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + "", + ), + SenderVisualRole::None + ); +} + +#[test] +fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { + let icon = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example player".to_string(), + app_icon: "example-player".to_string(), + summary: "Track".to_string(), + body: "Artist".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + sender_visual: Some(icon), + sender_visual_role: SenderVisualRole::ApplicationProvidedIcon, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected local association", + "associated:system-app:org.example.Player".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ); + assert_eq!(notification.image.badge_icon, "example-player"); +} + +#[test] +fn large_avatar_is_downsampled_to_the_storage_bound() { + let source = vec![255_u8; 256 * 128 * 4]; + let (width, height, data) = downsample_avatar(256, 128, source, 64).expect("downsample"); + assert_eq!((width, height), (64, 32)); + assert_eq!(data.len(), 64 * 32 * 4); +} + +#[test] +fn avatar_downsampling_rejects_zero_dimensions_and_keeps_exact_size_images() { + assert!(downsample_avatar(0, 1, Vec::new(), 64).is_none()); + assert!(downsample_avatar(1, 0, Vec::new(), 64).is_none()); + + let source = vec![7_u8; 64 * 64 * 4]; + let source_ptr = source.as_ptr(); + let (width, height, data) = downsample_avatar(64, 64, source, 64).expect("exact bound"); + assert_eq!((width, height), (64, 64)); + assert_eq!(data.as_ptr(), source_ptr); +} + +#[test] +fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { + // Keep the source height unchanged after scaling so the early-return guard + // must compare both dimensions rather than accepting one matching value + let mut horizontal = vec![0_u8; 128 * 4]; + for x in 0..128 { + horizontal[x * 4] = u8::try_from(x).expect("horizontal fixture value"); + } + let (width, height, data) = + downsample_avatar(128, 1, horizontal, 64).expect("horizontal downsample"); + assert_eq!((width, height), (64, 1)); + assert_eq!(data[4], 2); + + let mut vertical = vec![0_u8; 64 * 128 * 4]; + for y in 0..128 { + vertical[y * 64 * 4] = u8::try_from(y).expect("vertical fixture value"); + } + let (width, height, data) = + downsample_avatar(64, 128, vertical, 64).expect("vertical downsample"); + assert_eq!((width, height), (32, 64)); + assert_eq!(data[32 * 4], 2); +} + +#[cfg(target_os = "linux")] +#[test] +fn fifo_avatar_path_is_rejected_without_opening_a_blocking_reader() { + let directory = std::env::temp_dir().join(format!( + "unixnotis-avatar-fifo-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + std::fs::create_dir(&directory).expect("create temporary directory"); + let path = directory.join("avatar.fifo"); + let path_string = path.to_string_lossy().into_owned(); + let status = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo available"); + assert!(status.success()); + assert!(materialize_sender_visual(&path_string, 64).is_none()); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(directory); +} + +#[test] +fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { + // This is a tiny 1x1 RGBA PNG used only to exercise the real decoder + let png = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, + 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unixnotis-avatar-{suffix}.png")); + std::fs::write(&path, png).expect("write avatar fixture"); + + let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); + let _ = std::fs::remove_file(&path); + + let avatar = avatar.expect("valid avatar should decode"); + assert_eq!((avatar.width, avatar.height), (1, 1)); + assert_eq!(avatar.channels, 4); + assert_eq!(avatar.data.len(), 4); +} + +#[test] +fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { + assert!(avatar_file_size_allowed(MAX_SENDER_VISUAL_BYTES)); + assert!(!avatar_file_size_allowed(MAX_SENDER_VISUAL_BYTES + 1)); + assert!(avatar_buffer_size_allowed(MAX_SENDER_VISUAL_BYTES as usize)); + assert!(!avatar_buffer_size_allowed( + MAX_SENDER_VISUAL_BYTES as usize + 1 + )); +} + +#[test] +fn sender_visual_file_policy_requires_a_regular_file_and_bounded_size() { + assert!(sender_visual_file_allowed(true, MAX_SENDER_VISUAL_BYTES)); + assert!(!sender_visual_file_allowed(false, MAX_SENDER_VISUAL_BYTES)); + assert!(!sender_visual_file_allowed( + true, + MAX_SENDER_VISUAL_BYTES + 1 + )); +} + +#[test] +fn sender_visual_decode_dimension_has_a_stable_upper_bound() { + assert_eq!(bounded_decode_dimension(64), 64); + assert_eq!(bounded_decode_dimension(512), 512); + assert_eq!( + bounded_decode_dimension(MAX_DECODE_DIMENSION), + MAX_DECODE_DIMENSION + ); + assert_eq!(bounded_decode_dimension(513), 512); +} + +#[test] +fn relative_or_missing_avatar_path_is_rejected() { + assert!(materialize_sender_visual("avatar.png", 64).is_none()); + assert!(materialize_sender_visual("/path/that/does/not/exist.png", 64).is_none()); +} + +#[test] +fn local_avatar_uri_decodes_local_file_paths() { + assert_eq!( + local_avatar_path("file:///tmp/avatar%20one.png") + .expect("encoded local path") + .to_string_lossy(), + "/tmp/avatar one.png" + ); + assert_eq!( + local_avatar_path("file://localhost/tmp/avatar.png") + .expect("localhost path") + .to_string_lossy(), + "/tmp/avatar.png" + ); +} + +#[test] +fn local_avatar_uri_rejects_remote_or_ambiguous_paths() { + for value in [ + "file://example.test/tmp/avatar.png", + "file:///tmp/avatar.png?download=1", + "file:///tmp/avatar.png#fragment", + "file:///tmp/%00avatar.png", + "file:///tmp/%ZZavatar.png", + ] { + assert!( + local_avatar_path(value).is_none(), + "unexpectedly accepted {value}" + ); + } +} + +#[test] +fn percent_escape_validation_requires_two_hex_digits() { + assert!(valid_percent_escapes("%20")); + assert!(valid_percent_escapes("file:///tmp/avatar%20one.png")); + assert!(valid_percent_escapes("file:///tmp/avatar%2Fone.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%2.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%GG.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs deleted file mode 100644 index e264644d0..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/payload.rs +++ /dev/null @@ -1,770 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use zbus::zvariant::OwnedValue; - -use super::{ - avatar_buffer_size_allowed, avatar_file_size_allowed, build_notification, - materialize_sender_visual, may_read_sender_host_visual, owned_to_string, parse_actions, - parse_urgency_hint, resolve_expiration, sanitize_hints_for_storage, sender_visual_role, - string_to_owned_value, NotificationInput, SenderMetadata, SenderVisualRole, MAX_ACTIONS, - MAX_BODY_BYTES, MAX_CONVERSATION_AVATAR_BYTES, MAX_SUMMARY_BYTES, -}; -use unixnotis_core::{ - AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationImage, Urgency, -}; - -#[test] -fn build_notification_clamps_summary_and_body_sizes() { - let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); - let body = "B".repeat(MAX_BODY_BYTES + 512); - - let notification = build_notification(NotificationInput { - app_name: "app".to_string(), - app_icon: "icon".to_string(), - summary, - body, - actions: Vec::new(), - hints: HashMap::::new(), - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata { - sender_name: Some(":1.test".to_string()), - sender_pid: Some(42), - sender_start_time: Some(77), - sender_executable: Some("/usr/bin/test-app".to_string()), - sender_executable_identity: None, - ..SenderMetadata::default() - }, - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert!(notification.summary.len() <= MAX_SUMMARY_BYTES); - assert!(notification.body.len() <= MAX_BODY_BYTES); -} - -#[test] -fn build_notification_strips_display_spoofing_controls() { - let notification = build_notification(NotificationInput { - app_name: "mail\u{202E}exe\nfake".to_string(), - app_icon: "icon".to_string(), - summary: "safe\u{202E}spoof".to_string(), - body: "line1\nline2\u{2066}tail".to_string(), - actions: vec!["default".to_string(), "Open\u{202E}".to_string()], - hints: HashMap::::new(), - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata { - sender_name: Some(":1.test".to_string()), - sender_pid: Some(42), - sender_start_time: Some(77), - sender_executable: Some("/usr/bin/test-app".to_string()), - sender_executable_identity: None, - ..SenderMetadata::default() - }, - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert_eq!(notification.app_name, "mailexe fake"); - assert_eq!(notification.summary, "safespoof"); - assert_eq!(notification.body, "line1\nline2tail"); - assert_eq!(notification.actions[0].label, "Open"); -} - -#[test] -fn build_notification_collects_inline_reply_action_and_kde_labels() { - let mut hints = HashMap::::new(); - hints.insert( - "x-kde-reply-placeholder-text".to_string(), - string_to_owned_value("Write a reply").expect("placeholder value"), - ); - hints.insert( - "x-kde-reply-submit-button-text".to_string(), - string_to_owned_value("Send now").expect("submit label value"), - ); - hints.insert( - "x-kde-reply-submit-button-icon-name".to_string(), - string_to_owned_value("mail-send-symbolic").expect("submit icon value"), - ); - - let notification = build_notification(NotificationInput { - app_name: "Messages".to_string(), - app_icon: String::new(), - summary: "New message".to_string(), - body: "Are you coming?".to_string(), - actions: vec!["inline-reply".to_string(), "Reply".to_string()], - hints, - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata { - sender_executable: Some("/usr/bin/messages".to_string()), - ..SenderMetadata::default() - }, - attribution: unixnotis_core::NotificationAttribution::verified( - "Messages", - "Messages", - "org.example.Messages", - "messages", - AttributionReason::ExactSystemExecutable, - "exact system executable /usr/bin/messages", - "system-app:org.example.Messages".to_string(), - ), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, - expire_timeout: 0, - }); - - assert!(notification.inline_reply.available); - assert_eq!(notification.inline_reply.label, "Reply"); - assert_eq!(notification.inline_reply.placeholder, "Write a reply"); - assert_eq!(notification.inline_reply.submit_label, "Send now"); - assert_eq!(notification.inline_reply.submit_icon, "mail-send-symbolic"); -} - -#[test] -fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy() { - let notification = build_notification(NotificationInput { - app_name: "Password Manager".to_string(), - app_icon: "password-manager".to_string(), - summary: "Sign in".to_string(), - body: "Enter the account password".to_string(), - actions: vec!["inline-reply".to_string(), "Password".to_string()], - hints: HashMap::new(), - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata { - sender_name: Some(":1.hostile".to_string()), - sender_executable: Some("/usr/bin/unknown-client".to_string()), - ..SenderMetadata::default() - }, - attribution: unixnotis_core::NotificationAttribution::conflict( - "Password Manager", - "org.example.PasswordManager", - AttributionReason::ExecutableMismatch, - "source /usr/bin/unknown-client", - "executable:1:2".to_string(), - ), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert!(notification.inline_reply.available); - assert_eq!( - notification.inline_reply_policy, - unixnotis_core::InlineReplyPolicy::Deny - ); - let view = notification.to_view(); - assert_eq!(view.app_name, "Unknown application"); - assert_eq!( - view.attribution.status, - unixnotis_core::AttributionStatus::Conflict - ); -} - -#[test] -fn build_notification_keeps_unknown_sender_reply_policy_denied() { - let notification = build_notification(NotificationInput { - app_name: "Messages".to_string(), - app_icon: String::new(), - summary: "New message".to_string(), - body: String::new(), - actions: vec!["inline-reply".to_string(), "Reply".to_string()], - hints: HashMap::new(), - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::unresolved( - "Messages", - AttributionReason::MissingSenderEvidence, - "", - "unknown:messages".to_string(), - ), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert!(notification.inline_reply.available); - assert_eq!( - notification.inline_reply_policy, - unixnotis_core::InlineReplyPolicy::Deny - ); - let view = notification.to_view(); - assert_eq!(view.app_name, "Unknown application"); - assert_eq!( - view.attribution.status, - unixnotis_core::AttributionStatus::Unresolved - ); -} - -#[test] -fn build_notification_ignores_reply_hints_without_explicit_action() { - let mut hints = HashMap::::new(); - hints.insert( - "x-kde-reply-placeholder-text".to_string(), - string_to_owned_value("Decoy reply").expect("placeholder value"), - ); - - let notification = build_notification(NotificationInput { - app_name: "Messages".to_string(), - app_icon: String::new(), - summary: "New message".to_string(), - body: String::new(), - actions: vec!["default".to_string(), "Open".to_string()], - hints, - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert!(!notification.inline_reply.available); - assert!(notification.inline_reply.placeholder.is_empty()); -} - -#[test] -fn conversation_avatar_never_changes_badge_or_unresolved_identity() { - let avatar = unixnotis_core::ImageData { - width: 1, - height: 1, - rowstride: 4, - has_alpha: true, - bits_per_sample: 8, - channels: 4, - data: vec![1, 2, 3, 255], - }; - let notification = build_notification(NotificationInput { - app_name: "Signal".to_string(), - app_icon: "/tmp/contact.png".to_string(), - summary: "New message".to_string(), - body: "Hello".to_string(), - actions: Vec::new(), - hints: HashMap::new(), - image_data: None, - sender_visual: Some(avatar), - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert_eq!(notification.attribution.display_name, "Unknown application"); - assert_eq!( - notification.attribution.badge_icon, - "application-x-executable-symbolic" - ); - assert_eq!( - notification.image.sender_visual_role, - unixnotis_core::NotificationVisualRole::None - ); -} - -#[test] -fn sender_image_path_is_not_retained_in_notification_model() { - let mut hints = HashMap::new(); - hints.insert( - "image-path".to_string(), - string_to_owned_value("/tmp/message-image.png").expect("image path"), - ); - - let notification = build_notification(NotificationInput { - app_name: "Messages".to_string(), - app_icon: String::new(), - summary: "New message".to_string(), - body: "Hello".to_string(), - actions: Vec::new(), - hints, - image_data: None, - sender_visual: None, - sender_visual_role: SenderVisualRole::ConversationAvatar, - sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::verified( - "Messages", - "Messages", - "org.example.Messages", - "messages", - AttributionReason::ExactSystemExecutable, - "exact system executable", - "verified:system-app:org.example.Messages".to_string(), - ), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert!(notification.image.content_image.data.is_empty()); - assert!(!notification.hints.contains_key("image-path")); -} - -#[test] -fn associated_sender_role_accepts_inline_reply_and_message_categories() { - let attribution = unixnotis_core::NotificationAttribution::associated( - "Messages", - "Messages", - "org.example.Messages", - "messages", - IdentityAssurance::SystemAssociated, - InteractionPolicies::NATIVE_COMPATIBILITY, - unixnotis_core::AttributionReason::ExactUserExecutable, - "associated executable", - "recognized:system-app:org.example.Messages:sender".to_string(), - ); - let index = super::super::super::identity::DesktopIdentityIndex::default(); - - assert_eq!( - sender_visual_role( - &attribution, - &index, - &HashMap::new(), - &["inline-reply".to_string(), "Reply".to_string()], - "", - ), - SenderVisualRole::ConversationAvatar - ); - - let mut hints = HashMap::new(); - hints.insert( - "category".to_string(), - string_to_owned_value("im.received").expect("category value"), - ); - assert_eq!( - sender_visual_role(&attribution, &index, &hints, &[], ""), - SenderVisualRole::ConversationAvatar - ); - - let mut exact = HashMap::new(); - exact.insert( - "category".to_string(), - string_to_owned_value("im").expect("exact category value"), - ); - assert_eq!( - sender_visual_role(&attribution, &index, &exact, &[], ""), - SenderVisualRole::ConversationAvatar - ); - - let mut unrelated = HashMap::new(); - unrelated.insert( - "category".to_string(), - string_to_owned_value("other").expect("unrelated category value"), - ); - assert_eq!( - sender_visual_role(&attribution, &index, &unrelated, &[], ""), - SenderVisualRole::None - ); - assert_eq!( - sender_visual_role(&attribution, &index, &HashMap::new(), &[], ""), - SenderVisualRole::None - ); -} - -#[test] -fn associated_noncommunication_path_is_a_small_application_visual() { - let attribution = unixnotis_core::NotificationAttribution::associated( - "Example player", - "Example player", - "org.example.Player", - "example-player", - IdentityAssurance::SystemAssociated, - InteractionPolicies::NATIVE_COMPATIBILITY, - unixnotis_core::AttributionReason::ExactSystemExecutable, - "associated executable", - "associated:system-app:org.example.Player:sender".to_string(), - ); - let role = sender_visual_role( - &attribution, - &super::super::super::identity::DesktopIdentityIndex::default(), - &HashMap::new(), - &[], - "/tmp/application-icon.png", - ); - - assert_eq!(role, SenderVisualRole::ApplicationProvidedIcon); -} - -#[test] -fn portal_association_cannot_start_host_avatar_materialization() { - let attribution = unixnotis_core::NotificationAttribution::associated( - "Portal app", - "Portal app", - "org.example.PortalApp", - "portal-app", - IdentityAssurance::PortalAssociated, - InteractionPolicies::CONFIRM_ACTIONS, - AttributionReason::PortalAppIdAssociation, - "portal supplied app id", - "recognized:portal:org.example.PortalApp".to_string(), - ); - assert!(!may_read_sender_host_visual(&attribution)); - assert_eq!( - sender_visual_role( - &attribution, - &super::super::super::identity::DesktopIdentityIndex::default(), - &HashMap::new(), - &["inline-reply".to_string(), "Reply".to_string()], - "", - ), - SenderVisualRole::None - ); -} - -#[test] -fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { - let icon = unixnotis_core::ImageData { - width: 1, - height: 1, - rowstride: 4, - has_alpha: true, - bits_per_sample: 8, - channels: 4, - data: vec![1, 2, 3, 255], - }; - let notification = build_notification(NotificationInput { - app_name: "Example player".to_string(), - app_icon: "example-player".to_string(), - summary: "Track".to_string(), - body: "Artist".to_string(), - actions: Vec::new(), - hints: HashMap::new(), - image_data: None, - sender_visual: Some(icon), - sender_visual_role: SenderVisualRole::ApplicationProvidedIcon, - sender: SenderMetadata::default(), - attribution: unixnotis_core::NotificationAttribution::associated( - "Example player", - "Example player", - "org.example.Player", - "example-player", - IdentityAssurance::SystemAssociated, - InteractionPolicies::NATIVE_COMPATIBILITY, - AttributionReason::ExactSystemExecutable, - "protected local association", - "associated:system-app:org.example.Player".to_string(), - ), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - expire_timeout: 0, - }); - - assert_eq!( - notification.image.sender_visual_role, - unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon - ); - assert_eq!(notification.image.badge_icon, "example-player"); -} - -#[test] -fn large_avatar_is_downsampled_to_the_storage_bound() { - let source = vec![255_u8; 256 * 128 * 4]; - let (width, height, data) = super::downsample_avatar(256, 128, source, 64).expect("downsample"); - assert_eq!((width, height), (64, 32)); - assert_eq!(data.len(), 64 * 32 * 4); -} - -#[test] -fn avatar_downsampling_rejects_zero_dimensions_and_keeps_exact_size_images() { - assert!(super::downsample_avatar(0, 1, Vec::new(), 64).is_none()); - assert!(super::downsample_avatar(1, 0, Vec::new(), 64).is_none()); - - let source = vec![7_u8; 64 * 64 * 4]; - let source_ptr = source.as_ptr(); - let (width, height, data) = super::downsample_avatar(64, 64, source, 64).expect("exact bound"); - assert_eq!((width, height), (64, 64)); - assert_eq!(data.as_ptr(), source_ptr); -} - -#[test] -fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { - // Keep the source height unchanged after scaling so the early-return guard - // must compare both dimensions rather than accepting one matching value - let mut horizontal = vec![0_u8; 128 * 4]; - for x in 0..128 { - horizontal[x * 4] = u8::try_from(x).expect("horizontal fixture value"); - } - let (width, height, data) = - super::downsample_avatar(128, 1, horizontal, 64).expect("horizontal downsample"); - assert_eq!((width, height), (64, 1)); - assert_eq!(data[4], 2); - - let mut vertical = vec![0_u8; 64 * 128 * 4]; - for y in 0..128 { - vertical[y * 64 * 4] = u8::try_from(y).expect("vertical fixture value"); - } - let (width, height, data) = - super::downsample_avatar(64, 128, vertical, 64).expect("vertical downsample"); - assert_eq!((width, height), (32, 64)); - assert_eq!(data[32 * 4], 2); -} - -#[cfg(target_os = "linux")] -#[test] -fn fifo_avatar_path_is_rejected_without_opening_a_blocking_reader() { - let directory = std::env::temp_dir().join(format!( - "unixnotis-avatar-fifo-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); - std::fs::create_dir(&directory).expect("create temporary directory"); - let path = directory.join("avatar.fifo"); - let path_string = path.to_string_lossy().into_owned(); - let status = std::process::Command::new("mkfifo") - .arg(&path) - .status() - .expect("mkfifo available"); - assert!(status.success()); - assert!(materialize_sender_visual(&path_string, 64).is_none()); - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_dir(directory); -} - -#[test] -fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { - // This is a tiny 1x1 RGBA PNG used only to exercise the real decoder - let png = [ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, - 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, - 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, - 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]; - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock") - .as_nanos(); - let path = std::env::temp_dir().join(format!("unixnotis-avatar-{suffix}.png")); - std::fs::write(&path, png).expect("write avatar fixture"); - - let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); - let _ = std::fs::remove_file(&path); - - let avatar = avatar.expect("valid avatar should decode"); - assert_eq!((avatar.width, avatar.height), (1, 1)); - assert_eq!(avatar.channels, 4); - assert_eq!(avatar.data.len(), 4); -} - -#[test] -fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { - assert!(avatar_file_size_allowed(MAX_CONVERSATION_AVATAR_BYTES)); - assert!(!avatar_file_size_allowed(MAX_CONVERSATION_AVATAR_BYTES + 1)); - assert!(avatar_buffer_size_allowed( - MAX_CONVERSATION_AVATAR_BYTES as usize - )); - assert!(!avatar_buffer_size_allowed( - MAX_CONVERSATION_AVATAR_BYTES as usize + 1 - )); -} - -#[test] -fn relative_or_missing_avatar_path_is_rejected() { - assert!(materialize_sender_visual("avatar.png", 64).is_none()); - assert!(materialize_sender_visual("/path/that/does/not/exist.png", 64).is_none()); -} - -#[test] -fn parse_actions_caps_pairs() { - let mut raw = Vec::new(); - for idx in 0..(MAX_ACTIONS + 10) { - raw.push(format!("key-{idx}")); - raw.push(format!("label-{idx}")); - } - - let actions = parse_actions(raw); - assert_eq!(actions.len(), MAX_ACTIONS); -} - -#[test] -fn parse_actions_ignores_dangling_key_without_label() { - let actions = parse_actions(vec![ - "default".to_string(), - "Open".to_string(), - "orphan-key".to_string(), - ]); - - // D-Bus action arrays are pairs; a trailing key cannot produce a safe button - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].key, "default"); - assert_eq!(actions[0].label, "Open"); -} - -#[test] -fn parse_actions_reserves_capacity_for_complete_pairs_only() { - let actions = parse_actions(vec![ - "default".to_string(), - "Open".to_string(), - "dismiss".to_string(), - "Dismiss".to_string(), - ]); - - assert_eq!(actions.len(), 2); - assert_eq!(actions.capacity(), 2); -} - -#[test] -fn sanitize_hints_drops_untrusted_and_bounds_strings() { - let mut hints = HashMap::::new(); - hints.insert("transient".to_string(), OwnedValue::from(true)); - hints.insert("urgency".to_string(), OwnedValue::from(9u32)); - hints.insert( - "sound-name".to_string(), - string_to_owned_value(&"n".repeat(5000)).expect("sound-name"), - ); - hints.insert("image-data".to_string(), OwnedValue::from(123u32)); - hints.insert( - "x-custom".to_string(), - string_to_owned_value("custom").expect("custom"), - ); - - let sanitized = sanitize_hints_for_storage(hints); - assert_eq!(sanitized.len(), 3); - assert!(sanitized.contains_key("transient")); - assert!(sanitized.contains_key("sound-name")); - assert_eq!( - u32::try_from(sanitized.get("urgency").expect("urgency")), - Ok(2) - ); - - let sound_name = owned_to_string( - sanitized - .get("sound-name") - .expect("sound-name should remain"), - ) - .expect("sound-name should be string"); - assert!(sound_name.len() <= 2048); -} - -#[test] -fn parse_urgency_hint_accepts_byte_and_integer_values_with_cap() { - assert_eq!(parse_urgency_hint(&OwnedValue::from(0u8)), Some(0)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(1u32)), Some(1)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(99u32)), Some(2)); - assert_eq!( - parse_urgency_hint(&string_to_owned_value("high").expect("string")), - None - ); -} - -#[test] -fn owned_to_string_accepts_only_string_values() { - assert_eq!( - owned_to_string(&string_to_owned_value("sound").expect("string")).as_deref(), - Some("sound") - ); - assert_eq!(owned_to_string(&OwnedValue::from(7u32)), None); -} - -#[test] -fn resolve_expiration_respects_protocol_and_config_rules() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 5_000; - config.popups.critical_timeout_ms = Some(9_000); - - let mut notification = unixnotis_core::Notification { - id: 1, - generation: 1, - app_name: "app".to_string(), - app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - inline_reply: unixnotis_core::InlineReply::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: -1, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.urgency = Urgency::Critical; - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = 0; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.expire_timeout = 100; - notification.is_resident = true; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.is_resident = false; - let before = Instant::now(); - let deadline = resolve_expiration(&config, ¬ification).expect("explicit timeout"); - assert!(deadline > before); - assert!(deadline <= Instant::now() + Duration::from_millis(500)); - - notification.expire_timeout = -1; - notification.urgency = Urgency::Critical; - config.popups.critical_timeout_ms = None; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} - -#[test] -fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_is_zero() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 0; - let mut notification = unixnotis_core::Notification { - id: 1, - generation: 1, - app_name: "app".to_string(), - app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - inline_reply: unixnotis_core::InlineReply::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 25, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = -1; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} From 6a968960812deefb79d2a5b07db2a3b204966bad Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 03:44:39 -0500 Subject: [PATCH 202/275] fix(media): collapse duplicate browser players Summary: collapse duplicate browser players. Scope: media. --- .../src/media/runtime/snapshot.rs | 66 +++++++------- .../src/media/runtime/tests/snapshot.rs | 88 +++++++++++++++++++ 2 files changed, 120 insertions(+), 34 deletions(-) diff --git a/crates/unixnotis-center/src/media/runtime/snapshot.rs b/crates/unixnotis-center/src/media/runtime/snapshot.rs index 1372dccf9..882b28425 100644 --- a/crates/unixnotis-center/src/media/runtime/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/snapshot.rs @@ -39,7 +39,6 @@ pub(super) fn build_snapshot(cache: &HashMap) -> Vec) -> Vec u8 { - match status { - "Playing" => 0, - "Paused" => 1, - _ => 2, - } + u8::from(status != "Playing") } fn is_active_player(info: &MediaInfo) -> bool { @@ -75,60 +62,71 @@ fn dedupe_players(infos: Vec) -> Vec { let mut output: Vec = Vec::with_capacity(infos.len()); let mut seen: HashMap = HashMap::new(); for info in infos { - let Some(key) = dedupe_key(&info) else { + let keys = dedupe_keys(&info); + if keys.is_empty() { output.push(info); continue; - }; - if let Some(existing_index) = seen.get(&key).copied() { + } + if let Some(existing_index) = keys.iter().find_map(|key| seen.get(key).copied()) { let existing = &output[existing_index]; // Lower score wins, so a playing player with art beats a paused // or artless duplicate from the same browser family or track key if media_score(&info) < media_score(existing) { output[existing_index] = info; } + for key in keys { + seen.insert(key, existing_index); + } continue; } - seen.insert(key, output.len()); + let output_index = output.len(); + for key in keys { + seen.insert(key, output_index); + } output.push(info); } output } -fn dedupe_key(info: &MediaInfo) -> Option { +fn dedupe_keys(info: &MediaInfo) -> Vec { let title = info.title.trim(); if let Some(family) = info.browser_family.as_deref() { - if let Some(pid) = info.owner_pid { - // Only the broker-derived owner PID is safe for cross-name deduplication - return Some(format!("browser-pid:{pid}")); - } + let mut keys = Vec::with_capacity(2); if !title.is_empty() { - // Browser-backed players can expose one webpage through multiple MPRIS names - // Track metadata is the stable key across Brave, Chromium, and browser instances + // Browser bridges often expose the same track under different names and PIDs + // Track identity is the useful cross-browser key when both title and artist exist let artist = info.artist.trim(); - return Some(format!( + keys.push(format!( "browser-track\n{}\n{}", normalize_token(title), - normalize_token(artist) + normalize_token(artist), )); } - // Empty browser metadata is too weak for cross-browser matching - // Keep the old family fallback so duplicate instances still collapse - return Some(format!("browser:{family}")); + if let Some(pid) = info.owner_pid { + // A broker-derived PID also collapses aliases owned by one browser process + keys.push(format!("browser-pid:{pid}")); + } + if keys.is_empty() { + // Empty browser metadata is too weak for cross-browser matching + // Keep the family fallback so duplicate instances still collapse + keys.push(format!("browser:{family}")); + } + return keys; } if title.is_empty() { // Empty titles are too weak to build a stable cross-player key - return None; + return Vec::new(); } let artist = info.artist.trim(); let identity = info.identity.trim(); let normalized_title = normalize_token(title); let normalized_artist = normalize_token(artist); - Some(format!( + vec![format!( "{}\n{}\n{}", normalize_token(identity), normalized_title, normalized_artist - )) + )] } fn media_score(info: &MediaInfo) -> (u8, u8) { diff --git a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs index 34c81012a..9ee4989fc 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs @@ -74,6 +74,37 @@ fn build_snapshot_sorts_by_status_then_identity() { assert_eq!(identities, vec!["Alpha", "Beta", "Zeta"]); } +#[test] +fn build_snapshot_keeps_paused_players_before_inactive_sessions() { + let mut cache = HashMap::new(); + cache.insert( + "org.mpris.MediaPlayer2.stopped".to_string(), + make_info( + "org.mpris.MediaPlayer2.stopped", + "Stopped", + "Stopped", + false, + None, + None, + ), + ); + cache.insert( + "org.mpris.MediaPlayer2.paused".to_string(), + make_info( + "org.mpris.MediaPlayer2.paused", + "Paused", + "Paused", + false, + None, + None, + ), + ); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Paused"); +} + #[test] fn build_snapshot_dedupes_browser_family_by_score() { let mut cache = HashMap::new(); @@ -165,6 +196,63 @@ fn build_snapshot_keeps_distinct_browser_tracks() { assert_eq!(snapshot.len(), 2); } +#[test] +fn build_snapshot_collapses_same_track_across_browser_families() { + let mut cache = HashMap::new(); + let mut chromium = make_info( + "org.mpris.MediaPlayer2.chromium.instance", + "Chromium", + "Playing", + false, + Some("chromium"), + Some(11), + ); + chromium.title = "The Thing 1982 - What does it mean".to_string(); + chromium.artist = "That Scouse Dude".to_string(); + let mut brave = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave", + "Playing", + true, + Some("brave"), + Some(22), + ); + brave.title = chromium.title.clone(); + brave.artist = chromium.artist.clone(); + cache.insert(chromium.bus_name.clone(), chromium); + cache.insert(brave.bus_name.clone(), brave); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Brave"); + assert!(snapshot[0].art_source.is_some()); +} + +#[test] +fn build_snapshot_keeps_the_first_equal_score_duplicate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + true, + Some("first"), + Some(11), + ); + first.title = "shared track".to_string(); + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "First"); +} + #[test] fn normalize_token_compacts_and_lowercases() { let token = normalize_token(" Foo--Bar\tBaz "); From 72d0764a64414dfd898a0da5b189384aea32a36c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 03:45:02 -0500 Subject: [PATCH 203/275] fix(center): preserve recycled rows and decorative visuals Summary: preserve recycled rows and decorative visuals. Scope: center. --- crates/unixnotis-center/src/ui/events.rs | 37 +++++++++++++++++++ .../src/ui/events/tests/mod.rs | 14 +++++++ .../src/ui/icons/resolution.rs | 20 +++++----- .../unixnotis-center/src/ui/icons/resolver.rs | 4 +- .../src/ui/init/constructor.rs | 1 + .../row/notification/update/row.rs | 25 ++++++++++--- .../row/notification/update/thumbnail.rs | 7 ++++ .../src/ui/panel/behavior/visibility.rs | 4 ++ crates/unixnotis-center/src/ui/state.rs | 2 + .../src/ui/entry/builders/mod.rs | 15 +++++++- crates/unixnotis-popups/src/ui/icon_state.rs | 16 +++++++- 11 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/events/tests/mod.rs diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 562050947..eae939e0f 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -3,6 +3,7 @@ //! Centralizes `UiEvent` handling so UI state transitions remain coherent and //! traceable in logs +use gtk::prelude::*; use tracing::debug; use unixnotis_core::PanelDebugLevel; @@ -17,6 +18,7 @@ impl UiState { debug!("UnixNotis control service disconnected"); // Old rows and state must not survive into a later daemon generation self.list.clear_for_disconnect(); + self.mark_notifications_changed(); self.update_state(unixnotis_core::ControlState::default()); self.refresh_counts(); } @@ -32,6 +34,7 @@ impl UiState { ); // Seed list data before applying state to keep counts aligned self.list.seed(active, history); + self.mark_notifications_changed(); self.update_state(state); self.refresh_counts(); } @@ -48,6 +51,7 @@ impl UiState { ) }); self.list.add_or_update(notification, true); + self.mark_notifications_changed(); // Header count reflects the combined active + history totals self.refresh_counts(); } @@ -64,6 +68,7 @@ impl UiState { ) }); self.list.add_or_update(notification, true); + self.mark_notifications_changed(); // Updates may shift groups; refresh count even when list is stable self.refresh_counts(); } @@ -78,6 +83,7 @@ impl UiState { format!("notification closed: #{} ({reason:?})", key.id) }); self.list.mark_closed(key, reason); + self.mark_notifications_changed(); // Marking closed can move entries between active/history buckets self.refresh_counts(); } @@ -218,10 +224,41 @@ impl UiState { } pub fn flush_list_rebuild(&mut self) { + let snap_to_top = self.panel_visible && should_snap_to_top(&self.panel.sections.scroller); self.list.flush_rebuild(); + if snap_to_top { + reset_notification_scroll(&self.panel.sections.scroller); + } + } + + pub(in crate::ui) const fn mark_notifications_changed(&mut self) { + if !self.panel_visible { + self.notifications_changed_while_hidden = true; + } } pub const fn list_needs_rebuild(&self) -> bool { self.list.needs_rebuild() } } + +fn should_snap_to_top(scroller: >k::ScrolledWindow) -> bool { + let adjustment = scroller.vadjustment(); + should_snap_to_top_value(adjustment.value(), adjustment.lower()) +} + +const fn should_snap_to_top_value(value: f64, lower: f64) -> bool { + value <= lower + 18.0 +} + +pub(in crate::ui) fn reset_notification_scroll(scroller: >k::ScrolledWindow) { + let scroller = scroller.clone(); + gtk::glib::idle_add_local_once(move || { + let adjustment = scroller.vadjustment(); + adjustment.set_value(adjustment.lower()); + }); +} + +#[cfg(test)] +#[path = "events/tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/events/tests/mod.rs b/crates/unixnotis-center/src/ui/events/tests/mod.rs new file mode 100644 index 000000000..109e67ee8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/events/tests/mod.rs @@ -0,0 +1,14 @@ +use super::should_snap_to_top_value; + +#[test] +fn near_top_insertions_snap_to_the_first_row() { + assert!(should_snap_to_top_value(0.0, 0.0)); + assert!(should_snap_to_top_value(17.5, 0.0)); + assert!(!should_snap_to_top_value(18.1, 0.0)); +} + +#[test] +fn scroll_threshold_follows_nonzero_adjustment_lower_bound() { + assert!(should_snap_to_top_value(118.0, 100.0)); + assert!(!should_snap_to_top_value(118.1, 100.0)); +} diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index 23d988867..243bfba3f 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -17,22 +17,20 @@ use super::theme::{ use super::types::{IconDecodeRequest, IconResolution}; impl IconResolverInner { - pub(super) fn apply_conversation_avatar( - &self, - image: >k::Image, - notification: &NotificationView, - ) { + pub(super) fn apply_sender_visual(&self, image: >k::Image, notification: &NotificationView) { // The daemon has already decoded and bounded this sender-provided raster - if matches!( + if !matches!( notification.image.sender_visual_role, unixnotis_core::NotificationVisualRole::ConversationAvatar | unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon ) { - if let Some(texture) = image_data_texture_for_data(¬ification.image.sender_visual) { - image.set_paintable(Some(&texture)); - image.set_visible(true); - return; - } + image.set_visible(false); + return; + } + if let Some(texture) = image_data_texture_for_data(¬ification.image.sender_visual) { + image.set_paintable(Some(&texture)); + image.set_visible(true); + return; } image.set_visible(false); } diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index 24860a703..e665834f1 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -68,8 +68,8 @@ impl IconResolver { self.inner.apply_badge(image, notification, size, scale); } - pub fn apply_conversation_avatar(&self, image: >k::Image, notification: &NotificationView) { - self.inner.apply_conversation_avatar(image, notification); + pub fn apply_sender_visual(&self, image: >k::Image, notification: &NotificationView) { + self.inner.apply_sender_visual(image, notification); } pub fn clear_missing_cache(&self) { diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index ad2b35879..97741f93f 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -95,6 +95,7 @@ impl UiState { dnd_expiration_source: None, search_toggle_guard, panel_visible: false, + notifications_changed_while_hidden: false, panel_visible_flag, work_area: None, last_count: None, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 6907419cf..a38d93eb4 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -14,7 +14,10 @@ use super::super::state::{IconSignature, NotificationRowWidgets}; use super::actions::{update_actions, visible_action_count}; use super::labels::update_notification_text; use super::metadata::update_metadata_labels; -use super::thumbnail::{notification_has_conversation_avatar, notification_has_thumbnail}; +use super::thumbnail::{ + notification_has_conversation_avatar, notification_has_sender_visual, + notification_has_thumbnail, +}; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { @@ -110,8 +113,9 @@ pub(in crate::ui::notifications) fn update_notification_row( let has_content_thumbnail = notification_has_thumbnail(notification); // The daemon has already assigned the visual role after attribution and safe decoding let has_conversation_avatar = notification_has_conversation_avatar(notification); - let has_thumbnail = - data.presentation.show_thumbnail && (has_content_thumbnail || has_conversation_avatar); + let has_sender_visual = notification_has_sender_visual(notification); + let has_thumbnail = data.presentation.show_thumbnail + && (has_content_thumbnail || has_conversation_avatar || has_sender_visual); apply_visual_state(row, data, notification, has_actions, has_thumbnail); update_notification_text( @@ -176,12 +180,23 @@ pub(in crate::ui::notifications) fn update_notification_row( set_widget_visible_if_changed(&row.close_button, true); if has_thumbnail { // Reapply visible thumbnails so config reloads cannot leave stale previews - if has_conversation_avatar && !has_content_thumbnail { - icon_resolver.apply_conversation_avatar(&row.thumbnail, notification); + if (has_conversation_avatar || has_sender_visual) && !has_content_thumbnail { + icon_resolver.apply_sender_visual(&row.thumbnail, notification); + if has_sender_visual { + row.thumbnail.add_css_class("unixnotis-panel-sender-visual"); + } else { + row.thumbnail + .remove_css_class("unixnotis-panel-sender-visual"); + } } else { let scale = row.card.scale_factor(); icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); + row.thumbnail + .remove_css_class("unixnotis-panel-sender-visual"); } + } else { + row.thumbnail + .remove_css_class("unixnotis-panel-sender-visual"); } set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); set_widget_visible_if_changed(&row.card_plate, true); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index e7c5a50b0..f91c4832a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -17,3 +17,10 @@ pub(super) const fn notification_has_conversation_avatar(notification: &Notifica unixnotis_core::NotificationVisualRole::ConversationAvatar ) } + +pub(super) const fn notification_has_sender_visual(notification: &NotificationView) -> bool { + matches!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ) +} diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index a26c654ca..15d6770e7 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -102,6 +102,10 @@ impl UiState { } // Only show the window after geometry is correct to avoid visible jitter self.panel.window.set_visible(true); + if self.notifications_changed_while_hidden { + crate::ui::events::reset_notification_scroll(&self.panel.sections.scroller); + self.notifications_changed_while_hidden = false; + } // Refresh counts after pending updates land so header stays accurate self.refresh_counts(); // Run the first widget pass after the window is visible diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 929987737..f166a5c99 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -32,6 +32,8 @@ pub struct UiState { pub(super) dnd_expiration_source: Option, pub(super) search_toggle_guard: Rc>, pub(super) panel_visible: bool, + // A hidden panel defers list painting, so the next open must reveal the newest complete row + pub(super) notifications_changed_while_hidden: bool, pub(super) panel_visible_flag: Arc, pub(super) work_area: Option, // Tracks the last rendered counts to avoid redundant label updates diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 9b9d55ca0..0920f5b7a 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -43,12 +43,23 @@ pub(super) fn append_thumbnail( view: &PopupEntryViewModel, content: >k::Box, ) -> bool { - if view.thumbnail != super::presentation::ThumbnailKind::Content { + let is_application_visual = notification.image.sender_visual_role + == unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + if view.thumbnail != super::presentation::ThumbnailKind::Content && !is_application_visual { return false; } - let Some(image) = UiState::build_content_image_widget(notification) else { + let image = + if is_application_visual && view.thumbnail != super::presentation::ThumbnailKind::Content { + UiState::build_sender_visual_widget(notification) + } else { + UiState::build_content_image_widget(notification) + }; + let Some(image) = image else { return false; }; + if image.paintable().is_none() { + return false; + } // Content images stay bounded and visually separate from the application badge image.set_halign(gtk::Align::Start); diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index c2280f6c9..892ffde3d 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -35,7 +35,6 @@ impl UiState { if !matches!( notification.image.sender_visual_role, unixnotis_core::NotificationVisualRole::ConversationAvatar - | unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon ) { return None; } @@ -47,6 +46,21 @@ impl UiState { Some(widget) } + pub(super) fn build_sender_visual_widget( + notification: &NotificationView, + ) -> Option { + if notification.image.sender_visual_role + != unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + { + return None; + } + let texture = image_data_texture_for_data(¬ification.image.sender_visual)?; + let widget = gtk::Image::from_paintable(Some(&texture)); + set_popup_icon_size(&widget, POPUP_CONTENT_THUMBNAIL_SIZE); + widget.add_css_class("unixnotis-popup-application-visual"); + Some(widget) + } + pub(super) fn build_content_image_widget( notification: &NotificationView, ) -> Option { From f9b5cc83f4cec52e0df1c55ebeb4a711636b471e Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 03:45:07 -0500 Subject: [PATCH 204/275] style(ui): refine popup, panel, and media surfaces Summary: refine popup, panel, and media surfaces. Scope: ui. --- crates/unixnotis-core/assets/media.css | 21 +++++++++++---------- crates/unixnotis-core/assets/panel.css | 16 ++++++++++++++-- crates/unixnotis-core/assets/popup.css | 19 +++++++++++++++---- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 42e26d6e3..d28266f94 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -5,8 +5,8 @@ * without touching the rest of widgets.css */ :root { - --unixnotis-media-card-radius: 20px; - --unixnotis-media-card-min-height: 72px; + --unixnotis-media-card-radius: 18px; + --unixnotis-media-card-min-height: 82px; --unixnotis-media-card-padding-x: 10px; --unixnotis-media-card-padding-y: 8px; --unixnotis-media-card-padding-inline-x: 10px; @@ -16,10 +16,10 @@ --unixnotis-media-card-padding-showcase-y: 8px; --unixnotis-media-button-padding-x: 6px; --unixnotis-media-button-padding-y: 4px; - --unixnotis-media-art-size: 48px; - --unixnotis-media-art-radius: 10px; + --unixnotis-media-art-size: 56px; + --unixnotis-media-art-radius: 12px; --unixnotis-media-art-frame-radius: 12px; - --unixnotis-media-title-font-size: 13px; + --unixnotis-media-title-font-size: 14px; --unixnotis-media-title-font-weight: 700; --unixnotis-media-source-font-size: 11px; --unixnotis-media-artist-font-size: 12px; @@ -106,14 +106,14 @@ border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: var(--unixnotis-media-card-radius); padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); - min-height: 68px; + min-height: var(--unixnotis-media-card-min-height); box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-media-card-carousel { padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); - min-height: 68px; + min-height: var(--unixnotis-media-card-min-height); } .unixnotis-media-card-inline { @@ -183,7 +183,7 @@ min-height: var(--unixnotis-media-art-frame-size); border-radius: 12px; border-radius: var(--unixnotis-media-art-frame-radius); - background: alpha(#000000, 0.12); + background: alpha(@unixnotis-surface-strong-base, 0.44); border-top: 1px solid alpha(#ffffff, 0.10); border-left: 1px solid alpha(#ffffff, 0.08); border-right: 1px solid alpha(#ffffff, 0.04); @@ -192,7 +192,8 @@ } .unixnotis-media-art.empty { - background: alpha(#000000, 0.18); + background: alpha(@unixnotis-surface-strong-base, 0.58); + border-color: alpha(@unixnotis-accent, 0.10); } .unixnotis-media-source { @@ -206,7 +207,7 @@ .unixnotis-media-title { color: #ffffff; font-weight: 800; - font-size: 13px; + font-size: var(--unixnotis-media-title-font-size); letter-spacing: -0.01em; } diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index fc114cd91..c28dc617c 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -3,7 +3,7 @@ * Panel shell, list, and notification group styling. */ .unixnotis-panel { - min-width: 420px; + min-width: 432px; background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); color: @unixnotis-text; border-radius: 20px; @@ -525,6 +525,16 @@ entry selection { transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } +.unixnotis-panel-card-thumbnail { + border-radius: 10px; + background: alpha(@unixnotis-surface-strong-base, 0.42); + box-shadow: 0 5px 14px -8px alpha(#000000, 0.78); +} + +.unixnotis-panel-card-thumbnail.unixnotis-panel-sender-visual { + opacity: 0.92; +} + .unixnotis-panel-card.collapsed-group-preview { box-shadow: 0 12px 24px -20px @unixnotis-shadow-strong, @@ -672,7 +682,8 @@ entry selection { } .unixnotis-panel-summary { - font-size: 13px; + font-size: 14px; + line-height: 1.2; color: @unixnotis-text; font-weight: 650; } @@ -680,6 +691,7 @@ entry selection { .unixnotis-panel-body { color: @unixnotis-muted; font-size: 12px; + line-height: 1.34; } .unixnotis-popup-status { diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index d394e9709..057d4fed4 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -47,7 +47,7 @@ } .unixnotis-popup-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-popup-bg-2, 0.94), alpha(@unixnotis-popup-bg-1, 0.98)); + background-image: linear-gradient(165deg, alpha(@unixnotis-popup-bg-2, 0.96), alpha(@unixnotis-popup-bg-1, 0.99)); color: @unixnotis-text; border-radius: var(--unixnotis-popup-card-radius); padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); @@ -91,9 +91,9 @@ } .unixnotis-popup-app-name { - color: alpha(@unixnotis-text, 0.68); - font-weight: 600; - font-size: 12px; + color: alpha(@unixnotis-text, 0.82); + font-weight: 700; + font-size: 13px; } .unixnotis-popup-time { @@ -127,6 +127,7 @@ color: alpha(@unixnotis-text, 0.98); font-weight: 700; font-size: 15px; + line-height: 1.18; margin-top: 0; } @@ -136,6 +137,13 @@ .unixnotis-popup-conversation-avatar { border-radius: 8px; + box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); +} + +.unixnotis-popup-application-visual { + border-radius: 10px; + opacity: 0.92; + box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); } .unixnotis-identity-avatar { @@ -169,6 +177,7 @@ color: alpha(@unixnotis-muted, 0.88); font-weight: 400; font-size: 13px; + line-height: 1.28; margin-top: 3px; } @@ -190,6 +199,8 @@ min-height: 64px; margin-top: 6px; border-radius: 9px; + background: alpha(#000000, 0.12); + box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); } .unixnotis-popup-card.recognized, From 07283e831f7d5beb456a02b0927b9b09e6a329bf Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 12:53:46 -0500 Subject: [PATCH 205/275] fix(panel): restore stable notification group stacking Summary: restore stable notification group stacking. Scope: panel. --- .../src/ui/notifications/row/notification/build.rs | 2 +- .../src/ui/notifications/row/notification/stack.rs | 4 ++-- .../src/ui/notifications/row/notification/state.rs | 2 +- .../src/ui/notifications/row/notification/tests/stack.rs | 2 +- .../ui/notifications/row/notification/update/tests/state.rs | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 7261230b5..b34e8c166 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -198,7 +198,7 @@ pub(in crate::ui::notifications) fn build_notification_row( let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); - // Rear layers and the readable foreground remain one virtualized ListView row + // Master-style silhouettes preserve the visible group depth without accepting input let (stack_middle, stack_back) = append_stack_layers(&root, &card_plate); let notify_key = Rc::new(Cell::new(NotificationKey { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs index 27667240e..baecb3fb8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -1,4 +1,4 @@ -//! Collapsed group depth layers and paint order +//! Collapsed group paint order and bounded rear silhouettes use gtk::prelude::*; @@ -25,7 +25,7 @@ pub(super) fn append_stack_layers( let middle = build_stack_layer("unixnotis-stack-layer-middle"); let back = build_stack_layer("unixnotis-stack-layer-back"); - // Later GTK siblings paint over earlier layers when negative margins overlap + // Later GTK siblings paint above earlier siblings when margins overlap for layer in STACK_LAYER_ORDER { match layer { StackLayer::Back => root.append(&back), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 04445ac78..7d7b1ba77 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -19,7 +19,7 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing pub(super) card_plate: unixnotis_ui::CutCorner, - // Collapsed groups use at most two non-interactive rear silhouettes + // Collapsed groups use two non-interactive rear silhouettes pub(super) stack_middle: gtk::Box, pub(super) stack_back: gtk::Box, // Main icon shown at the top-left of the row diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs index 19fde444c..53e93ec9e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -3,7 +3,7 @@ use gtk::prelude::*; use super::{append_stack_layers, stack_layer_visibility, StackLayerVisibility}; #[test] -fn collapsed_stack_depth_maps_to_at_most_two_rear_layers() { +fn collapsed_stack_depth_maps_to_two_rear_layers() { assert_eq!(stack_layer_visibility(0), StackLayerVisibility::default()); assert_eq!( stack_layer_visibility(1), diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 2c330fa32..a4a353d3f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -242,16 +242,15 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { } #[gtk::test] -fn collapsed_group_preview_uses_one_readable_card_above_depth_layers() { +fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { let (root, row) = notification_row(); - let mut data = row_data( + let data = row_data( Rc::new(sample_notification()), RowFlags { collapsed_group_preview: true, ..Default::default() }, ); - data.stack_depth = 2; let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); update_notification_row(&row, &data, &IconResolver::new(), &command_tx); @@ -259,6 +258,7 @@ fn collapsed_group_preview_uses_one_readable_card_above_depth_layers() { assert_eq!(child_count(&root), 3); assert!(row.stack_middle.get_visible()); assert!(row.stack_back.get_visible()); + assert!(row.card_plate.get_visible()); assert!( row.card .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW), From 3a437c4a10755c9a8fb57db2f5ba99cff3fc3519 Mon Sep 17 00:00:00 2001 From: locainin <68669971+locainin@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:12:50 -0500 Subject: [PATCH 206/275] test(center): model two-layer collapsed preview accurately Summary: model two-layer collapsed preview accurately. Scope: center. --- .../ui/notifications/row/notification/update/tests/state.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index a4a353d3f..c7f2d7ccc 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -244,13 +244,15 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { #[gtk::test] fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { let (root, row) = notification_row(); - let data = row_data( + let mut data = row_data( Rc::new(sample_notification()), RowFlags { collapsed_group_preview: true, ..Default::default() }, ); + // Two hidden notifications are required for both rear stack layers + data.stack_depth = 2; let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); update_notification_row(&row, &data, &IconResolver::new(), &command_tx); From 9df661f044aaab62974e6c6a34128586250e3fdb Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:39:16 -0500 Subject: [PATCH 207/275] fix(media): make browser deduplication process-aware Build MPRIS duplicate components from authenticated owner and bridge source relationships before using bounded metadata fallbacks. Keep deterministic representative ordering and retain native-player behavior. Carry the owner and source identifiers through media snapshots and their fixtures. --- .../unixnotis-center/src/media/api/model.rs | 4 +- .../src/media/mpris/metadata.rs | 28 +- .../unixnotis-center/src/media/mpris/mod.rs | 2 +- .../src/media/mpris/tests/metadata.rs | 83 +++- .../src/media/mpris/tests/support.rs | 30 +- .../src/media/runtime/cache.rs | 5 + .../src/media/runtime/snapshot.rs | 137 ++++-- .../src/media/runtime/tests/cache.rs | 1 + .../src/media/runtime/tests/dispatch.rs | 1 + .../src/media/runtime/tests/schedule.rs | 1 + .../src/media/runtime/tests/snapshot.rs | 430 +++++++++++++++++- .../src/ui/media/tests/config.rs | 1 + .../src/ui/media/widget/tests/card.rs | 1 + .../src/ui/media/widget/tests/format.rs | 2 + .../src/ui/media/widget/tests/selection.rs | 1 + 15 files changed, 669 insertions(+), 58 deletions(-) diff --git a/crates/unixnotis-center/src/media/api/model.rs b/crates/unixnotis-center/src/media/api/model.rs index 97b200f8b..714425b89 100644 --- a/crates/unixnotis-center/src/media/api/model.rs +++ b/crates/unixnotis-center/src/media/api/model.rs @@ -8,8 +8,10 @@ pub struct MediaInfo { pub identity: String, /// Browser family tag used for grouping browser-backed players pub browser_family: Option, - /// Browser or source PID from MPRIS metadata or the owning bus process + /// Authenticated PID of the D-Bus connection that owns the player pub owner_pid: Option, + /// Untrusted browser-bridge PID hint used only for duplicate detection + pub source_pid_hint: Option, pub title: String, pub artist: String, pub playback_status: String, diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 7c52ec6c7..f7eec38d9 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -14,6 +14,7 @@ use zbus::Proxy; const MAX_TITLE_BYTES: usize = 256; const MAX_ARTIST_BYTES: usize = 256; const MAX_ART_URL_BYTES: usize = 2048; +const PLASMA_BRIDGE: &str = "org.mpris.MediaPlayer2.plasma-browser-integration"; pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option { if state.timeout.is_quarantined() { @@ -72,6 +73,10 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option Option, key: &str) -> O String::try_from(owned).ok() } +pub(in crate::media) fn is_plasma_browser_bridge(bus_name: &str) -> bool { + // Only the known bridge name may contribute an untrusted source-PID hint + bus_name == PLASMA_BRIDGE + || bus_name + .strip_prefix(PLASMA_BRIDGE) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +pub(super) fn metadata_pid(map: &HashMap, key: &str) -> Option { + // Zero and negative values do not identify a live process + let value = map.get(key)?; + let owned = value.try_clone().ok()?; + if let Ok(pid) = i32::try_from(owned) { + return u32::try_from(pid).ok().filter(|pid| *pid != 0); + } + let owned = value.try_clone().ok()?; + u32::try_from(owned).ok().filter(|pid| *pid != 0) +} + pub(super) fn metadata_artist(map: &HashMap) -> Option { let value = map.get("xesam:artist")?; let artists_value = value.try_clone().ok()?; diff --git a/crates/unixnotis-center/src/media/mpris/mod.rs b/crates/unixnotis-center/src/media/mpris/mod.rs index 43a8b53d4..91fc8c332 100644 --- a/crates/unixnotis-center/src/media/mpris/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/mod.rs @@ -15,7 +15,7 @@ pub(in crate::media) use command::handle_command; pub(in crate::media) use constants::MPRIS_PREFIX; pub(in crate::media) use discovery::refresh_players; pub(in crate::media) use listener::spawn_properties_listener; -pub(in crate::media) use metadata::fetch_media_info; +pub(in crate::media) use metadata::{fetch_media_info, is_plasma_browser_bridge}; #[cfg_attr( not(test), expect( diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index ee91b8034..d1a85fd43 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -1,14 +1,16 @@ use super::super::constants::MAX_MPRIS_PROPERTY_REPLY_BYTES; use super::super::metadata::fetch_media_info; use super::super::metadata::{ - bound_string, metadata_artist, metadata_entry_count_allowed, metadata_string, - property_reply_body_allowed, + bound_string, is_plasma_browser_bridge, metadata_artist, metadata_entry_count_allowed, + metadata_pid, metadata_string, property_reply_body_allowed, }; use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use super::support::{MprisFixture, TEST_BRIDGE_PLAYER_NAME, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; use zbus::zvariant::{OwnedValue, Value}; +const SOURCE_BROWSER_PID: u32 = 42_424; + #[test] fn bounded_metadata_strings_trim_and_preserve_utf8_boundaries() { assert_eq!(bound_string(" title ", 32), "title"); @@ -33,6 +35,46 @@ fn metadata_fields_accept_expected_string_shapes() { assert_eq!(metadata_artist(&metadata).as_deref(), Some("Artist")); } +#[test] +fn metadata_pid_accepts_unsigned_values_and_rejects_negative_values() { + let positive = std::collections::HashMap::from([( + "kde:pid".to_string(), + OwnedValue::from(SOURCE_BROWSER_PID), + )]); + let negative = + std::collections::HashMap::from([("kde:pid".to_string(), OwnedValue::from(-1_i32))]); + let zero = std::collections::HashMap::from([("kde:pid".to_string(), OwnedValue::from(0_u32))]); + + assert_eq!(metadata_pid(&positive, "kde:pid"), Some(SOURCE_BROWSER_PID)); + assert_eq!(metadata_pid(&negative, "kde:pid"), None); + assert_eq!(metadata_pid(&zero, "kde:pid"), None); +} + +#[test] +fn metadata_pid_accepts_positive_signed_values() { + let positive = std::collections::HashMap::from([( + "kde:pid".to_string(), + OwnedValue::from(i32::try_from(SOURCE_BROWSER_PID).expect("fixture PID fits")), + )]); + + assert_eq!(metadata_pid(&positive, "kde:pid"), Some(SOURCE_BROWSER_PID)); +} + +#[test] +fn source_pid_hints_are_limited_to_plasma_bridge_names() { + assert!(is_plasma_browser_bridge(TEST_BRIDGE_PLAYER_NAME)); + assert!(is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.plasma-browser-integration.instance" + )); + assert!(!is_plasma_browser_bridge(TEST_PLAYER_NAME)); + assert!(!is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.other-browser-bridge" + )); + assert!(!is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.plasma-browser-integration-fake" + )); +} + #[test] fn metadata_artist_rejects_empty_and_oversized_artist_lists() { let empty = @@ -108,3 +150,38 @@ async fn oversized_art_url_is_not_retained() { .expect("playback status remains available"); assert_eq!(info.art_source, None); } + +#[tokio::test] +async fn browser_bridge_ingestion_keeps_owner_and_source_pids_separate() { + let fixture = MprisFixture::start_with_kde_pid(SOURCE_BROWSER_PID).await; + let player = build_player_state( + &fixture.client, + TEST_BRIDGE_PLAYER_NAME, + &MediaConfig::default(), + ) + .await + .expect("build bridge player") + .expect("bridge owner should remain stable"); + + let info = fetch_media_info(&player) + .await + .expect("bridge metadata should be readable"); + + assert_eq!(info.owner_pid, Some(std::process::id())); + assert_eq!(info.source_pid_hint, Some(SOURCE_BROWSER_PID)); + assert_eq!( + info.browser_family, None, + "the fixture keeps the bridge family unresolved so source-PID fallback is exercised" + ); + + let ordinary = MprisFixture::start().await; + let ordinary_player = + build_player_state(&ordinary.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build ordinary player") + .expect("ordinary owner should remain stable"); + let ordinary_info = fetch_media_info(&ordinary_player) + .await + .expect("ordinary metadata should be readable"); + assert_eq!(ordinary_info.source_pid_hint, None); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index ddc05c0ad..9ceb4760d 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -12,6 +12,8 @@ use super::super::constants::MPRIS_PATH; use crate::test_support::broker::read_broker_address; pub(in crate::media) const TEST_PLAYER_NAME: &str = "org.mpris.MediaPlayer2.unixnotis_test"; +pub(in crate::media) const TEST_BRIDGE_PLAYER_NAME: &str = + "org.mpris.MediaPlayer2.plasma-browser-integration"; pub(in crate::media) const TEST_PLAYER_IDENTITY: &str = "UnixNotis Test Player"; // Parallel fixtures need distinct socket directories even inside one process @@ -107,6 +109,7 @@ struct TestMprisPlayer { commands: Arc, metadata_bytes: usize, art_url_bytes: usize, + metadata_pid: Option, } #[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] @@ -144,6 +147,9 @@ impl TestMprisPlayer { .expect("build art URL value"), ); } + if let Some(pid) = self.metadata_pid { + metadata.insert("kde:pid".to_string(), OwnedValue::from(pid)); + } metadata } @@ -194,6 +200,10 @@ impl MprisFixture { Self::start_with_payload(0, art_url_bytes, 0).await } + pub(in crate::media) async fn start_with_kde_pid(pid: u32) -> Self { + Self::start_with_payload_for_name(TEST_BRIDGE_PLAYER_NAME, 0, 0, 0, Some(pid)).await + } + pub(in crate::media) async fn start_with_identity_bytes(identity_bytes: usize) -> Self { Self::start_with_payload(0, 0, identity_bytes).await } @@ -202,6 +212,23 @@ impl MprisFixture { metadata_bytes: usize, art_url_bytes: usize, identity_bytes: usize, + ) -> Self { + Self::start_with_payload_for_name( + TEST_PLAYER_NAME, + metadata_bytes, + art_url_bytes, + identity_bytes, + None, + ) + .await + } + + async fn start_with_payload_for_name( + name: &str, + metadata_bytes: usize, + art_url_bytes: usize, + identity_bytes: usize, + metadata_pid: Option, ) -> Self { let broker = PrivateBroker::start(); let commands = Arc::new(CommandCounts::default()); @@ -213,7 +240,7 @@ impl MprisFixture { // The service exports both MPRIS interfaces at the standard object path let server = ConnectionBuilder::address(broker.address.as_str()) .expect("parse private broker address") - .name(TEST_PLAYER_NAME) + .name(name) .expect("request test MPRIS name") .serve_at(MPRIS_PATH, TestMprisRoot { identity }) .expect("register test MPRIS root") @@ -223,6 +250,7 @@ impl MprisFixture { commands: commands.clone(), metadata_bytes, art_url_bytes, + metadata_pid, }, ) .expect("register test MPRIS player") diff --git a/crates/unixnotis-center/src/media/runtime/cache.rs b/crates/unixnotis-center/src/media/runtime/cache.rs index 1aa87b6c2..291660a1b 100644 --- a/crates/unixnotis-center/src/media/runtime/cache.rs +++ b/crates/unixnotis-center/src/media/runtime/cache.rs @@ -92,6 +92,11 @@ fn preserve_transition_fields(existing: &MediaInfo, mut fetched: MediaInfo) -> M fetched.art_source = existing.art_source.clone(); } + if fetched.source_pid_hint.is_none() && existing.source_pid_hint.is_some() { + // Bridge hints can arrive one refresh after the rest of the track metadata + fetched.source_pid_hint = existing.source_pid_hint; + } + if metadata_is_blank(&fetched) && metadata_has_content(existing) { // A blank transition frame is worse than holding the prior text for one retry window fetched.title = existing.title.clone(); diff --git a/crates/unixnotis-center/src/media/runtime/snapshot.rs b/crates/unixnotis-center/src/media/runtime/snapshot.rs index 882b28425..238edab89 100644 --- a/crates/unixnotis-center/src/media/runtime/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/snapshot.rs @@ -5,7 +5,7 @@ use tracing::debug; use crate::control::UiEvent; -use crate::media::MediaInfo; +use crate::media::{mpris::is_plasma_browser_bridge, MediaInfo}; pub(super) async fn send_snapshot_if_changed( sender: &Sender, @@ -44,6 +44,7 @@ pub(super) fn build_snapshot(cache: &HashMap) -> Vec bool { } fn dedupe_players(infos: Vec) -> Vec { - let mut output: Vec = Vec::with_capacity(infos.len()); - let mut seen: HashMap = HashMap::new(); - for info in infos { - let keys = dedupe_keys(&info); - if keys.is_empty() { - output.push(info); - continue; - } - if let Some(existing_index) = keys.iter().find_map(|key| seen.get(key).copied()) { - let existing = &output[existing_index]; - // Lower score wins, so a playing player with art beats a paused - // or artless duplicate from the same browser family or track key - if media_score(&info) < media_score(existing) { - output[existing_index] = info; + // A player can share one key with one group and another key with a second + // group, so pairwise replacement is not enough. Build connected components + // first, then choose one deterministic representative per component + let mut parents = (0..infos.len()).collect::>(); + let mut key_owner = HashMap::::new(); + for (index, info) in infos.iter().enumerate() { + for key in dedupe_keys(info) { + if let Some(previous) = key_owner.insert(key, index) { + union(&mut parents, previous, index); } - for key in keys { - seen.insert(key, existing_index); - } - continue; } - let output_index = output.len(); - for key in keys { - seen.insert(key, output_index); - } - output.push(info); } - output + + let mut representatives = HashMap::::new(); + for index in 0..infos.len() { + let root = find(&mut parents, index); + representatives + .entry(root) + .and_modify(|selection| { + selection.first_index = selection.first_index.min(index); + if representative_precedes(&infos[index], &infos[selection.representative_index]) { + selection.representative_index = index; + } + }) + .or_insert(ComponentSelection { + first_index: index, + representative_index: index, + }); + } + + let mut selected = representatives.into_values().collect::>(); + selected.sort_unstable_by_key(|selection| selection.first_index); + selected + .into_iter() + .map(|selection| infos[selection.representative_index].clone()) + .collect() +} + +struct ComponentSelection { + // Preserve the first component position even when a later player is the best card + first_index: usize, + // Artwork and playback state choose the representative shown to the user + representative_index: usize, +} + +fn find(parents: &mut [usize], index: usize) -> usize { + if parents[index] == index { + return index; + } + let root = find(parents, parents[index]); + parents[index] = root; + root +} + +fn union(parents: &mut [usize], left: usize, right: usize) { + let left = find(parents, left); + let right = find(parents, right); + if left != right { + parents[right] = left; + } +} + +fn representative_precedes(candidate: &MediaInfo, current: &MediaInfo) -> bool { + media_score(candidate) < media_score(current) + || (media_score(candidate) == media_score(current) && candidate.bus_name < current.bus_name) } fn dedupe_keys(info: &MediaInfo) -> Vec { - let title = info.title.trim(); - if let Some(family) = info.browser_family.as_deref() { - let mut keys = Vec::with_capacity(2); - if !title.is_empty() { - // Browser bridges often expose the same track under different names and PIDs - // Track identity is the useful cross-browser key when both title and artist exist - let artist = info.artist.trim(); - keys.push(format!( + let has_browser_process_identity = + info.browser_family.is_some() || info.source_pid_hint.is_some(); + if has_browser_process_identity { + // A bridge helper owns several sessions, so its PID is not the browser identity + if let Some(pid) = browser_process_pid(info) { + return vec![format!("browser-process:{pid}")]; + } + + let title = info.title.trim(); + let artist = info.artist.trim(); + if !title.is_empty() && !artist.is_empty() { + // Metadata is only a fallback when no process identity exists + return vec![format!( "browser-track\n{}\n{}", normalize_token(title), normalize_token(artist), - )); - } - if let Some(pid) = info.owner_pid { - // A broker-derived PID also collapses aliases owned by one browser process - keys.push(format!("browser-pid:{pid}")); + )]; } - if keys.is_empty() { - // Empty browser metadata is too weak for cross-browser matching - // Keep the family fallback so duplicate instances still collapse - keys.push(format!("browser:{family}")); - } - return keys; + // A family name alone is not a track identity + return Vec::new(); } + let title = info.title.trim(); if title.is_empty() { // Empty titles are too weak to build a stable cross-player key return Vec::new(); @@ -129,6 +166,18 @@ fn dedupe_keys(info: &MediaInfo) -> Vec { )] } +fn browser_process_pid(info: &MediaInfo) -> Option { + if let Some(source_pid) = info.source_pid_hint { + // kde:pid identifies the browser that supplied the bridge metadata + return Some(source_pid); + } + if is_plasma_browser_bridge(&info.bus_name) { + // The authenticated owner is only the shared bridge helper + return None; + } + info.owner_pid +} + fn media_score(info: &MediaInfo) -> (u8, u8) { // Duplicate groups keep the most useful card for the panel // Playing state matters first, then artwork breaks otherwise equal entries diff --git a/crates/unixnotis-center/src/media/runtime/tests/cache.rs b/crates/unixnotis-center/src/media/runtime/tests/cache.rs index b63b38ac2..f188e805a 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/cache.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/cache.rs @@ -13,6 +13,7 @@ fn make_info( identity: identity.to_string(), browser_family: browser_family.map(std::string::ToString::to_string), owner_pid: None, + source_pid_hint: None, title: "title".to_string(), artist: "artist".to_string(), playback_status: playback_status.to_string(), diff --git a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs index 028136884..45fb5893c 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs @@ -135,6 +135,7 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { identity: "UnixNotis Test Player".to_string(), browser_family: None, owner_pid: Some(std::process::id()), + source_pid_hint: None, title: String::new(), artist: String::new(), playback_status: "Playing".to_string(), diff --git a/crates/unixnotis-center/src/media/runtime/tests/schedule.rs b/crates/unixnotis-center/src/media/runtime/tests/schedule.rs index 7bdf15b0a..9ad2d4f34 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/schedule.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/schedule.rs @@ -13,6 +13,7 @@ fn make_info(status: &str) -> MediaInfo { identity: "Spotify".to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "track".to_string(), artist: "artist".to_string(), playback_status: status.to_string(), diff --git a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs index 9ee4989fc..4bed17853 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs @@ -8,6 +8,8 @@ use super::support::receive_ui_event; use crate::control::UiEvent; use crate::media::{MediaArtSource, MediaInfo}; +const SOURCE_BROWSER_PID: u32 = 42_424; + fn make_info( bus_name: &str, identity: &str, @@ -21,6 +23,7 @@ fn make_info( identity: identity.to_string(), browser_family: browser_family.map(std::string::ToString::to_string), owner_pid, + source_pid_hint: None, title: "title".to_string(), artist: "artist".to_string(), playback_status: playback_status.to_string(), @@ -145,7 +148,7 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { "Playing", false, Some("brave"), - Some(103_380), + Some(SOURCE_BROWSER_PID), ); brave.title = "Rumble".to_string(); brave.artist.clear(); @@ -155,11 +158,11 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { "Playing", true, Some("chromium"), - Some(103_380), + Some(22), ); - plasma_bridge.title = - "LA Mayor Karen Bass suffers POLITICAL EXPLOSION as DEMS CRY RACISM".to_string(); - plasma_bridge.artist = "DeVory Darkins".to_string(); + plasma_bridge.source_pid_hint = Some(SOURCE_BROWSER_PID); + plasma_bridge.title = "A Long Tutorial With Several Chapters".to_string(); + plasma_bridge.artist = "Example Artist".to_string(); cache.insert(brave.bus_name.clone(), brave); cache.insert(plasma_bridge.bus_name.clone(), plasma_bridge); @@ -168,6 +171,39 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { assert_eq!(snapshot[0].identity, "Chromium"); } +#[test] +fn source_pid_hint_dedupes_when_bridge_family_is_unresolved() { + let browser_pid = 42_424; + + let direct = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave", + "Playing", + false, + Some("brave"), + Some(browser_pid), + ); + + let mut bridge = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration", + "Plasma Browser Integration", + "Playing", + true, + None, + Some(2_400), + ); + bridge.source_pid_hint = Some(browser_pid); + bridge.title = "Completely different bridge metadata".to_string(); + bridge.artist = "Different artist".to_string(); + + let cache = HashMap::from([ + (direct.bus_name.clone(), direct), + (bridge.bus_name.clone(), bridge), + ]); + + assert_eq!(build_snapshot(&cache).len(), 1); +} + #[test] fn build_snapshot_keeps_distinct_browser_tracks() { let mut cache = HashMap::new(); @@ -196,6 +232,67 @@ fn build_snapshot_keeps_distinct_browser_tracks() { assert_eq!(snapshot.len(), 2); } +#[test] +fn identical_browser_metadata_with_different_processes_remains_separate() { + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "Shared Video".to_string(); + first.artist = "Shared Creator".to_string(); + + let mut second = make_info( + "org.mpris.MediaPlayer2.second", + "Second", + "Playing", + false, + Some("second"), + Some(22), + ); + second.title = first.title.clone(); + second.artist = first.artist.clone(); + + let mut cache = HashMap::new(); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); +} + +#[test] +fn empty_browser_artist_does_not_create_a_cross_browser_track_key() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "Generic stream".to_string(); + first.artist.clear(); + let mut second = make_info( + "org.mpris.MediaPlayer2.second", + "Second", + "Playing", + false, + Some("second"), + Some(22), + ); + second.title = first.title.clone(); + second.artist.clear(); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + assert_eq!(build_snapshot(&cache).len(), 2); +} + #[test] fn build_snapshot_collapses_same_track_across_browser_families() { let mut cache = HashMap::new(); @@ -205,7 +302,7 @@ fn build_snapshot_collapses_same_track_across_browser_families() { "Playing", false, Some("chromium"), - Some(11), + None, ); chromium.title = "The Thing 1982 - What does it mean".to_string(); chromium.artist = "That Scouse Dude".to_string(); @@ -215,7 +312,7 @@ fn build_snapshot_collapses_same_track_across_browser_families() { "Playing", true, Some("brave"), - Some(22), + None, ); brave.title = chromium.title.clone(); brave.artist = chromium.artist.clone(); @@ -229,6 +326,325 @@ fn build_snapshot_collapses_same_track_across_browser_families() { assert!(snapshot[0].art_source.is_some()); } +#[test] +fn distinct_bridge_sources_owned_by_one_helper_remain_separate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.first", + "First bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + first.source_pid_hint = Some(11_000); + + let mut second = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.second", + "Second bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + second.source_pid_hint = Some(22_000); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); + assert_eq!( + snapshot + .iter() + .map(|info| info.identity.as_str()) + .collect::>(), + vec!["First bridge", "Second bridge"] + ); +} + +#[test] +fn browser_sources_match_direct_players_without_crossjoining_a_shared_helper() { + let mut cache = HashMap::new(); + let mut direct_first = make_info( + "org.mpris.MediaPlayer2.first", + "First browser", + "Playing", + false, + Some("first"), + Some(11_000), + ); + direct_first.title = "First track".to_string(); + direct_first.artist = "First artist".to_string(); + + let mut bridge_first = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.first", + "First bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + bridge_first.source_pid_hint = Some(11_000); + bridge_first.title = "Different bridge metadata".to_string(); + bridge_first.artist = "Different bridge artist".to_string(); + + let mut direct_second = make_info( + "org.mpris.MediaPlayer2.second", + "Second browser", + "Playing", + false, + Some("second"), + Some(22_000), + ); + direct_second.title = "Second track".to_string(); + direct_second.artist = "Second artist".to_string(); + + let mut bridge_second = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.second", + "Second bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + bridge_second.source_pid_hint = Some(22_000); + bridge_second.title = "Another bridge metadata".to_string(); + bridge_second.artist = "Another bridge artist".to_string(); + + for info in [direct_first, bridge_first, direct_second, bridge_second] { + cache.insert(info.bus_name.clone(), info); + } + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); + assert_eq!( + snapshot + .iter() + .map(|info| info.identity.as_str()) + .collect::>(), + vec!["First bridge", "Second bridge"] + ); +} + +#[test] +fn browser_track_key_requires_artist_to_avoid_generic_title_collisions() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + None, + ); + first.title = "YouTube".to_string(); + first.artist.clear(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + assert_eq!(build_snapshot(&cache).len(), 2); +} + +#[test] +fn browser_source_pid_bridges_different_metadata() { + let mut cache = HashMap::new(); + let mut brave = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave Origin", + "Playing", + false, + Some("brave"), + Some(SOURCE_BROWSER_PID), + ); + brave.title = "A Long Tutorial With Several Chapters - YouTube".to_string(); + brave.artist.clear(); + + let mut bridge = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration", + "Chromium", + "Paused", + true, + Some("chromium"), + Some(22), + ); + bridge.source_pid_hint = Some(SOURCE_BROWSER_PID); + bridge.title = "A Long Tutorial With Several Chapters".to_string(); + bridge.artist = "Example Artist".to_string(); + + cache.insert(brave.bus_name.clone(), brave); + cache.insert(bridge.bus_name.clone(), bridge); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Brave Origin"); +} + +#[test] +fn browser_players_with_different_process_pids_remain_separate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "One Two Three Four".to_string(); + first.artist.clear(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + second.owner_pid = Some(22); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + // Four short words do not carry enough identity to bridge unrelated browser sessions + assert_eq!(build_snapshot(&cache).len(), 2); +} + +#[test] +fn duplicate_components_keep_first_component_order_when_art_selects_later_entry() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.component-a-first", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Component A".to_string(); + first.artist = "Artist A".to_string(); + + let mut middle = make_info( + "org.mpris.MediaPlayer2.component-b", + "Beta", + "Playing", + false, + Some("beta"), + None, + ); + middle.title = "Component B".to_string(); + middle.artist = "Artist B".to_string(); + + let mut later = first.clone(); + later.bus_name = "org.mpris.MediaPlayer2.component-a-later".to_string(); + later.identity = "Zeta".to_string(); + later.browser_family = Some("zeta".to_string()); + later.owner_pid = None; + later.art_source = Some(MediaArtSource::LocalFile(PathBuf::from("/tmp/art.png"))); + + cache.insert(first.bus_name.clone(), first); + cache.insert(middle.bus_name.clone(), middle); + cache.insert(later.bus_name.clone(), later); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 2); + assert_eq!(snapshot[0].identity, "Zeta"); + assert_eq!(snapshot[1].identity, "Beta"); +} + +#[test] +fn duplicate_selection_prefers_artwork_even_when_that_entry_sorts_later() { + let mut cache = HashMap::new(); + let mut no_art = make_info( + "org.mpris.MediaPlayer2.no-art", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + no_art.title = "Shared Long Tutorial Title With Context".to_string(); + no_art.artist = "Shared Artist".to_string(); + + let mut with_art = no_art.clone(); + with_art.bus_name = "org.mpris.MediaPlayer2.with-art".to_string(); + with_art.identity = "Zeta".to_string(); + with_art.browser_family = Some("zeta".to_string()); + with_art.owner_pid = None; + with_art.art_source = Some(MediaArtSource::LocalFile(PathBuf::from("/tmp/art.png"))); + + cache.insert(no_art.bus_name.clone(), no_art); + cache.insert(with_art.bus_name.clone(), with_art); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Zeta"); + assert!(snapshot[0].art_source.is_some()); +} + +#[test] +fn equal_score_duplicate_uses_bus_name_as_a_stable_tie_breaker() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.z-order", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Shared Track With Stable Metadata".to_string(); + first.artist = "Shared Artist".to_string(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.a-order".to_string(); + second.identity = "Zeta".to_string(); + second.browser_family = Some("zeta".to_string()); + second.owner_pid = None; + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Zeta"); +} + +#[test] +fn equal_bus_name_duplicate_keeps_the_first_equal_score_entry() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.same", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Shared Track With Stable Metadata".to_string(); + first.artist = "Shared Artist".to_string(); + + let mut second = first.clone(); + second.identity = "Zeta".to_string(); + second.browser_family = Some("zeta".to_string()); + second.owner_pid = None; + + cache.insert("entry-a".to_string(), first); + cache.insert("entry-b".to_string(), second); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Alpha"); +} + #[test] fn build_snapshot_keeps_the_first_equal_score_duplicate() { let mut cache = HashMap::new(); diff --git a/crates/unixnotis-center/src/ui/media/tests/config.rs b/crates/unixnotis-center/src/ui/media/tests/config.rs index fbef79154..9227df978 100644 --- a/crates/unixnotis-center/src/ui/media/tests/config.rs +++ b/crates/unixnotis-center/src/ui/media/tests/config.rs @@ -65,6 +65,7 @@ fn sample_media(title: &str) -> MediaInfo { identity: "Test Player".to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: title.to_string(), artist: "Artist".to_string(), playback_status: "Playing".to_string(), diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/card.rs b/crates/unixnotis-center/src/ui/media/widget/tests/card.rs index d2bc4d4d0..67d925d78 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/card.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/card.rs @@ -71,6 +71,7 @@ fn media_info() -> MediaInfo { identity: "Test Player".to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "A Track".to_string(), artist: "An Artist".to_string(), playback_status: "Playing".to_string(), diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/format.rs b/crates/unixnotis-center/src/ui/media/widget/tests/format.rs index c6b4ef176..733d29bed 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/format.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/format.rs @@ -14,6 +14,7 @@ fn media_info(identity: &str, title: &str, artist: &str) -> MediaInfo { identity: identity.to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: title.to_string(), artist: artist.to_string(), playback_status: "Paused".to_string(), @@ -120,6 +121,7 @@ fn blank_identity_falls_back_to_bus_name_tail() { identity: String::new(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "Track".to_string(), artist: String::new(), playback_status: "Paused".to_string(), diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs b/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs index 41b6d5863..f5fbe08d2 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs @@ -8,6 +8,7 @@ fn media_info(bus_name: &str, title: &str) -> MediaInfo { identity: bus_name.to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: title.to_string(), artist: String::new(), playback_status: "Paused".to_string(), From 9f9c0fbc10c8e865262d975d3f0181223e6698b4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:39:16 -0500 Subject: [PATCH 208/275] fix(config): preserve explicit artwork restrictions Apply the current local-art default only when the policy key is absent. An explicit exact-executable policy remains unchanged, including an empty allowlist, so migration never widens a deliberate restriction. --- .../src/config/loading/diagnostics.rs | 4 -- .../src/config/loading/tests/diagnostics.rs | 17 ++++++++ .../src/config/validation/schema.rs | 39 ++++++------------- .../src/config/validation/tests/schema.rs | 13 ++++++- 4 files changed, 39 insertions(+), 34 deletions(-) diff --git a/crates/unixnotis-core/src/config/loading/diagnostics.rs b/crates/unixnotis-core/src/config/loading/diagnostics.rs index c8da6ff85..d8dcbe413 100644 --- a/crates/unixnotis-core/src/config/loading/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/diagnostics.rs @@ -80,10 +80,6 @@ pub(super) fn migrated_field_diagnostic(path: String) -> ConfigDiagnostic { pub(super) fn empty_exact_media_policy_diagnostic(contents: &str) -> Option { let document = contents.parse::().ok()?; let root = document.as_table()?; - let version = root.get("config_version").and_then(Value::as_integer)?; - if u32::try_from(version).ok()? != CURRENT_CONFIG_VERSION { - return None; - } let media = root.get("media").and_then(Value::as_table)?; let exact = media .get("local_art_policy") diff --git a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs index 561f02aca..060358b82 100644 --- a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs @@ -53,6 +53,23 @@ fn current_empty_exact_media_policy_emits_a_warning() { })); } +#[test] +fn legacy_empty_exact_media_policy_emits_a_warning_without_widening_policy() { + let report = Config::parse_with_report( + "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", + ) + .expect("legacy config should parse"); + + assert_eq!( + report.config.media.local_art_policy, + crate::MediaLocalArtPolicy::ExactExecutableOnly + ); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "config.media.empty-exact-allowlist" + && diagnostic.kind == ConfigDiagnosticKind::Warning + })); +} + #[test] fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { let mut before = Config::default(); diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index 3d4b80908..bf12712ad 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -114,21 +114,18 @@ fn migrate_document(document: &mut toml::Value) -> Result { let result = migrate_legacy_layout(root); migrate_legacy_commands(root)?; - migrate_media_art_policy(root, version); result } 2 => { migrate_legacy_commands(root)?; - migrate_media_art_policy(root, version); MigrationResult::default() } - 3 => { - migrate_media_art_policy(root, version); - MigrationResult::default() - } - CURRENT_CONFIG_VERSION => MigrationResult::default(), + 3 | CURRENT_CONFIG_VERSION => MigrationResult::default(), _ => return Err(format!("unsupported config version {version}")), }; + // Only an absent field receives the current default. An explicit policy, + // including an empty exact allowlist, remains the user's decision. + ensure_media_art_policy_default(root); root.insert( "config_version".to_string(), toml::Value::Integer(i64::from(CURRENT_CONFIG_VERSION)), @@ -136,33 +133,19 @@ fn migrate_document(document: &mut toml::Value) -> Result Result<(), String> { diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index 5b972d78e..b0a9a37ee 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -108,14 +108,23 @@ fn version_three_accepts_structured_direct_commands_without_inference() { } #[test] -fn old_media_defaults_restore_native_artwork() { +fn missing_local_art_policy_uses_the_current_default() { + let (config, _) = deserialize_config("config_version = 4\n[media]\n").expect("parse media"); + assert_eq!( + config.media.local_art_policy, + crate::MediaLocalArtPolicy::AllAdmitted + ); +} + +#[test] +fn old_explicit_empty_exact_policy_is_preserved() { let (config, _) = deserialize_config( "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", ) .expect("old media config should migrate"); assert_eq!( config.media.local_art_policy, - crate::MediaLocalArtPolicy::AllAdmitted + crate::MediaLocalArtPolicy::ExactExecutableOnly ); assert!(config.media.local_art_executable_allowlist.is_empty()); } From 0a4f8f51d7549afc74b5e5bd493cf72d0231eb18 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:39:29 -0500 Subject: [PATCH 209/275] fix(notifications): separate visual capabilities from activation Keep sender visuals, content images, identity assurance, grouping, and click permissions as independent model decisions. The daemon can therefore render bounded presentation data without turning a visual hint into authority or coupling it to default activation. --- .../unixnotis-core/src/model/attribution.rs | 13 ++++++---- .../unixnotis-core/src/model/image/hints.rs | 21 +++++++++++++++- .../unixnotis-core/src/model/image/model.rs | 3 +++ .../src/model/image/tests/hints.rs | 17 +++++++++++++ .../src/model/image/tests/projection.rs | 1 + .../src/model/tests/attribution.rs | 8 +++---- .../src/model/tests/notification.rs | 1 + .../notifications/ingress/payload/build.rs | 4 ++-- .../notifications/ingress/payload/mod.rs | 2 +- .../ingress/payload/tests/mod.rs | 5 ++-- .../ingress/payload/tests/visuals.rs | 24 ++++++++++++++++++- .../notifications/ingress/payload/visuals.rs | 12 +++++++--- .../src/daemon/notifications/server/flow.rs | 8 +++---- 13 files changed, 96 insertions(+), 23 deletions(-) diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs index 92eb12467..8b9e88de1 100644 --- a/crates/unixnotis-core/src/model/attribution.rs +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -313,20 +313,23 @@ impl NotificationAttribution { self.interactions.action_buttons } - /// Host visuals require a positively associated executable and one-click local authority + /// Application-provided decorative visuals require a positive local association #[must_use] - pub const fn may_read_sender_host_visual(&self) -> bool { + pub const fn may_materialize_application_icon(&self) -> bool { matches!( self.assurance, IdentityAssurance::Authenticated | IdentityAssurance::SystemAssociated | IdentityAssurance::UserAssociated - ) && matches!( - self.default_activation_policy(), - ApplicationActionPolicy::Allow ) } + /// Message content can be decoded without granting any application action + #[must_use] + pub const fn may_materialize_content_image(&self) -> bool { + self.may_materialize_application_icon() + } + /// Whether this attribution has kernel or broker-backed identity evidence #[must_use] pub const fn is_verified(&self) -> bool { diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 56a97b77b..b186b6efb 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -9,7 +9,7 @@ use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; impl NotificationImage { pub fn from_hints( _app_name: &str, - _app_icon: &str, + app_icon: &str, hints: &HashMap, ) -> Self { // Embedded pixels are already detached from the sender's filesystem @@ -22,12 +22,31 @@ impl NotificationImage { Self { badge_icon: String::new(), + claimed_theme_icon: Self::sanitize_theme_icon_hint(app_icon), sender_visual_role: super::NotificationVisualRole::None, sender_visual: ImageData::default(), content_image: image_data.unwrap_or_default(), } } + fn sanitize_theme_icon_hint(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.len() > 128 + || value.starts_with('.') + || value.contains('/') + || value.contains('\\') + || value.contains(':') + || value.chars().any(char::is_whitespace) + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return String::new(); + } + value.to_string() + } + pub(super) fn parse_image_data(value: &OwnedValue) -> Option { // The image-data hint is a struct of (iiibiiay) per the notification spec let structure = <&Structure>::try_from(value).ok()?; diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index b4fb181c9..b9806cbf7 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -36,6 +36,9 @@ pub enum NotificationVisualRole { pub struct NotificationImage { /// Desktop-index-selected identity icon pub badge_icon: String, + /// Sender-supplied theme name retained only as a decorative lookup hint + #[serde(default)] + pub claimed_theme_icon: String, /// Safely decoded sender-provided visual pub sender_visual_role: NotificationVisualRole, pub sender_visual: ImageData, diff --git a/crates/unixnotis-core/src/model/image/tests/hints.rs b/crates/unixnotis-core/src/model/image/tests/hints.rs index 6d5adf79a..f8beee8c7 100644 --- a/crates/unixnotis-core/src/model/image/tests/hints.rs +++ b/crates/unixnotis-core/src/model/image/tests/hints.rs @@ -27,6 +27,23 @@ fn app_icon_and_image_path_never_become_retained_host_paths() { assert!(image.content_image.data.is_empty()); } +#[test] +fn app_icon_theme_names_are_retained_only_as_bounded_lookup_hints() { + let image = NotificationImage::from_hints("App", "example-player", &HashMap::new()); + assert_eq!(image.claimed_theme_icon, "example-player"); + + for value in [ + "/tmp/icon.png", + "file:///tmp/icon.png", + "../icon", + "icon name", + "icon:remote", + ] { + let image = NotificationImage::from_hints("App", value, &HashMap::new()); + assert!(image.claimed_theme_icon.is_empty(), "unsafe hint: {value}"); + } +} + #[test] fn parse_image_data_rejects_wrong_structure() { let wrong = Structure::from((1_i32, 1_i32)); diff --git a/crates/unixnotis-core/src/model/image/tests/projection.rs b/crates/unixnotis-core/src/model/image/tests/projection.rs index 18a924f37..8ff76ee1f 100644 --- a/crates/unixnotis-core/src/model/image/tests/projection.rs +++ b/crates/unixnotis-core/src/model/image/tests/projection.rs @@ -3,6 +3,7 @@ use super::super::{ImageData, NotificationImage, NotificationVisualRole}; fn image() -> NotificationImage { NotificationImage { badge_icon: "mail".to_string(), + claimed_theme_icon: String::new(), sender_visual_role: NotificationVisualRole::ConversationAvatar, sender_visual: ImageData { width: 1, diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs index 0c5559a70..dca01890f 100644 --- a/crates/unixnotis-core/src/model/tests/attribution.rs +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -271,7 +271,7 @@ fn authenticated_and_native_policies_keep_action_surfaces_separate() { InlineReplyPolicy::Deny, "same-user native association cannot protect credential-like reply text" ); - assert!(native.may_read_sender_host_visual()); + assert!(native.may_materialize_application_icon()); } #[test] @@ -292,7 +292,7 @@ fn portal_and_unassociated_policies_never_allow_silent_actions() { ApplicationActionPolicy::Confirm, "an app id without unforgeable provenance must not activate silently" ); - assert!(!portal.may_read_sender_host_visual()); + assert!(!portal.may_materialize_application_icon()); for attribution in [ NotificationAttribution::recognized( @@ -329,7 +329,7 @@ fn portal_and_unassociated_policies_never_allow_silent_actions() { } #[test] -fn host_visuals_require_both_positive_assurance_and_allowed_activation() { +fn host_visuals_do_not_require_action_authority() { let mut authenticated = NotificationAttribution::verified( "Example", "Example", @@ -341,7 +341,7 @@ fn host_visuals_require_both_positive_assurance_and_allowed_activation() { ); authenticated.interactions = InteractionPolicies::DENY; - assert!(!authenticated.may_read_sender_host_visual()); + assert!(authenticated.may_materialize_application_icon()); } #[test] diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index c49c47a36..d1ed3e0e2 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -70,6 +70,7 @@ fn image_with_raw_bytes() -> NotificationImage { sender_visual_role: crate::NotificationVisualRole::None, sender_visual: ImageData::default(), badge_icon: "mail".to_string(), + claimed_theme_icon: String::new(), } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs index 68c6a3d84..e2159c52e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -13,7 +13,7 @@ use super::super::limits::{ MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_SUMMARY_BYTES, }; use super::sanitize::parse_actions; -use super::visuals::{may_read_sender_host_visual, SenderVisualRole}; +use super::visuals::{may_materialize_application_icon, SenderVisualRole}; use super::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) struct NotificationInput { @@ -145,7 +145,7 @@ fn build_image( if let Some(image_data) = image_data.and_then(NotificationImage::normalize_image_data) { image.content_image = image_data; } - if may_read_sender_host_visual(attribution) { + if may_materialize_application_icon(attribution) { if let Some(visual) = sender_visual.and_then(NotificationImage::normalize_image_data) { image.sender_visual_role = match sender_visual_role { SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs index 20cb0aad6..07db96adc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -9,7 +9,7 @@ pub(in crate::daemon::notifications) use build::{build_notification, Notificatio pub(in crate::daemon::notifications) use expiration::resolve_expiration; pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) use visuals::{ - materialize_sender_visual, may_read_sender_host_visual, sender_visual_role, SenderVisualRole, + materialize_sender_visual, may_materialize_content_image, sender_visual_role, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_CONTENT_DIMENSION, }; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs index b3de3986a..48daee0bf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -13,13 +13,14 @@ pub(super) use super::sanitize::{ }; pub(super) use super::visuals::{ avatar_buffer_size_allowed, avatar_file_size_allowed, bounded_decode_dimension, - materialize_sender_visual, may_read_sender_host_visual, sender_visual_file_allowed, + materialize_sender_visual, may_materialize_application_icon, sender_visual_file_allowed, MAX_SENDER_VISUAL_BYTES, }; pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; pub(super) use unixnotis_core::{ - AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationImage, Urgency, + ApplicationActionPolicy, AttributionReason, Config, IdentityAssurance, InteractionPolicies, + NotificationImage, Urgency, }; mod build; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs index d1069bc7d..dfe10f6a0 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -100,7 +100,7 @@ fn portal_association_cannot_start_host_avatar_materialization() { "portal supplied app id", "recognized:portal:org.example.PortalApp".to_string(), ); - assert!(!may_read_sender_host_visual(&attribution)); + assert!(!may_materialize_application_icon(&attribution)); assert_eq!( sender_visual_role( &attribution, @@ -158,6 +158,28 @@ fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { assert_eq!(notification.image.badge_icon, "example-player"); } +#[test] +fn decorative_visual_materialization_is_independent_of_click_authority() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example", + IdentityAssurance::SystemAssociated, + InteractionPolicies::DENY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.App:sender".to_string(), + ); + + assert!(may_materialize_application_icon(&attribution)); + assert!(attribution.may_materialize_content_image()); + assert_eq!( + attribution.default_activation_policy(), + ApplicationActionPolicy::Deny + ); +} + #[test] fn large_avatar_is_downsampled_to_the_storage_bound() { let source = vec![255_u8; 256 * 128 * 4]; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs index caa846261..97f341952 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -36,10 +36,16 @@ pub(in crate::daemon::notifications) enum SenderVisualRole { ApplicationProvidedIcon, } -pub(in crate::daemon::notifications) const fn may_read_sender_host_visual( +pub(in crate::daemon::notifications) const fn may_materialize_application_icon( attribution: &NotificationAttribution, ) -> bool { - attribution.may_read_sender_host_visual() + attribution.may_materialize_application_icon() +} + +pub(in crate::daemon::notifications) const fn may_materialize_content_image( + attribution: &NotificationAttribution, +) -> bool { + attribution.may_materialize_content_image() } pub(in crate::daemon::notifications) fn sender_visual_role( @@ -50,7 +56,7 @@ pub(in crate::daemon::notifications) fn sender_visual_role( app_icon: &str, ) -> SenderVisualRole { // Sender paths are never opened until attribution grants positive local evidence - if !may_read_sender_host_visual(attribution) { + if !may_materialize_application_icon(attribution) { return SenderVisualRole::None; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 171c8250e..a8c6c8e75 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -11,9 +11,9 @@ use crate::daemon::notifications::identity::{ resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, }; use crate::daemon::notifications::ingress::payload::{ - build_notification, materialize_sender_visual, owned_to_string, resolve_expiration, - sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, - MAX_STORED_CONTENT_DIMENSION, + build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, + resolve_expiration, sender_visual_role, NotificationInput, SenderVisualRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; @@ -344,7 +344,7 @@ async fn materialize_content_visual( attribution: &unixnotis_core::NotificationAttribution, image_path: Option<&str>, ) -> Option { - if !crate::daemon::notifications::ingress::payload::may_read_sender_host_visual(attribution) { + if !may_materialize_content_image(attribution) { return None; } let path = image_path From 5c98f6d93054545fd9c2ce5c09790e2c1261cf7a Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:39:42 -0500 Subject: [PATCH 210/275] fix(notifications): retain bounded decorative icon hints Allow sanitized claimed theme names to remain useful as presentation-only fallbacks without deriving identity, grouping, actions, or host filesystem access from arbitrary application labels. Keep daemon-selected badges authoritative in both clients. refactor(ui): share notification visual roles Use one presentation model for conversation avatars, decorative application visuals, content thumbnails, trust labels, and action visibility. Popup and panel rows now render the same role decisions instead of re-deriving safety from widget kind. --- .../src/ui/icons/tests/theme.rs | 20 +++++ crates/unixnotis-center/src/ui/icons/theme.rs | 19 ++++- .../notifications/row/notification/state.rs | 19 +++-- .../row/notification/update/actions.rs | 14 +-- .../row/notification/update/metadata.rs | 6 +- .../row/notification/update/row.rs | 27 +++--- .../row/notification/update/tests/state.rs | 1 + .../notification/update/tests/thumbnail.rs | 24 +++++- .../row/notification/update/thumbnail.rs | 47 +++++++--- .../row/notification/update/visual.rs | 7 ++ .../src/ui/entry/builders/common.rs | 8 +- .../src/ui/entry/builders/mod.rs | 5 +- .../src/ui/entry/builders/tests/common.rs | 39 +++++++++ .../src/ui/entry/builders/tests/layout.rs | 9 +- .../src/ui/entry/presentation/view_model.rs | 5 +- .../src/ui/entry/tests/activation.rs | 9 +- .../unixnotis-popups/src/ui/icons/resolver.rs | 19 ++++- .../src/ui/icons/tests/resolver/candidates.rs | 7 +- crates/unixnotis-ui/src/presentation/build.rs | 24 +++++- crates/unixnotis-ui/src/presentation/mod.rs | 3 +- .../src/presentation/tests/mod.rs | 1 + .../src/presentation/tests/presentation.rs | 37 +++++++- .../src/presentation/tests/visual_contract.rs | 85 +++++++++++++++++++ crates/unixnotis-ui/src/presentation/types.rs | 15 ++++ 24 files changed, 395 insertions(+), 55 deletions(-) create mode 100644 crates/unixnotis-ui/src/presentation/tests/visual_contract.rs diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 2812a7b35..a9f28d4a7 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -82,6 +82,26 @@ fn badge_candidates_exclude_unresolved_application_claim() { .any(|candidate| candidate == "Trusted Brand")); } +#[test] +fn unresolved_notifications_keep_only_bounded_decorative_theme_hints() { + let attribution = unixnotis_core::NotificationAttribution { + claimed_name: "Example Player".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }; + let image = NotificationImage { + claimed_theme_icon: "example-player".to_string(), + ..NotificationImage::default() + }; + + let notification = notification_view("Unknown", attribution, image); + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-player")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + #[test] fn expand_rgb_to_rgba_appends_alpha() { let data = ImageData { diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index f69255baf..bc9bf493f 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -91,7 +91,11 @@ pub(super) fn collect_icon_candidates(notification: &NotificationView) -> Vec Vec bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('.') + && !value.contains('/') + && !value.contains('\\') + && !value.contains(':') + && !value.chars().any(char::is_whitespace) + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) +} + fn is_missing_icon(path: &Path) -> bool { // Ignore theme placeholders to avoid rendering missing-icon glyphs // Many icon themes provide an "image-missing" asset; treating it as a real icon looks bad diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 7d7b1ba77..6e148635a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -96,15 +96,24 @@ pub(in crate::ui::notifications) struct IconSignature { } impl IconSignature { - pub(super) fn from(notification: &NotificationView) -> Self { + pub(super) fn from_presentation( + notification: &NotificationView, + presentation: &NotificationPresentation, + ) -> Self { // Signature includes all fields that can change icon resolution output - // This keeps row refreshes cheap when only text or actions changed + // Reuse the row presentation so icon checks do not rebuild all labels and actions Self { badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), - presentation: NotificationPresentation::from_view(notification) - .identity - .badge, + presentation: presentation.identity.badge, } } + + #[cfg(test)] + pub(super) fn from(notification: &NotificationView) -> Self { + Self::from_presentation( + notification, + &NotificationPresentation::from_view(notification), + ) + } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 27360c5b9..3e0276fbf 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -39,14 +39,14 @@ pub(super) fn update_actions( row: &NotificationRowWidgets, command_tx: &mpsc::Sender, notification: &Rc, + presentation: &NotificationPresentation, is_active: bool, ) { - let presentation = NotificationPresentation::from_view(notification); configure_inline_reply(&row.inline_reply, notification, is_active); - let has_actions = visible_action_count_from(&presentation, is_active) > 0; + let has_actions = visible_action_count_from(presentation, is_active) > 0; // Recycled rows may have hidden this container before the current bind row.actions_box.set_visible(has_actions); - let action_signature = action_signature(&presentation, is_active); + let action_signature = action_signature(presentation, is_active); // Fast path skips button rebuilding when the action set is unchanged { let cached = row.action_cache.borrow(); @@ -118,7 +118,7 @@ pub(super) fn update_actions( &presentation.actions.overflow, )); } - if let Some(default_key) = hidden_default_action_key(&presentation) { + if let Some(default_key) = hidden_default_action_key(presentation) { row.actions_box.append(&build_default_action_button( command_tx, notification.key(), @@ -335,6 +335,7 @@ fn build_overflow_menu( menu } +#[cfg(test)] pub(super) fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { visible_action_count_from( &NotificationPresentation::from_view(notification), @@ -342,7 +343,10 @@ pub(super) fn visible_action_count(notification: &NotificationView, is_active: b ) } -fn visible_action_count_from(presentation: &NotificationPresentation, is_active: bool) -> usize { +pub(super) fn visible_action_count_from( + presentation: &NotificationPresentation, + is_active: bool, +) -> usize { if !is_active { return 0; } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index 5fefad998..ceb08e2da 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -3,10 +3,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unixnotis_core::{NotificationMetadataConfig, NotificationView, Urgency}; +use unixnotis_ui::presentation::NotificationPresentation; use super::super::super::super::item::RowData; use super::super::state::NotificationRowWidgets; -use super::actions::visible_action_count; +use super::actions::visible_action_count_from; use super::labels::{set_label_text_if_changed, set_label_visible_if_changed}; use super::visual::set_widget_visible_if_changed; @@ -14,6 +15,7 @@ pub(super) fn update_metadata_labels( row: &NotificationRowWidgets, data: &RowData, notification: &NotificationView, + presentation: &NotificationPresentation, ) { let metadata = data.presentation.metadata.as_ref(); let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); @@ -48,7 +50,7 @@ pub(super) fn update_metadata_labels( set_label_text_if_changed(&row.footer_left, footer_left); // Hidden reply actions are excluded from the displayed action count - let action_count = visible_action_count(notification, data.is_active); + let action_count = visible_action_count_from(presentation, data.is_active); let footer_right = if action_count == 0 { String::new() } else if action_count == 1 { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index a38d93eb4..5b7763469 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -11,13 +11,10 @@ use crate::ui::icons::IconResolver; use super::super::super::super::item::RowData; use super::super::state::{IconSignature, NotificationRowWidgets}; -use super::actions::{update_actions, visible_action_count}; +use super::actions::{update_actions, visible_action_count_from}; use super::labels::update_notification_text; use super::metadata::update_metadata_labels; -use super::thumbnail::{ - notification_has_conversation_avatar, notification_has_sender_visual, - notification_has_thumbnail, -}; +use super::thumbnail::{has_content_thumbnail, has_conversation_avatar, has_sender_visual}; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { @@ -109,11 +106,11 @@ pub(in crate::ui::notifications) fn update_notification_row( // a previous notification generation row.default_activation.set_target(default_target); let show_identity = !data.collapsed_group_preview && !data.expanded; - let has_actions = visible_action_count(notification, data.is_active) > 0; - let has_content_thumbnail = notification_has_thumbnail(notification); + let has_actions = visible_action_count_from(&presentation, data.is_active) > 0; + let has_content_thumbnail = has_content_thumbnail(&presentation); // The daemon has already assigned the visual role after attribution and safe decoding - let has_conversation_avatar = notification_has_conversation_avatar(notification); - let has_sender_visual = notification_has_sender_visual(notification); + let has_conversation_avatar = has_conversation_avatar(&presentation); + let has_sender_visual = has_sender_visual(&presentation); let has_thumbnail = data.presentation.show_thumbnail && (has_content_thumbnail || has_conversation_avatar || has_sender_visual); @@ -154,12 +151,18 @@ pub(in crate::ui::notifications) fn update_notification_row( &row.trust_chip, show_identity && presentation.trust.short_label.is_some(), ); - update_metadata_labels(row, data, notification); + update_metadata_labels(row, data, notification, &presentation); row.notify_key.set(notification.key()); - update_actions(row, command_tx, notification_snapshot, data.is_active); + update_actions( + row, + command_tx, + notification_snapshot, + &presentation, + data.is_active, + ); // Text and action changes must not restart an unchanged icon pipeline - let next_sig = IconSignature::from(notification); + let next_sig = IconSignature::from_presentation(notification, &presentation); let mut sig_guard = row.icon_sig.borrow_mut(); if show_identity && sig_guard.as_ref() != Some(&next_sig) { if apply_semantic_badge(&row.icon, presentation.identity.badge, 20) { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index c7f2d7ccc..ca4b7f146 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -136,6 +136,7 @@ fn update_notification_row_applies_state_classes_and_text() { .card .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW)); assert!(row.card.has_css_class(hooks::panel_card::GROUPED)); + assert!(row.card.has_css_class("group-owned-identity")); assert!(!row.app_label.get_visible()); assert!(!row.icon.get_visible()); assert!(row.header.get_visible()); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index d754588d5..78a876213 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -10,7 +10,9 @@ use crate::ui::icons::IconResolver; use super::super::super::test_support::{ notification_row, row_data, sample_notification, RowFlags, }; -use super::super::thumbnail::notification_has_conversation_avatar; +use super::super::thumbnail::{ + notification_has_conversation_avatar, notification_has_sender_visual, +}; use super::{notification_has_thumbnail, update_notification_row}; #[test] @@ -49,6 +51,26 @@ fn conversation_avatar_is_a_separate_thumbnail_source() { assert!(!notification_has_thumbnail(¬ification)); } +#[test] +fn application_visual_is_a_decorative_thumbnail_source() { + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![0, 255, 0, 255], + }; + + assert!(notification_has_sender_visual(¬ification)); + assert!(!notification_has_conversation_avatar(¬ification)); + assert!(!notification_has_thumbnail(¬ification)); +} + #[gtk::test] fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index f91c4832a..85b2f7770 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -1,26 +1,45 @@ //! Thumbnail source decisions for notification rows -use unixnotis_core::NotificationView; -use unixnotis_ui::presentation::{NotificationPresentation, ThumbnailKind}; +use unixnotis_ui::presentation::{ + NotificationPresentation, SenderVisualPresentation, ThumbnailKind, +}; -pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> bool { - NotificationPresentation::from_view(notification) - .media - .thumbnail - == ThumbnailKind::Content +pub(super) fn has_content_thumbnail(presentation: &NotificationPresentation) -> bool { + // Content thumbnails are already classified by the shared presentation layer + presentation.media.thumbnail == ThumbnailKind::Content } -pub(super) const fn notification_has_conversation_avatar(notification: &NotificationView) -> bool { - // Avatars are presentation-only raster data and never count as message content +pub(super) const fn has_conversation_avatar(presentation: &NotificationPresentation) -> bool { + // Conversation photos may occupy the large sender-visual slot matches!( - notification.image.sender_visual_role, - unixnotis_core::NotificationVisualRole::ConversationAvatar + presentation.visuals.sender, + SenderVisualPresentation::ConversationAvatar ) } -pub(super) const fn notification_has_sender_visual(notification: &NotificationView) -> bool { +pub(super) const fn has_sender_visual(presentation: &NotificationPresentation) -> bool { + // Other sender visuals stay decorative and never replace the trusted badge matches!( - notification.image.sender_visual_role, - unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + presentation.visuals.sender, + SenderVisualPresentation::ApplicationProvidedIcon ) } + +#[cfg(test)] +pub(super) fn notification_has_thumbnail(notification: &unixnotis_core::NotificationView) -> bool { + has_content_thumbnail(&NotificationPresentation::from_view(notification)) +} + +#[cfg(test)] +pub(super) fn notification_has_conversation_avatar( + notification: &unixnotis_core::NotificationView, +) -> bool { + has_conversation_avatar(&NotificationPresentation::from_view(notification)) +} + +#[cfg(test)] +pub(super) fn notification_has_sender_visual( + notification: &unixnotis_core::NotificationView, +) -> bool { + has_sender_visual(&NotificationPresentation::from_view(notification)) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 55cd0e949..241383fae 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -59,6 +59,13 @@ pub(super) fn apply_visual_state( let grouped = data.collapsed_group_preview || data.expanded; set_class_state(card, hooks::panel_card::GROUPED, grouped); set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); + // Group headers own identity details while child rows stay message-first + set_class_state(card, "group-owned-identity", grouped && !data.expanded); + set_class_state( + &row.card_plate, + "group-owned-identity", + grouped && !data.expanded, + ); set_class_state( card, hooks::panel_card::HAS_SUMMARY, diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index aecdafa8a..497c5bb96 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -9,7 +9,9 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; use unixnotis_core::{hooks, NotificationView}; -use unixnotis_ui::presentation::{action_activation, build_semantic_badge, ActionActivation}; +use unixnotis_ui::presentation::{ + action_activation, build_semantic_badge, ActionActivation, SenderVisualPresentation, +}; use super::super::commands::try_send_command; use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; @@ -32,8 +34,8 @@ pub(super) fn build_identity_avatar( view: &PopupEntryViewModel, size: i32, ) -> IdentityAvatar { - let has_conversation_avatar = notification.image.sender_visual_role - == unixnotis_core::NotificationVisualRole::ConversationAvatar; + let has_conversation_avatar = + view.visuals.sender == SenderVisualPresentation::ConversationAvatar; let icon_size = if has_conversation_avatar { size } else { diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 0920f5b7a..59aa5a62f 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -11,6 +11,7 @@ use unixnotis_core::NotificationView; use super::presentation::{PopupEntryViewModel, PopupKind}; use crate::ui::UiState; +use unixnotis_ui::presentation::SenderVisualPresentation; pub(super) use common::{build_action_row, build_close_button}; pub(in crate::ui::entry) use reply::build_inline_reply; @@ -43,8 +44,8 @@ pub(super) fn append_thumbnail( view: &PopupEntryViewModel, content: >k::Box, ) -> bool { - let is_application_visual = notification.image.sender_visual_role - == unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + let sender_visual = view.visuals.sender; + let is_application_visual = sender_visual == SenderVisualPresentation::ApplicationProvidedIcon; if view.thumbnail != super::presentation::ThumbnailKind::Content && !is_application_visual { return false; } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 96773e668..2c029b186 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -372,6 +372,45 @@ fn communication_identity_avatar_prefers_materialized_conversation_image() { assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); } +#[gtk::test] +fn decorative_application_visual_does_not_replace_the_identity_badge() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupDecorativeVisual") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register decorative visual application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-decorative-visual"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 1, 1, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("identity slot should contain an image"); + + assert!(!icon.has_css_class("unixnotis-popup-application-visual")); + assert!(!icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert_eq!(icon.pixel_size(), 22); +} + fn view_model() -> PopupEntryViewModel { PopupEntryViewModel::for_notification_at(¬ification(), 1_000) } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs index 0060dde99..84c6c69f2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -1,6 +1,9 @@ use super::popup_accessible_label; use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; -use unixnotis_ui::presentation::{BadgePresentation, ThumbnailKind, TrustLevel, TrustPresentation}; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; #[test] fn popup_accessible_name_keeps_identity_and_message_context() { @@ -45,6 +48,10 @@ fn view_model() -> PopupEntryViewModel { title: "Build finished".to_string(), body: None, thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, default_action_key: None, primary_actions: Vec::new(), overflow_actions: Vec::new(), diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs index 284ede1ec..0dbecc725 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unixnotis_core::NotificationView; -use unixnotis_ui::presentation::NotificationPresentation; +use unixnotis_ui::presentation::{NotificationPresentation, VisualPresentation}; use super::{PopupKind, PopupTrustPresentation}; @@ -22,6 +22,8 @@ pub(in crate::ui::entry) struct PopupEntryViewModel { pub(in crate::ui::entry) title: String, pub(in crate::ui::entry) body: Option, pub(in crate::ui::entry) thumbnail: ThumbnailKind, + // Carry the shared visual decision so GTK builders do not derive it again + pub(in crate::ui::entry) visuals: VisualPresentation, pub(in crate::ui::entry) default_action_key: Option, pub(in crate::ui::entry) primary_actions: Vec, pub(in crate::ui::entry) overflow_actions: Vec, @@ -56,6 +58,7 @@ impl PopupEntryViewModel { title: shared.title, body: shared.body, thumbnail: shared.media.thumbnail, + visuals: shared.visuals, default_action_key: shared.actions.default_key, primary_actions: shared.actions.primary, overflow_actions: shared.actions.overflow, diff --git a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs index 8011059eb..cfb89c20a 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs @@ -7,7 +7,10 @@ use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresen use unixnotis_ui::presentation::default_activation::{ is_default_activation_key, picked_widget_blocks_default_action, }; -use unixnotis_ui::presentation::{BadgePresentation, ThumbnailKind, TrustLevel, TrustPresentation}; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; const KEY: NotificationKey = NotificationKey { id: 41, @@ -75,6 +78,10 @@ fn default_action_card_is_focusable_and_keyboard_activatable() { title: "Update complete".to_string(), body: None, thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, default_action_key: Some("default".to_string()), primary_actions: Vec::new(), overflow_actions: Vec::new(), diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index 9dfb94261..20cd77ff9 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -74,7 +74,11 @@ pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> candidates.push(notification.attribution.desktop_id.clone()); candidates.push(notification.attribution.desktop_id.to_lowercase()); } - + if is_safe_theme_name(¬ification.image.claimed_theme_icon) { + // Sender input is only a bounded theme lookup hint, never identity evidence + candidates.push(notification.image.claimed_theme_icon.clone()); + candidates.push(notification.image.claimed_theme_icon.to_lowercase()); + } let mut seen = HashSet::new(); candidates .into_iter() @@ -82,6 +86,19 @@ pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> .collect() } +fn is_safe_theme_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('.') + && !value.contains('/') + && !value.contains('\\') + && !value.contains(':') + && !value.chars().any(char::is_whitespace) + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) +} + fn is_missing_icon(path: &Path) -> bool { // Filter the theme placeholder to avoid rendering a missing-icon glyph. let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index a2fed6488..043f38ec6 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -38,15 +38,18 @@ fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { } #[test] -fn collect_icon_candidates_does_not_fallback_to_unresolved_brand_claim() { +fn collect_icon_candidates_keeps_a_bounded_unresolved_theme_hint_decorative() { let mut notification = notification("Trusted Brand", "dialog-warning-symbolic"); notification.attribution.status = unixnotis_core::AttributionStatus::Unresolved; notification.attribution.desktop_id.clear(); + notification.attribution.claimed_name = "Trusted Brand".to_string(); + notification.image.claimed_theme_icon = "trusted-brand".to_string(); let candidates = collect_icon_candidates(¬ification); assert!(candidates .iter() .any(|value| value == "dialog-warning-symbolic")); - assert!(!candidates.iter().any(|value| value == "Trusted Brand")); + assert!(candidates.iter().any(|value| value == "trusted-brand")); + assert!(candidates.iter().all(|value| !value.contains('/'))); } diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 6cd16bba2..67aad92c5 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -13,7 +13,8 @@ use super::text::{ }; use super::types::{ ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, - NotificationKind, ReplyPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + NotificationKind, ReplyPresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, + TrustPresentation, VisualPresentation, }; /// Complete non-GTK notification presentation shared by popup and panel adapters @@ -27,6 +28,8 @@ pub struct NotificationPresentation { pub timestamp: String, pub popup_status: Option, pub media: MediaPresentation, + /// Sender and content visual roles shared by every GTK adapter + pub visuals: VisualPresentation, pub actions: ActionPresentation, pub critical: bool, } @@ -60,6 +63,7 @@ impl NotificationPresentation { media: MediaPresentation { thumbnail: thumbnail_kind(notification), }, + visuals: visual_presentation(notification), actions: visible_actions(notification, kind), critical: notification.urgency == Urgency::Critical as u8, } @@ -322,6 +326,24 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { ThumbnailKind::None } +const fn visual_presentation(notification: &NotificationView) -> VisualPresentation { + // The daemon has already materialized safe pixels; clients only select a slot + let sender = match notification.image.sender_visual_role { + unixnotis_core::NotificationVisualRole::ConversationAvatar => { + SenderVisualPresentation::ConversationAvatar + } + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon => { + SenderVisualPresentation::ApplicationProvidedIcon + } + unixnotis_core::NotificationVisualRole::None + | unixnotis_core::NotificationVisualRole::ContentImage => SenderVisualPresentation::None, + }; + VisualPresentation { + sender, + content_image: !notification.image.content_image.data.is_empty(), + } +} + fn relative_time_label(received_at: i64, now: i64) -> String { if received_at <= 0 { return "now".to_string(); diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs index be4c4f6cf..1deb13423 100644 --- a/crates/unixnotis-ui/src/presentation/mod.rs +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -16,7 +16,8 @@ pub use text::{ }; pub use types::{ ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, - NotificationKind, ReplyPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + NotificationKind, ReplyPresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, + TrustPresentation, VisualPresentation, }; #[cfg(test)] diff --git a/crates/unixnotis-ui/src/presentation/tests/mod.rs b/crates/unixnotis-ui/src/presentation/tests/mod.rs index ddd6c940e..ef371bb62 100644 --- a/crates/unixnotis-ui/src/presentation/tests/mod.rs +++ b/crates/unixnotis-ui/src/presentation/tests/mod.rs @@ -4,3 +4,4 @@ mod interaction; mod presentation; mod support; mod text; +mod visual_contract; diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 05716e13a..3a5c0b9c4 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -1,11 +1,11 @@ use unixnotis_core::{ Action, AttributionReason, AttributionStatus, IdentityAssurance, ImageData, InlineReplyPolicy, - InteractionPolicies, NotificationAttribution, Urgency, + InteractionPolicies, NotificationAttribution, NotificationVisualRole, Urgency, }; use super::super::{ BadgePresentation, NotificationKind, NotificationPresentation, ReplyPresentation, - ThumbnailKind, TrustLevel, + SenderVisualPresentation, ThumbnailKind, TrustLevel, }; use super::support::notification; @@ -48,6 +48,39 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { assert_eq!(presentation.timestamp, "2m"); } +#[test] +fn shared_visual_roles_are_consistent_for_popup_and_panel_clients() { + let mut view = notification(); + view.image.sender_visual_role = NotificationVisualRole::ConversationAvatar; + let avatar = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + avatar.visuals.sender, + SenderVisualPresentation::ConversationAvatar + ); + assert!(!avatar.visuals.content_image); + + view.image.sender_visual_role = NotificationVisualRole::ApplicationProvidedIcon; + let decorative = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + decorative.visuals.sender, + SenderVisualPresentation::ApplicationProvidedIcon + ); + + view.image.sender_visual_role = NotificationVisualRole::ContentImage; + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + let content = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(content.visuals.sender, SenderVisualPresentation::None); + assert!(content.visuals.content_image); +} + #[test] fn native_association_keeps_card_activation_and_confirms_only_extra_buttons() { let mut view = notification(); diff --git a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs new file mode 100644 index 000000000..758fbd0a9 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs @@ -0,0 +1,85 @@ +use unixnotis_core::{ImageData, NotificationVisualRole}; + +use super::super::{NotificationKind, NotificationPresentation, SenderVisualPresentation}; +use super::support::notification; + +#[test] +fn shared_notification_visual_contract_covers_client_surface_matrix() { + let cases = [ + ( + "utility", + NotificationKind::Utility, + NotificationVisualRole::None, + ), + ( + "communication-avatar", + NotificationKind::Communication, + NotificationVisualRole::ConversationAvatar, + ), + ( + "media-content", + NotificationKind::Media, + NotificationVisualRole::ContentImage, + ), + ( + "utility-application-visual", + NotificationKind::Utility, + NotificationVisualRole::ApplicationProvidedIcon, + ), + ]; + + for (name, expected_kind, role) in cases { + let mut view = notification(); + view.category = match expected_kind { + NotificationKind::Utility => String::new(), + NotificationKind::Communication => "message.received".to_string(), + NotificationKind::Media => "media.player".to_string(), + }; + view.image.sender_visual_role = role; + if role == NotificationVisualRole::ConversationAvatar { + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + } + if role == NotificationVisualRole::ContentImage { + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![4, 5, 6, 255], + ..ImageData::default() + }; + } + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(presentation.kind, expected_kind, "case={name}"); + assert_eq!( + presentation.visuals.sender, + match role { + NotificationVisualRole::ConversationAvatar => { + SenderVisualPresentation::ConversationAvatar + } + NotificationVisualRole::None | NotificationVisualRole::ContentImage => { + SenderVisualPresentation::None + } + NotificationVisualRole::ApplicationProvidedIcon => { + SenderVisualPresentation::ApplicationProvidedIcon + } + }, + "case={name}" + ); + assert_eq!( + presentation.visuals.content_image, + role == NotificationVisualRole::ContentImage, + "case={name}" + ); + } +} diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index d984cdb44..3773997fc 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -128,3 +128,18 @@ pub enum ThumbnailKind { pub struct MediaPresentation { pub thumbnail: ThumbnailKind, } + +/// Sender visual role selected once for every client surface +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SenderVisualPresentation { + None, + ConversationAvatar, + ApplicationProvidedIcon, +} + +/// Safe visual roles shared by popup and panel adapters +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VisualPresentation { + pub sender: SenderVisualPresentation, + pub content_image: bool, +} From f0b5edfb9590caf9ec9db9c6580280284c327496 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:39:53 -0500 Subject: [PATCH 211/275] style(ui): align notification and media surface contracts Keep custom card radius values synchronized with stack layers and restore the compact notification geometry used by the stock theme. Refine media card gradients, typography, and controls while retaining bounded art and transport lanes. --- crates/unixnotis-core/assets/media.css | 12 ++++++----- .../src/css/hooks/tests/hooks.rs | 1 + .../unixnotis-core/src/css/tokens/layout.rs | 1 - .../unixnotis-core/src/css/tokens/modern.rs | 6 ++++++ .../src/css/tokens/tests/modern.rs | 1 + .../unixnotis-core/src/embedded/tests/css.rs | 20 +++++++++++++++++++ .../unixnotis-ui/src/css/tests/overrides.rs | 2 +- 7 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index d28266f94..7504c08fc 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -99,7 +99,7 @@ } .unixnotis-media-card { - background: alpha(#ffffff, 0.035); + background-image: linear-gradient(145deg, alpha(@unixnotis-card-base, 0.88), alpha(@unixnotis-surface-base, 0.94)); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); @@ -136,7 +136,7 @@ } .unixnotis-media-card.playing { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); + background-image: linear-gradient(145deg, alpha(@unixnotis-accent, 0.12), alpha(@unixnotis-card-base, 0.94)); border-top: 1px solid alpha(#ffffff, 0.16); border-left: 1px solid alpha(#ffffff, 0.12); border-right: 1px solid alpha(#ffffff, 0.06); @@ -209,6 +209,7 @@ font-weight: 800; font-size: var(--unixnotis-media-title-font-size); letter-spacing: -0.01em; + line-height: 1.2; } .unixnotis-marquee { @@ -221,6 +222,7 @@ color: #cbd5e1; font-weight: 500; font-size: 12px; + line-height: 1.25; } .unixnotis-media-artist.empty { @@ -255,9 +257,9 @@ } .unixnotis-media-button.primary { - background: #ffffff; - border: 1px solid #ffffff; - color: #0f172a; + background: @unixnotis-text; + border: 1px solid @unixnotis-text; + color: @unixnotis-surface-base; box-shadow: 0 4px 10px -3px alpha(#000000, 0.3); } diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 227a13d8d..883ed2395 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -281,6 +281,7 @@ fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { assert!(css.contains(".unixnotis-stack-layer-back")); assert!(css.contains(".unixnotis-stack-layer-middle")); assert!(css.contains("margin: -58px 14px 0")); + assert!(css.contains("margin: 0 20px")); assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); } diff --git a/crates/unixnotis-core/src/css/tokens/layout.rs b/crates/unixnotis-core/src/css/tokens/layout.rs index dc9e45ce1..8e72e7491 100644 --- a/crates/unixnotis-core/src/css/tokens/layout.rs +++ b/crates/unixnotis-core/src/css/tokens/layout.rs @@ -22,7 +22,6 @@ pub(super) const fn layout_tokens() -> &'static [(&'static str, &'static str)] { ("--unixnotis-panel-close-size", "28px"), ("--unixnotis-panel-search-min-height", "34px"), ("--unixnotis-panel-search-padding-x", "10px"), - ("--unixnotis-notification-card-radius", "14px"), ("--unixnotis-notification-action-padding-y", "4px"), ("--unixnotis-notification-action-padding-x", "10px"), ("--unixnotis-popup-stack-padding", "8px"), diff --git a/crates/unixnotis-core/src/css/tokens/modern.rs b/crates/unixnotis-core/src/css/tokens/modern.rs index b68d7d4c1..4a40e8a89 100644 --- a/crates/unixnotis-core/src/css/tokens/modern.rs +++ b/crates/unixnotis-core/src/css/tokens/modern.rs @@ -36,6 +36,12 @@ pub fn build_modern_theme_custom_properties( "--unixnotis-card-radius", card_style.card_radius_px, ); + // Stack shells use the same dynamic radius as foreground notification cards + push_px_token( + &mut block, + "--unixnotis-notification-card-radius", + card_style.card_radius_px, + ); push_alpha_token(&mut block, "--unixnotis-surface-alpha", surface_alpha); push_alpha_token( &mut block, diff --git a/crates/unixnotis-core/src/css/tokens/tests/modern.rs b/crates/unixnotis-core/src/css/tokens/tests/modern.rs index a8a33d161..b3f9c1e44 100644 --- a/crates/unixnotis-core/src/css/tokens/tests/modern.rs +++ b/crates/unixnotis-core/src/css/tokens/tests/modern.rs @@ -17,6 +17,7 @@ fn modern_theme_custom_properties_stay_additive() { ":root {", "--unixnotis-border-width: 2px;", "--unixnotis-card-radius: 12px;", + "--unixnotis-notification-card-radius: 12px;", "--unixnotis-panel-card-padding-y: 9px;", "--unixnotis-popup-reveal-duration: 200ms;", "--unixnotis-media-card-radius: 18px;", diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 6d8665eac..77ae01ff9 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -157,3 +157,23 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); } + +#[test] +fn notification_surfaces_keep_compact_master_geometry() { + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-notification-card-radius)")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-y)")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-x)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-radius)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-y)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-x)")); + assert!(DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); + assert!(DEFAULT_PANEL_CSS.contains("margin: 0 20px")); +} + +#[test] +fn media_cards_keep_art_and_transport_as_separate_visual_lanes() { + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-art-frame")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-control-strip")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-card.playing")); + assert!(DEFAULT_MEDIA_CSS.contains("--unixnotis-media-art-size")); +} diff --git a/crates/unixnotis-ui/src/css/tests/overrides.rs b/crates/unixnotis-ui/src/css/tests/overrides.rs index 420d03cba..9b605e587 100644 --- a/crates/unixnotis-ui/src/css/tests/overrides.rs +++ b/crates/unixnotis-ui/src/css/tests/overrides.rs @@ -53,7 +53,7 @@ fn base_overrides_can_emit_modern_custom_properties() { assert!(overrides.contains("--unixnotis-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-card-alpha: 0.52;")); assert!(overrides.contains("--unixnotis-panel-header-radius: 18px;")); - assert!(overrides.contains("--unixnotis-notification-card-radius: 14px;")); + assert!(overrides.contains("--unixnotis-notification-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-stat-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-panel-card-padding-y: 9px;")); assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); From 612a18f32a3d1f83a39576978d54eb1200c66157 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:02 -0500 Subject: [PATCH 212/275] fix(center): guard deferred notification scroll resets Track rebuild generations and distinguish live near-top updates from hidden-panel reseeds. Deferred callbacks recheck the generation and current adjustment before changing scroll, while hidden mutations force the first complete row into view. --- crates/unixnotis-center/src/ui/events.rs | 59 ++++++++++++-- .../src/ui/events/tests/mod.rs | 15 +--- .../src/ui/events/tests/scroll.rs | 79 +++++++++++++++++++ .../src/ui/init/constructor.rs | 1 + .../src/ui/panel/behavior/visibility.rs | 18 +++-- crates/unixnotis-center/src/ui/state.rs | 2 + 6 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/events/tests/scroll.rs diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index eae939e0f..80c8625c4 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -116,6 +116,7 @@ impl UiState { debug!(app = %key, "group toggled"); self.log_debug(PanelDebugLevel::Verbose, || format!("group toggled: {key}")); self.list.toggle_group(&key); + self.mark_notifications_changed(); // Toggling can change grouped visibility; counts reflect total entries self.refresh_counts(); } @@ -182,6 +183,7 @@ impl UiState { } UiEvent::FilterChanged(query) => { if self.list.set_filter_query(&query) { + self.mark_notifications_changed(); self.log_debug(PanelDebugLevel::Verbose, || { format!("notification filter updated: '{query}'") }); @@ -224,10 +226,21 @@ impl UiState { } pub fn flush_list_rebuild(&mut self) { + self.flush_list_rebuild_with_policy(ScrollResetPolicy::NearTopOnly); + } + + pub(in crate::ui) fn flush_list_rebuild_with_policy(&mut self, policy: ScrollResetPolicy) { let snap_to_top = self.panel_visible && should_snap_to_top(&self.panel.sections.scroller); + let generation = self.notification_rebuild_generation.get().wrapping_add(1); + self.notification_rebuild_generation.set(generation); self.list.flush_rebuild(); - if snap_to_top { - reset_notification_scroll(&self.panel.sections.scroller); + if matches!(policy, ScrollResetPolicy::Force) || snap_to_top { + reset_notification_scroll( + &self.panel.sections.scroller, + self.notification_rebuild_generation.clone(), + generation, + policy, + ); } } @@ -251,14 +264,50 @@ const fn should_snap_to_top_value(value: f64, lower: f64) -> bool { value <= lower + 18.0 } -pub(in crate::ui) fn reset_notification_scroll(scroller: >k::ScrolledWindow) { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::ui) enum ScrollResetPolicy { + // Live updates preserve a meaningful position once the user scrolls away + NearTopOnly, + // Hidden reseeds invalidate the old position and must show the first row + Force, +} + +pub(in crate::ui) fn reset_notification_scroll( + scroller: >k::ScrolledWindow, + rebuild_generation: std::rc::Rc>, + expected_generation: u64, + policy: ScrollResetPolicy, +) { let scroller = scroller.clone(); gtk::glib::idle_add_local_once(move || { - let adjustment = scroller.vadjustment(); - adjustment.set_value(adjustment.lower()); + // Layout work can yield to a real user scroll before this callback runs + // Recheck both the rebuild and scroll state so stale work cannot win + if should_apply_scroll_reset( + rebuild_generation.get(), + expected_generation, + &scroller, + policy, + ) { + let adjustment = scroller.vadjustment(); + adjustment.set_value(adjustment.lower()); + } }); } +fn should_apply_scroll_reset( + current_generation: u64, + expected_generation: u64, + scroller: >k::ScrolledWindow, + policy: ScrollResetPolicy, +) -> bool { + scroll_reset_generation_is_current(current_generation, expected_generation) + && (matches!(policy, ScrollResetPolicy::Force) || should_snap_to_top(scroller)) +} + +const fn scroll_reset_generation_is_current(current: u64, expected: u64) -> bool { + current == expected +} + #[cfg(test)] #[path = "events/tests/mod.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/events/tests/mod.rs b/crates/unixnotis-center/src/ui/events/tests/mod.rs index 109e67ee8..9fda4bf76 100644 --- a/crates/unixnotis-center/src/ui/events/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/events/tests/mod.rs @@ -1,14 +1 @@ -use super::should_snap_to_top_value; - -#[test] -fn near_top_insertions_snap_to_the_first_row() { - assert!(should_snap_to_top_value(0.0, 0.0)); - assert!(should_snap_to_top_value(17.5, 0.0)); - assert!(!should_snap_to_top_value(18.1, 0.0)); -} - -#[test] -fn scroll_threshold_follows_nonzero_adjustment_lower_bound() { - assert!(should_snap_to_top_value(118.0, 100.0)); - assert!(!should_snap_to_top_value(118.1, 100.0)); -} +mod scroll; diff --git a/crates/unixnotis-center/src/ui/events/tests/scroll.rs b/crates/unixnotis-center/src/ui/events/tests/scroll.rs new file mode 100644 index 000000000..dd1e30ef3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/events/tests/scroll.rs @@ -0,0 +1,79 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; + +use super::super::{ + reset_notification_scroll, scroll_reset_generation_is_current, should_apply_scroll_reset, + should_snap_to_top_value, ScrollResetPolicy, +}; + +#[test] +fn near_top_insertions_snap_to_the_first_row() { + assert!(should_snap_to_top_value(0.0, 0.0)); + assert!(should_snap_to_top_value(17.5, 0.0)); + assert!(!should_snap_to_top_value(18.1, 0.0)); +} + +#[test] +fn scroll_threshold_follows_nonzero_adjustment_lower_bound() { + assert!(should_snap_to_top_value(118.0, 100.0)); + assert!(!should_snap_to_top_value(118.1, 100.0)); +} + +#[test] +fn stale_scroll_reset_generation_is_rejected() { + assert!(!scroll_reset_generation_is_current(11, 12)); + assert!(scroll_reset_generation_is_current(12, 12)); +} + +#[gtk::test] +fn scroll_reset_requires_current_generation_and_near_top_position() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(100.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + adjustment.set_value(100.0); + + assert!(should_apply_scroll_reset( + 4, + 4, + &scroller, + ScrollResetPolicy::NearTopOnly + )); + assert!(!should_apply_scroll_reset( + 3, + 4, + &scroller, + ScrollResetPolicy::NearTopOnly + )); + + adjustment.set_value(130.0); + assert!(!should_apply_scroll_reset( + 4, + 4, + &scroller, + ScrollResetPolicy::NearTopOnly + )); + assert!(should_apply_scroll_reset( + 4, + 4, + &scroller, + ScrollResetPolicy::Force + )); +} + +#[gtk::test] +fn deferred_scroll_reset_updates_the_adjustment_after_idle() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(108.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + adjustment.set_value(108.0); + let generation = Rc::new(Cell::new(7)); + + reset_notification_scroll(&scroller, generation, 7, ScrollResetPolicy::NearTopOnly); + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + assert!((adjustment.value() - adjustment.lower()).abs() < f64::EPSILON); +} diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 97741f93f..ccb32b57d 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -96,6 +96,7 @@ impl UiState { search_toggle_guard, panel_visible: false, notifications_changed_while_hidden: false, + notification_rebuild_generation: Rc::new(Cell::new(0)), panel_visible_flag, work_area: None, last_count: None, diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index 15d6770e7..98d17c07d 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -81,10 +81,14 @@ impl UiState { widget.update(&infos); } } + // Capture hidden mutations before flushing so one rebuild owns one idle callback + let rebuild_was_deferred = self.list_needs_rebuild(); + let hidden_mutation = self.notifications_changed_while_hidden; + self.notifications_changed_while_hidden = false; // Flush deferred list rebuilds once to avoid repeated background work - if self.list_needs_rebuild() { + if rebuild_was_deferred { // Apply any deferred list rebuilds once the panel becomes visible - self.list.flush_rebuild(); + self.flush_list_rebuild_with_policy(crate::ui::events::ScrollResetPolicy::Force); } // Resolve work-area margins before showing the window to avoid a layout shift // This prevents a first-frame resize when Hyprland publishes margins after open @@ -102,9 +106,13 @@ impl UiState { } // Only show the window after geometry is correct to avoid visible jitter self.panel.window.set_visible(true); - if self.notifications_changed_while_hidden { - crate::ui::events::reset_notification_scroll(&self.panel.sections.scroller); - self.notifications_changed_while_hidden = false; + if hidden_mutation && !rebuild_was_deferred { + crate::ui::events::reset_notification_scroll( + &self.panel.sections.scroller, + self.notification_rebuild_generation.clone(), + self.notification_rebuild_generation.get(), + crate::ui::events::ScrollResetPolicy::Force, + ); } // Refresh counts after pending updates land so header stays accurate self.refresh_counts(); diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index f166a5c99..0c160ad05 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -34,6 +34,8 @@ pub struct UiState { pub(super) panel_visible: bool, // A hidden panel defers list painting, so the next open must reveal the newest complete row pub(super) notifications_changed_while_hidden: bool, + // Each list rebuild invalidates older idle scroll callbacks + pub(super) notification_rebuild_generation: Rc>, pub(super) panel_visible_flag: Arc, pub(super) work_area: Option, // Tracks the last rendered counts to avoid redundant label updates From c46e87a603dfcc8b5c2ed50a82c1a0b244ca00d0 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:17 -0500 Subject: [PATCH 213/275] refactor(config): extract shared transactional reset operation Move configuration, theme, and bundled-script reset ownership into unixnotis-core. The operation resolves paths, creates a retention-bounded backup, stages replacements atomically, and attempts best-effort rollback when publication fails. Shared installer settings now have one loader for every local frontend. --- Cargo.lock | 1 + crates/unixnotis-core/Cargo.toml | 1 + .../src/config/installer_settings.rs | 83 +++++ .../installer_settings/tests/settings.rs | 89 +++++ crates/unixnotis-core/src/config/mod.rs | 9 + crates/unixnotis-core/src/config/reset.rs | 308 ++++++++++++++++++ 6 files changed, 491 insertions(+) create mode 100644 crates/unixnotis-core/src/config/installer_settings.rs create mode 100644 crates/unixnotis-core/src/config/installer_settings/tests/settings.rs create mode 100644 crates/unixnotis-core/src/config/reset.rs diff --git a/Cargo.lock b/Cargo.lock index 389c25aa3..a3b34a26a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3645,6 +3645,7 @@ dependencies = [ name = "unixnotis-core" version = "1.2.0" dependencies = [ + "anyhow", "blake3", "chrono", "image", diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index 7cb56aec2..7491cea08 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +anyhow.workspace = true blake3.workspace = true chrono.workspace = true image.workspace = true diff --git a/crates/unixnotis-core/src/config/installer_settings.rs b/crates/unixnotis-core/src/config/installer_settings.rs new file mode 100644 index 000000000..0b032e5fa --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings.rs @@ -0,0 +1,83 @@ +//! Shared installer settings used by local reset frontends + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::filesystem::{create_directory_all, write_file_if_missing}; + +pub const INSTALLER_CONFIG_FILE: &str = "installer.toml"; +pub const DEFAULT_BACKUP_RETENTION: usize = 3; + +const INSTALLER_CONFIG_TEMPLATE: &str = r"# UnixNotis installer settings +# Backup retention for config/theme resets +[backups] +keep = 3 +"; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct InstallerConfig { + pub backups: BackupConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct BackupConfig { + pub keep: usize, +} + +impl Default for BackupConfig { + fn default() -> Self { + Self { + keep: DEFAULT_BACKUP_RETENTION, + } + } +} + +#[must_use] +pub fn installer_config_path(config_dir: &Path) -> PathBuf { + config_dir.join(INSTALLER_CONFIG_FILE) +} + +/// Ensure that the shared retention settings file exists +/// +/// # Errors +/// +/// Returns an error when the configuration directory or settings file cannot +/// be created +pub fn ensure_installer_config(config_dir: &Path) -> Result<(PathBuf, bool)> { + create_directory_all(config_dir, 0o700).context("create UnixNotis configuration directory")?; + let config_path = installer_config_path(config_dir); + let created = write_file_if_missing(&config_path, INSTALLER_CONFIG_TEMPLATE.as_bytes(), 0o644) + .context("write installer settings")?; + Ok((config_path, created)) +} + +/// Read retention settings while distinguishing absence from I/O failure +/// +/// # Errors +/// +/// Returns an error when an existing settings file cannot be read or parsed +pub fn load_installer_config(config_dir: &Path) -> Result { + let config_path = installer_config_path(config_dir); + let contents = match fs::read_to_string(&config_path) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => { + // A first-run install has no settings yet, so use the shared default + return Ok(InstallerConfig::default()); + } + Err(error) => { + // Permission, encoding, directory, and other failures must reach both callers + return Err(error).with_context(|| format!("read {}", config_path.display())); + } + }; + toml::from_str(&contents).with_context(|| format!("parse {}", config_path.display())) +} + +#[cfg(test)] +#[path = "installer_settings/tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs b/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs new file mode 100644 index 000000000..8902655c8 --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs @@ -0,0 +1,89 @@ +use super::super::{ensure_installer_config, load_installer_config}; + +fn test_directory(label: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "unixnotis-installer-settings-{label}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("create settings directory"); + path +} + +#[test] +fn ensure_creates_shared_defaults_once() { + let directory = test_directory("create"); + let (path, created) = ensure_installer_config(&directory).expect("create settings"); + assert!(created); + assert!(path.is_file()); + + let settings = load_installer_config(&directory).expect("load settings"); + assert_eq!(settings.backups.keep, 3); + + let (_, created_again) = ensure_installer_config(&directory).expect("keep existing settings"); + assert!(!created_again); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_preserves_existing_retention() { + let directory = test_directory("load"); + std::fs::write(directory.join("installer.toml"), "[backups]\nkeep = 9\n") + .expect("write settings"); + + let settings = load_installer_config(&directory).expect("load settings"); + assert_eq!(settings.backups.keep, 9); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_missing_settings_uses_defaults() { + let directory = test_directory("missing"); + + let settings = load_installer_config(&directory).expect("missing settings use defaults"); + + assert_eq!(settings.backups.keep, 3); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_invalid_settings_fails_instead_of_defaulting() { + let directory = test_directory("invalid"); + std::fs::write(directory.join("installer.toml"), "[backups\n").expect("write invalid settings"); + + let error = load_installer_config(&directory).expect_err("invalid settings must fail"); + + assert!(error.to_string().contains("parse")); + let _ = std::fs::remove_dir_all(directory); +} + +#[cfg(unix)] +#[test] +fn load_directory_settings_fails_instead_of_defaulting() { + let directory = test_directory("directory"); + std::fs::create_dir(directory.join("installer.toml")).expect("create invalid settings path"); + + let error = load_installer_config(&directory).expect_err("directory settings must fail"); + + assert!(error.to_string().contains("read")); + let _ = std::fs::remove_dir_all(directory); +} + +#[cfg(unix)] +#[test] +fn ensure_rejects_a_settings_symlink_without_replacing_it() { + let directory = test_directory("symlink"); + let target = directory.join("settings-target"); + std::fs::write(&target, b"[backups]\nkeep = 7\n").expect("write target settings"); + std::os::unix::fs::symlink(&target, directory.join("installer.toml")) + .expect("create settings symlink"); + + let error = ensure_installer_config(&directory).expect_err("symlink must be rejected"); + + assert!(error.to_string().contains("write installer settings")); + assert_eq!( + std::fs::read_to_string(target).expect("read target settings"), + "[backups]\nkeep = 7\n" + ); + let _ = std::fs::remove_dir_all(directory); +} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 9a19263bf..910b7cf44 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -4,10 +4,12 @@ mod appearance; mod command; +mod installer_settings; mod layout; mod loading; mod media; mod panel; +mod reset; mod runtime; mod types; mod validation; @@ -25,6 +27,10 @@ pub use icon_assets::{ ResolvedIconAsset, DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_BYTES, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; +pub use installer_settings::{ + ensure_installer_config, installer_config_path, load_installer_config, BackupConfig, + InstallerConfig, DEFAULT_BACKUP_RETENTION, INSTALLER_CONFIG_FILE, +}; pub use io::{ persist_theme_mode, ConfigError, ThemeContractState, ThemeIncompatibility, ThemeManifest, ThemeModeWriteError, ThemePaths, MAX_CONFIG_BYTES, THEME_API_VERSION, @@ -33,6 +39,9 @@ pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; pub use media::*; pub use panel::*; +pub use reset::{ + render_default_config_toml, reset_config_to_defaults, ResetConfigOptions, ResetConfigReport, +}; pub use rules::*; pub use runtime::{MAX_CARD_WIDGETS, MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, MAX_TOTAL_WIDGETS}; pub use theme::*; diff --git a/crates/unixnotis-core/src/config/reset.rs b/crates/unixnotis-core/src/config/reset.rs new file mode 100644 index 000000000..5715cb9f3 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset.rs @@ -0,0 +1,308 @@ +//! Shared, transactional reset of the user configuration and bundled scripts + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; + +use crate::filesystem::{ + copy_file_atomic, create_directory_all, remove_directory_tree, remove_regular_file, + write_file_atomic, CreateDirectoryOutcome, +}; +use crate::{Config, DEFAULT_SCRIPTS}; + +const BACKUP_PREFIX: &str = "Backup-"; +type ResetWriter = dyn Fn(&Path, &[u8], u32) -> std::io::Result<()>; + +/// Inputs for a configuration reset +#[derive(Debug, Clone)] +pub struct ResetConfigOptions { + pub config_dir: PathBuf, + pub backup_retention: usize, +} + +/// Files changed by a reset and the backup made before it +#[derive(Debug, Clone, Default)] +pub struct ResetConfigReport { + pub backup_dir: Option, + pub backed_up_files: Vec, + pub written_files: Vec, +} + +#[derive(Debug, Clone)] +struct OriginalFile { + path: PathBuf, + contents: Vec, + mode: u32, +} + +#[derive(Debug, Clone)] +struct ResetTarget { + path: PathBuf, + contents: Vec, + mode: u32, +} + +/// Render the annotated stock configuration used by both installer frontends +/// +/// # Errors +/// +/// Returns an error when serialization fails or the expected annotated fields +/// are missing from the serialized configuration +pub fn render_default_config_toml(config: &Config) -> Result { + let mut config_toml = toml::to_string_pretty(config).context("serialize default config")?; + let panel_height_line = format!("height = {}\n", config.panel.height); + let panel_height_block = format!( + "# Vertical size as a percent of usable monitor height after margins\n\ +# and reserved work area\n\ +height = {}\n\ +\n\ +# Exact pixel height override for advanced users\n\ +# height_override = 1487\n", + config.panel.height + ); + let reduced_motion_line = format!("reduced_motion = {}\n", config.panel.reduced_motion); + let reduced_motion_block = format!( + "# Disable panel animation and moving text without requiring GTK 4.20\n\ +reduced_motion = {}\n", + config.panel.reduced_motion + ); + if !config_toml.contains(&panel_height_line) { + return Err(anyhow!("default config template missing panel height line")); + } + if !config_toml.contains(&reduced_motion_line) { + return Err(anyhow!( + "default config template missing reduced motion line" + )); + } + config_toml = config_toml.replacen(&panel_height_line, &panel_height_block, 1); + config_toml = config_toml.replacen(&reduced_motion_line, &reduced_motion_block, 1); + Ok(config_toml) +} + +/// Reset config and bundled scripts while retaining a recoverable snapshot +/// +/// # Errors +/// +/// Returns an error when a destination is unsafe, backup or publication fails, +/// or a partial reset cannot be restored +pub fn reset_config_to_defaults(options: &ResetConfigOptions) -> Result { + reset_config_to_defaults_with_writer(options, &write_file_atomic) +} + +fn reset_config_to_defaults_with_writer( + options: &ResetConfigOptions, + write: &ResetWriter, +) -> Result { + reset_config_to_defaults_inner(options, write) +} + +fn reset_config_to_defaults_inner( + options: &ResetConfigOptions, + write: &ResetWriter, +) -> Result { + // Build the default once so every generated file uses one consistent schema + let config = Config::default(); + // Create the parent before validating child destinations + create_directory_all(&options.config_dir, 0o700) + .context("create UnixNotis configuration directory")?; + let theme_paths = config + .resolve_theme_paths_from(&options.config_dir) + .map_err(|error| anyhow!(error.to_string()))?; + let manifest_path = theme_paths.manifest_path(); + let config_path = options.config_dir.join("config.toml"); + let mut paths = vec![config_path.clone()]; + paths.extend([ + theme_paths.base_css, + theme_paths.panel_css, + theme_paths.popup_css, + theme_paths.widgets_css, + theme_paths.media_css, + manifest_path, + ]); + for script in DEFAULT_SCRIPTS { + paths.push(options.config_dir.join(script.relative_path)); + } + + // Validate every destination before touching the first file + let originals = paths + .iter() + .filter_map(|path| snapshot_existing_file(path).transpose()) + .collect::>>()?; + // The backup is created before any destination is replaced + let backup_dir = create_backup_dir(&options.config_dir, options.backup_retention)?; + let mut report = ResetConfigReport { + backup_dir: backup_dir.clone(), + ..ResetConfigReport::default() + }; + if let Some(backup_dir) = &backup_dir { + for original in &originals { + let destination = backup_dir.join( + original + .path + .file_name() + .ok_or_else(|| anyhow!("configuration path has no file name"))?, + ); + copy_file_atomic(&original.path, &destination) + .with_context(|| format!("backup {}", original.path.display()))?; + report.backed_up_files.push(destination); + } + } + // Prune only after the new backup exists, but before any live file changes + prune_backups( + &options.config_dir, + options.backup_retention, + backup_dir.as_deref(), + ) + .context("prune configuration backups")?; + + // Render all replacement content before starting publication + let config_toml = render_default_config_toml(&config)?; + let mut targets = vec![ResetTarget { + path: config_path, + contents: config_toml.into_bytes(), + mode: 0o644, + }]; + for script in DEFAULT_SCRIPTS { + let path = options.config_dir.join(script.relative_path); + if let Some(parent) = path.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create script directory {}", parent.display()))?; + } + targets.push(ResetTarget { + path, + contents: script.contents.as_bytes().to_vec(), + mode: 0o755, + }); + } + + // Keep the successful targets so a later write can be rolled back + let mut written = Vec::new(); + for target in &targets { + if let Err(error) = write(&target.path, &target.contents, target.mode) { + let rollback_error = rollback_reset(&written, &originals, write); + let message = format!("write {}: {error}", target.path.display()); + return match rollback_error { + Ok(()) => Err(anyhow!(message)), + Err(rollback) => Err(anyhow!("{message}; rollback failed: {rollback}")), + }; + } + written.push(target.path.clone()); + report.written_files.push(target.path.clone()); + } + Ok(report) +} + +fn snapshot_existing_file(path: &Path) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())), + }; + if !metadata.file_type().is_file() { + return Err(anyhow!( + "reset target is not a regular file: {}", + path.display() + )); + } + Ok(Some(OriginalFile { + path: path.to_path_buf(), + contents: fs::read(path).with_context(|| format!("read {}", path.display()))?, + mode: metadata.permissions().mode() & 0o777, + })) +} + +fn rollback_reset( + written: &[PathBuf], + originals: &[OriginalFile], + write: &ResetWriter, +) -> Result<()> { + let mut failures = Vec::new(); + // Attempt every restoration so one damaged destination does not hide others + for path in written.iter().rev() { + let result = + if let Some(original) = originals.iter().find(|original| original.path == *path) { + write(&original.path, &original.contents, original.mode) + .with_context(|| format!("restore {}", original.path.display())) + } else { + remove_regular_file(path) + .map(|_| ()) + .with_context(|| format!("remove {}", path.display())) + }; + if let Err(error) = result { + failures.push(format!("{error:#}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!(failures.join("; "))) + } +} + +fn create_backup_dir(config_dir: &Path, retention: usize) -> Result> { + if retention == 0 { + return Ok(None); + } + // A suffix handles repeated resets within one clock second + let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M%S"); + let mut candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}")); + let mut suffix = 1_u32; + loop { + // Directory creation reserves the name, so concurrent resets cannot choose one path + match create_directory_all(&candidate, 0o700) + .context("create configuration backup directory")? + { + CreateDirectoryOutcome::TargetCreated => return Ok(Some(candidate)), + CreateDirectoryOutcome::TargetAlreadyExisted => { + candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}-{suffix:03}")); + suffix = suffix + .checked_add(1) + .ok_or_else(|| anyhow!("configuration backup name space exhausted"))?; + } + } + } +} + +fn prune_backups(config_dir: &Path, retention: usize, protected: Option<&Path>) -> Result<()> { + if retention == 0 { + return Ok(()); + } + let mut backups = Vec::new(); + for entry in fs::read_dir(config_dir).context("read configuration backup directory")? { + let entry = entry.context("read configuration backup entry")?; + let file_type = entry + .file_type() + .with_context(|| format!("inspect backup entry {}", entry.path().display()))?; + if file_type.is_dir() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(BACKUP_PREFIX)) + { + backups.push(entry.path()); + } + } + // Lexical order matches the timestamped backup names + backups.sort(); + let excess = backups.len().saturating_sub(retention); + let mut failures = Vec::new(); + for backup in backups.into_iter().take(excess) { + if protected.is_some_and(|protected| protected == backup) { + continue; + } + if let Err(error) = remove_directory_tree(&backup) { + failures.push(format!("{}: {error}", backup.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!(failures.join("; "))) + } +} + +#[cfg(test)] +#[path = "reset/tests/mod.rs"] +mod tests; From e7d19bc2f2b42fa065bd81a2267ca890e38fc0d4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:28 -0500 Subject: [PATCH 214/275] test(config): cover reset backups and rollback Verify default rendering, missing files, large retained files, backup pruning, permission modes, retention bounds, and restoration after partial publication. Settings tests also distinguish missing files from malformed or non-regular installer.toml paths. --- .../config/installer_settings/tests/mod.rs | 1 + .../src/config/reset/tests/files.rs | 119 ++++++++++++++++++ .../src/config/reset/tests/mod.rs | 5 + .../src/config/reset/tests/renderer.rs | 9 ++ .../src/config/reset/tests/retention.rs | 47 +++++++ .../src/config/reset/tests/rollback.rs | 96 ++++++++++++++ .../src/config/reset/tests/support.rs | 8 ++ 7 files changed, 285 insertions(+) create mode 100644 crates/unixnotis-core/src/config/installer_settings/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/files.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/renderer.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/retention.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/rollback.rs create mode 100644 crates/unixnotis-core/src/config/reset/tests/support.rs diff --git a/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs b/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs new file mode 100644 index 000000000..9ca36aa69 --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs @@ -0,0 +1 @@ +mod settings; diff --git a/crates/unixnotis-core/src/config/reset/tests/files.rs b/crates/unixnotis-core/src/config/reset/tests/files.rs new file mode 100644 index 000000000..f727d69ea --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/files.rs @@ -0,0 +1,119 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use super::super::{reset_config_to_defaults, snapshot_existing_file, ResetConfigOptions}; +use super::support::temp_config_dir; +use crate::DEFAULT_SCRIPTS; + +#[test] +fn reset_backs_up_existing_files_and_writes_stock_files() { + let root = temp_config_dir("present"); + fs::write(root.join("config.toml"), "custom = true\n").expect("seed config"); + let script = root.join(DEFAULT_SCRIPTS[0].relative_path); + fs::create_dir_all(script.parent().expect("script parent")).expect("script directory"); + fs::write(&script, "custom script\n").expect("seed script"); + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 3, + }) + .expect("reset should succeed"); + + let config_text = fs::read_to_string(root.join("config.toml")).expect("read reset config"); + toml::from_str::(&config_text).expect("reset config should parse"); + assert_eq!( + fs::read_to_string(&script).expect("read reset script"), + DEFAULT_SCRIPTS[0].contents + ); + let backup = report.backup_dir.expect("backup directory"); + assert_eq!( + fs::read_to_string(backup.join("config.toml")).expect("read config backup"), + "custom = true\n" + ); + assert_eq!( + fs::read_to_string(backup.join("unixnotis-blue-light-lib")).expect("read script backup"), + "custom script\n" + ); + assert_eq!(report.written_files.len(), 1 + DEFAULT_SCRIPTS.len()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_creates_missing_config_and_scripts_with_safe_modes() { + let root = temp_config_dir("missing"); + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("reset should create missing files"); + assert!(report.backup_dir.is_none()); + assert!(root.join("config.toml").is_file()); + for script in DEFAULT_SCRIPTS { + let path = root.join(script.relative_path); + assert!(path.is_file()); + #[cfg(unix)] + assert_eq!( + fs::metadata(path) + .expect("script metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_preserves_custom_theme_files_but_backs_them_up() { + let root = temp_config_dir("theme"); + fs::write(root.join("panel.css"), "custom panel\n").expect("seed custom CSS"); + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("reset should succeed"); + assert_eq!( + fs::read_to_string(root.join("panel.css")).expect("read custom CSS"), + "custom panel\n" + ); + let backup = report.backup_dir.expect("backup directory"); + assert_eq!( + fs::read_to_string(backup.join("panel.css")).expect("read CSS backup"), + "custom panel\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_accepts_large_existing_files_and_backs_them_up() { + let root = temp_config_dir("large-file"); + let original = vec![b'x'; 8 * 1024 * 1024 + 1]; + fs::write(root.join("config.toml"), &original).expect("seed large config"); + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("the configured boundary remains valid"); + + let backup = report.backup_dir.expect("boundary backup directory"); + assert_eq!( + fs::read(backup.join("config.toml")).expect("read large backup"), + original + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn snapshot_reports_errors_other_than_missing_files() { + let root = temp_config_dir("snapshot-error"); + let parent_file = root.join("not-a-directory"); + fs::write(&parent_file, b"file").expect("seed parent file"); + + let error = snapshot_existing_file(&parent_file.join("child")) + .expect_err("a non-directory parent must not look like a missing file"); + assert!(error.to_string().contains("inspect"), "{error}"); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/mod.rs b/crates/unixnotis-core/src/config/reset/tests/mod.rs new file mode 100644 index 000000000..abcfc286e --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/mod.rs @@ -0,0 +1,5 @@ +mod files; +mod renderer; +mod retention; +mod rollback; +mod support; diff --git a/crates/unixnotis-core/src/config/reset/tests/renderer.rs b/crates/unixnotis-core/src/config/reset/tests/renderer.rs new file mode 100644 index 000000000..2684b8168 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/renderer.rs @@ -0,0 +1,9 @@ +use super::super::render_default_config_toml; +use crate::Config; + +#[test] +fn reset_uses_the_same_annotated_default_renderer() { + let rendered = render_default_config_toml(&Config::default()).expect("render defaults"); + assert!(rendered.contains("# Exact pixel height override")); + assert!(rendered.contains("# Disable panel animation")); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/retention.rs b/crates/unixnotis-core/src/config/reset/tests/retention.rs new file mode 100644 index 000000000..595ccecf0 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/retention.rs @@ -0,0 +1,47 @@ +use std::fs; + +use super::super::{reset_config_to_defaults, ResetConfigOptions}; +use super::support::temp_config_dir; + +#[test] +fn reset_retains_only_the_newest_backup_directory() { + let root = temp_config_dir("retention"); + for name in ["Backup-2026-07-30-120000", "Backup-2026-07-31-120000"] { + fs::create_dir(root.join(name)).expect("seed old backup"); + } + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("reset should succeed"); + + let backups = fs::read_dir(&root) + .expect("read config directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(); + assert_eq!(backups, 1); + assert!(report.backup_dir.is_some()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn retention_ignores_backup_named_regular_files() { + let root = temp_config_dir("retention-file"); + for name in ["Backup-2026-07-30-120000", "Backup-2026-07-31-120000"] { + fs::create_dir(root.join(name)).expect("seed old backup"); + } + let regular_backup = root.join("Backup-z-not-a-directory"); + fs::write(®ular_backup, b"keep this file").expect("seed regular backup-like file"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 2, + }) + .expect("reset should succeed"); + + assert!(regular_backup.is_file()); + assert!(root.join("Backup-2026-07-31-120000").is_dir()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/rollback.rs b/crates/unixnotis-core/src/config/reset/tests/rollback.rs new file mode 100644 index 000000000..ee6d17310 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/rollback.rs @@ -0,0 +1,96 @@ +use std::fs; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use super::super::{reset_config_to_defaults_with_writer, ResetConfigOptions}; +use super::support::temp_config_dir; + +#[test] +fn reset_restores_replaced_files_after_a_later_write_fails() { + let root = temp_config_dir("rollback"); + let config_path = root.join("config.toml"); + let script_path = root.join("scripts/unixnotis-blue-light-lib"); + fs::write(&config_path, "original config\n").expect("seed config"); + #[cfg(unix)] + fs::set_permissions(&config_path, fs::Permissions::from_mode(0o640)) + .expect("set original config mode"); + fs::create_dir_all(script_path.parent().expect("script parent")).expect("script directory"); + fs::write(&script_path, "original script\n").expect("seed script"); + let failure_path = script_path.clone(); + + let error = reset_config_to_defaults_with_writer( + &ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }, + &move |path, contents, mode| { + if path == failure_path.as_path() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected publication failure", + )); + } + crate::filesystem::write_file_atomic(path, contents, mode) + }, + ) + .expect_err("the injected failure must be returned"); + + assert!(error.to_string().contains("injected publication failure")); + assert_eq!( + fs::read_to_string(&config_path).expect("restored config"), + "original config\n" + ); + assert_eq!( + fs::read_to_string(script_path).expect("original script"), + "original script\n" + ); + #[cfg(unix)] + assert_eq!( + fs::metadata(config_path) + .expect("restored config metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_attempts_every_written_path_and_reports_all_failures() { + let root = temp_config_dir("rollback-all"); + let config_path = root.join("config.toml"); + let first_script = root.join("scripts/unixnotis-blue-light-state"); + let second_script = root.join("scripts/unixnotis-blue-light-on"); + fs::write(&config_path, "original config\n").expect("seed config"); + fs::create_dir_all(first_script.parent().expect("script parent")).expect("script directory"); + fs::write(&first_script, "original state\n").expect("seed first script"); + fs::write(&second_script, "original on\n").expect("seed second script"); + let error = reset_config_to_defaults_with_writer( + &ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }, + &move |path, contents, mode| { + if path == second_script && contents != b"original on\n" { + return Err(io::Error::other("injected publication failure")); + } + if path == first_script && contents == b"original state\n" { + return Err(io::Error::other("injected rollback failure")); + } + crate::filesystem::write_file_atomic(path, contents, mode) + }, + ) + .expect_err("the injected failure must be returned"); + + let message = error.to_string(); + assert!(message.contains("injected publication failure")); + assert!(message.contains("rollback failed"), "{message}"); + assert!(message.contains("injected rollback failure"), "{message}"); + assert_eq!( + fs::read_to_string(config_path).expect("config rollback should be attempted"), + "original config\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/support.rs b/crates/unixnotis-core/src/config/reset/tests/support.rs new file mode 100644 index 000000000..783c41e81 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/support.rs @@ -0,0 +1,8 @@ +use std::fs; + +pub(super) fn temp_config_dir(label: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("unixnotis-reset-{label}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create reset fixture"); + path +} From f102d5b223e4aacbf8c1226c559f653ce912dd6c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:35 -0500 Subject: [PATCH 215/275] refactor(installer): use the shared reset operation Keep the installer as a thin presentation wrapper around the core reset API. It reports backup and theme decisions through the existing action log while propagating invalid settings and filesystem failures without silently substituting defaults. --- .../src/actions/config/backup/mod.rs | 2 - .../src/actions/config/backup/retention.rs | 7 + .../src/actions/config/backup/settings.rs | 61 +------- .../src/actions/config/backup/snapshot.rs | 10 +- .../src/actions/config/provision.rs | 136 +++--------------- 5 files changed, 43 insertions(+), 173 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/backup/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/mod.rs index 382f5d436..8110be966 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/mod.rs @@ -8,8 +8,6 @@ mod snapshot; // Keep config reads separate from dated backup directory churn pub(in crate::actions::config) use settings::{ensure_installer_config, load_installer_config}; // Backup file copies stay separate from restore logic so reset paths stay easy to scan -pub(in crate::actions::config) use retention::create_backup_dir; -pub(in crate::actions::config) use snapshot::backup_existing_file; pub use restore::restore_config; pub use snapshot::list_backup_dirs_for_ui; diff --git a/crates/unixnotis-installer/src/actions/config/backup/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/retention.rs index 6855f0541..a295c8e16 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/retention.rs @@ -13,6 +13,13 @@ use super::super::super::{log_line, ActionContext}; pub(in crate::actions::config::backup) const BACKUP_PREFIX: &str = "Backup-"; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "legacy backup unit tests cover this isolated retention helper" + ) +)] pub(in crate::actions::config) fn create_backup_dir( ctx: &mut ActionContext, config_dir: &Path, diff --git a/crates/unixnotis-installer/src/actions/config/backup/settings.rs b/crates/unixnotis-installer/src/actions/config/backup/settings.rs index ea88a13c8..27a505671 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/settings.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/settings.rs @@ -1,48 +1,20 @@ //! Installer backup settings and config file helpers -use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; -use serde::Deserialize; -use unixnotis_core::filesystem::write_file_atomic; - use crate::paths::format_with_home; +use anyhow::Result; use super::super::super::{log_line, ActionContext}; -const INSTALLER_CONFIG_FILE: &str = "installer.toml"; -const INSTALLER_CONFIG_TEMPLATE: &str = r"# UnixNotis installer settings -# Backup retention for config/theme resets -[backups] -keep = 3 -"; - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub(in crate::actions::config) struct InstallerConfig { - pub(in crate::actions::config) backups: BackupConfig, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub(in crate::actions::config) struct BackupConfig { - // Number of dated backup directories to keep in the config root - pub(in crate::actions::config) keep: usize, -} - -impl Default for BackupConfig { - fn default() -> Self { - Self { keep: 3 } - } -} +pub(in crate::actions::config) use unixnotis_core::InstallerConfig; pub(in crate::actions::config) fn ensure_installer_config( ctx: &mut ActionContext, config_dir: &Path, ) -> Result { - let config_path = config_dir.join(INSTALLER_CONFIG_FILE); - if config_path.exists() { + let (config_path, created) = unixnotis_core::ensure_installer_config(config_dir)?; + if !created { log_line( ctx, format!( @@ -53,8 +25,6 @@ pub(in crate::actions::config) fn ensure_installer_config( return Ok(config_path); } - write_file_atomic(&config_path, INSTALLER_CONFIG_TEMPLATE.as_bytes(), 0o644) - .with_context(|| "failed to write installer.toml")?; log_line( ctx, format!( @@ -67,25 +37,6 @@ pub(in crate::actions::config) fn ensure_installer_config( pub(in crate::actions::config) fn load_installer_config( config_dir: &Path, - ctx: &mut ActionContext, -) -> InstallerConfig { - let config_path = config_dir.join(INSTALLER_CONFIG_FILE); - let Ok(contents) = fs::read_to_string(&config_path) else { - return InstallerConfig::default(); - }; - - match toml::from_str(&contents) { - Ok(config) => config, - Err(err) => { - log_line( - ctx, - format!( - "Warning: invalid installer config at {}: {}", - format_with_home(&config_path), - err - ), - ); - InstallerConfig::default() - } - } +) -> Result { + unixnotis_core::load_installer_config(config_dir) } diff --git a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs index 8bcff17a3..9e947d350 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs @@ -1,6 +1,7 @@ //! Backup snapshot helpers for config and theme files -use std::path::{Path, PathBuf}; +use std::path::Path; +use std::path::PathBuf; use anyhow::{Context, Result}; use unixnotis_core::filesystem::copy_file_atomic; @@ -11,6 +12,13 @@ use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; use super::retention::list_backup_dirs; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "kept for the installer backup unit tests and future reset callers" + ) +)] pub(in crate::actions::config) fn backup_existing_file( ctx: &mut ActionContext, path: &Path, diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index 13da956a9..e4ea9eb51 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -3,15 +3,15 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::write_file_atomic; -use unixnotis_core::Config; +use unixnotis_core::{ + filesystem::write_file_atomic, render_default_config_toml, reset_config_to_defaults, Config, + ResetConfigOptions, +}; use crate::paths::format_with_home; use super::super::{log_line, ActionContext}; -use super::backup::{ - backup_existing_file, create_backup_dir, ensure_installer_config, load_installer_config, -}; +use super::backup::{ensure_installer_config, load_installer_config}; pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { let config = Config::default(); @@ -48,74 +48,27 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { } pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { - let config = Config::default(); let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; - let config_path = Config::default_config_path().map_err(|err| anyhow!(err.to_string()))?; - ensure_installer_config(ctx, &config_dir)?; - - let installer_config = load_installer_config(&config_dir, ctx); - let backup_dir = create_backup_dir(ctx, &config_dir, installer_config.backups.keep)?; - - // Preserve the live config before writing defaults over it - backup_existing_file(ctx, &config_path, "config.toml", backup_dir.as_deref())?; - - let config_toml = render_default_config_toml(&config)?; - write_file_atomic(&config_path, config_toml.as_bytes(), 0o644) - .with_context(|| "failed to write config.toml")?; + let installer_config = load_installer_config(&config_dir).context("load installer settings")?; + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: config_dir.clone(), + backup_retention: installer_config.backups.keep, + }) + .context("reset configuration to defaults")?; + if let Some(backup_dir) = report.backup_dir { + log_line( + ctx, + format!( + "Backed up existing configuration to {}", + format_with_home(&backup_dir) + ), + ); + } log_line( ctx, - format!( - "Reset config file to defaults: {}", - format_with_home(&config_path) - ), + "Reset config file and bundled scripts to defaults".to_string(), ); - - let theme_paths = config - .resolve_theme_paths() - .map_err(|err| anyhow!(err.to_string()))?; - - // Backup theme files before reset so user styling is still recoverable - backup_existing_file( - ctx, - &theme_paths.base_css, - "base.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.panel_css, - "panel.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.popup_css, - "popup.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.widgets_css, - "widgets.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.media_css, - "media.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.manifest_path(), - "theme.toml", - backup_dir.as_deref(), - )?; - backup_default_scripts(ctx, &config_dir, backup_dir.as_deref())?; - - write_default_scripts(&config_dir)?; - log_line( ctx, format!( @@ -149,50 +102,3 @@ fn ensure_default_scripts(ctx: &mut ActionContext, config_dir: &Path) -> Result< } Ok(()) } - -fn backup_default_scripts( - ctx: &mut ActionContext, - config_dir: &Path, - backup_dir: Option<&Path>, -) -> Result<()> { - for script in unixnotis_core::DEFAULT_SCRIPTS { - let path = config_dir.join(script.relative_path); - backup_existing_file(ctx, &path, script.relative_path, backup_dir)?; - } - Ok(()) -} - -pub(in crate::actions::config) fn write_default_scripts(config_dir: &Path) -> Result<()> { - Config::write_default_scripts_in(config_dir).map_err(|err| anyhow!(err.to_string())) -} - -pub(in crate::actions::config) fn render_default_config_toml(config: &Config) -> Result { - let mut config_toml = toml::to_string_pretty(config).map_err(|err| anyhow!(err.to_string()))?; - let panel_height_line = format!("height = {}\n", config.panel.height); - let panel_height_block = format!( - "# Vertical size as a percent of usable monitor height after margins\n\ -# and reserved work area\n\ -height = {}\n\ -\n\ -# Exact pixel height override for advanced users\n\ -# height_override = 1487\n", - config.panel.height - ); - let reduced_motion_line = format!("reduced_motion = {}\n", config.panel.reduced_motion); - let reduced_motion_block = format!( - "# Disable panel animation and moving text without requiring GTK 4.20\n\ -reduced_motion = {}\n", - config.panel.reduced_motion - ); - if !config_toml.contains(&panel_height_line) { - return Err(anyhow!("default config template missing panel height line")); - } - if !config_toml.contains(&reduced_motion_line) { - return Err(anyhow!( - "default config template missing reduced motion line" - )); - } - config_toml = config_toml.replacen(&panel_height_line, &panel_height_block, 1); - config_toml = config_toml.replacen(&reduced_motion_line, &reduced_motion_block, 1); - Ok(config_toml) -} From e993b0bb62bf35d3c5d11d7507ef832ec91e729b Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:42 -0500 Subject: [PATCH 216/275] test(config): verify installer reset parity and failure handling Exercise the installer wrapper against shared defaults, custom-file backups, embedded stock theme selection, and invalid installer settings. The parity fixture checks that the wrapper leaves the same bytes and backup contents as the core operation. --- .../actions/config/backup/tests/retention.rs | 4 +- .../actions/config/tests/default_template.rs | 5 +- .../src/actions/config/tests/provision.rs | 121 ++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs index 78f980c14..95132faa1 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs @@ -1,6 +1,5 @@ -use super::super::create_backup_dir; +use super::super::retention::create_backup_dir; use super::super::retention::{list_backup_dirs, prune_old_backups_except}; -use super::super::settings::BackupConfig; use crate::app::events::UiMessage; use crate::detect::Detection; use crate::model::ActionMode; @@ -10,6 +9,7 @@ use std::os::unix::fs::{symlink, PermissionsExt}; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; +use unixnotis_core::BackupConfig; fn prune_old_backups( ctx: &mut crate::actions::ActionContext, diff --git a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs index 16016ae8d..6d63de3cd 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs @@ -1,8 +1,7 @@ use std::fs; use std::path::PathBuf; -use super::super::provision::{render_default_config_toml, write_default_scripts}; -use unixnotis_core::Config; +use unixnotis_core::{render_default_config_toml, Config}; #[test] fn default_config_template_documents_panel_height_modes() { @@ -72,7 +71,7 @@ fn write_default_scripts_creates_executable_helpers() { )); let _ = fs::remove_dir_all(&root); - write_default_scripts(&root).expect("write default scripts"); + Config::write_default_scripts_in(&root).expect("write default scripts"); for script in unixnotis_core::DEFAULT_SCRIPTS { let path = root.join(script.relative_path); diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index 644a42848..831807611 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -143,3 +143,124 @@ fn reset_config_backs_up_custom_files_and_selects_embedded_stock() { ); let _ = fs::remove_dir_all(root); } + +#[test] +fn installer_and_core_reset_wrappers_produce_the_same_files() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-parity"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let installer_dir = xdg_root.join("unixnotis"); + let core_dir = root.join("core-config"); + + let seed = |directory: &std::path::Path| { + fs::create_dir_all(directory.join("scripts")).expect("create reset fixture"); + fs::write(directory.join("config.toml"), "custom = true\n").expect("seed config"); + fs::write(directory.join("installer.toml"), "[backups]\nkeep = 3\n") + .expect("seed settings"); + fs::write(directory.join("panel.css"), "custom panel\n").expect("seed theme"); + fs::write( + directory.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path), + "custom script\n", + ) + .expect("seed script"); + }; + seed(&installer_dir); + seed(&core_dir); + + reset_config(&mut context).expect("installer reset should succeed"); + unixnotis_core::reset_config_to_defaults(&unixnotis_core::ResetConfigOptions { + config_dir: core_dir.clone(), + backup_retention: 3, + }) + .expect("core reset should succeed"); + + for relative in [ + "config.toml", + "panel.css", + "scripts/unixnotis-blue-light-state", + ] { + assert_eq!( + fs::read(installer_dir.join(relative)).expect("read installer result"), + fs::read(core_dir.join(relative)).expect("read core result"), + "reset wrappers must write the same {relative}" + ); + } + let installer_backup = fs::read_dir(&installer_dir) + .expect("read installer backups") + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .expect("installer backup"); + let core_backup = fs::read_dir(&core_dir) + .expect("read core backups") + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .expect("core backup"); + for name in ["config.toml", "panel.css"] { + assert_eq!( + fs::read(installer_backup.path().join(name)).expect("read installer backup"), + fs::read(core_backup.path().join(name)).expect("read core backup"), + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_rejects_invalid_installer_settings_without_changes() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-invalid-settings"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed reset fixture"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + let script_path = config_dir.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path); + fs::write(&config_path, "custom config\n").expect("customize config"); + fs::write(config_dir.join("panel.css"), "custom panel\n").expect("customize theme"); + fs::write(&script_path, "custom script\n").expect("customize script"); + fs::write(config_dir.join("installer.toml"), "[backups\n").expect("corrupt installer settings"); + let before_config = fs::read(&config_path).expect("read config before reset"); + let before_theme = fs::read(config_dir.join("panel.css")).expect("read theme before reset"); + let before_script = fs::read(&script_path).expect("read script before reset"); + + let error = reset_config(&mut context).expect_err("invalid settings must abort reset"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(&config_path).expect("read config after reset"), + before_config + ); + assert_eq!( + fs::read(config_dir.join("panel.css")).expect("read theme after reset"), + before_theme + ); + assert_eq!( + fs::read(&script_path).expect("read script after reset"), + before_script + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "invalid settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} From effc5f06cdac1ef8d41fc6f2b78f2500d3db074d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:47 -0500 Subject: [PATCH 217/275] feat(noticenterctl): add preset reset-config Expose the shared configuration reset through a local synchronous command. Interactive runs require explicit confirmation, unattended runs require --yes, and successful output reports the retained backup path without opening a daemon connection. --- crates/noticenterctl/src/cli/args.rs | 6 +++ crates/noticenterctl/src/preset/mod.rs | 2 + crates/noticenterctl/src/preset/reset.rs | 69 ++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 crates/noticenterctl/src/preset/reset.rs diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index 895b9e017..298952dcf 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -104,6 +104,12 @@ pub enum PresetCommand { Inspect { input: String, }, + // Replace the local configuration and bundled scripts with current defaults + ResetConfig { + /// Skip the interactive confirmation prompt + #[arg(long)] + yes: bool, + }, } #[derive(Subcommand, Debug)] diff --git a/crates/noticenterctl/src/preset/mod.rs b/crates/noticenterctl/src/preset/mod.rs index 88caa71af..829327cfc 100644 --- a/crates/noticenterctl/src/preset/mod.rs +++ b/crates/noticenterctl/src/preset/mod.rs @@ -13,6 +13,7 @@ mod import; mod inspect; mod manifest; mod pathing; +mod reset; #[cfg(test)] mod tests; @@ -44,5 +45,6 @@ pub fn run_preset(command: PresetCommand) -> Result<()> { allow_external_css, ), PresetCommand::Inspect { input } => inspect::run_inspect(Path::new(&input)), + PresetCommand::ResetConfig { yes } => reset::run_reset_config(yes), } } diff --git a/crates/noticenterctl/src/preset/reset.rs b/crates/noticenterctl/src/preset/reset.rs new file mode 100644 index 000000000..7e507d956 --- /dev/null +++ b/crates/noticenterctl/src/preset/reset.rs @@ -0,0 +1,69 @@ +//! Non-D-Bus configuration reset frontend + +use anyhow::{Context, Result}; +use std::io::{self, BufRead, IsTerminal, Write}; +use unixnotis_core::{ + ensure_installer_config, load_installer_config, reset_config_to_defaults, Config, + ResetConfigOptions, +}; + +pub(super) fn run_reset_config(skip_confirmation: bool) -> Result<()> { + let stdin = io::stdin(); + // Local reset is destructive, so unattended calls must opt in explicitly + if !skip_confirmation && !confirm_reset(&mut stdin.lock(), stdin.is_terminal())? { + println!("Reset cancelled."); + return Ok(()); + } + let config_dir = + Config::default_config_dir().map_err(|error| anyhow::anyhow!(error.to_string()))?; + // Both local frontends create and read the same settings file + let _ = ensure_installer_config(&config_dir).context("prepare installer settings")?; + let retention = load_installer_config(&config_dir) + .context("load installer settings")? + .backups + .keep; + // The core operation owns all filesystem changes and rollback behavior + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir, + backup_retention: retention, + }) + .context("reset configuration to defaults")?; + if let Some(backup_dir) = report.backup_dir { + println!( + "Backed up existing configuration to:\n{}", + backup_dir.display() + ); + } else { + println!("No backup was created because backup retention is disabled."); + } + println!("Reset config.toml to current defaults."); + println!("Reset bundled scripts."); + println!("Theme source is now embedded stock."); + Ok(()) +} + +fn confirm_reset(input: &mut impl BufRead, interactive: bool) -> Result { + if !interactive { + return Err(anyhow::anyhow!( + "reset-config requires --yes when standard input is not interactive" + )); + } + print!( + "This will reset UnixNotis configuration and bundled scripts.\n\ +Existing files will be backed up before replacement.\n\ +Continue? [y/N] " + ); + io::stdout().flush().context("flush reset confirmation")?; + let mut answer = String::new(); + input + .read_line(&mut answer) + .context("read reset confirmation")?; + Ok(matches!( + answer.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +#[cfg(test)] +#[path = "tests/reset.rs"] +mod tests; From 9ea9a8fab505e3ed59379d7dbc3ec174a4ea64f2 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 20:40:54 -0500 Subject: [PATCH 218/275] test(config): cover reset-config CLI behavior Cover command parsing, local-only routing, confirmation defaults, non-interactive rejection, successful --yes resets, and invalid settings failures without modifying live files. --- crates/noticenterctl/src/cli/tests/args.rs | 12 ++ crates/noticenterctl/src/cli/tests/help.rs | 2 +- .../noticenterctl/src/preset/tests/reset.rs | 119 ++++++++++++++++++ .../src/actions/config/tests/provision.rs | 41 ++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 crates/noticenterctl/src/preset/tests/reset.rs diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs index 0f20bc168..1b1755013 100644 --- a/crates/noticenterctl/src/cli/tests/args.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -243,6 +243,18 @@ fn parses_preset_inspect() { } } +#[test] +fn parses_preset_reset_config_confirmation_flag() { + let args = Args::try_parse_from(["noticenterctl", "preset", "reset-config", "--yes"]) + .expect("parse reset-config"); + assert!(matches!( + args.command, + Command::Preset { + command: PresetCommand::ResetConfig { yes: true } + } + )); +} + #[test] fn parses_doctor_output_and_service_manager_options() { let args = Args::try_parse_from([ diff --git a/crates/noticenterctl/src/cli/tests/help.rs b/crates/noticenterctl/src/cli/tests/help.rs index 2fdce64e2..cdebc3192 100644 --- a/crates/noticenterctl/src/cli/tests/help.rs +++ b/crates/noticenterctl/src/cli/tests/help.rs @@ -26,7 +26,7 @@ fn command_help_lists_output_debug_and_preset_controls() { ), ( vec!["noticenterctl", "preset", "--help"], - vec!["export", "import", "inspect"], + vec!["export", "import", "inspect", "reset-config"], ), ( vec!["noticenterctl", "theme", "--help"], diff --git a/crates/noticenterctl/src/preset/tests/reset.rs b/crates/noticenterctl/src/preset/tests/reset.rs new file mode 100644 index 000000000..71f2b49bd --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/reset.rs @@ -0,0 +1,119 @@ +use std::fs; +use std::io::Cursor; + +use super::super::reset::{confirm_reset, run_reset_config}; +use crate::test_support::{test_env_lock, EnvGuard}; + +#[test] +fn confirmation_accepts_yes_and_defaults_to_no() { + assert!(confirm_reset(&mut Cursor::new("yes\n"), true).expect("read yes")); + assert!(!confirm_reset(&mut Cursor::new("\n"), true).expect("read default")); +} + +#[test] +fn noninteractive_confirmation_fails_closed() { + let error = confirm_reset(&mut Cursor::new("yes\n"), false).expect_err("require --yes"); + assert!(error.to_string().contains("--yes")); +} + +#[test] +fn yes_mode_executes_reset_and_creates_shared_settings() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-reset-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom = true\n").expect("seed config"); + + run_reset_config(true).expect("--yes reset should execute"); + + assert!(config_dir.join("installer.toml").is_file()); + let config = fs::read_to_string(config_dir.join("config.toml")).expect("read reset config"); + toml::from_str::(&config).expect("reset config should parse"); + assert!(fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with("Backup-"))); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn yes_mode_rejects_invalid_settings_without_changes() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-invalid-settings-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom config\n").expect("seed config"); + fs::write(config_dir.join("installer.toml"), "[backups\n").expect("corrupt settings"); + let before = fs::read(config_dir.join("config.toml")).expect("read config before reset"); + + let error = run_reset_config(true).expect_err("invalid settings must fail"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(config_dir.join("config.toml")).expect("read config after reset"), + before + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "invalid settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn yes_mode_rejects_non_file_settings_without_changes() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-directory-settings-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom config\n").expect("seed config"); + fs::create_dir(config_dir.join("installer.toml")).expect("create settings directory"); + let before = fs::read(config_dir.join("config.toml")).expect("read config before reset"); + + let error = run_reset_config(true).expect_err("directory settings must fail"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(config_dir.join("config.toml")).expect("read config after reset"), + before + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "non-file settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index 831807611..d88450233 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -264,3 +264,44 @@ fn reset_rejects_invalid_installer_settings_without_changes() { ); let _ = fs::remove_dir_all(root); } + +#[test] +fn reset_rejects_non_file_installer_settings_without_changes() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-directory-settings"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed reset fixture"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::write(&config_path, "custom config\n").expect("customize config"); + fs::remove_file(config_dir.join("installer.toml")).expect("remove settings file"); + fs::create_dir(config_dir.join("installer.toml")).expect("create settings directory"); + let before_config = fs::read(&config_path).expect("read config before reset"); + + let error = reset_config(&mut context).expect_err("directory settings must abort reset"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(&config_path).expect("read config after reset"), + before_config + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "non-file settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} From 926566c1b4d2053b5ffb1851f0228aa0b05f00a0 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 22:28:12 -0500 Subject: [PATCH 219/275] refactor(installer): isolate backup directory listing Separate the restore view's directory listing from the legacy reset implementation so the shared core reset operation remains the only backup writer. --- Cargo.lock | 1 - crates/noticenterctl/src/preset/reset.rs | 2 +- crates/unixnotis-installer/Cargo.toml | 1 - .../src/actions/config/backup/listing.rs | 31 +++ .../src/actions/config/backup/mod.rs | 2 +- .../src/actions/config/backup/restore.rs | 2 +- .../src/actions/config/backup/retention.rs | 126 ---------- .../src/actions/config/backup/snapshot.rs | 43 +--- .../actions/config/backup/tests/listing.rs | 22 ++ .../src/actions/config/backup/tests/mod.rs | 3 +- .../actions/config/backup/tests/retention.rs | 231 ------------------ .../actions/config/backup/tests/snapshot.rs | 72 ------ 12 files changed, 60 insertions(+), 476 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/config/backup/listing.rs delete mode 100644 crates/unixnotis-installer/src/actions/config/backup/retention.rs create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs delete mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs delete mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index a3b34a26a..2370bc3ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3701,7 +3701,6 @@ name = "unixnotis-installer" version = "1.2.0" dependencies = [ "anyhow", - "chrono", "crossterm", "ratatui", "rustix", diff --git a/crates/noticenterctl/src/preset/reset.rs b/crates/noticenterctl/src/preset/reset.rs index 7e507d956..8cd8d7fa6 100644 --- a/crates/noticenterctl/src/preset/reset.rs +++ b/crates/noticenterctl/src/preset/reset.rs @@ -42,7 +42,7 @@ pub(super) fn run_reset_config(skip_confirmation: bool) -> Result<()> { Ok(()) } -fn confirm_reset(input: &mut impl BufRead, interactive: bool) -> Result { +pub(super) fn confirm_reset(input: &mut impl BufRead, interactive: bool) -> Result { if !interactive { return Err(anyhow::anyhow!( "reset-config requires --yes when standard input is not interactive" diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index 959991197..182fead48 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -12,7 +12,6 @@ toml.workspace = true serde_json.workspace = true semver.workspace = true serde.workspace = true -chrono.workspace = true rustix.workspace = true tokio.workspace = true unixnotis-core = { path = "../unixnotis-core" } diff --git a/crates/unixnotis-installer/src/actions/config/backup/listing.rs b/crates/unixnotis-installer/src/actions/config/backup/listing.rs new file mode 100644 index 000000000..94ff7f28c --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/listing.rs @@ -0,0 +1,31 @@ +//! Backup-directory listing for the installer restore view + +use std::fs; +use std::path::{Path, PathBuf}; + +pub(in crate::actions::config::backup) const BACKUP_PREFIX: &str = "Backup-"; + +pub(in crate::actions::config::backup) fn list_backup_dirs(config_dir: &Path) -> Vec { + // A missing config directory simply means there is nothing to restore + let Ok(entries) = fs::read_dir(config_dir) else { + return Vec::new(); + }; + + entries + .filter_map(std::result::Result::ok) + .filter_map(|entry| { + // Restore only real directories so backup-like files cannot enter the picker + let file_type = entry.file_type().ok()?; + if !file_type.is_dir() { + return None; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + // The prefix keeps unrelated user directories out of the restore list + if !name.starts_with(BACKUP_PREFIX) { + return None; + } + Some(entry.path()) + }) + .collect() +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/mod.rs index 8110be966..5b380d0ee 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/mod.rs @@ -1,7 +1,7 @@ //! Config backup entry points +mod listing; mod restore; -mod retention; mod settings; mod snapshot; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index e0308bf54..65a126aa0 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -10,7 +10,7 @@ use unixnotis_core::Config; use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; -use super::retention::BACKUP_PREFIX; +use super::listing::BACKUP_PREFIX; pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { let Some(backup_dir) = ctx.restore_backup.clone() else { diff --git a/crates/unixnotis-installer/src/actions/config/backup/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/retention.rs deleted file mode 100644 index a295c8e16..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/retention.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Backup directory creation and retention policy helpers - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::Local; -use unixnotis_core::filesystem::{create_directory_all, remove_directory_tree}; - -use crate::paths::format_with_home; - -use super::super::super::{log_line, ActionContext}; - -pub(in crate::actions::config::backup) const BACKUP_PREFIX: &str = "Backup-"; - -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "legacy backup unit tests cover this isolated retention helper" - ) -)] -pub(in crate::actions::config) fn create_backup_dir( - ctx: &mut ActionContext, - config_dir: &Path, - keep: usize, -) -> Result> { - if keep == 0 { - log_line(ctx, "Backups disabled (installer.toml keep = 0)"); - return Ok(None); - } - - // Each reset gets its own dated directory so filenames stay simple - let stamp = backup_stamp_from_system_time(); - let base_name = format!("{BACKUP_PREFIX}{stamp}"); - let mut candidate = config_dir.join(base_name); - - // If a backup already exists for that day, add a zero-padded suffix - let mut suffix = 1; - while candidate.exists() { - candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}-{suffix:03}")); - suffix += 1; - } - - // Backups may contain private configuration, so their root is always user-only - create_directory_all(&candidate, 0o700).with_context(|| "failed to create backup directory")?; - log_line( - ctx, - format!("Backup directory created: {}", format_with_home(&candidate)), - ); - - prune_old_backups_except(ctx, config_dir, keep, Some(candidate.as_path())); - Ok(Some(candidate)) -} - -pub(in crate::actions::config::backup) fn list_backup_dirs(config_dir: &Path) -> Vec { - let Ok(entries) = fs::read_dir(config_dir) else { - return Vec::new(); - }; - - entries - .filter_map(std::result::Result::ok) - .filter_map(|entry| { - let file_type = entry.file_type().ok()?; - if !file_type.is_dir() { - return None; - } - let name = entry.file_name(); - let name = name.to_string_lossy(); - if !name.starts_with(BACKUP_PREFIX) { - return None; - } - Some(entry.path()) - }) - .collect() -} - -pub(in crate::actions::config::backup) fn prune_old_backups_except( - ctx: &mut ActionContext, - config_dir: &Path, - keep: usize, - protected_backup: Option<&Path>, -) { - if keep == 0 { - return; - } - - let mut backups = list_backup_dirs(config_dir); - // YYYY-MM-DD names and zero-padded suffixes sort in age order - backups.sort(); - - if backups.len() <= keep { - return; - } - - let mut excess = backups.len().saturating_sub(keep); - for path in backups { - if excess == 0 { - break; - } - if protected_backup.is_some_and(|protected| protected == path) { - continue; - } - if let Err(err) = remove_directory_tree(&path) { - log_line( - ctx, - format!( - "Warning: failed to remove old backup {}: {}", - format_with_home(&path), - err - ), - ); - } else { - log_line( - ctx, - format!("Removed old backup {}", format_with_home(&path)), - ); - } - excess -= 1; - } -} - -fn backup_stamp_from_system_time() -> String { - // Use chrono for a stable YYYY-MM-DD stamp without hand-rolled time math - Local::now().format("%Y-%m-%d").to_string() -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs index 9e947d350..96bbd15a2 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs @@ -1,55 +1,18 @@ //! Backup snapshot helpers for config and theme files -use std::path::Path; use std::path::PathBuf; -use anyhow::{Context, Result}; -use unixnotis_core::filesystem::copy_file_atomic; use unixnotis_core::Config; -use crate::paths::format_with_home; - -use super::super::super::{log_line, ActionContext}; -use super::retention::list_backup_dirs; - -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "kept for the installer backup unit tests and future reset callers" - ) -)] -pub(in crate::actions::config) fn backup_existing_file( - ctx: &mut ActionContext, - path: &Path, - label: &str, - backup_dir: Option<&Path>, -) -> Result<()> { - if !path.exists() { - return Ok(()); - } - - let Some(backup_dir) = backup_dir else { - return Ok(()); - }; - - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let backup_path = backup_dir.join(file_name.as_ref()); - - // Open the live file once and publish its snapshot without following either path through links - copy_file_atomic(path, &backup_path).with_context(|| format!("failed to backup {label}"))?; - log_line( - ctx, - format!("Backed up {} to {}", label, format_with_home(&backup_path)), - ); - Ok(()) -} +use super::listing::list_backup_dirs; pub fn list_backup_dirs_for_ui() -> Vec { + // The restore screen remains usable when default path discovery fails let Ok(config_dir) = Config::default_config_dir() else { return Vec::new(); }; + // Stable ordering keeps keyboard selection and redraws predictable let mut backups = list_backup_dirs(&config_dir); backups.sort(); backups diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs new file mode 100644 index 000000000..043c70d01 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs @@ -0,0 +1,22 @@ +use std::fs; +use std::path::PathBuf; + +use super::super::listing::list_backup_dirs; + +#[test] +fn list_backup_dirs_filters_non_backup_entries_and_files() { + let root = PathBuf::from("target").join(format!( + "unixnotis-installer-backup-list-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + let _ = fs::create_dir_all(&root); + fs::create_dir_all(root.join("Backup-2026-06-01")).expect("backup dir"); + fs::create_dir_all(root.join("Other-2026-06-01")).expect("foreign dir"); + fs::write(root.join("Backup-2026-06-02"), "not a dir").expect("backup-like file"); + + let backups = list_backup_dirs(&root); + + assert_eq!(backups, vec![root.join("Backup-2026-06-01")]); + let _ = fs::remove_dir_all(&root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs index 8750adad0..76fc13407 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs @@ -1,5 +1,4 @@ +mod listing; mod restore; -mod retention; mod settings; -mod snapshot; mod support; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs deleted file mode 100644 index 95132faa1..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::super::retention::create_backup_dir; -use super::super::retention::{list_backup_dirs, prune_old_backups_except}; -use crate::app::events::UiMessage; -use crate::detect::Detection; -use crate::model::ActionMode; -use crate::paths::InstallPaths; -use std::fs; -use std::os::unix::fs::{symlink, PermissionsExt}; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{mpsc, Arc}; -use unixnotis_core::BackupConfig; - -fn prune_old_backups( - ctx: &mut crate::actions::ActionContext, - config_dir: &std::path::Path, - keep: usize, -) { - // Direct retention tests do not need to protect a newly created backup - prune_old_backups_except(ctx, config_dir, keep, None); -} - -#[test] -fn prune_old_backups_keeps_newest() { - let _lock = crate::test_support::env::test_env_lock(); - // Backup names are date-ordered, so lexical sort can drive retention - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-prune-test-{}", - std::process::id() - )); - let _ = fs::create_dir_all(&root); - let names = [ - "Backup-2024-01-01", - "Backup-2024-01-02", - "Backup-2024-01-03", - "Backup-2024-01-04", - ]; - for name in names { - let _ = fs::create_dir_all(root.join(name)); - } - - // Minimal installer context for pruning logic - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - prune_old_backups(&mut ctx, &root, 2); - - // Only the two newest entries should remain - let mut remaining = list_backup_dirs(&root) - .into_iter() - .map(|path: std::path::PathBuf| { - path.file_name() - .expect("backup directory should have a file name") - .to_string_lossy() - .to_string() - }) - .collect::>(); - remaining.sort(); - assert_eq!( - remaining, - vec![ - "Backup-2024-01-03".to_string(), - "Backup-2024-01-04".to_string() - ] - ); - - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn backup_config_defaults_to_three() { - // Default retention should match installer template behavior - let config = BackupConfig::default(); - assert_eq!(config.keep, 3); -} - -#[test] -fn create_backup_dir_keeps_new_directory_when_retention_is_full() { - let _lock = crate::test_support::env::test_env_lock(); - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-create-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - for name in [ - "Backup-2026-05-31-003", - "Backup-2026-05-31-004", - "Backup-2026-05-31-005", - ] { - let _ = fs::create_dir_all(root.join(name)); - } - - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let backup_dir = create_backup_dir(&mut ctx, &root, 3) - .expect("backup directory should be created") - .expect("backups should be enabled"); - - assert!( - backup_dir.exists(), - "new backup directory must survive retention pruning" - ); - assert_eq!( - fs::metadata(&backup_dir) - .expect("backup directory metadata") - .permissions() - .mode() - & 0o777, - 0o700 - ); - assert_eq!(list_backup_dirs(&root).len(), 3); - - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn prune_old_backups_rejects_symlink_children_without_touching_target() { - let root = crate::test_support::fs::unique_temp_path("backup-prune-child-link"); - let oldest = root.join("Backup-2026-07-20"); - let newest = root.join("Backup-2026-07-21"); - let protected = root.join("protected"); - fs::create_dir_all(&oldest).expect("create oldest backup"); - fs::create_dir_all(&newest).expect("create newest backup"); - fs::write(&protected, "protected").expect("write protected file"); - symlink(&protected, oldest.join("linked-file")).expect("create backup child link"); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = super::support::test_paths(&root); - let mut context = super::support::test_context(&detection, &paths); - - prune_old_backups(&mut context, &root, 1); - - assert!(oldest.exists()); - assert!(newest.exists()); - assert_eq!( - fs::read_to_string(protected).expect("read protected file"), - "protected" - ); - assert!(fs::symlink_metadata(oldest.join("linked-file")) - .expect("backup child link remains") - .file_type() - .is_symlink()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn create_backup_dir_returns_none_when_retention_is_disabled() { - let _lock = crate::test_support::env::test_env_lock(); - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-disabled-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let backup = create_backup_dir(&mut ctx, &root, 0).expect("disabled backups should succeed"); - - // keep = 0 is an explicit opt-out and must not create a backup directory - assert!(backup.is_none()); - assert!(list_backup_dirs(&root).is_empty()); - let log = rx.try_recv().expect("disabled backup log"); - assert!(matches!( - log, - UiMessage::Worker(crate::app::events::WorkerEvent::LogLine(message)) - if message.contains("Backups disabled") - )); - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn list_backup_dirs_filters_non_backup_entries_and_files() { - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-list-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - fs::create_dir_all(root.join("Backup-2026-06-01")).expect("backup dir"); - fs::create_dir_all(root.join("Other-2026-06-01")).expect("foreign dir"); - fs::write(root.join("Backup-2026-06-02"), "not a dir").expect("backup-like file"); - - let backups = list_backup_dirs(&root); - - // Restore UI must show only installer backup directories, not similarly named files - assert_eq!(backups, vec![root.join("Backup-2026-06-01")]); - let _ = fs::remove_dir_all(&root); -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs deleted file mode 100644 index d61db3053..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/snapshot.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::fs; -use std::os::unix::fs::{symlink, PermissionsExt}; - -use crate::detect::Detection; - -use super::super::snapshot::backup_existing_file; -use super::support::{test_context, test_paths}; - -#[test] -fn backup_snapshot_copies_contents_and_source_mode() { - let root = crate::test_support::fs::unique_temp_path("backup-snapshot-copy"); - let source = root.join("config.toml"); - let backup_dir = root.join("Backup-2026-07-22"); - fs::create_dir_all(&backup_dir).expect("create backup directory"); - fs::write(&source, "private config\n").expect("write source config"); - fs::set_permissions(&source, fs::Permissions::from_mode(0o600)).expect("set source mode"); - let paths = test_paths(&root); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let mut context = test_context(&detection, &paths); - - backup_existing_file(&mut context, &source, "config.toml", Some(&backup_dir)) - .expect("create backup snapshot"); - - let snapshot = backup_dir.join("config.toml"); - assert_eq!( - fs::read_to_string(&snapshot).expect("read backup snapshot"), - "private config\n" - ); - assert_eq!( - fs::metadata(snapshot) - .expect("snapshot metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn backup_snapshot_rejects_destination_symlink_without_changing_target() { - let root = crate::test_support::fs::unique_temp_path("backup-snapshot-symlink"); - let source = root.join("config.toml"); - let backup_dir = root.join("Backup-2026-07-22"); - let protected = root.join("protected"); - fs::create_dir_all(&backup_dir).expect("create backup directory"); - fs::write(&source, "new config\n").expect("write source config"); - fs::write(&protected, "protected\n").expect("write protected file"); - symlink(&protected, backup_dir.join("config.toml")).expect("create snapshot link"); - let paths = test_paths(&root); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let mut context = test_context(&detection, &paths); - - backup_existing_file(&mut context, &source, "config.toml", Some(&backup_dir)) - .expect_err("snapshot destination link should fail"); - - assert_eq!( - fs::read_to_string(&protected).expect("read protected file"), - "protected\n" - ); - assert!(fs::symlink_metadata(backup_dir.join("config.toml")) - .expect("snapshot link remains") - .file_type() - .is_symlink()); - let _ = fs::remove_dir_all(root); -} From e527008db5d178a97ab7d2ad4dcd33d0b7b6f584 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 22:39:12 -0500 Subject: [PATCH 220/275] perf(center): reuse notification presentation during row updates Pass the already-built presentation through visual-state updates so panel rows do not rebuild trust, identity, timestamp, and media decisions. Keep row rendering aligned with the shared presentation computed at bind time. --- .../notifications/row/notification/state.rs | 8 -------- .../row/notification/update/actions.rs | 8 -------- .../row/notification/update/row.rs | 9 ++++++++- .../row/notification/update/tests/actions.rs | 13 ++++++++++-- .../row/notification/update/tests/mod.rs | 3 +-- .../row/notification/update/tests/state.rs | 15 ++++++++++++-- .../notification/update/tests/thumbnail.rs | 20 +++++++++++++++---- .../row/notification/update/thumbnail.rs | 19 ------------------ .../row/notification/update/visual.rs | 2 +- 9 files changed, 50 insertions(+), 47 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 6e148635a..6d67b6c70 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -108,12 +108,4 @@ impl IconSignature { presentation: presentation.identity.badge, } } - - #[cfg(test)] - pub(super) fn from(notification: &NotificationView) -> Self { - Self::from_presentation( - notification, - &NotificationPresentation::from_view(notification), - ) - } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs index 3e0276fbf..0e29063e5 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -335,14 +335,6 @@ fn build_overflow_menu( menu } -#[cfg(test)] -pub(super) fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { - visible_action_count_from( - &NotificationPresentation::from_view(notification), - is_active, - ) -} - pub(super) fn visible_action_count_from( presentation: &NotificationPresentation, is_active: bool, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 5b7763469..da65f22a1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -114,7 +114,14 @@ pub(in crate::ui::notifications) fn update_notification_row( let has_thumbnail = data.presentation.show_thumbnail && (has_content_thumbnail || has_conversation_avatar || has_sender_visual); - apply_visual_state(row, data, notification, has_actions, has_thumbnail); + apply_visual_state( + row, + data, + notification, + &presentation, + has_actions, + has_thumbnail, + ); update_notification_text( row, &presentation.identity.primary_label, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs index df96f11bf..443f7cd3e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -3,7 +3,8 @@ use std::rc::Rc; use gtk::prelude::*; -use unixnotis_core::{hooks, Action, ApplicationActionPolicy, InlineReply}; +use unixnotis_core::{hooks, Action, ApplicationActionPolicy, InlineReply, NotificationView}; +use unixnotis_ui::presentation::NotificationPresentation; use crate::control::UiCommand; use crate::ui::icons::IconResolver; @@ -11,7 +12,15 @@ use crate::ui::icons::IconResolver; use super::super::super::test_support::{ child_count, notification_row, row_data, sample_notification, RowFlags, }; -use super::{update_notification_row, visible_action_count}; +use super::update_notification_row; + +fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { + // Test the same presentation-derived count used by the production row update + super::super::actions::visible_action_count_from( + &NotificationPresentation::from_view(notification), + is_active, + ) +} #[gtk::test] fn update_notification_row_rebuilds_actions_only_when_signature_changes() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs index db3a8c1a0..c0e54cba4 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -6,10 +6,9 @@ mod metadata; mod state; mod thumbnail; -pub(super) use super::actions::{clamp_action_label_text, visible_action_count}; +pub(super) use super::actions::clamp_action_label_text; pub(super) use super::labels::optional_label_state; pub(super) use super::metadata::{ notification_meta_label, relative_time_badge, relative_time_badge_at, }; pub(super) use super::row::{clear_notification_row, update_notification_row}; -pub(super) use super::thumbnail::notification_has_thumbnail; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index ca4b7f146..e0b83b02a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use gtk::prelude::*; use unixnotis_core::{hooks, Action, CutCorners, NotificationMetadataConfig, Urgency}; +use unixnotis_ui::presentation::NotificationPresentation; use crate::ui::icons::IconResolver; @@ -14,6 +15,14 @@ use super::super::super::test_support::{ }; use super::{clear_notification_row, update_notification_row}; +fn icon_signature(notification: &unixnotis_core::NotificationView) -> IconSignature { + // Test-only construction stays beside the tests while production consumes a shared presentation + IconSignature::from_presentation( + notification, + &NotificationPresentation::from_view(notification), + ) +} + #[test] fn icon_signature_changes_when_trust_presentation_changes() { let verified = sample_notification(); @@ -24,8 +33,8 @@ fn icon_signature_changes_when_trust_presentation_changes() { suspicious.attribution.interactions = unixnotis_core::InteractionPolicies::DENY; assert_ne!( - IconSignature::from(&verified), - IconSignature::from(&suspicious), + icon_signature(&verified), + icon_signature(&suspicious), "trust changes must refresh a recycled row badge" ); } @@ -142,6 +151,8 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row.header.get_visible()); assert!(row.urgency_badge.get_visible()); assert_eq!(row.urgency_badge.text().as_str(), "Critical"); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); assert_eq!(row.app_label.text().as_str(), "demo"); assert_eq!(row.summary_label.text().as_str(), "summary"); assert_eq!(row.body_label.text().as_str(), "body"); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index 78a876213..b3f176cde 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -4,16 +4,28 @@ use std::rc::Rc; use gtk::prelude::*; use unixnotis_core::{hooks, ImageData}; +use unixnotis_ui::presentation::NotificationPresentation; use crate::ui::icons::IconResolver; use super::super::super::test_support::{ notification_row, row_data, sample_notification, RowFlags, }; -use super::super::thumbnail::{ - notification_has_conversation_avatar, notification_has_sender_visual, -}; -use super::{notification_has_thumbnail, update_notification_row}; +use super::super::thumbnail::{has_content_thumbnail, has_conversation_avatar, has_sender_visual}; +use super::update_notification_row; + +fn notification_has_thumbnail(notification: &unixnotis_core::NotificationView) -> bool { + // Keep presentation construction in the mirrored test helper, not production code + has_content_thumbnail(&NotificationPresentation::from_view(notification)) +} + +fn notification_has_conversation_avatar(notification: &unixnotis_core::NotificationView) -> bool { + has_conversation_avatar(&NotificationPresentation::from_view(notification)) +} + +fn notification_has_sender_visual(notification: &unixnotis_core::NotificationView) -> bool { + has_sender_visual(&NotificationPresentation::from_view(notification)) +} #[test] fn notification_thumbnail_only_uses_real_image_sources() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index 85b2f7770..77cabb04c 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -24,22 +24,3 @@ pub(super) const fn has_sender_visual(presentation: &NotificationPresentation) - SenderVisualPresentation::ApplicationProvidedIcon ) } - -#[cfg(test)] -pub(super) fn notification_has_thumbnail(notification: &unixnotis_core::NotificationView) -> bool { - has_content_thumbnail(&NotificationPresentation::from_view(notification)) -} - -#[cfg(test)] -pub(super) fn notification_has_conversation_avatar( - notification: &unixnotis_core::NotificationView, -) -> bool { - has_conversation_avatar(&NotificationPresentation::from_view(notification)) -} - -#[cfg(test)] -pub(super) fn notification_has_sender_visual( - notification: &unixnotis_core::NotificationView, -) -> bool { - has_sender_visual(&NotificationPresentation::from_view(notification)) -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 241383fae..ba82ad784 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -13,11 +13,11 @@ pub(super) fn apply_visual_state( row: &NotificationRowWidgets, data: &RowData, notification: &NotificationView, + presentation: &NotificationPresentation, has_actions: bool, has_thumbnail: bool, ) { let card = &row.card; - let presentation = NotificationPresentation::from_view(notification); let is_critical = notification.urgency == Urgency::Critical as u8; // Theme changes update recycled rows without rebuilding the GTK child tree row.card_plate.set_corners(card_corners_for_row(data)); From e388472e41559f5b52bb193567ebb79dd971bc5c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 2 Aug 2026 22:56:59 -0500 Subject: [PATCH 221/275] style(ui): compact grouped cards and decorative visuals Keep panel and popup notification surfaces aligned around a message-first hierarchy. Grouped child rows remove redundant identity spacing, popup metadata uses a measured trailing lane, and sender-provided visuals remain decorative and distinct from trusted identity badges. Add GTK and CSS regressions for compact grouped rows and the shared presentation contract. --- .../row/notification/update/tests/state.rs | 23 +++++++++++++++++++ .../row/notification/update/visual.rs | 12 +++++----- crates/unixnotis-core/assets/panel.css | 15 ++++++++++++ crates/unixnotis-core/assets/popup.css | 11 ++++++++- .../unixnotis-core/src/css/tokens/layout.rs | 2 +- .../src/css/tokens/tests/modern.rs | 1 + .../src/ui/entry/builders/common.rs | 2 ++ .../src/ui/entry/builders/mod.rs | 8 +++++-- crates/unixnotis-popups/src/ui/icon_state.rs | 4 +++- .../src/ui/state/tests/mutation.rs | 22 ++++++++++++++++++ 10 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index e0b83b02a..13b40460b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -149,6 +149,7 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(!row.app_label.get_visible()); assert!(!row.icon.get_visible()); assert!(row.header.get_visible()); + assert_eq!(row.card.spacing(), 2); assert!(row.urgency_badge.get_visible()); assert_eq!(row.urgency_badge.text().as_str(), "Critical"); assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); @@ -188,6 +189,7 @@ fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { assert!(row.app_label.get_visible()); assert!(row.header.get_visible()); assert!(row.close_button.get_visible()); + assert_eq!(row.card.spacing(), 6); assert_eq!(row.app_label.text().as_str(), "demo"); assert!(row.icon_sig.borrow().is_some()); } @@ -253,6 +255,27 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { assert!(row.close_button.get_visible()); } +#[gtk::test] +fn expanded_group_rows_keep_the_message_first_compact_lane() { + let (_root, row) = notification_row(); + let data = row_data( + Rc::new(sample_notification()), + RowFlags { + collapsed_group_preview: false, + ..Default::default() + }, + ); + let mut data = data; + data.expanded = true; + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.app_label.get_visible()); + assert!(row.card.has_css_class("group-owned-identity")); + assert_eq!(row.card.spacing(), 2); +} + #[gtk::test] fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { let (root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index ba82ad784..b153ebe5d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -19,6 +19,10 @@ pub(super) fn apply_visual_state( ) { let card = &row.card; let is_critical = notification.urgency == Urgency::Critical as u8; + // Expanded children also sit below the group header, so they use the same compact lane + let group_owns_identity = data.collapsed_group_preview || data.expanded; + // Removing the hidden identity row also removes its old inter-row breathing room + card.set_spacing(if group_owns_identity { 2 } else { 6 }); // Theme changes update recycled rows without rebuilding the GTK child tree row.card_plate.set_corners(card_corners_for_row(data)); // Explicit state updates prevent recycled rows from retaining stale classes @@ -60,12 +64,8 @@ pub(super) fn apply_visual_state( set_class_state(card, hooks::panel_card::GROUPED, grouped); set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); // Group headers own identity details while child rows stay message-first - set_class_state(card, "group-owned-identity", grouped && !data.expanded); - set_class_state( - &row.card_plate, - "group-owned-identity", - grouped && !data.expanded, - ); + set_class_state(card, "group-owned-identity", group_owns_identity); + set_class_state(&row.card_plate, "group-owned-identity", group_owns_identity); set_class_state( card, hooks::panel_card::HAS_SUMMARY, diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index c28dc617c..b48c0da53 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -525,6 +525,21 @@ entry selection { transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } +/* Group headers already carry identity, so child cards use a compact metadata lane */ +.unixnotis-panel-card.group-owned-identity { + padding-top: 6px; + padding-bottom: 8px; +} + +.unixnotis-panel-card.group-owned-identity .unixnotis-panel-card-header { + margin-bottom: -6px; +} + +.unixnotis-panel-card.group-owned-identity .unixnotis-panel-close { + min-width: 24px; + min-height: 24px; +} + .unixnotis-panel-card-thumbnail { border-radius: 10px; background: alpha(@unixnotis-surface-strong-base, 0.42); diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 057d4fed4..c1a150f4d 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -141,11 +141,20 @@ } .unixnotis-popup-application-visual { - border-radius: 10px; + border-radius: 9px; opacity: 0.92; box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); } +/* Decorative sender art stays subordinate to the message and trusted badge */ +.unixnotis-popup-sender-visual { + min-width: 38px; + min-height: 38px; + margin-top: 4px; + border-radius: 9px; + background: alpha(#000000, 0.10); +} + .unixnotis-identity-avatar { min-width: 34px; min-height: 34px; diff --git a/crates/unixnotis-core/src/css/tokens/layout.rs b/crates/unixnotis-core/src/css/tokens/layout.rs index 8e72e7491..6a78ea0e2 100644 --- a/crates/unixnotis-core/src/css/tokens/layout.rs +++ b/crates/unixnotis-core/src/css/tokens/layout.rs @@ -26,7 +26,7 @@ pub(super) const fn layout_tokens() -> &'static [(&'static str, &'static str)] { ("--unixnotis-notification-action-padding-x", "10px"), ("--unixnotis-popup-stack-padding", "8px"), ("--unixnotis-popup-card-radius", "18px"), - ("--unixnotis-popup-card-padding-y", "12px"), + ("--unixnotis-popup-card-padding-y", "10px"), ("--unixnotis-popup-card-padding-x", "14px"), ("--unixnotis-popup-actions-gap", "6px"), ("--unixnotis-popup-close-size", "24px"), diff --git a/crates/unixnotis-core/src/css/tokens/tests/modern.rs b/crates/unixnotis-core/src/css/tokens/tests/modern.rs index b3f9c1e44..718af650d 100644 --- a/crates/unixnotis-core/src/css/tokens/tests/modern.rs +++ b/crates/unixnotis-core/src/css/tokens/tests/modern.rs @@ -19,6 +19,7 @@ fn modern_theme_custom_properties_stay_additive() { "--unixnotis-card-radius: 12px;", "--unixnotis-notification-card-radius: 12px;", "--unixnotis-panel-card-padding-y: 9px;", + "--unixnotis-popup-card-padding-y: 10px;", "--unixnotis-popup-reveal-duration: 200ms;", "--unixnotis-media-card-radius: 18px;", "--unixnotis-media-title-font-size: 13px;", diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 497c5bb96..75d95191a 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -97,6 +97,8 @@ pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeade trailing.add_css_class("unixnotis-popup-trailing"); trailing.set_halign(Align::End); trailing.set_valign(Align::Start); + // Reserve one measured lane for time and urgency beside the overlaid close control + trailing.set_size_request(42, -1); trailing.set_margin_end(30); let time = gtk::Label::new(Some(&view.timestamp_label)); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 59aa5a62f..e4b5b3db2 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -62,9 +62,13 @@ pub(super) fn append_thumbnail( return false; } - // Content images stay bounded and visually separate from the application badge + // Keep decorative sender art compact while content media gets the larger image treatment image.set_halign(gtk::Align::Start); - image.add_css_class("unixnotis-popup-content-image"); + if is_application_visual { + image.add_css_class("unixnotis-popup-sender-visual"); + } else { + image.add_css_class("unixnotis-popup-content-image"); + } content.append(&image); true } diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs index 892ffde3d..f1847a33b 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icon_state.rs @@ -23,6 +23,8 @@ const ICON_CACHE_MAX_ENTRIES: usize = 256; const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1_048_576; // Content stays visibly separate from the daemon-associated application badge const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 64; +// Decorative sender art is a small context cue, not the notification identity +const POPUP_APPLICATION_VISUAL_SIZE: i32 = 38; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); @@ -56,7 +58,7 @@ impl UiState { } let texture = image_data_texture_for_data(¬ification.image.sender_visual)?; let widget = gtk::Image::from_paintable(Some(&texture)); - set_popup_icon_size(&widget, POPUP_CONTENT_THUMBNAIL_SIZE); + set_popup_icon_size(&widget, POPUP_APPLICATION_VISUAL_SIZE); widget.add_css_class("unixnotis-popup-application-visual"); Some(widget) } diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 74d58ec69..1ca80d6a1 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -125,6 +125,28 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { // A daemon-selected badge remains independent from caller image content missing_content.attribution.badge_icon = "dialog-information".to_string(); assert!(state.build_app_icon_widget(&missing_content, 20).is_some()); + + let mut decorative = notification(10, 1, "decorative"); + decorative.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + decorative.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 32, 32, 255], + }; + let decorative_root = state.build_popup_root(&decorative); + assert!(descendant_has_class( + decorative_root.upcast_ref(), + "unixnotis-popup-sender-visual" + )); + assert!(!descendant_has_class( + decorative_root.upcast_ref(), + "unixnotis-popup-content-image" + )); } #[gtk::test] From 12e7918abaa643b32d75b58dae44f25f0564ee2c Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 01:47:27 -0500 Subject: [PATCH 222/275] fix(theme): restore unconditional file-backed CSS loading Load configured stylesheet layers on every startup and reload, ignore the retired runtime mode key, and fall back through the bounded provider path when a file cannot be used. Keep the shared CSS size limit and reload reports aligned with the file-backed contract. --- .../src/config/appearance/theme.rs | 21 ---- .../src/config/loading/io/mod.rs | 2 - .../src/config/loading/io/tests/load.rs | 20 ++++ .../src/config/loading/io/tests/mod.rs | 1 - .../src/config/loading/io/tests/theme_mode.rs | 85 -------------- .../src/config/loading/io/theme_mode.rs | 66 ----------- crates/unixnotis-core/src/config/mod.rs | 4 +- .../src/config/validation/schema.rs | 6 +- crates/unixnotis-core/src/css/limits.rs | 4 + crates/unixnotis-core/src/css/mod.rs | 3 + crates/unixnotis-ui/src/css/loader/mod.rs | 2 +- crates/unixnotis-ui/src/css/loader/model.rs | 11 +- .../unixnotis-ui/src/css/loader/provider.rs | 33 ++---- .../src/css/loader/tests/provider.rs | 101 +++++++++++++++++ crates/unixnotis-ui/src/css/manager/report.rs | 2 - .../src/css/manager/stack/model.rs | 20 +--- .../src/css/manager/stack/reload.rs | 41 +------ .../src/css/manager/stack/tests/reload.rs | 106 +++++++++++++----- .../src/css/manager/tests/report.rs | 2 +- 19 files changed, 232 insertions(+), 298 deletions(-) delete mode 100644 crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs delete mode 100644 crates/unixnotis-core/src/config/loading/io/theme_mode.rs create mode 100644 crates/unixnotis-core/src/css/limits.rs diff --git a/crates/unixnotis-core/src/config/appearance/theme.rs b/crates/unixnotis-core/src/config/appearance/theme.rs index b5fc6502b..08bd8c47a 100644 --- a/crates/unixnotis-core/src/config/appearance/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/theme.rs @@ -4,29 +4,9 @@ use serde::{Deserialize, Serialize}; use super::corners::CutCorners; -#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ThemeMode { - #[default] - Stock, - Custom, -} - -impl ThemeMode { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Stock => "stock", - Self::Custom => "custom", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ThemeConfig { - /// Embedded stock or explicitly enabled versioned custom CSS - pub mode: ThemeMode, #[serde(alias = "style_css")] pub base_css: String, pub popup_css: String, @@ -59,7 +39,6 @@ mod tests; impl Default for ThemeConfig { fn default() -> Self { Self { - mode: ThemeMode::Stock, base_css: "base.css".to_string(), popup_css: "popup.css".to_string(), panel_css: "panel.css".to_string(), diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs index 1b757038e..150406e91 100644 --- a/crates/unixnotis-core/src/config/loading/io/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -6,7 +6,6 @@ mod paths; mod script_migrations; mod scripts; mod theme_contract; -mod theme_mode; pub use error::ConfigError; pub use load::MAX_CONFIG_BYTES; @@ -14,7 +13,6 @@ pub use paths::ThemePaths; pub use theme_contract::{ ThemeContractState, ThemeIncompatibility, ThemeManifest, THEME_API_VERSION, }; -pub use theme_mode::{persist_theme_mode, ThemeModeWriteError}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/load.rs b/crates/unixnotis-core/src/config/loading/io/tests/load.rs index bbd33990a..fbe4b53b6 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/load.rs @@ -69,6 +69,26 @@ fn parse_returns_the_config_produced_by_the_report_pipeline() { assert_eq!(config.panel.title, "Parsed Title"); } +#[test] +fn legacy_theme_mode_is_ignored_and_default_rendering_omits_it() { + let report = Config::parse_with_report( + r#" + [theme] + mode = "stock" + popup_css = "popup.css" + "#, + ) + .expect("legacy theme mode should be ignored"); + + assert_eq!(report.config.theme.popup_css, "popup.css"); + assert!(!report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "config.unknown-key" && diagnostic.path.as_deref() == Some("theme.mode") + })); + let rendered = toml::to_string_pretty(&Config::default()).expect("default config renders"); + assert!(!rendered.contains("mode = \"stock\"")); + assert!(!rendered.contains("mode = \"custom\"")); +} + #[test] fn sound_file_hints_require_explicit_configuration() { let defaults = Config::parse("").expect("default config should parse"); diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs index a462c3256..0a38534f1 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -7,4 +7,3 @@ mod script_migrations; mod scripts; mod support; mod theme_contract; -mod theme_mode; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs deleted file mode 100644 index 75d9b6d58..000000000 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_mode.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Explicit theme-source persistence tests - -use std::fs; -use std::os::unix::fs::symlink; - -use crate::{persist_theme_mode, Config, ThemeMode}; - -use super::support::test_root; - -#[test] -fn persist_theme_mode_updates_only_the_existing_theme_mode() { - let root = test_root("theme-mode-update"); - fs::create_dir_all(&root).expect("test directory should be created"); - let path = root.join("config.toml"); - let original = "# retained heading\n[theme]\n# retained mode comment\nmode = \"custom\"\nbase_css = \"personal.css\"\n\n[panel]\nwidth = 418\n"; - fs::write(&path, original).expect("test config should be written"); - - persist_theme_mode(&path, ThemeMode::Stock).expect("theme mode should be persisted"); - - let contents = fs::read_to_string(&path).expect("updated config should be readable"); - assert!(contents.contains("# retained heading")); - assert!(contents.contains("# retained mode comment")); - assert!(contents.contains("base_css = \"personal.css\"")); - assert!(contents.contains("width = 418")); - assert_eq!( - Config::load_from_path(&path) - .expect("updated config should remain valid") - .theme - .mode, - ThemeMode::Stock - ); - fs::remove_dir_all(root).expect("test directory should be removable"); -} - -#[test] -fn persist_theme_mode_creates_a_theme_table_when_it_is_absent() { - let root = test_root("theme-mode-create"); - fs::create_dir_all(&root).expect("test directory should be created"); - let path = root.join("config.toml"); - fs::write(&path, "[panel]\nwidth = 419\n").expect("test config should be written"); - - persist_theme_mode(&path, ThemeMode::Custom).expect("theme table should be created"); - - let config = Config::load_from_path(&path).expect("updated config should remain valid"); - assert_eq!(config.theme.mode, ThemeMode::Custom); - assert_eq!(config.panel.width, 419); - fs::remove_dir_all(root).expect("test directory should be removable"); -} - -#[test] -fn persist_theme_mode_rejects_invalid_config_without_replacing_it() { - let root = test_root("theme-mode-invalid"); - fs::create_dir_all(&root).expect("test directory should be created"); - let path = root.join("config.toml"); - let invalid = b"[theme\nmode = \"custom\"\n"; - fs::write(&path, invalid).expect("invalid test config should be written"); - - persist_theme_mode(&path, ThemeMode::Stock).expect_err("invalid config must not be replaced"); - - assert_eq!( - fs::read(&path).expect("invalid config should remain readable"), - invalid - ); - fs::remove_dir_all(root).expect("test directory should be removable"); -} - -#[test] -fn persist_theme_mode_rejects_a_symlink_without_touching_its_target() { - let root = test_root("theme-mode-symlink"); - fs::create_dir_all(&root).expect("test directory should be created"); - let outside = root.join("outside.toml"); - let path = root.join("config.toml"); - let original = b"[theme]\nmode = \"custom\"\n"; - fs::write(&outside, original).expect("outside config should be written"); - symlink(&outside, &path).expect("config symlink should be created"); - - persist_theme_mode(&path, ThemeMode::Stock) - .expect_err("theme mode persistence must reject symbolic links"); - - assert_eq!( - fs::read(&outside).expect("outside config should remain readable"), - original - ); - fs::remove_dir_all(root).expect("test directory should be removable"); -} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_mode.rs b/crates/unixnotis-core/src/config/loading/io/theme_mode.rs deleted file mode 100644 index 6e86ba80c..000000000 --- a/crates/unixnotis-core/src/config/loading/io/theme_mode.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Atomic persistence for the explicit theme source - -use std::path::Path; - -use thiserror::Error; -use toml_edit::{value, DocumentMut, Item, Table}; - -use crate::filesystem::{read_regular_file_bounded, write_file_atomic_preserving_mode}; -use crate::{Config, ThemeMode}; - -use super::MAX_CONFIG_BYTES; - -#[derive(Debug, Error)] -pub enum ThemeModeWriteError { - #[error("read config: {0}")] - Read(std::io::Error), - #[error("config is not valid UTF-8")] - Encoding, - #[error("config is invalid: {0}")] - InvalidConfig(String), - #[error("config document is invalid: {0}")] - InvalidDocument(toml_edit::TomlError), - #[error("theme section is not a table")] - InvalidThemeSection, - #[error("write config: {0}")] - Write(std::io::Error), -} - -impl ThemeModeWriteError { - #[must_use] - pub const fn kind(&self) -> &'static str { - match self { - Self::Read(_) => "read", - Self::Encoding => "encoding", - Self::InvalidConfig(_) => "invalid-config", - Self::InvalidDocument(_) => "invalid-document", - Self::InvalidThemeSection => "invalid-theme-section", - Self::Write(_) => "write", - } - } -} - -/// Persist the theme source while retaining unrelated TOML formatting -/// -/// # Errors -/// -/// Returns an error when the existing config is unsafe, invalid, or cannot be replaced atomically -pub fn persist_theme_mode(path: &Path, mode: ThemeMode) -> Result<(), ThemeModeWriteError> { - let bytes = - read_regular_file_bounded(path, MAX_CONFIG_BYTES).map_err(ThemeModeWriteError::Read)?; - let contents = std::str::from_utf8(&bytes).map_err(|_error| ThemeModeWriteError::Encoding)?; - Config::parse_with_report(contents) - .map_err(|error| ThemeModeWriteError::InvalidConfig(error.to_string()))?; - let mut document = contents - .parse::() - .map_err(ThemeModeWriteError::InvalidDocument)?; - if !document.as_table().contains_key("theme") { - document["theme"] = Item::Table(Table::new()); - } - let Some(theme) = document["theme"].as_table_mut() else { - return Err(ThemeModeWriteError::InvalidThemeSection); - }; - theme["mode"] = value(mode.as_str()); - write_file_atomic_preserving_mode(path, document.to_string().as_bytes(), 0o600) - .map_err(ThemeModeWriteError::Write) -} diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 910b7cf44..1267a6149 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -32,8 +32,8 @@ pub use installer_settings::{ InstallerConfig, DEFAULT_BACKUP_RETENTION, INSTALLER_CONFIG_FILE, }; pub use io::{ - persist_theme_mode, ConfigError, ThemeContractState, ThemeIncompatibility, ThemeManifest, - ThemeModeWriteError, ThemePaths, MAX_CONFIG_BYTES, THEME_API_VERSION, + ConfigError, ThemeContractState, ThemeIncompatibility, ThemeManifest, ThemePaths, + MAX_CONFIG_BYTES, THEME_API_VERSION, }; pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index bf12712ad..c9efbbcc7 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -32,7 +32,11 @@ pub(in crate::config) fn deserialize_config_with_migrations( let deserializer = document.into_deserializer(); // Unknown fields are collected without weakening normal serde type validation let mut config: Config = serde_ignored::deserialize(deserializer, |path| { - ignored_keys.push(path.to_string()); + // V7 wrote a runtime-only mode that is intentionally obsolete now + let path = path.to_string(); + if path != "theme.mode" { + ignored_keys.push(path); + } }) .map_err(|err| err.to_string())?; diff --git a/crates/unixnotis-core/src/css/limits.rs b/crates/unixnotis-core/src/css/limits.rs new file mode 100644 index 000000000..e64464040 --- /dev/null +++ b/crates/unixnotis-core/src/css/limits.rs @@ -0,0 +1,4 @@ +//! Shared limits for CSS files read by runtime and preset tooling + +/// Maximum size of one configured CSS stylesheet +pub const MAX_CSS_FILE_BYTES: u64 = 16_777_216; diff --git a/crates/unixnotis-core/src/css/mod.rs b/crates/unixnotis-core/src/css/mod.rs index 72fb02484..51d0c68ac 100644 --- a/crates/unixnotis-core/src/css/mod.rs +++ b/crates/unixnotis-core/src/css/mod.rs @@ -10,11 +10,14 @@ pub mod references; pub mod tokens; // URI byte checks are shared by import policy and runtime rebasing mod uri; +// Keep the shared CSS-size policy separate from this module façade +mod limits; pub use self::features::{ gtk_css_features_for_version, gtk_css_features_from_version_string, GtkCssFeatures, GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, }; +pub use self::limits::MAX_CSS_FILE_BYTES; pub use self::references::{ collect_css_import_dependency_values, collect_css_import_url_spans, collect_css_import_values, collect_css_url_spans, collect_css_url_values, CssImportReference, CssReference, diff --git a/crates/unixnotis-ui/src/css/loader/mod.rs b/crates/unixnotis-ui/src/css/loader/mod.rs index 785f8f1f2..6b502d849 100644 --- a/crates/unixnotis-ui/src/css/loader/mod.rs +++ b/crates/unixnotis-ui/src/css/loader/mod.rs @@ -7,7 +7,7 @@ mod tokens; mod urls; pub(super) use model::{CssFileLoadResult, CssFileLoadSource}; -pub(super) use provider::{load_embedded_provider_with_overrides, load_provider_with_overrides}; +pub(super) use provider::load_provider_with_overrides; #[cfg(test)] #[path = "tests/provider.rs"] diff --git a/crates/unixnotis-ui/src/css/loader/model.rs b/crates/unixnotis-ui/src/css/loader/model.rs index a213d3d08..b17ea323d 100644 --- a/crates/unixnotis-ui/src/css/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/model.rs @@ -3,9 +3,7 @@ /// Source used for the CSS bytes passed to GTK #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(in crate::css) enum CssFileLoadSource { - /// Embedded stock CSS was selected by the versioned theme contract - EmbeddedStock, - /// Non-empty custom CSS was read from disk + /// Non-empty configured CSS was read from disk Custom, /// An intentionally empty file used embedded defaults EmptyFallback, @@ -21,13 +19,6 @@ pub(in crate::css) struct CssFileLoadResult { } impl CssFileLoadResult { - pub(in crate::css) const fn embedded_stock() -> Self { - Self { - source: CssFileLoadSource::EmbeddedStock, - error: None, - } - } - pub(in crate::css) const fn custom() -> Self { Self { source: CssFileLoadSource::Custom, diff --git a/crates/unixnotis-ui/src/css/loader/provider.rs b/crates/unixnotis-ui/src/css/loader/provider.rs index f23f9803f..646bb8e8b 100644 --- a/crates/unixnotis-ui/src/css/loader/provider.rs +++ b/crates/unixnotis-ui/src/css/loader/provider.rs @@ -1,37 +1,16 @@ //! CSS provider loading with explicit fallback outcomes -use std::fs; +use std::io; use std::path::Path; use tracing::warn; +use unixnotis_core::{filesystem::read_regular_file_bounded, MAX_CSS_FILE_BYTES}; use super::merge::merge_css_with_overrides; use super::model::CssFileLoadResult; use super::tokens::ensure_base_tokens; use super::urls::rebase_relative_css_asset_urls; -/// Load the embedded layer without consulting a configured stylesheet -pub fn load_embedded_provider_with_overrides( - load_css_data: impl Fn(&str), - path: &Path, - fallback: &str, - overrides: &str, - inject_base_tokens: bool, -) -> CssFileLoadResult { - let fallback = if inject_base_tokens { - ensure_base_tokens(fallback, path) - } else { - fallback.to_string() - }; - let merged = if overrides.trim().is_empty() { - fallback - } else { - format!("{fallback}\n{overrides}") - }; - load_css_data(&rebase_relative_css_asset_urls(&merged, path)); - CssFileLoadResult::embedded_stock() -} - /// Load CSS into a provider, applying overrides and falling back to defaults pub fn load_provider_with_overrides( load_css_data: impl Fn(&str), @@ -40,7 +19,7 @@ pub fn load_provider_with_overrides( overrides: &str, inject_base_tokens: bool, ) -> CssFileLoadResult { - match fs::read_to_string(path) { + match read_runtime_css(path) { Ok(contents) => { let contents = if inject_base_tokens { ensure_base_tokens(&contents, path) @@ -86,3 +65,9 @@ pub fn load_provider_with_overrides( } } } + +/// Read one configured stylesheet through the shared no-follow regular-file boundary +fn read_runtime_css(path: &Path) -> io::Result { + let bytes = read_regular_file_bounded(path, MAX_CSS_FILE_BYTES)?; + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} diff --git a/crates/unixnotis-ui/src/css/loader/tests/provider.rs b/crates/unixnotis-ui/src/css/loader/tests/provider.rs index d2924ecd8..f3b69f789 100644 --- a/crates/unixnotis-ui/src/css/loader/tests/provider.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/provider.rs @@ -4,6 +4,7 @@ use std::cell::RefCell; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; +use unixnotis_core::MAX_CSS_FILE_BYTES; use super::*; @@ -54,3 +55,103 @@ fn load_provider_with_overrides_loads_merged_and_rebased_css_into_sink() { fs::remove_dir_all(root).expect("remove css test directory"); } + +#[test] +fn empty_css_uses_the_embedded_fallback() { + let root = unique_css_test_dir("empty"); + let path = root.join("popup.css"); + fs::write(&path, "\n \t").expect("write empty stylesheet"); + let loaded = RefCell::new(Vec::new()); + + let result = load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback { color: red; }", + "", + false, + ); + + assert_eq!(result.source, CssFileLoadSource::EmptyFallback); + assert_eq!(loaded.borrow().as_slice(), [".fallback { color: red; }"]); + fs::remove_dir_all(root).expect("remove css test directory"); +} + +#[test] +fn unsafe_or_invalid_css_files_use_the_embedded_fallback() { + let root = unique_css_test_dir("unsafe"); + let fallback = ".fallback { color: red; }"; + let cases = [ + "missing", + "invalid-utf8", + "directory", + "symlink", + "oversized", + ]; + + for case in cases { + let path = root.join(case); + match case { + "missing" => {} + "invalid-utf8" => fs::write(&path, [0xff, 0xfe]).expect("write invalid CSS"), + "directory" => fs::create_dir(&path).expect("create CSS directory"), + "symlink" => { + let target = root.join("symlink-target.css"); + fs::write(&target, ".target { color: blue; }").expect("write symlink target"); + std::os::unix::fs::symlink(&target, &path).expect("create CSS symlink"); + } + "oversized" => { + let file = fs::File::create(&path).expect("create oversized CSS"); + file.set_len(MAX_CSS_FILE_BYTES + 1) + .expect("make CSS file oversized"); + } + _ => unreachable!("all cases are covered above"), + } + + let loaded = RefCell::new(Vec::new()); + let result = load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + fallback, + "", + false, + ); + + assert_eq!( + result.source, + CssFileLoadSource::ReadFailureFallback, + "{case}" + ); + assert_eq!(loaded.borrow().as_slice(), [fallback], "{case}"); + } + + fs::remove_dir_all(root).expect("remove css test directory"); +} + +#[test] +fn configured_css_reload_replaces_the_previous_contents() { + let root = unique_css_test_dir("reload"); + let path = root.join("popup.css"); + let loaded = RefCell::new(Vec::new()); + + fs::write(&path, ".popup { color: red; }").expect("write first stylesheet"); + load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback {}", + "", + false, + ); + fs::write(&path, ".popup { color: green; }").expect("write second stylesheet"); + load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback {}", + "", + false, + ); + + let loaded = loaded.borrow(); + assert!(loaded[0].contains("red")); + assert!(loaded[1].contains("green")); + fs::remove_dir_all(root).expect("remove css test directory"); +} diff --git a/crates/unixnotis-ui/src/css/manager/report.rs b/crates/unixnotis-ui/src/css/manager/report.rs index 81d0814cf..86bf053a3 100644 --- a/crates/unixnotis-ui/src/css/manager/report.rs +++ b/crates/unixnotis-ui/src/css/manager/report.rs @@ -7,8 +7,6 @@ use super::layers::CssProviderLayer; /// Source used for one active CSS layer #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CssLayerSource { - /// Embedded stock selected before any custom stylesheet was read - EmbeddedStock, /// Non-empty configured file Custom, /// Embedded defaults selected by an intentionally empty file diff --git a/crates/unixnotis-ui/src/css/manager/stack/model.rs b/crates/unixnotis-ui/src/css/manager/stack/model.rs index 493158986..826527ffa 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/model.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/model.rs @@ -1,7 +1,7 @@ //! CSS stack state and surface-specific construction use gtk::CssProvider; -use unixnotis_core::{ThemeConfig, ThemeContractState, ThemeMode, ThemePaths}; +use unixnotis_core::{ThemeConfig, ThemePaths}; use super::super::provider::CssProviderBackend; @@ -41,12 +41,6 @@ impl CssManager { pub const fn theme_paths(&self) -> &ThemePaths { &self.inner.theme_paths } - - /// Return the source contract selected for the next reload - #[must_use] - pub fn theme_contract(&self) -> ThemeContractState { - self.inner.theme_contract() - } } #[derive(Clone)] @@ -99,15 +93,3 @@ impl CssManagerInner { } } } - -impl

CssManagerInner

-where - P: CssProviderBackend, -{ - pub(super) fn theme_contract(&self) -> ThemeContractState { - match self.theme_config.mode { - ThemeMode::Stock => ThemeContractState::EmbeddedStock, - ThemeMode::Custom => self.theme_paths.inspect_theme_contract(), - } - } -} diff --git a/crates/unixnotis-ui/src/css/manager/stack/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/reload.rs index 344ddc296..f68dd18bc 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/reload.rs @@ -6,8 +6,7 @@ use unixnotis_core::{ }; use super::super::super::loader::{ - load_embedded_provider_with_overrides, load_provider_with_overrides, CssFileLoadResult, - CssFileLoadSource, + load_provider_with_overrides, CssFileLoadResult, CssFileLoadSource, }; use super::super::super::overrides::{ build_base_overrides, build_panel_overrides, build_popup_overrides, build_widgets_overrides, @@ -37,15 +36,13 @@ where { pub(super) fn reload(&self, fallback: &str) -> CssReloadReport { let mut loaded = Vec::new(); - let custom_theme_allowed = self.theme_contract().custom_theme_allowed(); // Structural fallbacks always load below every user-controlled layer self.internal_structure .load_css_data(INTERNAL_STRUCTURE_CSS); // Base variables load before every surface-specific provider let base_overrides = build_base_overrides(&self.theme_config); - let result = load_provider( - custom_theme_allowed, + let result = load_provider_with_overrides( |data| self.base.load_css_data(data), &self.theme_paths.base_css, fallback, @@ -61,8 +58,7 @@ where // Optional providers distinguish panel and popup process layouts if let Some(panel) = self.panel.as_ref() { let panel_overrides = build_panel_overrides(&self.theme_config); - let result = load_provider( - custom_theme_allowed, + let result = load_provider_with_overrides( |data| panel.load_css_data(data), &self.theme_paths.panel_css, DEFAULT_PANEL_CSS, @@ -79,8 +75,7 @@ where // Widget overrides remain isolated from panel structural rules if let Some(widgets) = self.widgets.as_ref() { let widgets_overrides = build_widgets_overrides(&self.theme_config); - let result = load_provider( - custom_theme_allowed, + let result = load_provider_with_overrides( |data| widgets.load_css_data(data), &self.theme_paths.widgets_css, DEFAULT_WIDGETS_CSS, @@ -96,8 +91,7 @@ where // Media has no generated override layer and remains fully theme controlled if let Some(media) = self.media.as_ref() { - let result = load_provider( - custom_theme_allowed, + let result = load_provider_with_overrides( |data| media.load_css_data(data), &self.theme_paths.media_css, DEFAULT_MEDIA_CSS, @@ -114,8 +108,7 @@ where // Popup geometry tokens apply only when the popup provider exists if let Some(popup) = self.popup.as_ref() { let popup_overrides = build_popup_overrides(&self.theme_config); - let result = load_provider( - custom_theme_allowed, + let result = load_provider_with_overrides( |data| popup.load_css_data(data), &self.theme_paths.popup_css, DEFAULT_POPUP_CSS, @@ -145,27 +138,6 @@ where } } -fn load_provider( - custom_theme_allowed: bool, - load_css_data: impl Fn(&str), - path: &std::path::Path, - fallback: &str, - overrides: &str, - inject_base_tokens: bool, -) -> CssFileLoadResult { - if custom_theme_allowed { - load_provider_with_overrides(load_css_data, path, fallback, overrides, inject_base_tokens) - } else { - load_embedded_provider_with_overrides( - load_css_data, - path, - fallback, - overrides, - inject_base_tokens, - ) - } -} - fn layer_reload( layer: CssProviderLayer, path: std::path::PathBuf, @@ -173,7 +145,6 @@ fn layer_reload( ) -> CssLayerReload { // Loader sources map into the stable public report vocabulary let source = match result.source { - CssFileLoadSource::EmbeddedStock => CssLayerSource::EmbeddedStock, CssFileLoadSource::Custom => CssLayerSource::Custom, CssFileLoadSource::EmptyFallback => CssLayerSource::EmptyFallback, CssFileLoadSource::ReadFailureFallback => CssLayerSource::ReadFailureFallback, diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs index 02524662a..a40942801 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs @@ -3,14 +3,17 @@ use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc; +use std::time::Duration; use gtk::gdk; -use unixnotis_core::{ThemeConfig, ThemeMode, ThemePaths, THEME_API_VERSION}; +use unixnotis_core::{ThemeConfig, ThemePaths}; use super::super::model::{CssManager, CssManagerInner}; use crate::css::manager::layers::CssProviderLayer; use crate::css::manager::provider::CssProviderBackend; use crate::css::manager::report::CssLayerSource; +use crate::css::{start_css_watcher, CssKind}; #[derive(Clone)] struct RecordingProvider { @@ -69,11 +72,6 @@ fn write_theme(paths: &ThemePaths, marker: &str) { .expect("widgets css"); fs::write(&paths.media_css, format!(".media {{ color: {marker}; }}")).expect("media css"); fs::write(&paths.popup_css, format!(".popup {{ color: {marker}; }}")).expect("popup css"); - fs::write( - paths.manifest_path(), - format!("api_version = {THEME_API_VERSION}\nname = \"Test theme\"\n"), - ) - .expect("theme manifest"); } #[expect( @@ -84,10 +82,7 @@ fn panel_manager( paths: ThemePaths, loaded: Rc>>, ) -> CssManagerInner { - let theme_config = ThemeConfig { - mode: ThemeMode::Custom, - ..ThemeConfig::default() - }; + let theme_config = ThemeConfig::default(); CssManagerInner { theme_paths: paths, theme_config, @@ -101,30 +96,47 @@ fn panel_manager( } } +fn popup_manager( + paths: ThemePaths, + loaded: Rc>>, +) -> CssManagerInner { + CssManagerInner { + theme_paths: paths, + theme_config: ThemeConfig::default(), + internal_structure: RecordingProvider::new("internal", Rc::clone(&loaded)), + base: RecordingProvider::new("base", Rc::clone(&loaded)), + panel: None, + widgets: None, + media: None, + motion_policy: None, + popup: Some(RecordingProvider::new("popup", loaded)), + } +} + #[test] -fn stock_mode_ignores_compatible_custom_theme_files() { - let root = unique_theme_root("stock-mode"); +fn configured_css_loads_without_a_theme_manifest() { + let root = unique_theme_root("without-manifest"); let paths = theme_paths(&root); let loaded = Rc::new(RefCell::new(Vec::new())); write_theme(&paths, "magenta"); - let mut manager = panel_manager(paths, Rc::clone(&loaded)); - manager.theme_config.mode = ThemeMode::Stock; + let manager = panel_manager(paths, Rc::clone(&loaded)); let report = manager.reload(".fallback { color: red; }"); assert!(report .layers .iter() - .all(|layer| layer.source == CssLayerSource::EmbeddedStock)); + .all(|layer| layer.source == CssLayerSource::Custom)); assert!(loaded .borrow() .iter() - .all(|(_label, css)| !css.contains("magenta"))); - fs::remove_dir_all(root).expect("remove stock mode test root"); + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) + .all(|(_label, css)| css.contains("magenta"))); + fs::remove_dir_all(root).expect("remove css manager test root"); } #[test] -fn incompatible_theme_uses_embedded_stock_without_reading_custom_css() { +fn incompatible_theme_manifest_does_not_block_configured_css() { let root = unique_theme_root("incompatible-theme"); let paths = theme_paths(&root); let loaded = Rc::new(RefCell::new(Vec::new())); @@ -137,12 +149,12 @@ fn incompatible_theme_uses_embedded_stock_without_reading_custom_css() { assert!(report .layers .iter() - .all(|layer| layer.source == CssLayerSource::EmbeddedStock)); + .all(|layer| layer.source == CssLayerSource::Custom)); assert!(loaded .borrow() .iter() .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) - .all(|(_, css)| !css.contains("magenta"))); + .all(|(_, css)| css.contains("magenta"))); fs::remove_dir_all(root).expect("remove incompatible theme test root"); } @@ -202,10 +214,7 @@ fn update_theme_changes_the_paths_used_by_the_next_reload() { write_theme(&new_paths, "blue"); let mut manager = panel_manager(old_paths, Rc::clone(&loaded)); - let theme = ThemeConfig { - mode: ThemeMode::Custom, - ..ThemeConfig::default() - }; + let theme = ThemeConfig::default(); manager.update_theme(new_paths, theme); let report = manager.reload(".fallback { color: red; }"); @@ -232,10 +241,7 @@ fn public_manager_reload_and_theme_update_report_the_applied_stack() { let new_paths = theme_paths(&new_root); write_theme(&old_paths, "red"); write_theme(&new_paths, "blue"); - let theme = ThemeConfig { - mode: ThemeMode::Custom, - ..ThemeConfig::default() - }; + let theme = ThemeConfig::default(); let mut manager = CssManager::new_panel(old_paths, theme.clone()); manager.update_theme(new_paths.clone(), theme); @@ -277,3 +283,47 @@ fn reload_report_distinguishes_empty_and_unreadable_theme_files() { fs::remove_dir_all(root).expect("remove css fallback test root"); } + +#[test] +fn popup_css_watcher_reloads_file_changes_without_a_manifest() { + let root = unique_theme_root("watcher"); + let paths = theme_paths(&root); + // The first load must use the configured file even though no theme manifest exists + fs::write(&paths.popup_css, ".popup { color: red; }").expect("write first popup CSS"); + let loaded = Rc::new(RefCell::new(Vec::new())); + let manager = popup_manager(paths.clone(), Rc::clone(&loaded)); + let initial = manager.reload(".fallback {}"); + assert_eq!( + initial + .layers + .iter() + .find(|layer| layer.layer == CssProviderLayer::Popup) + .expect("popup layer") + .source, + CssLayerSource::Custom + ); + assert!(loaded + .borrow() + .iter() + .any(|(label, css)| *label == "popup" && css.contains("red"))); + + let (reload_tx, reload_rx) = mpsc::channel(); + start_css_watcher(&paths, CssKind::Popup, move || { + let _ = reload_tx.send(()); + }) + .expect("start popup CSS watcher"); + // Atomic replacement mirrors a normal editor save and exercises the directory watcher + let replacement = root.join("popup.css.tmp"); + fs::write(&replacement, ".popup { color: green; }").expect("write replacement popup CSS"); + fs::rename(&replacement, &paths.popup_css).expect("atomically replace popup CSS"); + reload_rx + .recv_timeout(Duration::from_secs(3)) + .expect("watcher should report the popup replacement"); + + manager.reload(".fallback {}"); + assert!(loaded + .borrow() + .iter() + .any(|(label, css)| *label == "popup" && css.contains("green"))); + fs::remove_dir_all(root).expect("remove css manager test directory"); +} diff --git a/crates/unixnotis-ui/src/css/manager/tests/report.rs b/crates/unixnotis-ui/src/css/manager/tests/report.rs index 6933044e6..459fa6897 100644 --- a/crates/unixnotis-ui/src/css/manager/tests/report.rs +++ b/crates/unixnotis-ui/src/css/manager/tests/report.rs @@ -9,7 +9,7 @@ fn read_failures_excludes_custom_and_intentional_empty_fallbacks() { CssLayerReload { layer: CssProviderLayer::Popup, path: PathBuf::from("popup.css"), - source: CssLayerSource::EmbeddedStock, + source: CssLayerSource::Custom, error: None, }, CssLayerReload { From 94fc42bdc64906f42965f880949b3ce94260b57b Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 01:47:59 -0500 Subject: [PATCH 223/275] refactor(theme): decouple export metadata from runtime Keep exported theme manifests available for review and packaging while removing runtime compatibility actions, notices, and styling hooks. Local CSS loading no longer depends on manifest compatibility, and the panel retains only the reload feedback it still uses. --- crates/noticenterctl/src/cli/args.rs | 2 +- crates/noticenterctl/src/cli/command.rs | 2 +- .../src/preset/css_asset_refs/paths.rs | 3 +- crates/noticenterctl/src/theme/export.rs | 4 +- crates/unixnotis-center/src/control/model.rs | 2 - .../src/control/tests/model.rs | 2 - crates/unixnotis-center/src/main.rs | 2 +- crates/unixnotis-center/src/ui/events.rs | 8 - .../src/ui/init/constructor.rs | 9 +- crates/unixnotis-center/src/ui/mod.rs | 1 - .../unixnotis-center/src/ui/panel/notice.rs | 29 +--- .../src/ui/panel/tests/notice.rs | 21 --- .../src/ui/reload/config/flow.rs | 2 - .../src/ui/reload/config/notice.rs | 11 +- .../src/ui/reload/config/tests/notice.rs | 26 +--- .../src/ui/reload/config/tests/support.rs | 15 +- crates/unixnotis-center/src/ui/reload/mod.rs | 2 +- .../unixnotis-center/src/ui/reload/notices.rs | 9 -- .../src/ui/reload/tests/notices.rs | 42 ------ .../src/ui/theme_compatibility/actions.rs | 37 ----- .../src/ui/theme_compatibility/flow.rs | 57 -------- .../src/ui/theme_compatibility/mod.rs | 9 -- .../ui/theme_compatibility/tests/actions.rs | 24 --- .../src/ui/theme_compatibility/tests/flow.rs | 137 ------------------ .../src/ui/theme_compatibility/tests/mod.rs | 2 - .../assets/internal-structure.css | 9 -- crates/unixnotis-core/assets/panel.css | 32 ---- .../config/loading/io/tests/theme_contract.rs | 2 +- .../src/config/loading/io/theme_contract.rs | 21 +-- .../unixnotis-core/src/css/hooks/classes.rs | 3 - .../src/css/hooks/tests/hooks.rs | 3 - .../unixnotis-core/src/embedded/tests/css.rs | 2 - 32 files changed, 23 insertions(+), 507 deletions(-) delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/actions.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/flow.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/mod.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs delete mode 100644 crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index 298952dcf..1312a77b9 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -114,7 +114,7 @@ pub enum PresetCommand { #[derive(Subcommand, Debug)] pub enum ThemeCommand { - // Export editable copies of the embedded stock theme into a new directory + // Export editable copies of the bundled theme into a new directory ExportStock { #[arg(long, value_name = "DIRECTORY")] output: Option, diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index e2758e493..019c3bc7d 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -92,7 +92,7 @@ pub enum Command { #[command(subcommand)] command: PresetCommand, }, - // Export editable stock theme files without changing the active theme mode + // Export editable bundled theme files without changing the active configuration Theme { #[command(subcommand)] command: ThemeCommand, diff --git a/crates/noticenterctl/src/preset/css_asset_refs/paths.rs b/crates/noticenterctl/src/preset/css_asset_refs/paths.rs index 18889ff91..54f2ce441 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/paths.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/paths.rs @@ -6,12 +6,11 @@ use std::path::Path; use anyhow::{Context, Result}; use rustix::fs::{open, Mode, OFlags}; +use unixnotis_core::MAX_CSS_FILE_BYTES; use super::super::config_root::PresetFileSource; use super::super::pathing::normalize_lexical_path; -const MAX_CSS_FILE_BYTES: u64 = 16_777_216; - pub(in crate::preset) fn has_css_extension(path: &Path) -> bool { // CSS-only filtering keeps later URL parsing away from binary assets and config files path.extension() diff --git a/crates/noticenterctl/src/theme/export.rs b/crates/noticenterctl/src/theme/export.rs index 31b383ac6..9ad76c169 100644 --- a/crates/noticenterctl/src/theme/export.rs +++ b/crates/noticenterctl/src/theme/export.rs @@ -1,4 +1,4 @@ -//! Safe export of editable embedded stock theme files +//! Safe export of editable bundled theme files use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -26,7 +26,7 @@ pub(super) fn run(output: Option) -> Result<()> { }; export_stock_theme(&destination)?; crate::output::write_stdout(&format!( - "Exported editable stock theme to {}\nThe active theme mode was not changed.\n", + "Exported editable bundled theme to {}\nRuntime CSS loading remains automatic.\n", destination.display() )) } diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index f1bfc25c6..3189b7352 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -38,8 +38,6 @@ pub enum UiEvent { WidgetsCollapsed(bool), CssReload, ConfigReload, - UseStockTheme, - OpenThemeFolder, } /// Commands sent from GTK handlers to the D-Bus runtime. diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index 9f356b4a5..cb565911d 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -18,8 +18,6 @@ fn dismiss_command_preserves_notification_generation() { fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); assert!(matches!(UiEvent::ConfigReload, UiEvent::ConfigReload)); - assert!(matches!(UiEvent::UseStockTheme, UiEvent::UseStockTheme)); - assert!(matches!(UiEvent::OpenThemeFolder, UiEvent::OpenThemeFolder)); } #[test] diff --git a/crates/unixnotis-center/src/main.rs b/crates/unixnotis-center/src/main.rs index 77c8ad129..66f0872a8 100644 --- a/crates/unixnotis-center/src/main.rs +++ b/crates/unixnotis-center/src/main.rs @@ -70,7 +70,7 @@ fn main() -> Result<()> { let theme_paths = config .resolve_theme_paths_from(&theme_base) .context("resolve theme paths")?; - // Theme discovery is read-only; missing files intentionally select embedded stock CSS + // Theme paths are read-only at startup; missing files use the embedded layer fallback // Built-in defaults can run without the installer, so helper scripts are owned here too Config::ensure_default_scripts_in(&theme_base).context("ensure default scripts")?; diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index 80c8625c4..ca005d55e 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -214,14 +214,6 @@ impl UiState { } } } - UiEvent::UseStockTheme => { - debug!("embedded stock theme requested"); - self.use_stock_theme(); - } - UiEvent::OpenThemeFolder => { - debug!("theme folder requested"); - self.open_theme_folder(); - } } } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index ccb32b57d..42a5eac26 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use unixnotis_core::{Config, PanelDebugLevel}; -use super::super::{hyprland, icons, panel, theme_compatibility, widgets, UiState, UiStateInit}; +use super::super::{hyprland, icons, panel, widgets, UiState, UiStateInit}; use super::builders::{ build_media_widget, build_notification_list, build_widget_sections, has_visible_widget_section, icon_resolver_for_widgets, @@ -54,7 +54,6 @@ impl UiState { init.command_tx.clone(), ); panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); - theme_compatibility::connect_notice_actions(&panel.reload_notice, init.event_tx.clone()); panel::header::search::connect_widget_collapse_toggle( &panel.header.actions.focus_toggle, &panel.sections.widget_revealer, @@ -82,7 +81,7 @@ impl UiState { } // Long-lived state owns every channel, guard, and optional widget built above - let mut state = Self { + Self { config: init.config, config_path: init.config_path, css: init.css, @@ -118,8 +117,6 @@ impl UiState { // Reload notices preserve independent config and CSS failure identities reload_notices: super::super::reload::ReloadNoticeState::default(), _runtime: init.runtime, - }; - state.initialize_theme_compatibility(); - state + } } } diff --git a/crates/unixnotis-center/src/ui/mod.rs b/crates/unixnotis-center/src/ui/mod.rs index 7b091c457..27ff2d753 100644 --- a/crates/unixnotis-center/src/ui/mod.rs +++ b/crates/unixnotis-center/src/ui/mod.rs @@ -14,7 +14,6 @@ mod motion; mod notifications; mod panel; mod state; -mod theme_compatibility; mod widget_builders; mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index 4b22b84ed..8eb58dd34 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -10,13 +10,10 @@ pub(in crate::ui) struct ReloadNoticeWidgets { pub(in crate::ui) shell: gtk::Box, pub(in crate::ui) label: gtk::Label, pub(in crate::ui) close: gtk::Button, - pub(in crate::ui) actions: gtk::Box, - pub(in crate::ui) use_stock_button: gtk::Button, - pub(in crate::ui) open_theme_folder_button: gtk::Button, } pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { - // The outer column keeps compatibility choices below the compact status message + // The outer column keeps the status message independent from the panel contents let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); shell.add_css_class(hooks::panel_shell::RELOAD_NOTICE); shell.set_hexpand(true); @@ -42,20 +39,6 @@ pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { content.append(&close); shell.append(&content); - // Theme compatibility offers a safe fallback and access to the untouched files - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); - actions.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTIONS); - actions.set_homogeneous(true); - actions.set_visible(false); - - let use_stock_button = notice_action("Use stock theme", "Use bundled UnixNotis styling"); - use_stock_button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY); - let open_theme_folder_button = - notice_action("Open theme folder", "Open the configured theme folder"); - actions.append(&use_stock_button); - actions.append(&open_theme_folder_button); - shell.append(&actions); - // A short vertical transition keeps the header position stable let revealer = gtk::Revealer::new(); revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); @@ -71,19 +54,9 @@ pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { shell, label, close, - actions, - use_stock_button, - open_theme_folder_button, } } -fn notice_action(label: &str, tooltip: &str) -> gtk::Button { - let button = gtk::Button::with_label(label); - button.add_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION); - button.set_tooltip_text(Some(tooltip)); - button -} - #[cfg(test)] #[path = "tests/notice.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/notice.rs b/crates/unixnotis-center/src/ui/panel/tests/notice.rs index e543a92d7..9c866b63a 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/notice.rs @@ -14,30 +14,9 @@ fn reload_notice_starts_hidden_and_dismiss_button_hides_it() { assert!(notice .label .has_css_class(hooks::panel_shell::RELOAD_NOTICE_TEXT)); - assert!(!notice.actions.get_visible()); assert!(notice.close.get_visible()); notice.revealer.set_reveal_child(true); notice.close.emit_clicked(); assert!(!notice.revealer.reveals_child()); } - -#[gtk::test] -fn compatibility_actions_have_distinct_labels_and_primary_stock_style() { - let notice = build_reload_notice(); - - assert_eq!( - notice.use_stock_button.label().as_deref(), - Some("Use stock theme") - ); - assert_eq!( - notice.open_theme_folder_button.label().as_deref(), - Some("Open theme folder") - ); - assert!(notice - .use_stock_button - .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); - assert!(!notice - .open_theme_folder_button - .has_css_class(hooks::panel_shell::RELOAD_NOTICE_ACTION_PRIMARY)); -} diff --git a/crates/unixnotis-center/src/ui/reload/config/flow.rs b/crates/unixnotis-center/src/ui/reload/config/flow.rs index eaf485280..6bf01f93e 100644 --- a/crates/unixnotis-center/src/ui/reload/config/flow.rs +++ b/crates/unixnotis-center/src/ui/reload/config/flow.rs @@ -42,7 +42,6 @@ impl UiState { // Any accepted config replaces a prior rejection before CSS reports its own result self.clear_reload_notice(ReloadNoticeKind::Config); self.apply_css_reload_notice(&css); - self.refresh_theme_compatibility_notice(); ConfigReloadOutcome::Applied { diagnostics: reload.diagnostics, css, @@ -84,7 +83,6 @@ impl UiState { self.capture_notice_dismissal(); let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); self.apply_css_reload_notice(&report); - self.refresh_theme_compatibility_notice(); report } } diff --git a/crates/unixnotis-center/src/ui/reload/config/notice.rs b/crates/unixnotis-center/src/ui/reload/config/notice.rs index b93dae29b..871fd2b1b 100644 --- a/crates/unixnotis-center/src/ui/reload/config/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/notice.rs @@ -90,16 +90,7 @@ impl UiState { } else { hooks::panel_shell::RELOAD_NOTICE_WARNING }); - let shows_theme_actions = notice.fingerprint.kind == ReloadNoticeKind::ThemeCompatibility; - // Action notices cannot be dismissed without recording an explicit policy choice - self.panel - .reload_notice - .close - .set_visible(!shows_theme_actions); - self.panel - .reload_notice - .actions - .set_visible(shows_theme_actions); + self.panel.reload_notice.close.set_visible(true); self.panel.reload_notice.revealer.set_reveal_child(true); } diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs index bcde96fa0..ab3a5d64e 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs @@ -3,9 +3,7 @@ use std::fs; use gtk::prelude::*; use super::super::outcome::ConfigReloadOutcome; -use super::support::{ - enable_missing_panel_layer_fixture, state, write_compatible_theme_manifest, write_config, -}; +use super::support::{enable_missing_panel_layer_fixture, state, write_config}; #[gtk::test] fn accepted_reload_clears_rejected_config_notice() { @@ -27,7 +25,6 @@ fn accepted_reload_clears_rejected_config_notice() { ] { fs::write(path, "/* intentionally valid */").expect("theme css"); } - write_compatible_theme_manifest(&state); let outcome = state.reload_config(); @@ -94,7 +91,6 @@ fn successful_css_only_reload_does_not_clear_config_rejection_notice() { ] { fs::write(path, "/* valid reload css */").expect("theme css"); } - write_compatible_theme_manifest(&state); fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); let _outcome = state.reload_config(); let rejection = state.panel.reload_notice.label.text(); @@ -146,23 +142,3 @@ fn css_reload_notice_summarizes_multiple_unreadable_layers() { .shell .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); } - -#[gtk::test] -fn theme_compatibility_notice_requires_an_explicit_action() { - let mut state = state(); - state.set_reload_notice( - crate::ui::reload::ReloadNoticeKind::ThemeCompatibility, - "Theme is incompatible", - false, - "compatibility-a", - ); - - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert!(state.panel.reload_notice.actions.get_visible()); - assert!(!state.panel.reload_notice.close.get_visible()); - - state.capture_notice_dismissal(); - - assert!(state.panel.reload_notice.revealer.reveals_child()); - assert!(state.panel.reload_notice.actions.get_visible()); -} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs index f756ee9e5..0f806d2bc 100644 --- a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs +++ b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use gtk::prelude::*; -use unixnotis_core::{Config, ThemeMode, THEME_API_VERSION}; +use unixnotis_core::Config; use unixnotis_ui::css::CssManager; use crate::control::{UiCommand, UiEvent}; @@ -65,22 +65,11 @@ pub(super) fn write_config(path: &Path, config: &Config) { fs::write(path, text).expect("test config should be written"); } -pub(super) fn write_compatible_theme_manifest(state: &UiState) { - let manifest = state.css.theme_paths().manifest_path(); - fs::write( - manifest, - format!("api_version = {THEME_API_VERSION}\nname = \"Test theme\"\n"), - ) - .expect("compatible theme manifest should be written"); -} - pub(super) fn enable_missing_panel_layer_fixture(state: &mut UiState) { - state.config.theme.mode = ThemeMode::Custom; state .css .update_theme(state.css.theme_paths().clone(), state.config.theme.clone()); - // A popup-only custom layer makes the contract active while panel layers stay absent + // A popup-only custom layer makes the panel fallback path observable fs::write(&state.css.theme_paths().popup_css, "/* popup only */") .expect("popup theme fixture should be written"); - write_compatible_theme_manifest(state); } diff --git a/crates/unixnotis-center/src/ui/reload/mod.rs b/crates/unixnotis-center/src/ui/reload/mod.rs index ca86474fa..76baf8066 100644 --- a/crates/unixnotis-center/src/ui/reload/mod.rs +++ b/crates/unixnotis-center/src/ui/reload/mod.rs @@ -5,4 +5,4 @@ mod notices; mod refresh; pub(in crate::ui) use config::{log_reload_rejection, ConfigReloadOutcome}; -pub(in crate::ui) use notices::{ReloadNoticeKind, ReloadNoticeState}; +pub(in crate::ui) use notices::ReloadNoticeState; diff --git a/crates/unixnotis-center/src/ui/reload/notices.rs b/crates/unixnotis-center/src/ui/reload/notices.rs index ac3eb2ced..292171e1d 100644 --- a/crates/unixnotis-center/src/ui/reload/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/notices.rs @@ -4,7 +4,6 @@ pub(in crate::ui) enum ReloadNoticeKind { Config, Css, - ThemeCompatibility, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -24,7 +23,6 @@ pub(super) struct ReloadNotice { pub(in crate::ui) struct ReloadNoticeState { config: Option, css: Option, - theme_compatibility: Option, dismissed_config: Option, dismissed_css: Option, } @@ -40,10 +38,6 @@ impl ReloadNoticeState { // Duplicate watcher events retain dismissal for the same failure Self::replace_notice(&mut self.css, &mut self.dismissed_css, notice); } - ReloadNoticeKind::ThemeCompatibility => { - // Compatibility requires an explicit stock selection or a corrected manifest - self.theme_compatibility = Some(notice); - } } } @@ -58,7 +52,6 @@ impl ReloadNoticeState { self.css = None; self.dismissed_css = None; } - ReloadNoticeKind::ThemeCompatibility => self.theme_compatibility = None, } } @@ -86,7 +79,6 @@ impl ReloadNoticeState { // Each class remembers dismissal independently across priority changes ReloadNoticeKind::Config => self.dismissed_config = Some(notice.fingerprint), ReloadNoticeKind::Css => self.dismissed_css = Some(notice.fingerprint), - ReloadNoticeKind::ThemeCompatibility => {} } } @@ -101,7 +93,6 @@ impl ReloadNoticeState { .as_ref() .filter(|notice| self.dismissed_css.as_ref() != Some(¬ice.fingerprint)) }) - .or(self.theme_compatibility.as_ref()) } } diff --git a/crates/unixnotis-center/src/ui/reload/tests/notices.rs b/crates/unixnotis-center/src/ui/reload/tests/notices.rs index acb653d7f..dde074d4f 100644 --- a/crates/unixnotis-center/src/ui/reload/tests/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/tests/notices.rs @@ -114,45 +114,3 @@ fn old_dismissal_does_not_hide_a_failure_after_an_intervening_fingerprint() { Some("config-a") ); } - -#[test] -fn theme_compatibility_notice_waits_behind_failures_and_returns_after_recovery() { - let mut state = ReloadNoticeState::default(); - state.set(notice( - ReloadNoticeKind::ThemeCompatibility, - "compatibility-a", - )); - state.set(notice(ReloadNoticeKind::Css, "css-a")); - state.set(notice(ReloadNoticeKind::Config, "config-a")); - - assert_eq!( - state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::Config) - ); - state.clear(ReloadNoticeKind::Config); - assert_eq!( - state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::Css) - ); - state.clear(ReloadNoticeKind::Css); - assert_eq!( - state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::ThemeCompatibility) - ); -} - -#[test] -fn generic_dismissal_does_not_discard_a_theme_compatibility_choice() { - let mut state = ReloadNoticeState::default(); - state.set(notice( - ReloadNoticeKind::ThemeCompatibility, - "compatibility-a", - )); - - state.dismiss_visible(); - - assert_eq!( - state.visible().map(|notice| notice.fingerprint.kind), - Some(ReloadNoticeKind::ThemeCompatibility) - ); -} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs b/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs deleted file mode 100644 index 981abbe25..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/actions.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Theme compatibility notice action wiring - -use async_channel::TrySendError; -use gtk::prelude::*; - -use crate::control::UiEvent; -use crate::ui::panel::notice::ReloadNoticeWidgets; - -pub(in crate::ui) fn connect_notice_actions( - notice: &ReloadNoticeWidgets, - event_tx: async_channel::Sender, -) { - let stock_tx = event_tx.clone(); - notice.use_stock_button.connect_clicked(move |_| { - send_action(&stock_tx, UiEvent::UseStockTheme); - }); - - notice.open_theme_folder_button.connect_clicked(move |_| { - send_action(&event_tx, UiEvent::OpenThemeFolder); - }); -} - -fn send_action(event_tx: &async_channel::Sender, event: UiEvent) { - match event_tx.try_send(event) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - // Explicit choices wait for queue capacity instead of disappearing under load - let event_tx = event_tx.clone(); - gtk::glib::MainContext::default().spawn_local(async move { - let _result = event_tx.send(event).await; - }); - } - Err(TrySendError::Closed(_event)) => { - // Shutdown already owns the UI when the receiver is gone - } - } -} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs b/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs deleted file mode 100644 index 93a7c33b9..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/flow.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Non-mutating custom theme compatibility flow - -use gio::prelude::FileExt; -use tracing::warn; -use unixnotis_core::{persist_theme_mode, ThemeMode}; - -use crate::ui::reload::ReloadNoticeKind; -use crate::ui::UiState; - -impl UiState { - pub(in crate::ui) fn initialize_theme_compatibility(&mut self) { - self.refresh_theme_compatibility_notice(); - } - - pub(in crate::ui) fn refresh_theme_compatibility_notice(&mut self) { - let state = self.css.theme_contract(); - if state.is_incompatible() { - self.set_reload_notice( - ReloadNoticeKind::ThemeCompatibility, - "Theme is incompatible with this UnixNotis version.\nYour files were not changed; embedded stock styling is active.", - false, - &format!("{state:?}"), - ); - } else { - self.clear_reload_notice(ReloadNoticeKind::ThemeCompatibility); - } - } - - pub(in crate::ui) fn use_stock_theme(&mut self) { - if let Err(error) = persist_theme_mode(&self.config_path, ThemeMode::Stock) { - warn!( - kind = error.kind(), - "failed to persist the embedded stock theme selection" - ); - self.set_reload_notice( - ReloadNoticeKind::ThemeCompatibility, - "Could not save the stock theme selection.\nYour custom files were not changed.", - true, - error.kind(), - ); - return; - } - - // Reloading from disk makes the saved choice and active providers advance together - let _outcome = self.reload_config(); - } - - pub(in crate::ui) fn open_theme_folder(&self) { - let folder = gio::File::for_path(&self.css.theme_paths().base_dir); - let uri = folder.uri(); - if let Err(error) = - gio::AppInfo::launch_default_for_uri(uri.as_str(), None::<&gio::AppLaunchContext>) - { - warn!(?error, "failed to open the configured theme folder"); - } - } -} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs b/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs deleted file mode 100644 index 914d5edfd..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Versioned custom theme compatibility UI - -mod actions; -mod flow; - -pub(in crate::ui) use actions::connect_notice_actions; - -#[cfg(test)] -mod tests; diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs deleted file mode 100644 index 2d0df828a..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/tests/actions.rs +++ /dev/null @@ -1,24 +0,0 @@ -use gtk::prelude::*; - -use super::super::connect_notice_actions; -use crate::control::UiEvent; -use crate::ui::panel::notice::build_reload_notice; - -#[gtk::test] -fn compatibility_buttons_emit_stock_and_folder_events() { - let notice = build_reload_notice(); - let (event_tx, event_rx) = async_channel::bounded(2); - connect_notice_actions(¬ice, event_tx); - - notice.use_stock_button.emit_clicked(); - notice.open_theme_folder_button.emit_clicked(); - - assert!(matches!( - event_rx.try_recv().expect("stock action should emit"), - UiEvent::UseStockTheme - )); - assert!(matches!( - event_rx.try_recv().expect("folder action should emit"), - UiEvent::OpenThemeFolder - )); -} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs deleted file mode 100644 index 6a89ffcfa..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/tests/flow.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; - -use gtk::prelude::*; -use unixnotis_core::{Config, ThemeContractState, ThemeMode}; -use unixnotis_ui::css::CssManager; - -use crate::control::{UiCommand, UiEvent}; -use crate::ui::{UiState, UiStateInit}; - -static NEXT_APP: AtomicUsize = AtomicUsize::new(0); - -struct ThemeFixture { - state: UiState, - custom_css: PathBuf, - original: String, - root: PathBuf, -} - -impl Drop for ThemeFixture { - fn drop(&mut self) { - fs::remove_dir_all(&self.root).expect("theme test directory should be removable"); - } -} - -fn incompatible_theme_fixture(name: &str) -> ThemeFixture { - let serial = NEXT_APP.fetch_add(1, Ordering::Relaxed); - let app = gtk::Application::builder() - .application_id(format!("dev.unixnotis.theme.compatibility.test{serial}")) - .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) - .build(); - app.register(None::<>k::gio::Cancellable>) - .expect("test application should register"); - - let mut config = Config::default(); - config.theme.mode = ThemeMode::Custom; - config.panel.respect_work_area = false; - config.media.enabled = false; - config.widgets.volume.enabled = false; - config.widgets.brightness.enabled = false; - config.widgets.toggles.clear(); - config.widgets.stats.clear(); - config.widgets.cards.clear(); - - let root = std::env::temp_dir().join(format!( - "unixnotis-theme-compatibility-{name}-{}-{serial}", - std::process::id() - )); - fs::create_dir_all(&root).expect("theme test directory should be created"); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - let original = "/* incompatible custom theme must be preserved */".to_string(); - fs::write(&paths.panel_css, &original).expect("custom theme should be writable"); - let config_path = root.join("config.toml"); - fs::write( - &config_path, - toml::to_string_pretty(&config).expect("theme config should serialize"), - ) - .expect("theme config should be writable"); - - let css = CssManager::new_panel(paths.clone(), config.theme.clone()); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); - let (event_tx, _event_rx) = async_channel::bounded::(8); - let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); - let state = UiState::new(UiStateInit { - app, - config, - config_path, - command_tx, - css, - event_tx, - media_handle: None, - runtime, - }); - - ThemeFixture { - state, - custom_css: paths.panel_css, - original, - root, - } -} - -#[gtk::test] -fn incompatible_theme_shows_non_mutating_stock_fallback_notice() { - let fixture = incompatible_theme_fixture("notice"); - - assert!(fixture.state.panel.reload_notice.revealer.reveals_child()); - assert!(fixture.state.panel.reload_notice.actions.get_visible()); - assert!(!fixture.state.panel.reload_notice.close.get_visible()); - assert!(fixture - .state - .panel - .reload_notice - .label - .text() - .contains("incompatible")); - assert_eq!( - fs::read_to_string(&fixture.custom_css).expect("custom theme should remain readable"), - fixture.original - ); -} - -#[gtk::test] -fn stock_action_disables_custom_reads_without_changing_custom_file() { - let mut fixture = incompatible_theme_fixture("stock"); - - fixture.state.handle_event(UiEvent::UseStockTheme); - - assert_eq!( - fixture.state.css.theme_contract(), - ThemeContractState::EmbeddedStock - ); - assert!(!fixture.state.panel.reload_notice.revealer.reveals_child()); - assert_eq!( - fs::read_to_string(&fixture.custom_css).expect("custom theme should remain readable"), - fixture.original - ); - let persisted = Config::load_from_path(&fixture.state.config_path) - .expect("stock theme selection should leave a valid config"); - assert_eq!( - persisted.theme.mode, - ThemeMode::Stock, - "stock theme selection must survive a process restart" - ); - let paths = persisted - .resolve_theme_paths_from(&fixture.root) - .expect("persisted theme paths should resolve"); - assert_eq!( - CssManager::new_panel(paths, persisted.theme).theme_contract(), - ThemeContractState::EmbeddedStock, - "a new CSS manager must retain the persisted stock selection" - ); -} diff --git a/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs b/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs deleted file mode 100644 index 858b6b547..000000000 --- a/crates/unixnotis-center/src/ui/theme_compatibility/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod actions; -mod flow; diff --git a/crates/unixnotis-core/assets/internal-structure.css b/crates/unixnotis-core/assets/internal-structure.css index 670383ba6..fa8113194 100644 --- a/crates/unixnotis-core/assets/internal-structure.css +++ b/crates/unixnotis-core/assets/internal-structure.css @@ -18,15 +18,6 @@ padding: 0; } -.unixnotis-reload-notice-actions { - margin-top: 8px; -} - -.unixnotis-reload-notice-action { - min-height: 32px; - padding: 4px 10px; -} - /* GtkSearchEntry has no public icon child, so the native glyphs yield to owned controls */ .unixnotis-panel-search-owned-icons { -gtk-icon-source: none; diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index b48c0da53..090568de8 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -63,38 +63,6 @@ padding: 0; } -.unixnotis-reload-notice-actions { - margin-top: 8px; -} - -.unixnotis-reload-notice-action { - min-height: 32px; - padding: 4px 10px; - border-radius: 9px; - border: 1px solid alpha(#ffffff, 0.10); - background: alpha(#ffffff, 0.055); - color: alpha(#ffffff, 0.84); - box-shadow: none; -} - -.unixnotis-reload-notice-action:hover, -.unixnotis-reload-notice-action:focus-visible { - background: alpha(#ffffff, 0.10); - border-color: alpha(#ffffff, 0.18); -} - -.unixnotis-reload-notice-action-primary { - background: alpha(@unixnotis-accent, 0.20); - border-color: alpha(@unixnotis-accent, 0.38); - color: #d8fffb; -} - -.unixnotis-reload-notice-action-primary:hover, -.unixnotis-reload-notice-action-primary:focus-visible { - background: alpha(@unixnotis-accent, 0.28); - border-color: alpha(@unixnotis-accent, 0.52); -} - .unixnotis-panel-title-stack { min-width: 0; } diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs index 3d6466502..a3da94b4d 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs @@ -18,7 +18,7 @@ fn theme_paths(root: &std::path::Path) -> crate::ThemePaths { } #[test] -fn custom_mode_without_a_manifest_is_incompatible_without_creating_files() { +fn missing_manifest_is_incompatible_for_export_review_without_creating_files() { let root = theme_root("theme-contract-stock"); let paths = theme_paths(&root); diff --git a/crates/unixnotis-core/src/config/loading/io/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs index 2f5efc05d..3d989a54e 100644 --- a/crates/unixnotis-core/src/config/loading/io/theme_contract.rs +++ b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs @@ -1,4 +1,4 @@ -//! Read-only custom theme compatibility contract +//! Read-only metadata contract for exported theme directories use std::io::ErrorKind; use std::path::PathBuf; @@ -16,7 +16,7 @@ const THEME_MANIFEST_FILE: &str = "theme.toml"; const MAX_THEME_MANIFEST_BYTES: u64 = 64 * 1024; const MAX_THEME_NAME_CHARS: usize = 128; -/// Manifest required before user-controlled CSS can be loaded +/// Manifest written beside an exported theme directory #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ThemeManifest { @@ -24,7 +24,7 @@ pub struct ThemeManifest { pub name: String, } -/// Reason an existing custom theme could not be enabled safely +/// Reason an exported theme directory is not self-describing #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ThemeIncompatibility { MissingManifest, @@ -34,22 +34,15 @@ pub enum ThemeIncompatibility { InvalidName, } -/// Active source selected without changing user files +/// Result of inspecting exported theme directory metadata #[derive(Debug, Clone, Eq, PartialEq)] pub enum ThemeContractState { - EmbeddedStock, Compatible(ThemeManifest), Incompatible(ThemeIncompatibility), } impl ThemeContractState { - /// Return whether configured CSS may be loaded - #[must_use] - pub const fn custom_theme_allowed(&self) -> bool { - matches!(self, Self::Compatible(_)) - } - - /// Return whether the panel should explain the stock fallback + /// Return whether the exported directory metadata is incompatible #[must_use] pub const fn is_incompatible(&self) -> bool { matches!(self, Self::Incompatible(_)) @@ -57,13 +50,13 @@ impl ThemeContractState { } impl ThemePaths { - /// Return the manifest anchored beside the active configuration + /// Return the manifest anchored beside exported theme files #[must_use] pub fn manifest_path(&self) -> PathBuf { self.base_dir.join(THEME_MANIFEST_FILE) } - /// Select custom or embedded CSS without creating or changing files + /// Inspect exported theme metadata without creating or changing files #[must_use] pub fn inspect_theme_contract(&self) -> ThemeContractState { let manifest_path = self.manifest_path(); diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index dd646e40b..2fe0a860b 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -73,9 +73,6 @@ pub mod panel_shell { pub const RELOAD_NOTICE_CONTENT: &str = "unixnotis-reload-notice-content"; pub const RELOAD_NOTICE_TEXT: &str = "unixnotis-reload-notice-text"; pub const RELOAD_NOTICE_CLOSE: &str = "unixnotis-reload-notice-close"; - pub const RELOAD_NOTICE_ACTIONS: &str = "unixnotis-reload-notice-actions"; - pub const RELOAD_NOTICE_ACTION: &str = "unixnotis-reload-notice-action"; - pub const RELOAD_NOTICE_ACTION_PRIMARY: &str = "unixnotis-reload-notice-action-primary"; pub const BODY_STACK: &str = "unixnotis-panel-body-stack"; pub const EDGE_TOP: &str = "unixnotis-panel-edge-top"; pub const EDGE_BOTTOM: &str = "unixnotis-panel-edge-bottom"; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 883ed2395..0062a5d52 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -66,9 +66,6 @@ fn hook_names_stay_unique() { panel_shell::RELOAD_NOTICE_CONTENT, panel_shell::RELOAD_NOTICE_TEXT, panel_shell::RELOAD_NOTICE_CLOSE, - panel_shell::RELOAD_NOTICE_ACTIONS, - panel_shell::RELOAD_NOTICE_ACTION, - panel_shell::RELOAD_NOTICE_ACTION_PRIMARY, panel_shell::BODY_STACK, panel_shell::EDGE_TOP, panel_shell::EDGE_BOTTOM, diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 77ae01ff9..9e6fdaee0 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -29,8 +29,6 @@ fn motion_policy_disables_theme_motion_under_the_runtime_class() { #[test] fn internal_structure_css_contains_only_required_fallback_structure() { assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice")); - assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice-actions")); - assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice-action")); assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-panel-search-owned-icons")); assert!(!INTERNAL_STRUCTURE_CSS.contains("@define-color")); } From 19a44d1cb4afab8fc3437db3d211727ac9574b40 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 01:48:33 -0500 Subject: [PATCH 224/275] fix(config): reset and provision file-backed theme layers Reset active stylesheet files with the shared transaction and provision only contained paths during installation. Preserve existing configured files, handle external CSS without outside writes, and keep unsafe targets on the runtime fallback path with regression coverage. --- crates/noticenterctl/src/preset/reset.rs | 2 +- .../noticenterctl/src/preset/tests/reset.rs | 13 + crates/unixnotis-core/src/config/reset.rs | 31 +- .../src/config/reset/tests/files.rs | 68 ++++- .../src/actions/config/provision.rs | 125 +++++++- .../src/actions/config/tests/provision.rs | 289 +++++++++++++++++- 6 files changed, 483 insertions(+), 45 deletions(-) diff --git a/crates/noticenterctl/src/preset/reset.rs b/crates/noticenterctl/src/preset/reset.rs index 8cd8d7fa6..4be651cac 100644 --- a/crates/noticenterctl/src/preset/reset.rs +++ b/crates/noticenterctl/src/preset/reset.rs @@ -38,7 +38,7 @@ pub(super) fn run_reset_config(skip_confirmation: bool) -> Result<()> { } println!("Reset config.toml to current defaults."); println!("Reset bundled scripts."); - println!("Theme source is now embedded stock."); + println!("Reset theme CSS files to current defaults."); Ok(()) } diff --git a/crates/noticenterctl/src/preset/tests/reset.rs b/crates/noticenterctl/src/preset/tests/reset.rs index 71f2b49bd..3a2bf0b6b 100644 --- a/crates/noticenterctl/src/preset/tests/reset.rs +++ b/crates/noticenterctl/src/preset/tests/reset.rs @@ -37,6 +37,19 @@ fn yes_mode_executes_reset_and_creates_shared_settings() { assert!(config_dir.join("installer.toml").is_file()); let config = fs::read_to_string(config_dir.join("config.toml")).expect("read reset config"); toml::from_str::(&config).expect("reset config should parse"); + for (name, expected) in [ + ("base.css", unixnotis_core::DEFAULT_BASE_CSS), + ("panel.css", unixnotis_core::DEFAULT_PANEL_CSS), + ("popup.css", unixnotis_core::DEFAULT_POPUP_CSS), + ("widgets.css", unixnotis_core::DEFAULT_WIDGETS_CSS), + ("media.css", unixnotis_core::DEFAULT_MEDIA_CSS), + ] { + assert_eq!( + fs::read_to_string(config_dir.join(name)).expect("read reset stylesheet"), + expected, + "reset must restore {name}" + ); + } assert!(fs::read_dir(&config_dir) .expect("read reset directory") .filter_map(Result::ok) diff --git a/crates/unixnotis-core/src/config/reset.rs b/crates/unixnotis-core/src/config/reset.rs index 5715cb9f3..1ee5129d7 100644 --- a/crates/unixnotis-core/src/config/reset.rs +++ b/crates/unixnotis-core/src/config/reset.rs @@ -10,7 +10,10 @@ use crate::filesystem::{ copy_file_atomic, create_directory_all, remove_directory_tree, remove_regular_file, write_file_atomic, CreateDirectoryOutcome, }; -use crate::{Config, DEFAULT_SCRIPTS}; +use crate::{ + Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, + DEFAULT_SCRIPTS, DEFAULT_WIDGETS_CSS, +}; const BACKUP_PREFIX: &str = "Backup-"; type ResetWriter = dyn Fn(&Path, &[u8], u32) -> std::io::Result<()>; @@ -110,16 +113,14 @@ fn reset_config_to_defaults_inner( let theme_paths = config .resolve_theme_paths_from(&options.config_dir) .map_err(|error| anyhow!(error.to_string()))?; - let manifest_path = theme_paths.manifest_path(); let config_path = options.config_dir.join("config.toml"); let mut paths = vec![config_path.clone()]; paths.extend([ - theme_paths.base_css, - theme_paths.panel_css, - theme_paths.popup_css, - theme_paths.widgets_css, - theme_paths.media_css, - manifest_path, + theme_paths.base_css.clone(), + theme_paths.panel_css.clone(), + theme_paths.popup_css.clone(), + theme_paths.widgets_css.clone(), + theme_paths.media_css.clone(), ]); for script in DEFAULT_SCRIPTS { paths.push(options.config_dir.join(script.relative_path)); @@ -164,6 +165,20 @@ fn reset_config_to_defaults_inner( contents: config_toml.into_bytes(), mode: 0o644, }]; + // Reset every active stylesheet so file-backed loading is immediately usable + for (path, contents) in [ + (theme_paths.base_css, DEFAULT_BASE_CSS), + (theme_paths.panel_css, DEFAULT_PANEL_CSS), + (theme_paths.popup_css, DEFAULT_POPUP_CSS), + (theme_paths.widgets_css, DEFAULT_WIDGETS_CSS), + (theme_paths.media_css, DEFAULT_MEDIA_CSS), + ] { + targets.push(ResetTarget { + path, + contents: contents.as_bytes().to_vec(), + mode: 0o644, + }); + } for script in DEFAULT_SCRIPTS { let path = options.config_dir.join(script.relative_path); if let Some(parent) = path.parent() { diff --git a/crates/unixnotis-core/src/config/reset/tests/files.rs b/crates/unixnotis-core/src/config/reset/tests/files.rs index f727d69ea..c517de7c6 100644 --- a/crates/unixnotis-core/src/config/reset/tests/files.rs +++ b/crates/unixnotis-core/src/config/reset/tests/files.rs @@ -7,7 +7,7 @@ use super::support::temp_config_dir; use crate::DEFAULT_SCRIPTS; #[test] -fn reset_backs_up_existing_files_and_writes_stock_files() { +fn reset_backs_up_existing_files_and_writes_bundled_defaults() { let root = temp_config_dir("present"); fs::write(root.join("config.toml"), "custom = true\n").expect("seed config"); let script = root.join(DEFAULT_SCRIPTS[0].relative_path); @@ -35,7 +35,7 @@ fn reset_backs_up_existing_files_and_writes_stock_files() { fs::read_to_string(backup.join("unixnotis-blue-light-lib")).expect("read script backup"), "custom script\n" ); - assert_eq!(report.written_files.len(), 1 + DEFAULT_SCRIPTS.len()); + assert_eq!(report.written_files.len(), 6 + DEFAULT_SCRIPTS.len()); let _ = fs::remove_dir_all(root); } @@ -49,6 +49,15 @@ fn reset_creates_missing_config_and_scripts_with_safe_modes() { .expect("reset should create missing files"); assert!(report.backup_dir.is_none()); assert!(root.join("config.toml").is_file()); + for stylesheet in [ + "base.css", + "panel.css", + "popup.css", + "widgets.css", + "media.css", + ] { + assert!(root.join(stylesheet).is_file(), "missing {stylesheet}"); + } for script in DEFAULT_SCRIPTS { let path = root.join(script.relative_path); assert!(path.is_file()); @@ -66,7 +75,7 @@ fn reset_creates_missing_config_and_scripts_with_safe_modes() { } #[test] -fn reset_preserves_custom_theme_files_but_backs_them_up() { +fn reset_restores_custom_theme_files_but_backs_them_up() { let root = temp_config_dir("theme"); fs::write(root.join("panel.css"), "custom panel\n").expect("seed custom CSS"); let report = reset_config_to_defaults(&ResetConfigOptions { @@ -75,8 +84,8 @@ fn reset_preserves_custom_theme_files_but_backs_them_up() { }) .expect("reset should succeed"); assert_eq!( - fs::read_to_string(root.join("panel.css")).expect("read custom CSS"), - "custom panel\n" + fs::read_to_string(root.join("panel.css")).expect("read reset CSS"), + crate::DEFAULT_PANEL_CSS ); let backup = report.backup_dir.expect("backup directory"); assert_eq!( @@ -106,6 +115,55 @@ fn reset_accepts_large_existing_files_and_backs_them_up() { let _ = fs::remove_dir_all(root); } +#[test] +fn reset_ignores_a_theme_manifest_directory() { + let root = temp_config_dir("manifest-directory"); + let manifest = crate::Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths") + .manifest_path(); + fs::create_dir(&manifest).expect("create manifest directory"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("manifest metadata must not block reset"); + + assert!(manifest.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn reset_ignores_a_theme_manifest_symlink() { + use std::os::unix::fs::symlink; + + let root = temp_config_dir("manifest-symlink"); + let outside = temp_config_dir("manifest-symlink-outside"); + let manifest = crate::Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths") + .manifest_path(); + let target = outside.join("theme.toml"); + fs::write(&target, "external metadata\n").expect("write manifest target"); + symlink(&target, &manifest).expect("create manifest symlink"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("manifest metadata must not block reset"); + + assert!(manifest.is_symlink()); + assert_eq!( + fs::read_to_string(target).expect("read manifest target"), + "external metadata\n" + ); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(outside); +} + #[test] fn snapshot_reports_errors_other_than_missing_files() { let root = temp_config_dir("snapshot-error"); diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index e4ea9eb51..9c8134d87 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -1,11 +1,14 @@ //! Config and theme file creation or reset logic -use std::path::Path; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use unixnotis_core::{ - filesystem::write_file_atomic, render_default_config_toml, reset_config_to_defaults, Config, - ResetConfigOptions, + filesystem::open_regular_file, + filesystem::{create_directory_all, write_file_atomic, write_file_if_missing, ContainedPath}, + render_default_config_toml, reset_config_to_defaults, Config, ResetConfigOptions, + DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, }; use crate::paths::format_with_home; @@ -14,7 +17,6 @@ use super::super::{log_line, ActionContext}; use super::backup::{ensure_installer_config, load_installer_config}; pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { - let config = Config::default(); let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; let config_path = Config::default_config_path().map_err(|err| anyhow!(err.to_string()))?; log_line( @@ -22,12 +24,18 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { format!("Config directory: {}", format_with_home(&config_dir)), ); - if config_path.exists() { + let config = if config_path.exists() { log_line( ctx, format!("Config file present: {}", format_with_home(&config_path)), ); + + // Existing theme paths are part of the configuration contract + Config::load_from_path(&config_path) + .map_err(|error| anyhow!(error.to_string())) + .context("load existing configuration before provisioning theme files")? } else { + let config = Config::default(); // Write a default config so there is always a working base to edit let config_toml = render_default_config_toml(&config)?; write_file_atomic(&config_path, config_toml.as_bytes(), 0o644) @@ -36,13 +44,31 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { ctx, format!("Config file created: {}", format_with_home(&config_path)), ); - } + + config + }; ensure_installer_config(ctx, &config_dir)?; ensure_default_scripts(ctx, &config_dir)?; + for provision in ensure_default_theme_files(&config, &config_dir)? { + let path = format_with_home(&provision.path); + let message = match provision.status { + ThemeFileStatus::Created => format!("Default theme CSS created: {path}"), + ThemeFileStatus::Present => format!("Default theme CSS present: {path}"), + ThemeFileStatus::ExternalManaged => { + format!("External theme CSS preserved: {path}") + } + ThemeFileStatus::ExternalMissing => { + format!("External theme CSS missing; runtime fallback remains active: {path}") + } + ThemeFileStatus::ExternalUnsafe => { + format!("External theme CSS is unsafe; runtime fallback remains active: {path}") + } + }; + log_line(ctx, message); + } - // New installations use embedded stock CSS until a versioned custom theme is installed - log_line(ctx, "Theme source: embedded stock".to_string()); + log_line(ctx, "Theme CSS provisioning complete".to_string()); Ok(()) } @@ -69,13 +95,7 @@ pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { ctx, "Reset config file and bundled scripts to defaults".to_string(), ); - log_line( - ctx, - format!( - "Theme source reset to embedded stock; custom files preserved in {}", - format_with_home(&config_dir) - ), - ); + log_line(ctx, "Reset theme CSS files to current defaults".to_string()); Ok(()) } @@ -102,3 +122,78 @@ fn ensure_default_scripts(ctx: &mut ActionContext, config_dir: &Path) -> Result< } Ok(()) } + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum ThemeFileStatus { + Created, + Present, + ExternalManaged, + ExternalMissing, + ExternalUnsafe, +} + +#[derive(Debug, Eq, PartialEq)] +struct ThemeFileProvision { + path: PathBuf, + status: ThemeFileStatus, +} + +fn ensure_default_theme_files( + config: &Config, + config_dir: &Path, +) -> Result> { + // Use the generated configuration paths so provisioning matches runtime loading + let paths = config + .resolve_theme_paths_from(config_dir) + .map_err(|error| anyhow!(error.to_string()))?; + let files = [ + (paths.base_css, DEFAULT_BASE_CSS), + (paths.panel_css, DEFAULT_PANEL_CSS), + (paths.popup_css, DEFAULT_POPUP_CSS), + (paths.widgets_css, DEFAULT_WIDGETS_CSS), + (paths.media_css, DEFAULT_MEDIA_CSS), + ]; + + files + .into_iter() + .map(|(path, contents)| { + // Provisioning may only create files beneath the active config directory + let path = match ContainedPath::resolve(config_dir, &path) { + Ok(contained) => contained.absolute(), + Err(_) => { + return Ok(ThemeFileProvision { + status: classify_external_theme_file(&path), + path, + }); + } + }; + // Nested configured paths need secure parents before exclusive creation + if let Some(parent) = path.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create theme directory {}", parent.display()))?; + } + // Exclusive creation preserves custom files and rejects unsafe targets + let created = write_file_if_missing(&path, contents.as_bytes(), 0o644) + .with_context(|| format!("provision {}", path.display()))?; + Ok(ThemeFileProvision { + path, + status: if created { + ThemeFileStatus::Created + } else { + ThemeFileStatus::Present + }, + }) + }) + .collect() +} + +pub(super) fn classify_external_theme_file(path: &Path) -> ThemeFileStatus { + match open_regular_file(path) { + Ok(file) => { + drop(file); + ThemeFileStatus::ExternalManaged + } + Err(error) if error.kind() == ErrorKind::NotFound => ThemeFileStatus::ExternalMissing, + Err(_) => ThemeFileStatus::ExternalUnsafe, + } +} diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index d88450233..7637988cd 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -11,9 +11,14 @@ use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; use crate::test_support::env::{test_env_lock, EnvGuard}; -use unixnotis_core::{Config, ThemeMode}; +use unixnotis_core::{ + Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, + DEFAULT_WIDGETS_CSS, +}; -use super::super::provision::{ensure_config, reset_config}; +use super::super::provision::{ + classify_external_theme_file, ensure_config, reset_config, ThemeFileStatus, +}; fn test_paths(root: &std::path::Path) -> InstallPaths { InstallPaths { @@ -37,7 +42,7 @@ fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> Action } #[test] -fn ensure_config_uses_embedded_theme_and_preserves_the_live_config() { +fn ensure_config_provisions_default_css_and_preserves_the_live_config() { let _lock = test_env_lock(); let root = crate::test_support::fs::unique_temp_path("ensure-config"); let xdg_root = root.join("xdg"); @@ -57,34 +62,286 @@ fn ensure_config_uses_embedded_theme_and_preserves_the_live_config() { let config_text = fs::read_to_string(&config_path).expect("read generated config"); toml::from_str::(&config_text).expect("generated config should parse"); assert!(config_dir.join("installer.toml").is_file()); - for name in [ - "base.css", - "panel.css", - "popup.css", - "widgets.css", - "media.css", - "theme.toml", + for (name, expected) in [ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", DEFAULT_POPUP_CSS), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), ] { assert!( - !config_dir.join(name).exists(), - "new installs should not create custom theme file {name}" + config_dir.join(name).is_file(), + "new installs should create {name}" + ); + assert_eq!( + fs::read_to_string(config_dir.join(name)).expect("read default theme CSS"), + expected, + "new installs should use bundled {name}" ); } + assert!(!config_dir.join("theme.toml").exists()); for script in unixnotis_core::DEFAULT_SCRIPTS { assert!(config_dir.join(script.relative_path).is_file()); } fs::write(&config_path, "custom = true\n").expect("customize live config"); + fs::write(config_dir.join("popup.css"), "/* custom popup */\n").expect("customize popup CSS"); ensure_config(&mut context).expect("existing config should be preserved"); assert_eq!( fs::read_to_string(&config_path).expect("read retained config"), "custom = true\n" ); + assert_eq!( + fs::read_to_string(config_dir.join("popup.css")).expect("read retained popup CSS"), + "/* custom popup */\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn ensure_config_provisions_the_existing_configured_theme_paths() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-configured-theme-paths"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config directory"); + fs::write( + config_dir.join("config.toml"), + "[theme]\nbase_css = \"themes/base.css\"\npanel_css = \"themes/panel.css\"\npopup_css = \"themes/popup.css\"\nwidgets_css = \"themes/widgets.css\"\nmedia_css = \"themes/media.css\"\n", + ) + .expect("write configured theme paths"); + fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); + fs::write(config_dir.join("themes/popup.css"), "/* custom popup */\n") + .expect("seed custom configured popup"); + + ensure_config(&mut context).expect("configured theme paths should be provisioned"); + + for (name, expected) in [ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", "/* custom popup */\n"), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ] { + assert_eq!( + fs::read_to_string(config_dir.join("themes").join(name)) + .expect("read configured theme file"), + expected, + "configured theme paths must be the provisioned targets" + ); + assert!( + !config_dir.join(name).exists(), + "installer must not create unrelated root-level {name}" + ); + } + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn ensure_config_preserves_external_theme_files_without_creating_missing_or_unsafe_targets() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-external-theme-paths"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config directory"); + let external_root = root.join("external-theme"); + fs::create_dir_all(&external_root).expect("create external theme directory"); + let external_base = external_root.join("base.css"); + let external_popup = external_root.join("popup.css"); + let external_panel = external_root.join("panel.css"); + let external_widgets = external_root.join("widgets.css"); + let external_media = external_root.join("media.css"); + fs::write(&external_base, "/* external base */\n").expect("seed external base"); + fs::write(&external_popup, "/* external popup */\n").expect("seed external popup"); + let external_target = root.join("external-target.css"); + fs::write(&external_target, "/* external target */\n").expect("seed external target"); + symlink(&external_target, &external_widgets).expect("create external symlink"); + fs::create_dir(&external_media).expect("create external special target"); + fs::write( + config_dir.join("config.toml"), + format!( + "[theme]\nbase_css = {:?}\npopup_css = {:?}\npanel_css = {:?}\nwidgets_css = {:?}\nmedia_css = {:?}\n", + external_base.to_string_lossy(), + external_popup.to_string_lossy(), + external_panel.to_string_lossy(), + external_widgets.to_string_lossy(), + external_media.to_string_lossy(), + ), + ) + .expect("write external theme paths"); + + ensure_config(&mut context).expect("external theme paths must remain compatible"); + + assert_eq!( + fs::read_to_string(&external_base).expect("read external base"), + "/* external base */\n" + ); + assert_eq!( + fs::read_to_string(&external_popup).expect("read external popup"), + "/* external popup */\n" + ); + assert!( + !external_panel.exists(), + "missing external files must stay absent" + ); + assert!( + external_media.is_dir(), + "external directories must remain intact" + ); + assert_eq!( + fs::read_link(&external_widgets).expect("read external symlink"), + external_target + ); + assert_eq!( + fs::read_to_string(&external_target).expect("read external symlink target"), + "/* external target */\n" + ); + for name in [ + "base.css", + "panel.css", + "popup.css", + "widgets.css", + "media.css", + ] { + assert!( + !config_dir.join(name).exists(), + "external theme paths must not create root-level {name}" + ); + } + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn external_theme_file_status_matches_runtime_file_safety() { + use std::os::unix::fs::symlink; + + let root = crate::test_support::fs::unique_temp_path("external-theme-status"); + fs::create_dir_all(&root).expect("create status fixture"); + let missing = root.join("missing.css"); + let regular = root.join("regular.css"); + let directory = root.join("directory.css"); + let symlink_path = root.join("symlink.css"); + let target = root.join("target.css"); + fs::write(®ular, "/* regular */\n").expect("seed regular file"); + fs::create_dir(&directory).expect("seed directory target"); + fs::write(&target, "/* target */\n").expect("seed symlink target"); + symlink(&target, &symlink_path).expect("seed symlink target"); + + assert_eq!( + classify_external_theme_file(&missing), + ThemeFileStatus::ExternalMissing + ); + assert_eq!( + classify_external_theme_file(®ular), + ThemeFileStatus::ExternalManaged + ); + assert_eq!( + classify_external_theme_file(&directory), + ThemeFileStatus::ExternalUnsafe + ); + assert_eq!( + classify_external_theme_file(&symlink_path), + ThemeFileStatus::ExternalUnsafe + ); + + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn ensure_config_rejects_theme_symlinks_without_touching_the_target() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-theme-symlink"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("initial install should succeed"); + + let config_dir = xdg_root.join("unixnotis"); + let target = root.join("outside-popup.css"); + fs::write(&target, "/* outside target */\n").expect("seed outside CSS"); + fs::remove_file(config_dir.join("popup.css")).expect("remove provisioned popup CSS"); + symlink(&target, config_dir.join("popup.css")).expect("create popup symlink"); + + ensure_config(&mut context).expect_err("theme symlink should fail closed"); + + assert_eq!( + fs::read_to_string(&target).expect("read outside CSS target"), + "/* outside target */\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn ensure_config_rejects_configured_theme_symlinks_without_touching_the_target() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-configured-theme-symlink"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); + fs::write( + config_dir.join("config.toml"), + "[theme]\npopup_css = \"themes/popup.css\"\n", + ) + .expect("write configured popup path"); + let target = root.join("outside-popup.css"); + fs::write(&target, "/* outside target */\n").expect("seed outside CSS"); + symlink(&target, config_dir.join("themes/popup.css")).expect("create configured symlink"); + + ensure_config(&mut context).expect_err("configured theme symlink should fail closed"); + + assert_eq!( + fs::read_to_string(&target).expect("read outside CSS target"), + "/* outside target */\n" + ); + assert_eq!( + fs::read_link(config_dir.join("themes/popup.css")).expect("read retained symlink"), + target + ); let _ = fs::remove_dir_all(root); } #[test] -fn reset_config_backs_up_custom_files_and_selects_embedded_stock() { +fn reset_config_backs_up_custom_files_and_restores_configured_css_defaults() { let _lock = test_env_lock(); let root = crate::test_support::fs::unique_temp_path("reset-config"); let xdg_root = root.join("xdg"); @@ -109,11 +366,11 @@ fn reset_config_backs_up_custom_files_and_selects_embedded_stock() { let config_text = fs::read_to_string(&config_path).expect("read reset config"); let reset = toml::from_str::(&config_text).expect("reset config should parse"); assert_ne!(config_text, "custom = true\n"); - assert_eq!(reset.theme.mode, ThemeMode::Stock); + assert_eq!(reset.theme.base_css, "base.css"); assert_eq!( fs::read_to_string(config_dir.join("base.css")).expect("read reset theme"), - "/* custom */\n", - "reset must not convert embedded stock into a custom theme snapshot" + DEFAULT_BASE_CSS, + "reset must restore the active configured stylesheet" ); assert!( !config_dir.join("theme.toml").exists(), From 460072f7a23d5128901ec1cfd5986e30b247178c Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 01:48:44 -0500 Subject: [PATCH 225/275] style(popups): revise the bundled popup presentation Refresh the bundled popup surface with the polished compact visual treatment and keep its embedded CSS assertions aligned with the new identity and media sizing. --- crates/unixnotis-core/assets/popup.css | 281 ++++++++++-------- .../unixnotis-core/src/embedded/tests/css.rs | 3 +- 2 files changed, 162 insertions(+), 122 deletions(-) diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index c1a150f4d..08dfe874f 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -1,36 +1,48 @@ -/* UnixNotis popup theme */ +/* UnixNotis popup theme + * + * Owns the toast popup surfaces and their cards. Scope is strictly the popup + * window/stack/card; the panel and its card stacking are untouched. + * + * Visual direction: a calm iOS-style glass banner. Quiet frosted dark glass, + * a hairline border, soft neutral depth, natural-case typography and minimal + * interaction feedback. No accent rails, no glow, nothing shouting. + */ /* Shared close button styling for popup surfaces */ .unixnotis-popup-close { - background: alpha(#ffffff, 0.055); + background: alpha(#ffffff, 0.06); border-radius: 999px; - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); + border: 1px solid alpha(#ffffff, 0.10); padding: 3px; min-width: 26px; min-width: var(--unixnotis-popup-close-size); min-height: 26px; min-height: var(--unixnotis-popup-close-size); - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.70); - opacity: 0.58; - transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; + color: alpha(#ffffff, 0.75); + opacity: 0; + transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } -.unixnotis-popup-card:hover .unixnotis-popup-close, -.unixnotis-popup-close:focus-visible, -.unixnotis-popup-close:hover { +.unixnotis-popup-card:hover .unixnotis-popup-close { opacity: 1; } -.unixnotis-popup-card .unixnotis-popup-close:hover { - background: alpha(#fb7185, 0.16); +.unixnotis-popup-close:focus-visible { + opacity: 1; + border-color: alpha(@unixnotis-accent, 0.55); + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.15); +} + +.unixnotis-popup-close:hover { + background: alpha(#fb7185, 0.18); border-color: alpha(#fb7185, 0.45); color: #fb7185; - box-shadow: 0 0 8px alpha(#fb7185, 0.35); - transform: translateY(-0.5px); +} + +.unixnotis-popup-close:active { + background: alpha(#fb7185, 0.28); + border-color: alpha(#fb7185, 0.60); + color: #fb7185; } /* Popup stack */ @@ -46,25 +58,38 @@ background: transparent; } +/* Card: quiet frosted dark glass with a hairline edge and soft depth. + * The subtle top highlight reads as the material's light catch. */ .unixnotis-popup-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-popup-bg-2, 0.96), alpha(@unixnotis-popup-bg-1, 0.99)); + background-image: linear-gradient( + 180deg, + alpha(#2c3762, 0.82) 0%, + alpha(#1a2242, 0.88) 55%, + alpha(#121834, 0.93) 100% + ); color: @unixnotis-text; border-radius: var(--unixnotis-popup-card-radius); - padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); - border: 1px solid alpha(@unixnotis-card-border, 0.90); + padding: calc(var(--unixnotis-popup-card-padding-y) + 4px) + calc(var(--unixnotis-popup-card-padding-x) + 6px); + border: 1px solid alpha(#ffffff, 0.10); font-family: "Inter", "Noto Sans", sans-serif; box-shadow: - 0 14px 34px -18px @unixnotis-shadow-strong, - inset 0 1px 0 alpha(#ffffff, 0.035); + inset 0 1px 0 alpha(#ffffff, 0.09), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); + transition: border-color 0.18s ease-out, box-shadow 0.18s ease-out; } -.unixnotis-popup-card.unixnotis-default-action:focus-visible { - border-color: alpha(@unixnotis-accent, 0.48); - outline: none; +.unixnotis-popup-card:hover { + border-color: alpha(#ffffff, 0.16); box-shadow: - 0 14px 34px -18px @unixnotis-shadow-strong, - 0 0 0 2px alpha(@unixnotis-accent, 0.18), - inset 0 1px 0 alpha(#ffffff, 0.035); + inset 0 1px 0 alpha(#ffffff, 0.11), + inset 0 2px 0 alpha(#ffffff, 0.04), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.42), + 0 18px 36px -18px alpha(#000000, 0.70); } .unixnotis-popup-card.utility { @@ -81,158 +106,149 @@ min-width: 0; } -.unixnotis-popup-identity-row { - min-width: 0; -} - +.unixnotis-popup-identity-row, .unixnotis-popup-message { min-width: 0; - margin-top: 1px; } +/* Identity header: natural-case app label, quiet timestamp, subtle chip */ .unixnotis-popup-app-name { - color: alpha(@unixnotis-text, 0.82); - font-weight: 700; - font-size: 13px; + color: alpha(@unixnotis-text, 0.90); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.01em; } .unixnotis-popup-time { color: alpha(@unixnotis-text, 0.52); font-weight: 400; font-size: 11px; + letter-spacing: 0.02em; + margin-right: 18px; } .unixnotis-popup-trust-chip { border-radius: 999px; - padding: 1px 6px; + padding: 1px 7px; font-size: 10px; font-weight: 600; + letter-spacing: 0.02em; } .unixnotis-popup-trust-chip.recognized, .unixnotis-popup-trust-chip.unresolved, .unixnotis-popup-trust-chip.relay { background: alpha(#fbbf24, 0.10); - color: alpha(#fde68a, 0.84); - border: 1px solid alpha(#fbbf24, 0.20); + color: alpha(#fde68a, 0.85); + border: 1px solid alpha(#fbbf24, 0.22); } .unixnotis-popup-trust-chip.conflict { - background: alpha(#fb7185, 0.13); + background: alpha(#fb7185, 0.12); color: #fecdd3; - border: 1px solid alpha(#fb7185, 0.34); + border: 1px solid alpha(#fb7185, 0.30); } +/* Message hierarchy: semibold title, quiet support copy */ .unixnotis-popup-summary { - color: alpha(@unixnotis-text, 0.98); font-weight: 700; font-size: 15px; - line-height: 1.18; - margin-top: 0; + letter-spacing: -0.015em; + margin-top: 3px; + color: #ffffff; } .unixnotis-popup-icon { color: inherit; } -.unixnotis-popup-conversation-avatar { - border-radius: 8px; - box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); -} - -.unixnotis-popup-application-visual { - border-radius: 9px; - opacity: 0.92; - box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); -} - -/* Decorative sender art stays subordinate to the message and trusted badge */ -.unixnotis-popup-sender-visual { - min-width: 38px; - min-height: 38px; - margin-top: 4px; - border-radius: 9px; - background: alpha(#000000, 0.10); -} - +/* Avatar: neutral rounded tile that quietly holds the app icon */ .unixnotis-identity-avatar { - min-width: 34px; - min-height: 34px; - border-radius: 10px; - background: alpha(#ffffff, 0.07); - color: alpha(#ffffff, 0.92); + min-width: 46px; + min-height: 46px; + border-radius: 13px; + background: alpha(#ffffff, 0.09); + border: 1px solid alpha(#ffffff, 0.13); + color: alpha(#ffffff, 0.95); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.10), + 0 2px 6px -3px alpha(#000000, 0.50); } .unixnotis-identity-avatar.recognized { - border: 1px solid alpha(#ffffff, 0.10); + border: 1px solid alpha(#ffffff, 0.12); } .unixnotis-identity-avatar.relay { - background: alpha(#fbbf24, 0.08); + background: alpha(#fbbf24, 0.09); + border: 1px solid alpha(#fbbf24, 0.24); color: alpha(#fde68a, 0.90); } .unixnotis-identity-avatar.unresolved { - background: alpha(#ffffff, 0.065); - color: alpha(#ffffff, 0.82); + background: alpha(#ffffff, 0.06); + border: 1px solid alpha(#ffffff, 0.08); + color: alpha(#ffffff, 0.84); } .unixnotis-identity-avatar.conflict { background: alpha(#fb7185, 0.12); + border: 1px solid alpha(#fb7185, 0.32); color: #fecdd3; } .unixnotis-popup-body { - color: alpha(@unixnotis-muted, 0.88); + color: alpha(@unixnotis-text, 0.82); font-weight: 400; font-size: 13px; - line-height: 1.28; - margin-top: 3px; + letter-spacing: 0.01em; + margin-top: 2px; } .unixnotis-popup-footer-note { - color: alpha(#fbbf24, 0.72); + color: alpha(#fde68a, 0.72); font-size: 11px; - margin-top: 2px; + font-weight: 500; + margin-top: 3px; } .unixnotis-popup-secondary-claim { color: alpha(@unixnotis-text, 0.58); font-size: 12px; font-weight: 400; - margin-top: 1px; + margin-top: 2px; } .unixnotis-popup-content-image { min-width: 64px; min-height: 64px; - margin-top: 6px; - border-radius: 9px; - background: alpha(#000000, 0.12); - box-shadow: 0 5px 14px -8px alpha(#000000, 0.8); + margin-top: 8px; + border-radius: 10px; + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04); } .unixnotis-popup-card.recognized, .unixnotis-popup-card.unresolved { - border-color: alpha(@unixnotis-card-border, 0.90); + border-color: alpha(#ffffff, 0.09); } .unixnotis-popup-card.relay { - border-color: alpha(@unixnotis-card-border, 0.96); + border-color: alpha(#fbbf24, 0.26); } .unixnotis-popup-card.conflict { - border-color: alpha(@unixnotis-critical-border, 0.48); - box-shadow: 0 14px 34px -18px @unixnotis-shadow-strong; + border-color: alpha(@unixnotis-critical-border, 0.34); } +/* Actions: hairline separator and quiet minimal buttons */ .unixnotis-popup-actions { margin-top: 8px; margin-top: var(--unixnotis-popup-actions-gap); } .unixnotis-popup-card-has-summary .unixnotis-popup-summary { - color: @unixnotis-text; + color: #ffffff; } .unixnotis-popup-card-has-actions .unixnotis-popup-actions { @@ -241,32 +257,36 @@ } .unixnotis-popup-action { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - color: alpha(#ffffff, 0.75); - border-radius: 10px; - padding: 4px 10px; + background: alpha(#ffffff, 0.04); + border: 1px solid alpha(#ffffff, 0.08); + color: alpha(#ffffff, 0.80); + border-radius: 9px; + padding: 5px 11px; font-size: 12px; - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + font-weight: 500; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-popup-action:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); + background: alpha(#ffffff, 0.09); + border-color: alpha(#ffffff, 0.14); color: #ffffff; - transform: translateY(-0.5px); +} + +.unixnotis-popup-action:active { + background: alpha(#ffffff, 0.13); + border-color: alpha(#ffffff, 0.18); +} + +.unixnotis-popup-action:focus-visible { + border-color: alpha(@unixnotis-accent, 0.55); + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.15); } .unixnotis-popup-action:checked { - background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); - border: 1px solid alpha(#00a2ff, 0.50); - box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); + background: alpha(@unixnotis-accent, 0.16); + border-color: alpha(@unixnotis-accent, 0.38); + color: alpha(#ffffff, 0.95); } .unixnotis-popup-action-overflow { @@ -279,35 +299,54 @@ padding: 6px; } +/* Inline reply: recessed entry with a quiet focus ring */ .unixnotis-popup-inline-reply { - margin-top: 8px; + margin-top: 10px; } .unixnotis-popup-reply-entry { - min-height: 30px; - border-radius: 10px; - padding-left: 9px; - padding-right: 9px; + min-height: 32px; + border-radius: 9px; + padding-left: 10px; + padding-right: 10px; + background: alpha(#0a0f1f, 0.45); + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: inset 0 1px 2px alpha(#000000, 0.35); + transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; +} + +.unixnotis-popup-reply-entry:hover { + border-color: alpha(#ffffff, 0.16); +} + +.unixnotis-popup-reply-entry:focus { + border-color: alpha(@unixnotis-accent, 0.50); + box-shadow: + inset 0 1px 2px alpha(#000000, 0.35), + 0 0 0 2px alpha(@unixnotis-accent, 0.14); } .unixnotis-popup-reply-error { - color: alpha(#fb7185, 0.88); + color: alpha(#fb7185, 0.90); font-size: 11px; - margin-top: 2px; + margin-top: 3px; } -/* Critical state composes after the ordinary card and interaction rules */ +/* Critical state: a quiet rose-tinted glass, no glow. Composes after the + * ordinary card and interaction rules. */ .unixnotis-popup-card.critical { background-image: linear-gradient( - 145deg, - alpha(@unixnotis-critical-surface-strong, 0.96), - alpha(#111522, 0.98) + 180deg, + alpha(#3a2430, 0.88) 0%, + alpha(#23161f, 0.92) 100% ); - border: 1px solid alpha(@unixnotis-critical-border, 0.58); + border: 1px solid alpha(@unixnotis-critical-border, 0.42); box-shadow: - 0 18px 38px -22px alpha(#000000, 0.92), - 0 0 20px -16px alpha(@unixnotis-critical-border, 0.38), - inset 3px 0 @unixnotis-critical-border; + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.22), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); } .unixnotis-popup-card.critical .unixnotis-popup-app-name { diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 9e6fdaee0..72aafe912 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -151,7 +151,8 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { // Default popups must not restore the old raw provenance body row assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); - assert!(DEFAULT_POPUP_CSS.contains("min-width: 34px")); + assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-identity-avatar")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 46px")); assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); } From e48b6de995cdc4ef49632b95d058705cfc9d13da Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 03:29:29 -0500 Subject: [PATCH 226/275] fix(notifications): keep popup display timeout local Keep the daemon notification active after the ordinary popup banner hides, so center actions remain valid until an explicit close or application-owned lifetime ends. Resolve the local timer from the committed notification policy, preserve exact-generation hidden state, and keep renderer lifecycle handling fail-closed across disconnects. --- .../src/output/tests/notifications.rs | 1 + .../src/control/tests/events.rs | 1 + .../src/ui/icons/tests/resolution.rs | 1 + .../src/ui/icons/tests/theme.rs | 1 + .../src/ui/notifications/model/tests/item.rs | 1 + .../row/notification/tests/support.rs | 1 + .../src/ui/notifications/row/tests/group.rs | 1 + .../ui/notifications/store/tests/mutation.rs | 2 + .../src/ui/notifications/tests/support.rs | 1 + .../src/control/notification.rs | 2 + .../unixnotis-core/src/model/notification.rs | 4 + .../ingress/payload/expiration.rs | 31 ------ .../notifications/ingress/payload/mod.rs | 2 - .../ingress/payload/tests/expiration.rs | 96 ------------------- .../ingress/payload/tests/mod.rs | 7 +- .../src/daemon/notifications/server/flow.rs | 8 +- .../daemon/notifications/server/tests/flow.rs | 45 +++++++++ crates/unixnotis-daemon/src/store/model.rs | 2 + .../src/store/notifications/insertion.rs | 15 ++- .../src/store/notifications/mod.rs | 1 + .../src/store/notifications/tests/mod.rs | 1 + .../src/store/notifications/tests/timeout.rs | 85 ++++++++++++++++ .../src/store/notifications/timeout.rs | 58 +++++++++++ crates/unixnotis-daemon/src/store/runtime.rs | 18 ++-- .../src/store/tests/runtime/config.rs | 4 +- .../src/store/tests/runtime/popup.rs | 64 +++++++++++++ crates/unixnotis-popups/src/app/command.rs | 1 + .../src/dbus/runtime/tests/delivery.rs | 1 + crates/unixnotis-popups/src/dbus/types.rs | 2 + crates/unixnotis-popups/src/ui/entry/build.rs | 22 +++++ .../src/ui/entry/builders/reply/tests/mod.rs | 1 + .../src/ui/entry/builders/tests/common.rs | 1 + .../ui/entry/presentation/tests/support.rs | 1 + .../src/ui/entry/tests/build.rs | 1 + .../src/ui/icons/tests/resolver/support.rs | 1 + crates/unixnotis-popups/src/ui/popups/mod.rs | 1 + .../src/ui/popups/mutation.rs | 47 +++++++++ .../src/ui/popups/reconcile.rs | 5 +- .../src/ui/popups/tests/timeout.rs | 62 ++++++++++++ .../unixnotis-popups/src/ui/popups/timeout.rs | 57 +++++++++++ .../src/ui/popups/visibility.rs | 5 + .../src/ui/state/constructor.rs | 10 ++ .../unixnotis-popups/src/ui/state/events.rs | 9 ++ crates/unixnotis-popups/src/ui/state/model.rs | 8 +- .../src/ui/state/tests/constructor.rs | 6 ++ .../src/ui/state/tests/mutation.rs | 53 ++++++++++ .../src/presentation/tests/presentation.rs | 1 + .../src/presentation/tests/support.rs | 1 + 48 files changed, 599 insertions(+), 150 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs create mode 100644 crates/unixnotis-daemon/src/store/notifications/timeout.rs create mode 100644 crates/unixnotis-popups/src/ui/popups/tests/timeout.rs create mode 100644 crates/unixnotis-popups/src/ui/popups/timeout.rs diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 2716068d8..79c47bd18 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -29,6 +29,7 @@ fn sample_notification() -> NotificationView { // CLI formatting only needs the lightweight transport fields image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index 24f073173..cae84147c 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -19,6 +19,7 @@ fn notification(id: u32) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index 6e8213b97..be9eed391 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -53,6 +53,7 @@ fn sender_paths_are_not_resolved_by_client_icon_lookup() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; assert!(resolver.resolve_icon(¬ification, 16, 1).is_none()); diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index a9f28d4a7..9f33d1ab5 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -29,6 +29,7 @@ fn notification_view( received_at_unix_seconds: 0, image, popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index b91a01bae..9be209026 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -23,6 +23,7 @@ fn notification(id: u32) -> Rc { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 9aa07fbc9..20c1abf4d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -37,6 +37,7 @@ pub(super) fn sample_notification() -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index a1e083f30..6cea2925a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -35,6 +35,7 @@ fn notification(app_name: &str) -> Rc { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }) } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 3a3a29d11..ad9a8d33f 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -27,6 +27,7 @@ fn make_view(is_transient: bool) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } @@ -51,6 +52,7 @@ fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index f88bfe03d..8a7c85632 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -75,5 +75,6 @@ pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index c025a1342..4ae3db946 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -69,6 +69,8 @@ pub struct PopupDecisionRecord { pub max_visible_at_commit: u32, pub decided_at_unix_ms: i64, pub delivery_stage: PopupDeliveryStage, + /// Sanitized banner visibility duration fixed for this notification generation + pub popup_hide_after_ms: u64, } /// One atomic popup payload and its current admission decision diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index d61fbf70e..a843c1b28 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -99,6 +99,7 @@ impl Notification { // UIs only need the text, actions, and image payload used for rendering image: self.image.clone(), popup_decision: PopupDecisionRecord::default(), + popup_hide_after_ms: 0, // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -125,6 +126,7 @@ impl Notification { // List rows should avoid carrying raw image buffers across D-Bus image: self.image.for_listing(), popup_decision: PopupDecisionRecord::default(), + popup_hide_after_ms: 0, // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -346,6 +348,8 @@ pub struct NotificationView { pub image: NotificationImage, // Arrival-time popup reasoning stays stable while DND and renderer state change later pub popup_decision: PopupDecisionRecord, + // Sanitized banner duration resolved by the daemon for this generation + pub popup_hide_after_ms: u64, } impl NotificationView { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs deleted file mode 100644 index d84a7d040..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/expiration.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Expiration policy for stored notifications - -use std::cmp::Ordering; -use std::time::{Duration, Instant}; - -use unixnotis_core::{Config, Notification, Urgency}; - -pub(in crate::daemon::notifications) fn resolve_expiration( - config: &Config, - notification: &Notification, -) -> Option { - // Resident notifications stay visible until the sender or user closes them - if notification.is_resident { - return None; - } - - // Zero is an explicit request to disable the expiration timer - let timeout_ms = match notification.expire_timeout.cmp(&0) { - Ordering::Equal => return None, - // Positive values are already bounded at the wire boundary - Ordering::Greater => notification.expire_timeout as u64, - // Negative values select the configured urgency default - Ordering::Less => match notification.urgency { - Urgency::Critical => config.popups.critical_timeout_ms?, - _ => config.popups.default_timeout_ms, - }, - }; - - // Avoid allocating a timer deadline when the configured default is disabled - (timeout_ms != 0).then(|| Instant::now() + Duration::from_millis(timeout_ms)) -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs index 07db96adc..7f66c68ee 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -1,12 +1,10 @@ //! Bounded notification payload construction mod build; -mod expiration; mod sanitize; mod visuals; pub(in crate::daemon::notifications) use build::{build_notification, NotificationInput}; -pub(in crate::daemon::notifications) use expiration::resolve_expiration; pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) use visuals::{ materialize_sender_visual, may_materialize_content_image, sender_visual_role, SenderVisualRole, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs deleted file mode 100644 index ac1b3fb96..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/expiration.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::*; -#[test] -fn resolve_expiration_respects_protocol_and_config_rules() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 5_000; - config.popups.critical_timeout_ms = Some(9_000); - - let mut notification = unixnotis_core::Notification { - id: 1, - generation: 1, - app_name: "app".to_string(), - app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - inline_reply: unixnotis_core::InlineReply::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: -1, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.urgency = Urgency::Critical; - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = 0; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.expire_timeout = 100; - notification.is_resident = true; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.is_resident = false; - let before = Instant::now(); - let deadline = resolve_expiration(&config, ¬ification).expect("explicit timeout"); - assert!(deadline > before); - assert!(deadline <= Instant::now() + Duration::from_millis(500)); - - notification.expire_timeout = -1; - notification.urgency = Urgency::Critical; - config.popups.critical_timeout_ms = None; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} - -#[test] -fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_is_zero() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 0; - let mut notification = unixnotis_core::Notification { - id: 1, - generation: 1, - app_name: "app".to_string(), - app_icon: String::new(), - attribution: unixnotis_core::NotificationAttribution::default(), - attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - inline_reply: unixnotis_core::InlineReply::default(), - inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 25, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = -1; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs index 48daee0bf..f7f669fc3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use zbus::zvariant::OwnedValue; pub(super) use super::super::super::identity::SenderMetadata; pub(super) use super::super::limits::{MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES}; pub(super) use super::build::{build_notification, NotificationInput}; -pub(super) use super::expiration::resolve_expiration; pub(super) use super::sanitize::{ owned_to_string, parse_actions, parse_urgency_hint, sanitize_hints_for_storage, string_to_owned_value, @@ -19,11 +18,9 @@ pub(super) use super::visuals::{ pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; pub(super) use unixnotis_core::{ - ApplicationActionPolicy, AttributionReason, Config, IdentityAssurance, InteractionPolicies, - NotificationImage, Urgency, + ApplicationActionPolicy, AttributionReason, IdentityAssurance, InteractionPolicies, }; mod build; -mod expiration; mod sanitize; mod visuals; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index a8c6c8e75..33cd2c1fb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -12,8 +12,8 @@ use crate::daemon::notifications::identity::{ }; use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, - resolve_expiration, sender_visual_role, NotificationInput, SenderVisualRole, - CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_CONTENT_DIMENSION, + sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, + MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; @@ -217,8 +217,8 @@ impl NotificationServer { let ui_health = self.state.ui_health(); let outcome = store.insert_with_ui_health(notification, replaces_id, &ui_health); if !outcome.dropped { - // Resolve timeout after insertion so rule-mapped fields are already final - let expiration = resolve_expiration(store.config(), &outcome.notification); + // The store resolved both clocks after applying rules and committing the generation + let expiration = outcome.expiration; store.set_expiration(&outcome.notification, expiration); // Unbounded send is synchronous, so commit order is preserved without an await self.scheduler.schedule( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 4bf25936c..3ebaae719 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -63,6 +63,7 @@ fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { allow_sound: !dropped, evicted: Vec::new(), dropped, + expiration: None, } } @@ -241,6 +242,14 @@ async fn ingest_notify_schedules_expiration_for_positive_timeout() { .await .expect("notify should store"); + let view = state + .store + .lock() + .await + .active_notification_view(id) + .expect("positive-timeout notification should be active initially"); + assert_eq!(view.popup_hide_after_ms, 25); + for _ in 0..30 { if state .store @@ -257,6 +266,42 @@ async fn ingest_notify_schedules_expiration_for_positive_timeout() { panic!("notification should expire after scheduled timeout"); } +#[tokio::test] +async fn default_popup_display_timeout_does_not_archive_the_active_notification() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "keeps actions live".to_string(), + "body".to_string(), + vec!["default".to_string(), "View".to_string()], + HashMap::new().into(), + &header, + -1, + ) + .await + .expect("notify should store"); + + tokio::time::sleep(Duration::from_millis(40)).await; + let store = state.store.lock().await; + let active = store + .active_notification_view(id) + .expect("default popup timeout must not close active storage"); + assert_eq!(active.summary, "keeps actions live"); + assert_eq!(active.actions.len(), 1); + assert_eq!( + active.popup_hide_after_ms, + Config::default().popups.default_timeout_ms + ); +} + #[tokio::test] async fn ingest_notify_emits_notification_added_signal() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index 3b5f2657c..3b6a1a056 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -57,6 +57,8 @@ pub struct InsertOutcome { pub evicted: Vec, // True when payload was intentionally dropped by inhibit mode pub dropped: bool, + // Commit-time daemon deadline for this exact generation + pub expiration: Option, } /// Exact identity required to expire one committed notification diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index 6f96e16aa..b61517f74 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -7,6 +7,8 @@ use unixnotis_core::{ use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; +use super::timeout::resolve_timeout_policy; + // Hard ceiling for concurrently active notifications to protect panel/popups stability const ACTIVE_HARD_CAP: usize = 12; @@ -32,6 +34,7 @@ impl NotificationStore { ) -> InsertOutcome { // Rule transforms happen before any storage decision self.apply_rules(&mut notification); + let timeout_policy = resolve_timeout_policy(&self.config, ¬ification); if self.should_drop_inhibited() { // DropAll mode still assigns an ID so call sites can log consistent metadata let assigned_id = self.next_id(); @@ -46,6 +49,7 @@ impl NotificationStore { replaced: false, evicted: Vec::new(), dropped: true, + expiration: None, }; } @@ -79,6 +83,9 @@ impl NotificationStore { self.popup_decisions .retain(|key, _decision| key.id != assigned_id); + let expiration = timeout_policy + .active_close_after + .map(|duration| std::time::Instant::now() + duration); let notification = Arc::new(notification); // Active map keeps insertion order so oldest eviction is deterministic self.active.insert(assigned_id, notification.clone()); @@ -86,7 +93,12 @@ impl NotificationStore { let evicted = self.enforce_active_limit(); let popup_admission = self.popup_admission(¬ification); - self.record_popup_commit_environment(notification.key(), popup_admission, ui_health); + self.record_popup_commit_environment( + notification.key(), + popup_admission, + ui_health, + timeout_policy.popup_hide_after_ms, + ); InsertOutcome { popup_admission, allow_sound: self.should_play_sound(¬ification), @@ -94,6 +106,7 @@ impl NotificationStore { replaced, evicted, dropped: false, + expiration, } } diff --git a/crates/unixnotis-daemon/src/store/notifications/mod.rs b/crates/unixnotis-daemon/src/store/notifications/mod.rs index 52f280909..8dceef3b4 100644 --- a/crates/unixnotis-daemon/src/store/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/store/notifications/mod.rs @@ -5,6 +5,7 @@ mod insertion; mod lifecycle; mod ownership; pub(super) mod rules; +mod timeout; pub(super) use history::HistoryStore; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs index c457e5701..6aa43b49b 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs @@ -4,3 +4,4 @@ mod lifecycle; mod ownership; mod rules; mod support; +mod timeout; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs new file mode 100644 index 000000000..eb1b003b6 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use super::super::timeout::resolve_timeout_policy; +use super::support::make_notification; +use unixnotis_core::{Config, Urgency}; + +#[test] +fn zero_protocol_timeout_disables_both_clocks() { + let config = Config::default(); + let mut notification = make_notification("never"); + notification.expire_timeout = 0; + + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 0, + active_close_after: None, + } + ); +} + +#[test] +fn positive_protocol_timeout_controls_popup_and_active_lifetime() { + let config = Config::default(); + let mut notification = make_notification("bounded"); + notification.expire_timeout = 30_000; + + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 30_000, + active_close_after: Some(Duration::from_secs(30)), + } + ); +} + +#[test] +fn resident_positive_timeout_only_hides_the_banner() { + let config = Config::default(); + let mut notification = make_notification("resident"); + notification.expire_timeout = 30_000; + notification.is_resident = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 30_000); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn default_normal_timeout_hides_but_keeps_active_record() { + let config = Config::default(); + let mut notification = make_notification("normal"); + notification.expire_timeout = -1; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn default_critical_without_timeout_stays_visible() { + let config = Config::default(); + let mut notification = make_notification("critical"); + notification.expire_timeout = -1; + notification.urgency = Urgency::Critical; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 0); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn transient_default_timeout_closes_without_history_by_default() { + let config = Config::default(); + let mut notification = make_notification("transient"); + notification.expire_timeout = -1; + notification.is_transient = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!( + policy.active_close_after, + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/timeout.rs new file mode 100644 index 000000000..049b6b767 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/timeout.rs @@ -0,0 +1,58 @@ +//! Resolve popup visibility and daemon lifetime clocks at commit time + +use std::time::Duration; + +use unixnotis_core::{Config, Notification, Urgency}; + +/// Sanitized timeout decisions shared by the daemon scheduler and popup view +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ResolvedTimeoutPolicy { + /// Zero keeps the banner visible until an explicit close or replacement + pub(super) popup_hide_after_ms: u64, + /// None keeps the active record available for panel actions indefinitely + pub(super) active_close_after: Option, +} + +/// Resolve both clocks after rule mutations and before the generation is committed +pub(super) fn resolve_timeout_policy( + config: &Config, + notification: &Notification, +) -> ResolvedTimeoutPolicy { + let configured_popup_ms = match notification.urgency { + Urgency::Critical => config.popups.critical_timeout_ms.unwrap_or(0), + _ => config.popups.default_timeout_ms, + }; + + match notification.expire_timeout { + // A zero protocol timeout disables both automatic clocks + 0 => ResolvedTimeoutPolicy { + popup_hide_after_ms: 0, + active_close_after: None, + }, + // Positive values are an application-owned lifetime and banner duration + timeout if timeout > 0 => { + let timeout_ms = timeout as u64; + ResolvedTimeoutPolicy { + popup_hide_after_ms: timeout_ms, + active_close_after: (!notification.is_resident) + .then(|| Duration::from_millis(timeout_ms)), + } + } + // The default protocol value uses UnixNotis display policy + _ => { + let active_close_after = if notification.is_transient + && !notification.is_resident + && configured_popup_ms > 0 + { + Some(Duration::from_millis(configured_popup_ms)) + } else { + None + }; + + ResolvedTimeoutPolicy { + popup_hide_after_ms: configured_popup_ms, + active_close_after, + } + } + } +} diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index b88fe3654..c111b0ac5 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -83,10 +83,6 @@ impl NotificationStore { } } - pub const fn config(&self) -> &Config { - &self.config - } - pub const fn inhibited(&self) -> bool { self.inhibited } @@ -134,7 +130,7 @@ impl NotificationStore { matches!( decision.admission_at_commit, PopupAdmissionView::Show | PopupAdmissionView::RendererUnavailable - ) + ) && decision.delivery_stage.rank() < PopupDeliveryStage::Visible.rank() }) }) .map(|notification| self.list_view_with_popup_decision(notification)) @@ -153,7 +149,13 @@ impl NotificationStore { // Payload and its arrival-time policy are read from one store-lock snapshot let notification = self.active.get(&id)?; let key = notification.key(); - let admission = self.popup_decisions.get(&key)?.admission_at_commit; + let decision = self.popup_decisions.get(&key)?; + // A generation that was already rendered must not be fetched again after + // a delayed signal or renderer reconnect + if decision.delivery_stage.rank() >= PopupDeliveryStage::Visible.rank() { + return None; + } + let admission = decision.admission_at_commit; let view = self.view_with_popup_decision(notification); if admission.should_show() { self.record_popup_delivery_stage(key, PopupDeliveryStage::RendererFetched); @@ -194,6 +196,7 @@ impl NotificationStore { key: NotificationKey, admission: super::PopupAdmission, ui_health: &UiHealth, + popup_hide_after_ms: u64, ) { let max_visible = u32::try_from(self.config.popups.max_visible).unwrap_or(u32::MAX); let effective_admission = if !admission.should_show() { @@ -220,6 +223,7 @@ impl NotificationStore { max_visible_at_commit: max_visible, decided_at_unix_ms: chrono::Utc::now().timestamp_millis(), delivery_stage, + popup_hide_after_ms, }, ); } @@ -253,6 +257,7 @@ impl NotificationStore { let mut view = notification.to_view(); if let Some(decision) = self.popup_decisions.get(¬ification.key()) { view.popup_decision.clone_from(decision); + view.popup_hide_after_ms = decision.popup_hide_after_ms; } view } @@ -261,6 +266,7 @@ impl NotificationStore { let mut view = notification.to_list_view(); if let Some(decision) = self.popup_decisions.get(¬ification.key()) { view.popup_decision.clone_from(decision); + view.popup_hide_after_ms = decision.popup_hide_after_ms; } view } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs index 7120552d0..33412a868 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs @@ -10,8 +10,8 @@ fn config_accessor_returns_runtime_config_snapshot() { config.history.max_active = 3; let store = NotificationStore::new(config); - assert_eq!(store.config().history.max_entries, 77); - assert_eq!(store.config().history.max_active, 3); + assert_eq!(store.config.history.max_entries, 77); + assert_eq!(store.config.history.max_active, 3); } #[test] diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs index 29cdd18ad..02914a395 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -91,6 +91,7 @@ fn notification_diagnostics_require_both_renderer_process_and_readiness() { visible.key(), crate::store::PopupAdmission::Show, &health, + 0, ); let diagnostics = store .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) @@ -138,6 +139,7 @@ fn disabled_popups_are_recorded_when_max_visible_is_zero() { notification.key(), crate::store::PopupAdmission::Show, &ready, + 0, ); let diagnostics = store @@ -182,6 +184,7 @@ fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { notification.key(), crate::store::PopupAdmission::Show, &ready, + 0, ); let candidate = store @@ -212,6 +215,27 @@ fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { ); } +#[test] +fn visible_popup_candidate_cannot_be_fetched_again_after_reconnect() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("visible once"), 0) + .notification; + + assert!(store.popup_candidate(notification.id).is_some()); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert!( + store.popup_candidate(notification.id).is_none(), + "visible generations stay active for panel actions but cannot re-enter popups" + ); +} + #[test] fn delivery_stage_never_moves_backward() { let mut store = make_store_with_limits(10, 10); @@ -298,6 +322,7 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( rule_suppressed.key(), crate::store::PopupAdmission::Show, &ready, + 0, ); let arrival_suppressed = store @@ -307,6 +332,7 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( arrival_suppressed.key(), crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), &ready, + 0, ); let admitted = store.insert(make_notification("admitted"), 0).notification; @@ -314,6 +340,7 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( admitted.key(), crate::store::PopupAdmission::Show, &ready, + 0, ); let candidates = store.list_popup_candidates(); @@ -321,6 +348,43 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( assert_eq!(candidates[0].key(), admitted.key()); } +#[test] +fn visible_popup_generations_are_not_seeded_after_renderer_reconnect() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("already visible"), 0) + .notification; + + assert_eq!(store.list_popup_candidates().len(), 1); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + + // The active panel row remains available, but a restarted popup renderer + // must not receive a generation that already reached the visible stage + assert_eq!(store.list_popup_candidates().len(), 0); + assert_eq!(store.list_active().len(), 1); +} + +#[test] +fn materialized_but_not_visible_popup_remains_eligible_for_reconnect_seed() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("overflow"), 0).notification; + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Materialized, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!(store.list_popup_candidates().len(), 1); +} + #[test] fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { let mut store = make_store_with_limits(10, 10); diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index 096d131a9..d19dc141c 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -96,6 +96,7 @@ pub fn run(args: Args) -> Result<()> { command_tx, css_manager, ))); + ui.borrow_mut().set_popup_event_sender(event_tx.clone()); // Composite readiness now means GTK state exists as well as D-Bus seeding succeeding dbus_runtime.mark_gtk_ready(); diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs index 113d1b38b..5dba8d748 100644 --- a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -21,6 +21,7 @@ fn candidate(generation: u64, admission: PopupAdmissionView) -> PopupCandidate { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }, admission, } diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 326e60477..4ddccb4aa 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -17,6 +17,8 @@ pub enum UiEvent { NotificationAdded(NotificationView, bool), NotificationUpdated(NotificationView, bool), NotificationClosed(NotificationKey, CloseReason), + // Hiding a banner is local UI state and must not close the daemon record + PopupHidden(NotificationKey), // Popup gate is split out so panel-only state changes do not wake the popup UI PopupGateChanged(PopupGateState), CssReload, diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 02496f4bc..b2027d613 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -1,5 +1,8 @@ //! Popup entry lifecycle and high-level card assembly +use std::cell::Cell; +use std::rc::Rc; + use gtk::prelude::*; use gtk::Align; use unixnotis_core::{hooks, NotificationKey, NotificationView}; @@ -23,6 +26,10 @@ pub(in crate::ui) struct PopupEntry { pub(in crate::ui) revealer: Option, pub(in crate::ui) root: Option, pub(in crate::ui) visibility: Option, + // The display timer hides only this popup row and never closes the daemon record + pub(in crate::ui) hide_timer: Option, + // GLib has already removed a source when its one-shot callback starts + pub(in crate::ui) hide_timer_fired: Option>>, } impl PopupEntry { @@ -33,6 +40,8 @@ impl PopupEntry { revealer: None, root: None, visibility: None, + hide_timer: None, + hide_timer_fired: None, } } @@ -40,6 +49,17 @@ impl PopupEntry { // Both widgets must exist before stack operations can touch this row safely self.revealer.is_some() && self.root.is_some() } + pub(in crate::ui) fn cancel_hide_timer(&mut self) { + let fired = self + .hide_timer_fired + .take() + .is_some_and(|state| state.get()); + if let Some(timer) = self.hide_timer.take() { + if !fired { + timer.remove(); + } + } + } } impl UiState { @@ -57,6 +77,8 @@ impl UiState { revealer: Some(revealer), root: Some(root), visibility: Some(visibility), + hide_timer: None, + hide_timer_fired: None, } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs index 85b161816..bf89982a6 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs @@ -102,5 +102,6 @@ fn notification() -> NotificationView { received_at_unix_seconds: 1_000, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 2c029b186..27a9b141a 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -449,6 +449,7 @@ fn notification() -> NotificationView { received_at_unix_seconds: 1_000, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs index ce4afb7bb..a42dc33bb 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs @@ -28,5 +28,6 @@ pub(super) fn notification() -> NotificationView { received_at_unix_seconds: 1_000, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index f399752c3..02e2e6d8c 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -99,5 +99,6 @@ pub(super) fn notification() -> NotificationView { received_at_unix_seconds: 1_000, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index ae4718239..3aa6e3f84 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -24,5 +24,6 @@ pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView ..NotificationImage::default() }, popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/popups/mod.rs b/crates/unixnotis-popups/src/ui/popups/mod.rs index d5dcbcf04..81bed33ee 100644 --- a/crates/unixnotis-popups/src/ui/popups/mod.rs +++ b/crates/unixnotis-popups/src/ui/popups/mod.rs @@ -2,4 +2,5 @@ mod mutation; mod reconcile; +mod timeout; mod visibility; diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 14a23283d..34b372f78 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -45,6 +45,11 @@ impl UiState { refresh_visibility: bool, ) { let id = notification.id; + let key = notification.key(); + if self.hidden_popups.contains(&key) { + // A local display timeout suppresses duplicate banner updates for this generation + return; + } if let Some(existing) = self.popups.get(&id) { // A later generation always dominates an old or duplicated add event if existing.notification.generation >= notification.generation { @@ -55,6 +60,9 @@ impl UiState { return; } + // A replacement generation starts a fresh popup display lifecycle + self.hidden_popups.retain(|hidden| hidden.id != id); + // Hidden overflow rows stay as plain data until they can actually be shown self.popups.insert(id, PopupEntry::queued(notification)); self.popup_order.push_front(id); @@ -80,6 +88,7 @@ impl UiState { .popups .get(&id) .map(|entry| entry.notification.generation); + let new_generation = existing_generation != Some(notification.generation); if incoming_generation_is_stale(existing_generation, notification.generation) { // Reordered older updates cannot roll a popup back debug!( @@ -89,6 +98,16 @@ impl UiState { ); return false; } + let key = notification.key(); + self.hidden_popups + .retain(|hidden| hidden.id != id || hidden.generation >= key.generation); + if self.hidden_popups.contains(&key) { + // Keep the payload current without reviving a banner the user already saw + if let Some(entry) = self.popups.get_mut(&id) { + entry.notification = notification; + } + return false; + } if !show_popup { // A newer suppressed generation removes any older visible payload for this ID self.remove_popup_internal(id, refresh_visibility); @@ -119,6 +138,10 @@ impl UiState { if refresh_visibility { self.update_popup_visibility(rebuilt_visible_row); } + if rebuilt_visible_row && new_generation { + // A replacement generation starts a fresh local banner timeout + self.schedule_popup_hide(key); + } debug!(id, "popup updated"); rebuilt_visible_row } @@ -128,13 +151,24 @@ impl UiState { .popups .get(&key.id) .map(|entry| entry.notification.generation); + // A hidden banner may already be absent from the widget map + // Remove its exact-generation marker when the daemon closes it + let hidden_marker_removed = self.hidden_popups.remove(&key); if generation_matches(existing_generation, key.generation) { self.remove_popup_internal(key.id, true); + } else if hidden_marker_removed { + debug!( + id = key.id, + generation = key.generation, + "cleared hidden popup marker after close" + ); } } pub(super) fn remove_popup_internal(&mut self, id: u32, refresh_visibility: bool) { if let Some(entry) = self.popups.remove(&id) { + let mut entry = entry; + entry.cancel_hide_timer(); if let Some(revealer) = entry.revealer { // Visible rows animate out before leaving the stack revealer.set_reveal_child(false); @@ -158,6 +192,19 @@ impl UiState { debug!(id, total = self.popup_order.len(), "popup removed"); } + pub(in crate::ui) fn hide_popup_if_generation(&mut self, key: NotificationKey) { + let matches = self + .popups + .get(&key.id) + .is_some_and(|entry| entry.notification.key() == key); + if !matches { + return; + } + // Keep this marker until the generation closes or is replaced + self.hidden_popups.insert(key); + self.remove_popup_internal(key.id, true); + } + fn rebuild_materialized_popup(&mut self, notification: &NotificationView) -> bool { let id = notification.id; let Some(revealer) = self diff --git a/crates/unixnotis-popups/src/ui/popups/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/reconcile.rs index d089a49ba..42c1016d3 100644 --- a/crates/unixnotis-popups/src/ui/popups/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/reconcile.rs @@ -10,7 +10,10 @@ use super::mutation::ReconcilePlan; impl UiState { pub(in super::super) fn reconcile_seed(&mut self, active: Vec) { // Seed is a full snapshot, so desired popups come only from this list - let desired = desired_seed_popups(active, &self.control_state); + let desired = desired_seed_popups(active, &self.control_state) + .into_iter() + .filter(|notification| !self.hidden_popups.contains(¬ification.key())) + .collect::>(); // Compare only the portable notification payload so seed logic stays deterministic let local = self .popups diff --git a/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs b/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs new file mode 100644 index 000000000..ca394b238 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use super::super::timeout::popup_display_timeout; +use unixnotis_core::{Config, NotificationImage, NotificationView, Urgency}; + +fn notification(timeout_ms: u64, urgency: Urgency) -> NotificationView { + NotificationView { + id: 1, + generation: 1, + app_name: "TestApp".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "summary".to_string(), + body: "body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: urgency as u8, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: timeout_ms, + } +} + +#[test] +fn normal_popup_uses_the_configured_display_timeout() { + let config = Config::default(); + + assert_eq!( + popup_display_timeout(¬ification( + config.popups.default_timeout_ms, + Urgency::Normal + )), + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} + +#[test] +fn critical_popup_without_a_critical_timeout_stays_visible() { + assert_eq!( + popup_display_timeout(¬ification(0, Urgency::Critical)), + None + ); +} + +#[test] +fn critical_popup_uses_its_own_configured_timeout_when_present() { + assert_eq!( + popup_display_timeout(¬ification(2_500, Urgency::Critical)), + Some(Duration::from_millis(2_500)) + ); +} + +#[test] +fn zero_display_timeout_disables_local_hiding() { + assert_eq!( + popup_display_timeout(¬ification(0, Urgency::Normal)), + None + ); +} diff --git a/crates/unixnotis-popups/src/ui/popups/timeout.rs b/crates/unixnotis-popups/src/ui/popups/timeout.rs new file mode 100644 index 000000000..d49cb94fc --- /dev/null +++ b/crates/unixnotis-popups/src/ui/popups/timeout.rs @@ -0,0 +1,57 @@ +//! Local popup display timers + +use std::time::Duration; +use std::{cell::Cell, rc::Rc}; + +use unixnotis_core::{NotificationKey, NotificationView}; + +use crate::dbus::UiEvent; + +use super::super::UiState; + +pub(super) fn popup_display_timeout(notification: &NotificationView) -> Option { + // The daemon has already resolved protocol, urgency, rule, and resident policy + let timeout_ms = notification.popup_hide_after_ms; + + // Zero disables local hiding while the active daemon record remains available + (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)) +} + +impl UiState { + pub(super) fn schedule_popup_hide(&mut self, key: NotificationKey) { + let Some(sender) = self.popup_event_tx.clone() else { + // Unit tests construct state without an application event channel + return; + }; + let Some(notification) = self + .popups + .get(&key.id) + .filter(|entry| entry.notification.key() == key) + .map(|entry| entry.notification.clone()) + else { + return; + }; + let Some(timeout) = popup_display_timeout(¬ification) else { + return; + }; + let Some(entry) = self.popups.get_mut(&key.id) else { + return; + }; + entry.cancel_hide_timer(); + let fired = Rc::new(Cell::new(false)); + let callback_fired = Rc::clone(&fired); + entry.hide_timer = Some(glib::timeout_add_local_once(timeout, move || { + // This event only removes the popup process's banner + callback_fired.set(true); + // Wait asynchronously if the shared UI queue is briefly full + glib::MainContext::default().spawn_local(async move { + let _ = sender.send(UiEvent::PopupHidden(key)).await; + }); + })); + entry.hide_timer_fired = Some(fired); + } +} + +#[cfg(test)] +#[path = "tests/timeout.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index 205f76f15..e11c03167 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -109,6 +109,7 @@ impl UiState { let restack_ids = visible_popup_restack_ids(&previous_visible, &desired_visible); let mut update = VisiblePopupUpdate::default(); let mut applied_visible = Vec::with_capacity(desired_visible.len()); + let mut newly_materialized = Vec::new(); for id in &previous_visible { if desired_visible_set.contains(id) { // Rows that stay visible keep their current widgets @@ -155,6 +156,7 @@ impl UiState { &self.command_tx, UiCommand::Materialized(entry.notification.key()), ); + newly_materialized.push(entry.notification.key()); } applied_visible.push(*id); } @@ -182,6 +184,9 @@ impl UiState { } self.visible_popups = applied_visible; + for key in newly_materialized { + self.schedule_popup_hide(key); + } update } } diff --git a/crates/unixnotis-popups/src/ui/state/constructor.rs b/crates/unixnotis-popups/src/ui/state/constructor.rs index f026bc33b..2143c157f 100644 --- a/crates/unixnotis-popups/src/ui/state/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/constructor.rs @@ -42,11 +42,13 @@ impl UiState { config_path, css, command_tx, + popup_event_tx: None, popup_window, popup_stack, popup_input_region, popups: HashMap::new(), popup_order: VecDeque::new(), + hidden_popups: std::collections::HashSet::new(), visible_popups: Vec::new(), // Startup remains permissive until the daemon seed arrives control_state: ControlState::default(), @@ -59,4 +61,12 @@ impl UiState { icon_texture_cache: Rc::new(RefCell::new(TextureCache::new_for_popups())), } } + + pub(crate) fn set_popup_event_sender( + &mut self, + sender: async_channel::Sender, + ) { + // The production event loop owns this sender; tests can leave it unset + self.popup_event_tx = Some(sender); + } } diff --git a/crates/unixnotis-popups/src/ui/state/events.rs b/crates/unixnotis-popups/src/ui/state/events.rs index 6943f365e..c23308f2d 100644 --- a/crates/unixnotis-popups/src/ui/state/events.rs +++ b/crates/unixnotis-popups/src/ui/state/events.rs @@ -15,6 +15,7 @@ impl UiState { UiEvent::Disconnected => { debug!("UnixNotis control service disconnected"); self.control_state = ControlState::default(); + self.hidden_popups.clear(); self.reconcile_seed(Vec::new()); } UiEvent::Seed { state, active } => { @@ -44,6 +45,14 @@ impl UiState { debug!(id = key.id, generation = key.generation, "popup closed"); self.remove_popup_if_generation(key); } + UiEvent::PopupHidden(key) => { + debug!( + id = key.id, + generation = key.generation, + "popup banner hidden" + ); + self.hide_popup_if_generation(key); + } UiEvent::PopupGateChanged(gate) => { // Gate updates change only policy fields and preserve unrelated daemon state apply_popup_gate(&mut self.control_state, gate); diff --git a/crates/unixnotis-popups/src/ui/state/model.rs b/crates/unixnotis-popups/src/ui/state/model.rs index baa10160a..d202fd27d 100644 --- a/crates/unixnotis-popups/src/ui/state/model.rs +++ b/crates/unixnotis-popups/src/ui/state/model.rs @@ -1,7 +1,7 @@ //! Popup UI state owned by the GTK main thread use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::time::Instant; @@ -10,7 +10,7 @@ use unixnotis_core::{Config, ControlState}; use unixnotis_ui::css::CssManager; use unixnotis_ui::icons::DesktopIconIndex; -use crate::dbus::UiCommand; +use crate::dbus::{UiCommand, UiEvent}; use super::super::entry::PopupEntry; use super::super::icons::TextureCache; @@ -22,12 +22,16 @@ pub struct UiState { pub(in crate::ui) config_path: std::path::PathBuf, pub(in crate::ui) css: CssManager, pub(in crate::ui) command_tx: Sender, + // Popup-only events let local banner timers avoid mutating daemon state + pub(in crate::ui) popup_event_tx: Option>, pub(in crate::ui) popup_window: gtk::ApplicationWindow, pub(in crate::ui) popup_stack: gtk::Box, // Shared popup input shaping state for config and runtime updates pub(in crate::ui) popup_input_region: PopupInputRegionState, pub(in crate::ui) popups: HashMap, pub(in crate::ui) popup_order: VecDeque, + // A hidden banner stays hidden for its exact generation until it is replaced + pub(in crate::ui) hidden_popups: HashSet, // Only visible ids need repeated GTK updates during backlog churn pub(in crate::ui) visible_popups: Vec, // Latest daemon gate state used to keep visible popups in policy diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 67ca4d059..b87601410 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -46,6 +46,7 @@ fn popup_entry_uses_the_configured_cut_corner_primitive() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; let entry = state.build_popup_entry(¬ification); @@ -95,6 +96,7 @@ fn default_popup_entry_uses_the_native_rounded_card_without_a_clipper() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; let entry = state.build_popup_entry(¬ification); @@ -176,6 +178,7 @@ fn critical_popup_probe_builds_the_root_class_and_badge() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; let root = state.build_popup_root(¬ification); @@ -237,6 +240,7 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; let root = state.build_popup_root(¬ification); @@ -295,6 +299,7 @@ fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; let root = state.build_popup_root(¬ification); @@ -351,6 +356,7 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; notification.image.badge_icon = "signal-desktop".to_string(); diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 1ca80d6a1..ff80bc9d9 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -199,6 +199,58 @@ fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { )); } +#[gtk::test] +fn popup_display_timeout_hides_only_the_local_banner_generation() { + let (mut state, _command_rx) = popup_state_with_commands("org.unixnotis.PopupLocalHide", 1); + state.config.popups.default_timeout_ms = 1; + let (event_tx, event_rx) = async_channel::bounded(2); + state.set_popup_event_sender(event_tx); + let mut first = notification(31, 1, "active action"); + first.popup_hide_after_ms = 1; + + state.handle_event(UiEvent::NotificationAdded(first.clone(), true)); + assert!(state.popups.contains_key(&first.id)); + + std::thread::sleep(std::time::Duration::from_millis(15)); + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + let hidden = event_rx + .try_recv() + .expect("display timeout should emit a local hide event"); + assert!(matches!(hidden, UiEvent::PopupHidden(key) if key == first.key())); + state.handle_event(hidden); + + assert!(!state.popups.contains_key(&first.id)); + assert!(state.hidden_popups.contains(&first.key())); + + // An update for the same live generation must not resurrect its banner + state.handle_event(UiEvent::NotificationUpdated(first.clone(), true)); + assert!(!state.popups.contains_key(&first.id)); + + // Closing the active record also releases the local hidden-banner marker + state.handle_event(UiEvent::NotificationClosed( + first.key(), + unixnotis_core::CloseReason::DismissedByUser, + )); + assert!(!state.hidden_popups.contains(&first.key())); + + // A replacement generation is a new notification lifecycle + let replacement = notification(31, 2, "replacement action"); + state.handle_event(UiEvent::NotificationUpdated(replacement.clone(), true)); + assert!(state.popups.contains_key(&replacement.id)); + assert_eq!( + state + .popups + .get(&replacement.id) + .expect("replacement popup") + .notification + .generation, + replacement.generation + ); +} + #[gtk::test] fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation() { let (mut state, mut command_rx) = @@ -398,5 +450,6 @@ fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 3a5c0b9c4..9e37219e0 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -414,6 +414,7 @@ fn popup_status_uses_the_committed_reason_instead_of_current_state() { max_visible_at_commit: 0, decided_at_unix_ms: 1_000, delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, + popup_hide_after_ms: 0, }; assert_eq!( diff --git a/crates/unixnotis-ui/src/presentation/tests/support.rs b/crates/unixnotis-ui/src/presentation/tests/support.rs index 5da54d6df..1bb054308 100644 --- a/crates/unixnotis-ui/src/presentation/tests/support.rs +++ b/crates/unixnotis-ui/src/presentation/tests/support.rs @@ -28,5 +28,6 @@ pub(super) fn notification() -> NotificationView { received_at_unix_seconds: 1_000, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } From a200f04730b02b0a56288260c7b0105be0483eb3 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 03:29:51 -0500 Subject: [PATCH 227/275] fix(center): keep stacked notification rows inside measured bounds Measure collapsed rear layers in the same overlay as the foreground card and keep all visible offsets inside the row allocation. Apply scroll resets after allocation, preserve the hidden-panel breathing room, and keep group-owned rows compact when identity moves to the group header. Update the bundled surface hooks and GTK geometry regressions together. --- crates/unixnotis-center/src/ui/events.rs | 95 ++++++++++++++++--- .../src/ui/events/tests/scroll.rs | 60 +++++++++++- .../src/ui/init/constructor.rs | 12 ++- .../notifications/row/notification/build.rs | 7 +- .../notifications/row/notification/stack.rs | 28 ++---- .../row/notification/tests/stack.rs | 6 +- .../row/notification/update/tests/state.rs | 8 +- .../src/ui/notifications/view/build.rs | 4 + .../src/ui/notifications/view/tests/build.rs | 20 +++- .../src/ui/panel/behavior/keyboard.rs | 6 ++ .../src/ui/panel/behavior/visibility.rs | 2 + crates/unixnotis-center/src/ui/state.rs | 2 + crates/unixnotis-core/assets/panel.css | 5 +- crates/unixnotis-core/assets/popup.css | 4 +- .../src/css/hooks/tests/hooks.rs | 4 +- .../unixnotis-core/src/embedded/tests/css.rs | 4 +- 16 files changed, 220 insertions(+), 47 deletions(-) diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index ca005d55e..d43ca844b 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -11,6 +11,20 @@ use crate::control::UiEvent; use super::{panel, UiState}; +pub(in crate::ui) fn connect_user_scroll_tracking( + scroller: >k::ScrolledWindow, + generation: std::rc::Rc>, +) { + let controller = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + controller.connect_scroll(move |_, _, delta_y| { + if delta_y.abs() > f64::EPSILON { + generation.set(generation.get().wrapping_add(1)); + } + gtk::glib::Propagation::Proceed + }); + scroller.add_controller(controller); +} + impl UiState { pub fn handle_event(&mut self, event: UiEvent) { match event { @@ -231,6 +245,8 @@ impl UiState { &self.panel.sections.scroller, self.notification_rebuild_generation.clone(), generation, + self.scroll_user_generation.clone(), + self.scroll_user_generation.get(), policy, ); } @@ -268,32 +284,89 @@ pub(in crate::ui) fn reset_notification_scroll( scroller: >k::ScrolledWindow, rebuild_generation: std::rc::Rc>, expected_generation: u64, + scroll_user_generation: std::rc::Rc>, + expected_user_generation: u64, policy: ScrollResetPolicy, ) { let scroller = scroller.clone(); gtk::glib::idle_add_local_once(move || { - // Layout work can yield to a real user scroll before this callback runs - // Recheck both the rebuild and scroll state so stale work cannot win - if should_apply_scroll_reset( - rebuild_generation.get(), - expected_generation, - &scroller, - policy, - ) { - let adjustment = scroller.vadjustment(); + // A mapped panel gets a frame callback after recycled rows are allocated + if scroller.is_mapped() { + scroller.add_tick_callback(move |scroller, _clock| { + apply_scroll_reset_after_allocation( + scroller, + &rebuild_generation, + expected_generation, + &scroll_user_generation, + expected_user_generation, + policy, + ) + }); + return; + } + + // Unmapped unit-test widgets have no frame clock; apply only with valid geometry + let adjustment = scroller.vadjustment(); + if adjustment.page_size() > 0.0 + && should_apply_scroll_reset( + rebuild_generation.get(), + expected_generation, + scroll_user_generation.get(), + expected_user_generation, + &scroller, + policy, + ) + { adjustment.set_value(adjustment.lower()); } }); } +fn apply_scroll_reset_after_allocation( + scroller: >k::ScrolledWindow, + rebuild_generation: &std::rc::Rc>, + expected_generation: u64, + scroll_user_generation: &std::rc::Rc>, + expected_user_generation: u64, + policy: ScrollResetPolicy, +) -> gtk::glib::ControlFlow { + // A tick runs after GTK has had a chance to measure recycled rows + let adjustment = scroller.vadjustment(); + if adjustment.page_size() <= 0.0 { + // Unmapped panels can need another frame before allocation is valid + return gtk::glib::ControlFlow::Continue; + } + + // Layout work can yield to a real user scroll before this callback runs + // Recheck both the rebuild and scroll state so stale work cannot win + if should_apply_scroll_reset( + rebuild_generation.get(), + expected_generation, + scroll_user_generation.get(), + expected_user_generation, + scroller, + policy, + ) { + adjustment.set_value(adjustment.lower()); + } + gtk::glib::ControlFlow::Break +} + fn should_apply_scroll_reset( current_generation: u64, expected_generation: u64, + current_user_generation: u64, + expected_user_generation: u64, scroller: >k::ScrolledWindow, policy: ScrollResetPolicy, ) -> bool { - scroll_reset_generation_is_current(current_generation, expected_generation) - && (matches!(policy, ScrollResetPolicy::Force) || should_snap_to_top(scroller)) + if !scroll_reset_generation_is_current(current_generation, expected_generation) + || !scroll_reset_generation_is_current(current_user_generation, expected_user_generation) + { + return false; + } + + matches!(policy, ScrollResetPolicy::Force) || should_snap_to_top(scroller) } const fn scroll_reset_generation_is_current(current: u64, expected: u64) -> bool { diff --git a/crates/unixnotis-center/src/ui/events/tests/scroll.rs b/crates/unixnotis-center/src/ui/events/tests/scroll.rs index dd1e30ef3..c638cfe9c 100644 --- a/crates/unixnotis-center/src/ui/events/tests/scroll.rs +++ b/crates/unixnotis-center/src/ui/events/tests/scroll.rs @@ -37,28 +37,70 @@ fn scroll_reset_requires_current_generation_and_near_top_position() { assert!(should_apply_scroll_reset( 4, 4, + 2, + 2, &scroller, - ScrollResetPolicy::NearTopOnly + ScrollResetPolicy::NearTopOnly, )); assert!(!should_apply_scroll_reset( 3, 4, + 2, + 2, &scroller, - ScrollResetPolicy::NearTopOnly + ScrollResetPolicy::NearTopOnly, )); adjustment.set_value(130.0); assert!(!should_apply_scroll_reset( 4, 4, + 2, + 2, &scroller, - ScrollResetPolicy::NearTopOnly + ScrollResetPolicy::NearTopOnly, )); assert!(should_apply_scroll_reset( 4, 4, + 2, + 2, &scroller, - ScrollResetPolicy::Force + ScrollResetPolicy::Force, + )); +} + +#[gtk::test] +fn force_scroll_reset_rejects_a_new_user_scroll_generation() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(100.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + + // Adjustment movement alone may come from layout, so only the explicit + // interaction generation identifies a real user scroll + assert!(!should_apply_scroll_reset( + 4, + 4, + 3, + 2, + &scroller, + ScrollResetPolicy::Force, + )); +} + +#[gtk::test] +fn force_scroll_reset_accepts_layout_adjustment_changes_without_user_input() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(130.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + + assert!(should_apply_scroll_reset( + 4, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::Force, )); } @@ -70,7 +112,15 @@ fn deferred_scroll_reset_updates_the_adjustment_after_idle() { adjustment.set_value(108.0); let generation = Rc::new(Cell::new(7)); - reset_notification_scroll(&scroller, generation, 7, ScrollResetPolicy::NearTopOnly); + let user_generation = Rc::new(Cell::new(3)); + reset_notification_scroll( + &scroller, + generation, + 7, + user_generation, + 3, + ScrollResetPolicy::NearTopOnly, + ); while gtk::glib::MainContext::default().pending() { gtk::glib::MainContext::default().iteration(false); } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 42a5eac26..0ff1b9d47 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -23,6 +23,11 @@ impl UiState { // Build the panel widget tree first so child widgets can be attached safely let panel = panel::build::build_panel_widgets(&init.app, &init.config); + let scroll_user_generation = Rc::new(Cell::new(0)); + super::super::events::connect_user_scroll_tracking( + &panel.sections.scroller, + scroll_user_generation.clone(), + ); let icon_resolver = Rc::new(icons::IconResolver::new()); debug::set_level(PanelDebugLevel::Off); let list = build_notification_list(&panel, &init, icon_resolver.clone()); @@ -70,7 +75,11 @@ impl UiState { search_toggle_guard.clone(), ); panel::behavior::autoclose::connect_auto_close(&panel, &init, panel_visible_flag.clone()); - panel::behavior::keyboard::connect_keyboard_shortcuts(&panel, init.command_tx.clone()); + panel::behavior::keyboard::connect_keyboard_shortcuts( + &panel, + init.command_tx.clone(), + scroll_user_generation.clone(), + ); if init.config.panel.respect_work_area { // Work area is refreshed early to ensure the panel anchors correctly @@ -96,6 +105,7 @@ impl UiState { panel_visible: false, notifications_changed_while_hidden: false, notification_rebuild_generation: Rc::new(Cell::new(0)), + scroll_user_generation, panel_visible_flag, work_area: None, last_count: None, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index b34e8c166..753f94846 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -198,8 +198,13 @@ pub(in crate::ui::notifications) fn build_notification_row( let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); + // One overlay cell keeps positive stack offsets inside the measured row bounds + let stack = gtk::Overlay::new(); + stack.add_css_class("unixnotis-panel-notification-stack"); + stack.set_hexpand(true); + root.append(&stack); // Master-style silhouettes preserve the visible group depth without accepting input - let (stack_middle, stack_back) = append_stack_layers(&root, &card_plate); + let (stack_middle, stack_back) = append_stack_layers(&stack, &card_plate); let notify_key = Rc::new(Cell::new(NotificationKey { id: 0, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs index baecb3fb8..0330a13c1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -2,16 +2,6 @@ use gtk::prelude::*; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StackLayer { - Back, - Middle, - Foreground, -} - -const STACK_LAYER_ORDER: [StackLayer; 3] = - [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground]; - #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct StackLayerVisibility { pub(super) middle: bool, @@ -19,20 +9,20 @@ pub(super) struct StackLayerVisibility { } pub(super) fn append_stack_layers( - root: >k::Box, + root: >k::Overlay, foreground: &unixnotis_ui::CutCorner, ) -> (gtk::Box, gtk::Box) { let middle = build_stack_layer("unixnotis-stack-layer-middle"); let back = build_stack_layer("unixnotis-stack-layer-back"); - // Later GTK siblings paint above earlier siblings when margins overlap - for layer in STACK_LAYER_ORDER { - match layer { - StackLayer::Back => root.append(&back), - StackLayer::Middle => root.append(&middle), - StackLayer::Foreground => root.append(foreground), - } - } + // One overlay allocation keeps all three layers in the same measured cell + root.set_child(Some(&back)); + root.add_overlay(&middle); + root.add_overlay(foreground); + + // Rear shells never determine row height; the readable card does + root.set_measure_overlay(&middle, false); + root.set_measure_overlay(foreground, true); (middle, back) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs index 53e93ec9e..098533cfe 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -24,18 +24,20 @@ fn collapsed_stack_depth_maps_to_two_rear_layers() { #[gtk::test] fn stack_layers_paint_behind_foreground_and_never_accept_input() { - let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let root = gtk::Overlay::new(); let card = gtk::Box::new(gtk::Orientation::Vertical, 0); let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); let (middle, back) = append_stack_layers(&root, &foreground); - assert_eq!(root.first_child().as_ref(), Some(back.upcast_ref())); + assert_eq!(root.child().as_ref(), Some(back.upcast_ref())); assert_eq!(back.next_sibling().as_ref(), Some(middle.upcast_ref())); assert_eq!( middle.next_sibling().as_ref(), Some(foreground.upcast_ref()) ); + assert!(!root.is_measure_overlay(&middle)); + assert!(root.is_measure_overlay(&foreground)); assert!(!middle.can_target()); assert!(!back.can_target()); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 13b40460b..52ce7dd13 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -292,7 +292,13 @@ fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert_eq!(child_count(&root), 3); + let stack = root + .first_child() + .and_downcast::() + .expect("notification row should use one measured stack overlay"); + let stack_child_count = + std::iter::successors(stack.first_child(), gtk::prelude::WidgetExt::next_sibling).count(); + assert_eq!(stack_child_count, 3); assert!(row.stack_middle.get_visible()); assert!(row.stack_back.get_visible()); assert!(row.card_plate.get_visible()); diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index d6f560e44..3b6f0fd84 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -18,6 +18,9 @@ use super::types::{NotificationList, NotificationListConfig}; use super::widgets::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; use crate::ui::icons::IconResolver; +// Leave a complete-card breathing room at the end of the nested notification viewport +const NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM: i32 = 16; + impl NotificationList { pub fn new( scroller: gtk::ScrolledWindow, @@ -34,6 +37,7 @@ impl NotificationList { list_view.add_css_class("unixnotis-panel-list"); list_view.set_hexpand(true); list_view.set_vexpand(true); + list_view.set_margin_bottom(NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM); let overlay = gtk::Overlay::new(); overlay.add_css_class("unixnotis-panel-list-overlay"); diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index 8210b233e..25858bcaa 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -1,9 +1,11 @@ -use gtk::prelude::WidgetExt; +use gtk::prelude::*; use gtk::Align; use unixnotis_core::EmptyStateAlignment; use crate::ui::notifications::test_support as support; +use super::NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM; + #[gtk::test] fn new_list_attaches_overlay_to_scroller() { support::init_gtk(); @@ -19,6 +21,22 @@ fn new_list_attaches_overlay_to_scroller() { ); assert!(scroller.child().is_some()); + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should wrap the notification list in a viewport"); + let overlay = viewport + .child() + .and_downcast::() + .expect("viewport should contain the notification-list overlay"); + let list_view = overlay + .child() + .and_downcast::() + .expect("overlay should keep the virtualized list as its main child"); + assert_eq!( + list_view.margin_bottom(), + NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM + ); assert_eq!(list.empty_text, "No notifications"); assert_eq!(list.no_matching_text, "No matching notifications"); assert_eq!(list.empty_offset_top, 24); diff --git a/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs index 856e3c638..bb00638fd 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs @@ -1,5 +1,8 @@ //! Keyboard shortcut wiring for the panel +use std::cell::Cell; +use std::rc::Rc; + use gtk::gdk; use gtk::prelude::*; @@ -75,6 +78,7 @@ pub(super) fn keyboard_action_for( pub(in crate::ui) fn connect_keyboard_shortcuts( panel: &PanelWidgets, command_tx: tokio::sync::mpsc::Sender, + scroll_user_generation: Rc>, ) { let focus_toggle = panel.header.actions.focus_toggle.clone(); let search_toggle = panel.header.actions.search_toggle.clone(); @@ -114,10 +118,12 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( gtk::glib::Propagation::Stop } KeyboardPanelAction::ScrollDown => { + scroll_user_generation.set(scroll_user_generation.get().wrapping_add(1)); nudge_scroller(&scroller, 72.0); gtk::glib::Propagation::Stop } KeyboardPanelAction::ScrollUp => { + scroll_user_generation.set(scroll_user_generation.get().wrapping_add(1)); nudge_scroller(&scroller, -72.0); gtk::glib::Propagation::Stop } diff --git a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index 98d17c07d..a2301c940 100644 --- a/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -111,6 +111,8 @@ impl UiState { &self.panel.sections.scroller, self.notification_rebuild_generation.clone(), self.notification_rebuild_generation.get(), + self.scroll_user_generation.clone(), + self.scroll_user_generation.get(), crate::ui::events::ScrollResetPolicy::Force, ); } diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 0c160ad05..da1d5cdc4 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -36,6 +36,8 @@ pub struct UiState { pub(super) notifications_changed_while_hidden: bool, // Each list rebuild invalidates older idle scroll callbacks pub(super) notification_rebuild_generation: Rc>, + // Pointer and touch scrolling invalidate deferred forced resets explicitly + pub(super) scroll_user_generation: Rc>, pub(super) panel_visible_flag: Arc, pub(super) work_area: Option, // Tracks the last rendered counts to avoid redundant label updates diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 090568de8..2f5289b43 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -539,8 +539,7 @@ entry selection { } .unixnotis-panel-card-foreground.collapsed-group-preview { - margin-top: -58px; - margin-bottom: 10px; + margin: 12px 8px 10px; } .unixnotis-stack-layer { @@ -558,7 +557,7 @@ entry selection { } .unixnotis-stack-layer-middle { - margin: -58px 14px 0; + margin: 6px 14px 0; opacity: 0.86; } diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 08dfe874f..7aa78c8c3 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -19,7 +19,8 @@ min-height: 26px; min-height: var(--unixnotis-popup-close-size); color: alpha(#ffffff, 0.75); - opacity: 0; + /* Touch and keyboard users need a visible resting target */ + opacity: 0.58; transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } @@ -34,6 +35,7 @@ } .unixnotis-popup-close:hover { + opacity: 1; background: alpha(#fb7185, 0.18); border-color: alpha(#fb7185, 0.45); color: #fb7185; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 0062a5d52..07d5f6381 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -277,7 +277,9 @@ fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { assert!(!css.contains("unixnotis-stack-ghost")); assert!(css.contains(".unixnotis-stack-layer-back")); assert!(css.contains(".unixnotis-stack-layer-middle")); - assert!(css.contains("margin: -58px 14px 0")); + assert!(css.contains("margin: 6px 14px 0")); + assert!(css.contains("margin: 12px 8px 10px")); + assert!(!css.contains("margin: -58px 14px 0")); assert!(css.contains("margin: 0 20px")); assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); } diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 72aafe912..786b7fee2 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -165,7 +165,9 @@ fn notification_surfaces_keep_compact_master_geometry() { assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-radius)")); assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-y)")); assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-x)")); - assert!(DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); + assert!(DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); + assert!(DEFAULT_PANEL_CSS.contains("margin: 12px 8px 10px")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); assert!(DEFAULT_PANEL_CSS.contains("margin: 0 20px")); } From fd1c1186d20b508f944f1c368772ac20691c65e0 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 03:26:05 -0500 Subject: [PATCH 228/275] fix(popups): prevent visible generations from replaying after reconnect Use the daemon delivery stage as the durable replay boundary and retain the local hidden-generation filter as a defensive layer. A renderer restart can seed active notifications again, but generations that reached the visible stage remain panel-active without returning to the popup banner. --- crates/unixnotis-popups/src/ui/popups/reconcile.rs | 8 ++++++-- .../unixnotis-popups/src/ui/popups/tests/reconcile.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/unixnotis-popups/src/ui/popups/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/reconcile.rs index 42c1016d3..a3389884b 100644 --- a/crates/unixnotis-popups/src/ui/popups/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/reconcile.rs @@ -2,7 +2,7 @@ use std::borrow::Borrow; use std::collections::{HashMap, HashSet, VecDeque}; use tracing::debug; -use unixnotis_core::{popup_allowed_by_state, ControlState, NotificationView}; +use unixnotis_core::{popup_allowed_by_state, ControlState, NotificationView, PopupDeliveryStage}; use super::super::UiState; use super::mutation::ReconcilePlan; @@ -111,7 +111,11 @@ pub(super) fn desired_seed_popups( // This keeps reconnect snapshots and live signals on the same visibility rules active .into_iter() - .filter(|notification| popup_allowed_by_state(notification.urgency, state)) + .filter(|notification| { + popup_allowed_by_state(notification.urgency, state) + && notification.popup_decision.delivery_stage.rank() + < PopupDeliveryStage::Visible.rank() + }) .collect() } diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index a6bf38ed2..0437c47e2 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -23,9 +23,20 @@ fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { received_at_unix_seconds: 0, image: NotificationImage::default(), popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } +#[test] +fn visible_generations_are_not_recreated_from_a_reconnect_seed() { + let mut notification = make_view(10, Urgency::Normal, "already shown"); + notification.popup_decision.delivery_stage = unixnotis_core::PopupDeliveryStage::Visible; + + let desired = desired_seed_popups(vec![notification], &ControlState::default()); + + assert!(desired.is_empty()); +} + #[test] fn desired_seed_clears_all_popups_when_inhibited() { let state = ControlState { From f2b6086af063cf9963ac97d7db3b5f91373dc138 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 13:46:19 -0500 Subject: [PATCH 229/275] fix(notifications): keep ordinary positive timeouts active Treat positive protocol timeouts as banner durations for ordinary notifications. Keep the daemon record and its generation-bound actions active unless the notification is transient or resident policy requires a different lifecycle. --- .../daemon/notifications/server/tests/flow.rs | 40 ++++++++++++++++++- .../src/store/notifications/tests/timeout.rs | 16 ++++++++ .../src/store/notifications/timeout.rs | 5 ++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 3ebaae719..47c2fc275 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -220,12 +220,14 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { } #[tokio::test] -async fn ingest_notify_schedules_expiration_for_positive_timeout() { +async fn ingest_notify_schedules_expiration_for_positive_transient_timeout() { let state = daemon_state_for_test(false).await; let scheduler = ExpirationScheduler::start(state.clone()); let server = NotificationServer::new(state.clone(), scheduler); let message = notify_header_message(); let header = message.header(); + let mut hints = HashMap::new(); + hints.insert("transient".to_string(), OwnedValue::from(true)); let id = server .ingest_notify( @@ -235,7 +237,7 @@ async fn ingest_notify_schedules_expiration_for_positive_timeout() { "expires".to_string(), "body".to_string(), Vec::new(), - HashMap::new().into(), + hints.into(), &header, 25, ) @@ -266,6 +268,40 @@ async fn ingest_notify_schedules_expiration_for_positive_timeout() { panic!("notification should expire after scheduled timeout"); } +#[tokio::test] +async fn ingest_notify_keeps_ordinary_positive_timeout_active() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "persistent action".to_string(), + "body".to_string(), + vec!["default".to_string(), "View".to_string()], + HashMap::new().into(), + &header, + 25, + ) + .await + .expect("notify should store"); + + tokio::time::sleep(Duration::from_millis(40)).await; + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("ordinary positive timeout must keep the active record"); + assert_eq!(active.popup_hide_after_ms, 25); + assert_eq!(active.actions.len(), 1); +} + #[tokio::test] async fn default_popup_display_timeout_does_not_archive_the_active_notification() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs index eb1b003b6..1917fd90c 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs @@ -25,6 +25,22 @@ fn positive_protocol_timeout_controls_popup_and_active_lifetime() { let mut notification = make_notification("bounded"); notification.expire_timeout = 30_000; + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 30_000, + active_close_after: None, + } + ); +} + +#[test] +fn positive_protocol_timeout_closes_transient_notifications() { + let config = Config::default(); + let mut notification = make_notification("transient-bounded"); + notification.expire_timeout = 30_000; + notification.is_transient = true; + assert_eq!( resolve_timeout_policy(&config, ¬ification), super::super::timeout::ResolvedTimeoutPolicy { diff --git a/crates/unixnotis-daemon/src/store/notifications/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/timeout.rs index 049b6b767..78e306c8c 100644 --- a/crates/unixnotis-daemon/src/store/notifications/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/timeout.rs @@ -29,12 +29,13 @@ pub(super) fn resolve_timeout_policy( popup_hide_after_ms: 0, active_close_after: None, }, - // Positive values are an application-owned lifetime and banner duration + // Positive values control the banner; only transient notifications close + // automatically so ordinary panel actions remain available timeout if timeout > 0 => { let timeout_ms = timeout as u64; ResolvedTimeoutPolicy { popup_hide_after_ms: timeout_ms, - active_close_after: (!notification.is_resident) + active_close_after: (notification.is_transient && !notification.is_resident) .then(|| Duration::from_millis(timeout_ms)), } } From e4221827e060b2e4ff7c163afea0775da8909e00 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 13:46:25 -0500 Subject: [PATCH 230/275] fix(notifications): classify communication images as sender avatars Promote bounded wire image-data to the conversation-avatar role only after the daemon has established an associated communication context. Keep image-path content separate, downsample retained avatars to the shared limit, and preserve the no-host-path client boundary. --- .../notifications/ingress/payload/build.rs | 49 +++++++-- .../ingress/payload/tests/build.rs | 101 ++++++++++++++++++ .../ingress/payload/tests/visuals.rs | 1 + .../notifications/ingress/payload/visuals.rs | 41 +++++++ .../src/daemon/notifications/server/flow.rs | 10 +- 5 files changed, 192 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs index e2159c52e..2fe172f95 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -13,7 +13,7 @@ use super::super::limits::{ MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_SUMMARY_BYTES, }; use super::sanitize::parse_actions; -use super::visuals::{may_materialize_application_icon, SenderVisualRole}; +use super::visuals::{may_materialize_application_icon, normalize_avatar_visual, SenderVisualRole}; use super::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) struct NotificationInput { @@ -24,6 +24,7 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) actions: Vec, pub(in crate::daemon::notifications) hints: HashMap, pub(in crate::daemon::notifications) image_data: Option, + pub(in crate::daemon::notifications) sender_visual_data: Option, pub(in crate::daemon::notifications) sender_visual: Option, pub(in crate::daemon::notifications) sender_visual_role: SenderVisualRole, pub(in crate::daemon::notifications) sender: SenderMetadata, @@ -33,6 +34,13 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) expire_timeout: i32, } +struct ImageBuildInput { + image_data: Option, + sender_visual_data: Option, + sender_visual: Option, + sender_visual_role: SenderVisualRole, +} + pub(in crate::daemon::notifications) fn build_notification( input: NotificationInput, ) -> Notification { @@ -44,6 +52,7 @@ pub(in crate::daemon::notifications) fn build_notification( actions, hints, image_data, + sender_visual_data, sender_visual, sender_visual_role, sender, @@ -75,9 +84,12 @@ pub(in crate::daemon::notifications) fn build_notification( &app_name, &app_icon, &hints, - image_data, - sender_visual, - sender_visual_role, + ImageBuildInput { + image_data, + sender_visual_data, + sender_visual, + sender_visual_role, + }, &attribution, ); @@ -134,19 +146,38 @@ fn build_image( app_name: &str, app_icon: &str, hints: &HashMap, - image_data: Option, - sender_visual: Option, - sender_visual_role: SenderVisualRole, + input: ImageBuildInput, attribution: &NotificationAttribution, ) -> NotificationImage { + let ImageBuildInput { + image_data, + sender_visual_data, + sender_visual, + sender_visual_role, + } = input; // Keep daemon-selected badge identity separate from sender-provided pixels let mut image = NotificationImage::from_hints(app_name, app_icon, hints); image.badge_icon.clone_from(&attribution.badge_icon); - if let Some(image_data) = image_data.and_then(NotificationImage::normalize_image_data) { + let (wire_sender_visual, content_image) = match sender_visual_role { + SenderVisualRole::ConversationAvatar + if sender_visual_data.is_some() || sender_visual.is_some() => + { + (sender_visual_data, image_data) + } + // Direct payload builders may provide only image-data for a communication avatar + SenderVisualRole::ConversationAvatar => (image_data, None), + SenderVisualRole::ApplicationProvidedIcon | SenderVisualRole::None => { + (sender_visual_data, image_data) + } + }; + if let Some(image_data) = content_image.and_then(NotificationImage::normalize_image_data) { image.content_image = image_data; } if may_materialize_application_icon(attribution) { - if let Some(visual) = sender_visual.and_then(NotificationImage::normalize_image_data) { + if let Some(visual) = wire_sender_visual + .or(sender_visual) + .and_then(normalize_avatar_visual) + { image.sender_visual_role = match sender_visual_role { SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, SenderVisualRole::ApplicationProvidedIcon => { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs index 47a754735..1a4bdb853 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -12,6 +12,7 @@ fn build_notification_clamps_summary_and_body_sizes() { actions: Vec::new(), hints: HashMap::::new(), image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { @@ -50,6 +51,7 @@ fn build_notification_rejects_content_pixels_above_retained_limit() { channels: 4, data: vec![0; 512 * 512 * 4], }), + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::None, sender: SenderMetadata::default(), @@ -72,6 +74,7 @@ fn build_notification_strips_display_spoofing_controls() { actions: vec!["default".to_string(), "Open\u{202E}".to_string()], hints: HashMap::::new(), image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { @@ -118,6 +121,7 @@ fn build_notification_collects_inline_reply_action_and_kde_labels() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints, image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { @@ -155,6 +159,7 @@ fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy( actions: vec!["inline-reply".to_string(), "Password".to_string()], hints: HashMap::new(), image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata { @@ -197,6 +202,7 @@ fn build_notification_keeps_unknown_sender_reply_policy_denied() { actions: vec!["inline-reply".to_string(), "Reply".to_string()], hints: HashMap::new(), image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), @@ -240,6 +246,7 @@ fn build_notification_ignores_reply_hints_without_explicit_action() { actions: vec!["default".to_string(), "Open".to_string()], hints, image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), @@ -272,6 +279,7 @@ fn conversation_avatar_never_changes_badge_or_unresolved_identity() { actions: Vec::new(), hints: HashMap::new(), image_data: None, + sender_visual_data: None, sender_visual: Some(avatar), sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), @@ -308,6 +316,7 @@ fn sender_image_path_is_not_retained_in_notification_model() { actions: Vec::new(), hints, image_data: None, + sender_visual_data: None, sender_visual: None, sender_visual_role: SenderVisualRole::ConversationAvatar, sender: SenderMetadata::default(), @@ -328,3 +337,95 @@ fn sender_image_path_is_not_retained_in_notification_model() { assert!(notification.image.content_image.data.is_empty()); assert!(!notification.hints.contains_key("image-path")); } + +#[test] +fn associated_communication_image_data_becomes_a_bounded_conversation_avatar() { + let image = unixnotis_core::ImageData { + width: 128, + height: 128, + rowstride: 128 * 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7; 128 * 128 * 4], + }; + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category"), + )]), + image_data: Some(image), + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(notification.image.content_image.data.is_empty()); + assert_eq!(notification.image.sender_visual.width, 64); + assert_eq!(notification.image.sender_visual.height, 64); + assert!(notification.image.sender_visual.data.len() <= 64 * 64 * 4); +} + +#[test] +fn unassociated_communication_image_data_stays_untrusted_content() { + let image = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7, 8, 9, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: Some(image), + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Messages", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); + assert!(!notification.image.content_image.data.is_empty()); + assert!(notification.image.sender_visual.data.is_empty()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs index dfe10f6a0..a44497832 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -132,6 +132,7 @@ fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { actions: Vec::new(), hints: HashMap::new(), image_data: None, + sender_visual_data: None, sender_visual: Some(icon), sender_visual_role: SenderVisualRole::ApplicationProvidedIcon, sender: SenderMetadata::default(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs index 97f341952..376c790d8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -220,6 +220,47 @@ pub(in crate::daemon::notifications::ingress) fn downsample_avatar( Some((target_width, target_height, output)) } +pub(in crate::daemon::notifications) fn normalize_avatar_visual( + image: ImageData, +) -> Option { + // Wire images use the shared validator before entering this final avatar boundary + let image = unixnotis_core::NotificationImage::normalize_image_data(image)?; + let width = u32::try_from(image.width).ok()?; + let height = u32::try_from(image.height).ok()?; + let row_bytes = usize::try_from(width).ok()?.checked_mul(4)?; + let source_stride = usize::try_from(image.rowstride).ok()?; + if image.channels != 4 || source_stride < row_bytes { + return None; + } + let required = source_stride.checked_mul(usize::try_from(height).ok()?)?; + if image.data.len() < required { + return None; + } + + // Strip protocol row padding before the bounded downsampler runs + let mut rgba = vec![0_u8; row_bytes.checked_mul(usize::try_from(height).ok()?)?]; + for row in 0..usize::try_from(height).ok()? { + let source_start = row.checked_mul(source_stride)?; + let target_start = row.checked_mul(row_bytes)?; + rgba[target_start..target_start + row_bytes] + .copy_from_slice(&image.data[source_start..source_start + row_bytes]); + } + let (width, height, rgba) = + downsample_avatar(width, height, rgba, MAX_STORED_AVATAR_DIMENSION)?; + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let rowstride = width.checked_mul(4)?; + Some(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) +} + fn width_to_height(width: u32, height: u32, target_width: u32) -> u32 { if width <= target_width { return height; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 33cd2c1fb..e7e4f6134 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -161,6 +161,13 @@ impl NotificationServer { materialize_sender_visual_for_role(sender_visual_role, input.app_icon.clone()).await; let materialized_content = materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; + // Communication image-data is a bounded conversation visual, not a message attachment + let (image_data, wire_sender_visual) = + if matches!(sender_visual_role, SenderVisualRole::ConversationAvatar) { + (materialized_content, input.image_data) + } else { + (input.image_data.or(materialized_content), None) + }; if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -194,7 +201,8 @@ impl NotificationServer { body: input.body, actions: input.actions, hints: input.hints, - image_data: input.image_data.or(materialized_content), + image_data, + sender_visual_data: wire_sender_visual, sender_visual, sender_visual_role, sender, From 52167179c7394b46281424e036d3088ab4bd9694 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 13:46:34 -0500 Subject: [PATCH 231/275] fix(center): restore master-style panel conversation avatars Keep the existing 56 by 56 lead image slot visible for bounded conversation avatars by default. Give avatars priority over optional content thumbnails, clear recycled paintables before rebinding, and expose a separate configuration switch for privacy-sensitive avatar hiding. --- crates/unixnotis-center/src/ui/icons/cache.rs | 41 +----- .../src/ui/icons/resolution.rs | 74 ++-------- .../unixnotis-center/src/ui/icons/resolver.rs | 14 +- .../src/ui/icons/tests/cache.rs | 6 +- .../src/ui/icons/tests/resolution.rs | 2 +- .../unixnotis-center/src/ui/init/builders.rs | 1 + .../src/ui/notifications/model/item.rs | 3 + .../src/ui/notifications/model/types.rs | 2 + .../notifications/row/notification/state.rs | 4 +- .../row/notification/tests/support.rs | 18 ++- .../row/notification/update/metadata.rs | 10 +- .../row/notification/update/row.rs | 62 ++++---- .../row/notification/update/tests/state.rs | 25 ++-- .../notification/update/tests/thumbnail.rs | 135 ++++++++++++++++- .../row/notification/update/thumbnail.rs | 28 ++++ .../row/notification/update/visual.rs | 5 +- .../src/ui/notifications/store/blocks.rs | 1 + .../src/ui/notifications/store/lifecycle.rs | 1 + .../src/ui/notifications/store/mutation.rs | 1 + .../src/ui/notifications/tests/support.rs | 1 + .../src/ui/notifications/view/build.rs | 3 + .../src/ui/reload/config/widgets.rs | 1 + crates/unixnotis-core/assets/media.css | 136 ++++++++++++------ crates/unixnotis-core/assets/panel.css | 132 ++++++++++++----- .../unixnotis-core/src/config/panel/config.rs | 9 ++ .../src/config/panel/tests/config.rs | 2 + .../unixnotis-core/src/css/hooks/classes.rs | 2 + .../src/css/hooks/tests/hooks.rs | 2 +- .../unixnotis-core/src/embedded/tests/css.rs | 51 ++++++- .../unixnotis-core/src/model/notification.rs | 2 + .../src/model/tests/notification.rs | 25 ++++ .../notifications/ingress/payload/build.rs | 5 + .../ingress/payload/tests/build.rs | 11 +- crates/unixnotis-ui/src/presentation/build.rs | 22 +-- .../src/presentation/tests/presentation.rs | 44 ++++-- .../src/presentation/tests/visual_contract.rs | 11 ++ 36 files changed, 630 insertions(+), 262 deletions(-) diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index 930d00751..e229090e8 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -10,7 +10,6 @@ use std::rc::Rc; use gtk::gdk::{Paintable, Texture}; use gtk::prelude::*; use gtk::IconPaintable; -use unixnotis_core::NotificationImage; const DEFAULT_MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; const MAX_TRACKED_IMAGE_KEYS: usize = 4096; @@ -24,14 +23,6 @@ thread_local! { #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub(super) enum IconKey { - ImageData { - hash: [u8; 32], - len: usize, - width: i32, - height: i32, - size: i32, - scale: i32, - }, Path { path: PathBuf, size: i32, @@ -47,36 +38,11 @@ pub(super) enum IconKey { impl IconKey { pub(super) const fn size_and_scale(&self) -> (i32, i32) { match self { - Self::ImageData { size, scale, .. } - | Self::Path { size, scale, .. } - | Self::Name { size, scale, .. } => (*size, *scale), + Self::Path { size, scale, .. } | Self::Name { size, scale, .. } => (*size, *scale), } } } -pub(super) fn icon_key_for_image( - image: &NotificationImage, - size: i32, - scale: i32, -) -> Option { - if image.content_image.data.is_empty() { - return None; - } - let data = &image.content_image; - if data.data.is_empty() { - return None; - } - let hash = hash_image_data(&data.data); - Some(IconKey::ImageData { - hash, - len: data.data.len(), - width: data.width, - height: data.height, - size, - scale, - }) -} - pub(super) fn icon_key_for_path(path: &Path, size: i32, scale: i32) -> Option { // Empty path means “no icon path provided”; treat as absent rather than creating a useless cache key if path.as_os_str().is_empty() { @@ -106,11 +72,6 @@ pub(super) fn icon_key_for_name(name: &str, size: i32, scale: i32) -> Option [u8; 32] { - // Notification image payloads are already bounded, so hashing every byte keeps cache identity exact - *blake3::hash(data).as_bytes() -} - pub(super) fn set_image_key(image: >k::Image, key: IconKey) { IMAGE_KEYS.with(|entries| { let mut entries = entries.borrow_mut(); diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index 243bfba3f..023056838 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -1,14 +1,9 @@ //! Icon source selection and synchronous cache lookup -use std::rc::Rc; - use gtk::prelude::*; use unixnotis_core::NotificationView; -use super::cache::{ - icon_key_for_image, icon_key_for_name, icon_key_for_path, set_image_key, CachedPaintable, - IconKey, -}; +use super::cache::{icon_key_for_name, icon_key_for_path, set_image_key, CachedPaintable}; use super::resolver::IconResolverInner; use super::theme::{ collect_icon_candidates, image_data_texture, image_data_texture_for_data, resolve_icon_source, @@ -35,32 +30,27 @@ impl IconResolverInner { image.set_visible(false); } - pub(super) fn apply_badge( - &self, - image: >k::Image, - notification: &NotificationView, - size: i32, - scale: i32, - ) { - if let Some(resolved) = self.resolve_badge(notification, size, scale) { - self.apply_resolution(image, resolved); - return; + pub(super) fn apply_content_visual(&self, image: >k::Image, notification: &NotificationView) { + // Content pixels were bounded by the daemon before reaching GTK + if let Some(texture) = image_data_texture(¬ification.image) { + image.set_paintable(Some(&texture)); + image.set_visible(true); + } else { + image.set_visible(false); } - image.set_visible(false); } - pub(super) fn apply_icon( + pub(super) fn apply_badge( &self, image: >k::Image, notification: &NotificationView, size: i32, scale: i32, ) { - if let Some(resolved) = self.resolve_icon(notification, size, scale) { + if let Some(resolved) = self.resolve_badge(notification, size, scale) { self.apply_resolution(image, resolved); return; } - image.set_visible(false); } @@ -68,39 +58,6 @@ impl IconResolverInner { self.missing_names.borrow_mut().clear(); } - fn resolve_icon( - &self, - notification: &NotificationView, - size: i32, - scale: i32, - ) -> Option { - let image = ¬ification.image; - if let Some(key) = icon_key_for_image(image, size, scale) { - if let Some(paintable) = self.lookup_cached(key.clone(), || { - image_data_texture(image).map(CachedPaintable::from_texture) - }) { - return Some(IconResolution::Ready { key, paintable }); - } - } - - let candidates = collect_icon_candidates(notification); - for candidate in &candidates { - if let Some(icons) = self.desktop_index.icons_for(candidate) { - for icon_name in icons { - if let Some(resolution) = self.resolve_icon_name(&icon_name, size, scale) { - return Some(resolution); - } - } - } - } - for candidate in candidates { - if let Some(resolution) = self.resolve_icon_name(&candidate, size, scale) { - return Some(resolution); - } - } - None - } - fn resolve_badge( &self, notification: &NotificationView, @@ -186,17 +143,6 @@ impl IconResolverInner { } } } - - fn lookup_cached(&self, key: IconKey, build: F) -> Option> - where - F: FnOnce() -> Option, - { - if let Some(paintable) = self.cache.borrow_mut().get(&key) { - return Some(paintable); - } - let paintable = build()?; - Some(self.cache.borrow_mut().insert(key, paintable)) - } } const fn icon_name_is_usable(name: &str) -> bool { diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index e665834f1..b725ba637 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -47,16 +47,6 @@ impl IconResolver { Self { inner } } - pub fn apply_icon( - &self, - image: >k::Image, - notification: &NotificationView, - size: i32, - scale: i32, - ) { - self.inner.apply_icon(image, notification, size, scale); - } - pub fn apply_badge( &self, image: >k::Image, @@ -72,6 +62,10 @@ impl IconResolver { self.inner.apply_sender_visual(image, notification); } + pub fn apply_content_visual(&self, image: >k::Image, notification: &NotificationView) { + self.inner.apply_content_visual(image, notification); + } + pub fn clear_missing_cache(&self) { // Theme reloads must retry names that were previously unavailable self.inner.clear_missing_cache(); diff --git a/crates/unixnotis-center/src/ui/icons/tests/cache.rs b/crates/unixnotis-center/src/ui/icons/tests/cache.rs index 39776b257..9f383cebc 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/cache.rs @@ -1,4 +1,8 @@ -use super::{hash_image_data, icon_key_for_path, image_key_matches, set_image_key, IconKey}; +use super::{icon_key_for_path, image_key_matches, set_image_key, IconKey}; + +fn hash_image_data(data: &[u8]) -> [u8; 32] { + *blake3::hash(data).as_bytes() +} fn key(name: &str) -> IconKey { IconKey::Name { diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index be9eed391..52dabba4b 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -56,7 +56,7 @@ fn sender_paths_are_not_resolved_by_client_icon_lookup() { popup_hide_after_ms: 0, }; - assert!(resolver.resolve_icon(¬ification, 16, 1).is_none()); + assert!(resolver.resolve_badge(¬ification, 16, 1).is_none()); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index 96cd03b2a..c0c368967 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -19,6 +19,7 @@ pub(super) fn build_notification_list( notification_metadata: init.config.panel.notification_metadata.clone(), notification_corners: init.config.theme.notification_corners, show_notification_thumbnails: init.config.panel.notification_thumbnails_visible, + show_notification_avatars: init.config.panel.notification_avatars_visible, reduced_motion: init.config.panel.reduced_motion, empty_text: init.config.panel.empty_text.clone(), no_matching_text: init.config.panel.no_matching_text.clone(), diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index e2d849bbd..0e2cfe609 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -22,6 +22,7 @@ pub struct RowPresentation { // Optional lanes are disabled by default to preserve the compact stock card pub show_metadata: bool, pub show_thumbnail: bool, + pub show_avatar: bool, // Runtime motion policy keeps recycled row revealers in sync with panel settings pub reduced_motion: bool, // Shared config avoids cloning every metadata string into every row snapshot @@ -36,6 +37,7 @@ impl Default for RowPresentation { received_at_ms: 0, show_metadata: false, show_thumbnail: false, + show_avatar: true, reduced_motion: false, metadata: Rc::new(NotificationMetadataConfig::default()), card_corners: CutCorners::default(), @@ -48,6 +50,7 @@ impl PartialEq for RowPresentation { self.received_at_ms == other.received_at_ms && self.show_metadata == other.show_metadata && self.show_thumbnail == other.show_thumbnail + && self.show_avatar == other.show_avatar && self.reduced_motion == other.reduced_motion && Rc::ptr_eq(&self.metadata, &other.metadata) && self.card_corners == other.card_corners diff --git a/crates/unixnotis-center/src/ui/notifications/model/types.rs b/crates/unixnotis-center/src/ui/notifications/model/types.rs index 18cb2d9b2..fc8cb142b 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/types.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/types.rs @@ -52,6 +52,7 @@ pub struct NotificationList { pub(in crate::ui::notifications) notification_metadata: Rc, pub(in crate::ui::notifications) notification_corners: CutCorners, pub(in crate::ui::notifications) show_notification_thumbnails: bool, + pub(in crate::ui::notifications) show_notification_avatars: bool, pub(in crate::ui::notifications) reduced_motion: bool, pub(in crate::ui::notifications) max_active: usize, pub(in crate::ui::notifications) max_entries: usize, @@ -66,6 +67,7 @@ pub struct NotificationListConfig { pub notification_metadata: NotificationMetadataConfig, pub notification_corners: CutCorners, pub show_notification_thumbnails: bool, + pub show_notification_avatars: bool, pub reduced_motion: bool, pub empty_text: String, pub no_matching_text: String, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 6d67b6c70..ef9df4a81 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -24,7 +24,7 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) stack_back: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, - // Identity header collapses completely for rows owned by a group header + // Identity header compacts only for collapsed rows owned by a group header pub(super) header: gtk::Box, // App name text shown beside the icon pub(super) app_label: gtk::Label, @@ -39,7 +39,7 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) meta_top: gtk::Box, // Optional top metadata label for category/urgency styling pub(super) meta_label: gtk::Label, - // Compact relative time badge shown beside the summary + // Optional relative time badge shown when metadata is enabled pub(super) time_badge: gtk::Label, // Optional large image preview for notifications with image hints pub(super) thumbnail: gtk::Image, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 20c1abf4d..863b8c41b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -58,17 +58,32 @@ pub(super) fn notification_row_with_receiver() -> ( (root, row, command_rx) } -#[derive(Default)] pub(super) struct RowFlags { pub(super) is_active: bool, pub(super) collapsed_group_preview: bool, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, + pub(super) show_avatar: bool, pub(super) reduced_motion: bool, pub(super) metadata: Option, pub(super) card_corners: unixnotis_core::CutCorners, } +impl Default for RowFlags { + fn default() -> Self { + Self { + is_active: false, + collapsed_group_preview: false, + show_metadata: false, + show_thumbnail: false, + show_avatar: true, + reduced_motion: false, + metadata: None, + card_corners: unixnotis_core::CutCorners::default(), + } + } +} + pub(super) fn row_data(notification: Rc, flags: RowFlags) -> RowData { RowData::notification( Rc::from(notification.app_name.to_ascii_lowercase()), @@ -81,6 +96,7 @@ pub(super) fn row_data(notification: Rc, flags: RowFlags) -> R received_at_ms: current_millis(), show_metadata: flags.show_metadata, show_thumbnail: flags.show_thumbnail, + show_avatar: flags.show_avatar, reduced_motion: flags.reduced_motion, metadata: Rc::new(flags.metadata.unwrap_or_default()), card_corners: flags.card_corners, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs index ceb08e2da..7230b7f67 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -19,13 +19,17 @@ pub(super) fn update_metadata_labels( ) { let metadata = data.presentation.metadata.as_ref(); let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); - // Relative time stays on the title lane while optional diagnostics get their own row - set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); + // Keep the stock row compact unless the optional metadata lane is enabled + set_label_visible_if_changed( + &row.time_badge, + data.presentation.show_metadata && !time_badge.is_empty(), + ); set_label_text_if_changed(&row.time_badge, &time_badge); set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); if !data.presentation.show_metadata { - // Optional labels collapse while per-notification chronology remains + // Optional labels collapse together so ordinary rows match master spacing set_widget_visible_if_changed(&row.meta_top, false); + set_label_visible_if_changed(&row.time_badge, false); set_label_visible_if_changed(&row.meta_label, false); set_label_visible_if_changed(&row.footer_left, false); set_label_visible_if_changed(&row.footer_right, false); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index da65f22a1..dfe76f7c0 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -2,6 +2,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; +use unixnotis_core::hooks; use unixnotis_ui::presentation::{ apply_semantic_badge, default_activation::DefaultActionTarget, NotificationPresentation, }; @@ -14,7 +15,7 @@ use super::super::state::{IconSignature, NotificationRowWidgets}; use super::actions::{update_actions, visible_action_count_from}; use super::labels::update_notification_text; use super::metadata::update_metadata_labels; -use super::thumbnail::{has_content_thumbnail, has_conversation_avatar, has_sender_visual}; +use super::thumbnail::{panel_lead_visual, PanelLeadVisual}; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { @@ -36,6 +37,10 @@ pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRow ); row.icon_sig.borrow_mut().take(); row.inline_reply.reset_for_recycle(); + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); for widget in [ row.card.upcast_ref::(), @@ -105,14 +110,17 @@ pub(in crate::ui::notifications) fn update_notification_row( // Set this before action-cache early returns so recycled rows cannot retain // a previous notification generation row.default_activation.set_target(default_target); - let show_identity = !data.collapsed_group_preview && !data.expanded; + // Only the collapsed preview delegates identity to the group header + // Expanded groups retain the master-style identity lane on each child + let show_identity = !data.collapsed_group_preview; let has_actions = visible_action_count_from(&presentation, data.is_active) > 0; - let has_content_thumbnail = has_content_thumbnail(&presentation); // The daemon has already assigned the visual role after attribution and safe decoding - let has_conversation_avatar = has_conversation_avatar(&presentation); - let has_sender_visual = has_sender_visual(&presentation); - let has_thumbnail = data.presentation.show_thumbnail - && (has_content_thumbnail || has_conversation_avatar || has_sender_visual); + let lead_visual = panel_lead_visual( + &presentation, + data.presentation.show_avatar, + data.presentation.show_thumbnail, + ); + let has_thumbnail = lead_visual != PanelLeadVisual::None; apply_visual_state( row, @@ -188,27 +196,33 @@ pub(in crate::ui::notifications) fn update_notification_row( // Group rows keep the measured top lane so dismiss never covers message text set_widget_visible_if_changed(&row.header, true); set_widget_visible_if_changed(&row.close_button, true); - if has_thumbnail { - // Reapply visible thumbnails so config reloads cannot leave stale previews - if (has_conversation_avatar || has_sender_visual) && !has_content_thumbnail { + // Clear paintable state before selecting a new role on a recycled row + row.thumbnail.clear(); + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); + match lead_visual { + PanelLeadVisual::ConversationAvatar => { icon_resolver.apply_sender_visual(&row.thumbnail, notification); - if has_sender_visual { - row.thumbnail.add_css_class("unixnotis-panel-sender-visual"); - } else { - row.thumbnail - .remove_css_class("unixnotis-panel-sender-visual"); - } - } else { - let scale = row.card.scale_factor(); - icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); + } + PanelLeadVisual::ContentImage => { row.thumbnail - .remove_css_class("unixnotis-panel-sender-visual"); + .add_css_class(hooks::panel_card::CONTENT_IMAGE); + icon_resolver.apply_content_visual(&row.thumbnail, notification); } - } else { - row.thumbnail - .remove_css_class("unixnotis-panel-sender-visual"); + PanelLeadVisual::DecorativeSenderVisual => { + icon_resolver.apply_sender_visual(&row.thumbnail, notification); + row.thumbnail + .add_css_class(hooks::panel_card::SENDER_VISUAL); + } + PanelLeadVisual::None => {} } - set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); + // A role alone cannot make an empty or malformed image paintable + set_widget_visible_if_changed( + &row.thumbnail, + has_thumbnail && row.thumbnail.paintable().is_some(), + ); set_widget_visible_if_changed(&row.card_plate, true); set_widget_visible_if_changed(&row.card, true); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 52ce7dd13..6de91ebb8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -199,9 +199,9 @@ fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { let (_root, row) = notification_row(); let mut notification = sample_notification(); notification.attribution = unixnotis_core::NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); let data = row_data(Rc::new(notification), RowFlags::default()); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); @@ -209,7 +209,10 @@ fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); assert_eq!(row.app_label.text().as_str(), "Command-line notification"); - assert_eq!(row.secondary_claim.text().as_str(), "App label: Signal"); + assert_eq!( + row.secondary_claim.text().as_str(), + "App label: Example Chat" + ); assert!(row.secondary_claim.get_visible()); assert!(!row.trust_chip.get_visible()); assert!(row.card.has_css_class("relay")); @@ -232,9 +235,9 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { let (_root, row) = notification_row(); let mut notification = sample_notification(); notification.attribution = unixnotis_core::NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); let data = row_data( Rc::new(notification), @@ -256,7 +259,7 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { } #[gtk::test] -fn expanded_group_rows_keep_the_message_first_compact_lane() { +fn expanded_group_rows_retain_master_identity_lane() { let (_root, row) = notification_row(); let data = row_data( Rc::new(sample_notification()), @@ -271,9 +274,9 @@ fn expanded_group_rows_keep_the_message_first_compact_lane() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert!(!row.app_label.get_visible()); - assert!(row.card.has_css_class("group-owned-identity")); - assert_eq!(row.card.spacing(), 2); + assert!(row.app_label.get_visible()); + assert!(!row.card.has_css_class("group-owned-identity")); + assert_eq!(row.card.spacing(), 6); } #[gtk::test] @@ -337,7 +340,7 @@ fn recycled_standalone_row_clears_identity_cache_when_it_becomes_grouped() { } #[gtk::test] -fn compact_rows_place_relative_time_in_the_non_overlapping_header_lane() { +fn compact_rows_hide_optional_time_until_metadata_is_enabled() { let (_root, row) = notification_row(); let notification = Rc::new(sample_notification()); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); @@ -346,7 +349,7 @@ fn compact_rows_place_relative_time_in_the_non_overlapping_header_lane() { update_notification_row(&row, ¤t, &IconResolver::new(), &command_tx); assert!(!row.meta_top.get_visible()); - assert!(row.time_badge.get_visible()); + assert!(!row.time_badge.get_visible()); assert!(!row.meta_label.get_visible()); assert!(!row.footer.get_visible()); assert!(!row.footer_left.get_visible()); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index b3f176cde..ef159dc23 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -11,7 +11,10 @@ use crate::ui::icons::IconResolver; use super::super::super::test_support::{ notification_row, row_data, sample_notification, RowFlags, }; -use super::super::thumbnail::{has_content_thumbnail, has_conversation_avatar, has_sender_visual}; +use super::super::thumbnail::{ + has_content_thumbnail, has_conversation_avatar, has_sender_visual, panel_lead_visual, + PanelLeadVisual, +}; use super::update_notification_row; fn notification_has_thumbnail(notification: &unixnotis_core::NotificationView) -> bool { @@ -83,6 +86,33 @@ fn application_visual_is_a_decorative_thumbnail_source() { assert!(!notification_has_thumbnail(¬ification)); } +#[test] +fn conversation_avatar_has_priority_over_content_thumbnail() { + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + notification.image.content_image = notification.image.sender_visual.clone(); + let presentation = NotificationPresentation::from_view(¬ification); + + assert_eq!( + panel_lead_visual(&presentation, true, true), + PanelLeadVisual::ConversationAvatar + ); + assert_eq!( + panel_lead_visual(&presentation, false, true), + PanelLeadVisual::ContentImage + ); +} + #[gtk::test] fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { let (_root, row) = notification_row(); @@ -132,6 +162,109 @@ fn update_notification_row_shows_thumbnail_when_config_and_image_allow_it() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); assert!(row.thumbnail.get_visible()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); assert!(!row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); } + +#[gtk::test] +fn conversation_avatar_uses_the_master_panel_lead_slot_by_default() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.thumbnail.pixel_size(), 56); + assert_eq!(row.thumbnail.width_request(), 56); + assert_eq!(row.thumbnail.height_request(), 56); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(!row.thumbnail.has_css_class("unixnotis-panel-sender-visual")); +} + +#[gtk::test] +fn historical_empty_avatar_role_does_not_create_a_blank_lead_slot() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_none()); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn rebinding_avatar_row_to_history_clears_the_paintable_and_slot() { + let (_root, row) = notification_row(); + let mut active = sample_notification(); + active.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + active.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let active_data = row_data(Rc::new(active), RowFlags::default()); + + update_notification_row(&row, &active_data, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.paintable().is_some()); + assert!(row.thumbnail.get_visible()); + + let mut history = sample_notification(); + history.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + let history_data = row_data(Rc::new(history), RowFlags::default()); + + update_notification_row(&row, &history_data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn content_thumbnail_setting_does_not_hide_conversation_avatar() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs index 77cabb04c..ec12ae135 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -4,6 +4,14 @@ use unixnotis_ui::presentation::{ NotificationPresentation, SenderVisualPresentation, ThumbnailKind, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PanelLeadVisual { + ConversationAvatar, + ContentImage, + DecorativeSenderVisual, + None, +} + pub(super) fn has_content_thumbnail(presentation: &NotificationPresentation) -> bool { // Content thumbnails are already classified by the shared presentation layer presentation.media.thumbnail == ThumbnailKind::Content @@ -24,3 +32,23 @@ pub(super) const fn has_sender_visual(presentation: &NotificationPresentation) - SenderVisualPresentation::ApplicationProvidedIcon ) } + +pub(super) fn panel_lead_visual( + presentation: &NotificationPresentation, + show_avatars: bool, + show_thumbnails: bool, +) -> PanelLeadVisual { + // Conversation identity always wins the single master-style lead slot + if show_avatars && has_conversation_avatar(presentation) { + return PanelLeadVisual::ConversationAvatar; + } + // Content images are optional and come after a conversation avatar + if show_thumbnails && has_content_thumbnail(presentation) { + return PanelLeadVisual::ContentImage; + } + // Decorative sender art is lower priority than message content + if show_thumbnails && has_sender_visual(presentation) { + return PanelLeadVisual::DecorativeSenderVisual; + } + PanelLeadVisual::None +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index b153ebe5d..5555be546 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -19,8 +19,9 @@ pub(super) fn apply_visual_state( ) { let card = &row.card; let is_critical = notification.urgency == Urgency::Critical as u8; - // Expanded children also sit below the group header, so they use the same compact lane - let group_owns_identity = data.collapsed_group_preview || data.expanded; + // Only collapsed previews move identity into the shared group header + // Expanded children retain the normal master-style card composition + let group_owns_identity = data.collapsed_group_preview; // Removing the hidden identity row also removes its old inter-row breathing room card.set_spacing(if group_owns_identity { 2 } else { 6 }); // Theme changes update recycled rows without rebuilding the GTK child tree diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index 7aa954bec..a49b33082 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -56,6 +56,7 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 9cc7e4371..6884b4eeb 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -114,6 +114,7 @@ impl NotificationList { received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 7ba8e97ee..05b330932 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -96,6 +96,7 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, reduced_motion: self.reduced_motion, metadata: self.notification_metadata.clone(), card_corners: self.notification_corners, diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 8a7c85632..d471d11c2 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -26,6 +26,7 @@ pub(super) fn list_config() -> NotificationListConfig { notification_metadata: unixnotis_core::NotificationMetadataConfig::default(), notification_corners: unixnotis_core::CutCorners::default(), show_notification_thumbnails: false, + show_notification_avatars: true, reduced_motion: false, empty_text: "No notifications".to_string(), no_matching_text: "No matching notifications".to_string(), diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index 3b6f0fd84..f28e17d30 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -133,6 +133,7 @@ impl NotificationList { notification_metadata: Rc::new(config.notification_metadata), notification_corners: config.notification_corners, show_notification_thumbnails: config.show_notification_thumbnails, + show_notification_avatars: config.show_notification_avatars, reduced_motion: config.reduced_motion, max_active: config.max_active, max_entries: config.max_entries, @@ -147,6 +148,7 @@ impl NotificationList { || self.notification_metadata.as_ref() != &config.notification_metadata || self.notification_corners != config.notification_corners || self.show_notification_thumbnails != config.show_notification_thumbnails + || self.show_notification_avatars != config.show_notification_avatars || self.reduced_motion != config.reduced_motion; self.show_notification_metadata = config.show_notification_metadata; if self.notification_metadata.as_ref() != &config.notification_metadata { @@ -154,6 +156,7 @@ impl NotificationList { } self.notification_corners = config.notification_corners; self.show_notification_thumbnails = config.show_notification_thumbnails; + self.show_notification_avatars = config.show_notification_avatars; self.reduced_motion = config.reduced_motion; if self.empty_text != config.empty_text { self.empty_text = config.empty_text.clone(); diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs index 8cec3e950..33046ddc8 100644 --- a/crates/unixnotis-center/src/ui/reload/config/widgets.rs +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -33,6 +33,7 @@ impl UiState { notification_metadata: config.panel.notification_metadata.clone(), notification_corners: config.theme.notification_corners, show_notification_thumbnails: config.panel.notification_thumbnails_visible, + show_notification_avatars: config.panel.notification_avatars_visible, reduced_motion: config.panel.reduced_motion, empty_text: config.panel.empty_text.clone(), no_matching_text: config.panel.no_matching_text.clone(), diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 7504c08fc..6d675da72 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -59,56 +59,73 @@ background: transparent; } +/* Player switcher: glass chips that hug the card sides, with a count pill so + * switching players reads as an intentional control instead of bare arrows. */ .unixnotis-media-nav { - background: alpha(#ffffff, 0.04); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: var(--unixnotis-media-button-radius); - padding: 3px; + background: alpha(#ffffff, 0.07); + border: 1px solid alpha(#ffffff, 0.12); + border-radius: 12px; + padding: 0; margin: 0; - font-weight: 700; - font-size: 12px; - min-width: 18px; - min-height: 18px; - color: alpha(#ffffff, 0.85); - box-shadow: none; - transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; + min-width: 24px; + min-height: 44px; + -gtk-icon-size: 18px; + font-size: 18px; + font-weight: 600; + color: alpha(#ffffff, 0.92); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.10); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-media-nav:hover { - background: alpha(#ffffff, 0.09); - border-top-color: alpha(#ffffff, 0.16); - border-left-color: alpha(#ffffff, 0.12); - border-right-color: alpha(#ffffff, 0.06); - border-bottom-color: alpha(#ffffff, 0.03); - box-shadow: 0 4px 10px -5px alpha(#000000, 0.4), inset 0 1px 0 alpha(#ffffff, 0.05); + background: alpha(#ffffff, 0.15); + border-color: alpha(#ffffff, 0.22); color: #ffffff; + box-shadow: + 0 6px 12px -6px alpha(#000000, 0.45), + inset 0 1px 0 alpha(#ffffff, 0.12); +} + +.unixnotis-media-nav:active { + background: alpha(#ffffff, 0.19); + border-color: alpha(#ffffff, 0.26); + color: #ffffff; +} + +/* Point each tab at the card so it reads as a directional arrow. */ +.unixnotis-media-nav-prev { + border-radius: 999px 6px 6px 999px; } -.unixnotis-media-nav-prev, .unixnotis-media-nav-next { - min-width: 18px; + border-radius: 6px 999px 999px 6px; } .unixnotis-media-position { - color: @unixnotis-muted; - font-size: 11px; - letter-spacing: 0.08em; + color: alpha(#ffffff, 0.70); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + background: alpha(#ffffff, 0.06); + border: 1px solid alpha(#ffffff, 0.10); + border-radius: 999px; + padding: 1px 8px; + box-shadow: none; } .unixnotis-media-card { - background-image: linear-gradient(145deg, alpha(@unixnotis-card-base, 0.88), alpha(@unixnotis-surface-base, 0.94)); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); + background: alpha(#ffffff, 0.055); + border-top: 1px solid alpha(#ffffff, 0.10); + border-left: 1px solid alpha(#ffffff, 0.08); + border-right: 1px solid alpha(#ffffff, 0.05); border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: var(--unixnotis-media-card-radius); padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); min-height: var(--unixnotis-media-card-min-height); - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; + box-shadow: + 0 6px 16px -10px alpha(#000000, 0.55), + inset 0 1px 0 alpha(#ffffff, 0.05); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-media-card-carousel { @@ -136,14 +153,11 @@ } .unixnotis-media-card.playing { - background-image: linear-gradient(145deg, alpha(@unixnotis-accent, 0.12), alpha(@unixnotis-card-base, 0.94)); - border-top: 1px solid alpha(#ffffff, 0.16); - border-left: 1px solid alpha(#ffffff, 0.12); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); + background: alpha(#ffffff, 0.07); + border-color: alpha(@unixnotis-accent, 0.32); box-shadow: - 0 16px 36px -20px alpha(#000000, 0.8), - inset 0 1px 0 alpha(#ffffff, 0.12); + 0 8px 18px -12px alpha(#000000, 0.60), + inset 0 1px 0 alpha(#ffffff, 0.08); } /* @@ -242,7 +256,7 @@ border-radius: var(--unixnotis-media-button-radius); padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); box-shadow: none; - color: alpha(#ffffff, 0.85); + color: alpha(#ffffff, 0.92); transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } @@ -256,6 +270,15 @@ color: #ffffff; } +.unixnotis-media-button:disabled, +.unixnotis-media-button:disabled:hover { + background: alpha(#ffffff, 0.05); + border: 1px solid alpha(#ffffff, 0.10); + color: alpha(#ffffff, 0.92); + -gtk-icon-filter: none; + box-shadow: none; +} + .unixnotis-media-button.primary { background: @unixnotis-text; border: 1px solid @unixnotis-text; @@ -263,14 +286,16 @@ box-shadow: 0 4px 10px -3px alpha(#000000, 0.3); } -/* Restrained media transport */ +/* Restrained media transport: hover is a gentle lift within the panel family */ .unixnotis-media-card:hover { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); - border-top-color: alpha(#ffffff, 0.14); - border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.06); - border-bottom-color: alpha(#ffffff, 0.03); - box-shadow: 0 8px 20px -10px alpha(#000000, 0.7), inset 0 1px 0 alpha(#ffffff, 0.05); + background: alpha(#ffffff, 0.09); + border-top: 1px solid alpha(#ffffff, 0.16); + border-left: 1px solid alpha(#ffffff, 0.12); + border-right: 1px solid alpha(#ffffff, 0.07); + border-bottom: 1px solid alpha(#ffffff, 0.03); + box-shadow: + 0 10px 22px -12px alpha(#000000, 0.62), + inset 0 1px 0 alpha(#ffffff, 0.08); } .unixnotis-media-button.primary:hover { @@ -280,6 +305,25 @@ box-shadow: 0 6px 14px -2px alpha(#000000, 0.45); } +/* Transport icons stay white on every player (browser MPRIS icons can arrive + * dark); the primary play button keeps its dark-on-white glyph. */ +.unixnotis-media-button image, +.unixnotis-media-button .icon, +.unixnotis-media-button:disabled image, +.unixnotis-media-button:disabled .icon { + color: #ffffff; + -gtk-icon-palette: success alpha(#ffffff, 0.95), warning alpha(#ffffff, 0.95), error alpha(#ffffff, 0.95); + -gtk-icon-filter: none; + -gtk-icon-shadow: 0 0 0 transparent; +} + +.unixnotis-media-button.primary image, +.unixnotis-media-button.primary .icon { + color: @unixnotis-surface-base; + -gtk-icon-palette: success @unixnotis-surface-base, warning @unixnotis-surface-base, error @unixnotis-surface-base; + -gtk-icon-shadow: 0 0 0 transparent; +} + .unixnotis-media-button:focus, .unixnotis-media-nav:focus { /* Remove default blue focus rings from GTK button selections */ diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 2f5289b43..7fbbb6278 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -3,10 +3,11 @@ * Panel shell, list, and notification group styling. */ .unixnotis-panel { - min-width: 432px; + min-width: 420px; background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); color: @unixnotis-text; - border-radius: 20px; + border-radius: 30px; + border-radius: var(--unixnotis-panel-radius); padding: 16px; padding: var(--unixnotis-panel-padding); border: 1px solid alpha(#9bb8e8, 0.16); @@ -21,14 +22,14 @@ margin-bottom: 12px; padding: 12px; padding: var(--unixnotis-panel-header-padding); - padding-left: 2px; - padding-right: 2px; - padding-bottom: 12px; - border: 0; - border-bottom: 1px solid alpha(#9bb8e8, 0.12); - border-radius: 0; - background: transparent; - box-shadow: none; + border-radius: 18px; + border-radius: var(--unixnotis-panel-header-radius); + background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.7), alpha(@unixnotis-surface, 0.9)); + border: 1px solid alpha(@unixnotis-accent, 0.16); + box-shadow: + 0 10px 24px -18px @unixnotis-shadow-soft, + 0 0 22px -18px @unixnotis-glow-cyan, + inset 0 0 0 1px alpha(#ffffff, 0.03); } .unixnotis-panel-header-top { @@ -359,14 +360,14 @@ entry selection { */ .unixnotis-group { background: transparent; - margin-top: 14px; + margin-top: 0; margin-bottom: 8px; } .unixnotis-group-header { background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); color: @unixnotis-text; - border-radius: var(--unixnotis-notification-card-radius); + border-radius: 999px; padding: 6px 12px; border: 1px solid @unixnotis-card-border; box-shadow: none; @@ -452,12 +453,12 @@ entry selection { background: alpha(#ffffff, 0.06); color: alpha(#ffffff, 0.72); border-radius: 999px; - padding: 1px 6px; + padding: 2px 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; border: 1px solid alpha(#ffffff, 0.10); - min-width: 18px; + min-width: 22px; box-shadow: none; } @@ -482,14 +483,22 @@ entry selection { } .unixnotis-panel-card { - background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); - border: 1px solid @unixnotis-card-border; + background-image: linear-gradient( + 180deg, + alpha(#2c3762, 0.82) 0%, + alpha(#1a2242, 0.88) 55%, + alpha(#121834, 0.93) 100% + ); + border: 1px solid alpha(#ffffff, 0.10); border-radius: var(--unixnotis-notification-card-radius); padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); margin: 0; box-shadow: - 0 12px 26px -20px @unixnotis-shadow-strong, - inset 0 0 0 1px alpha(#ffffff, 0.04); + inset 0 1px 0 alpha(#ffffff, 0.09), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } @@ -509,19 +518,31 @@ entry selection { } .unixnotis-panel-card-thumbnail { - border-radius: 10px; - background: alpha(@unixnotis-surface-strong-base, 0.42); - box-shadow: 0 5px 14px -8px alpha(#000000, 0.78); + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + opacity: 1; } +.unixnotis-panel-card-thumbnail.unixnotis-panel-content-image, .unixnotis-panel-card-thumbnail.unixnotis-panel-sender-visual { + border-radius: 10px; + background: alpha(#0a0f1f, 0.40); + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: + inset 0 1px 2px alpha(#000000, 0.25), + inset 0 0 0 1px alpha(#ffffff, 0.03); opacity: 0.92; } .unixnotis-panel-card.collapsed-group-preview { box-shadow: - 0 12px 24px -20px @unixnotis-shadow-strong, - inset 0 0 0 1px alpha(#ffffff, 0.04); + inset 0 1px 0 alpha(#ffffff, 0.11), + inset 0 2px 0 alpha(#ffffff, 0.04), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.45), + 0 20px 40px -24px alpha(#000000, 0.75); } .unixnotis-panel-card-foreground { @@ -539,45 +560,82 @@ entry selection { } .unixnotis-panel-card-foreground.collapsed-group-preview { - margin: 12px 8px 10px; + margin: 12px 8px 8px; } .unixnotis-stack-layer { min-height: 68px; padding: 0; - border: 1px solid alpha(@unixnotis-card-border, 0.58); + border: 1px solid alpha(#ffffff, 0.10); border-radius: var(--unixnotis-notification-card-radius); - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.09); - box-shadow: 0 8px 16px -15px @unixnotis-shadow-soft; + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.10) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#2c3762, 0.85) 0%, + alpha(#1a2242, 0.90) 55%, + alpha(#121834, 0.94) 100% + ); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.10), + inset 0 2px 0 alpha(#ffffff, 0.04), + 0 6px 10px -10px alpha(#000000, 0.30); } .unixnotis-stack-layer-back { margin: 0 20px; - opacity: 0.72; + opacity: 0.80; + border-color: alpha(#ffffff, 0.07); + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.06) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#232c50, 0.82) 0%, + alpha(#151d3a, 0.88) 55%, + alpha(#0e122b, 0.92) 100% + ); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.06), + 0 6px 10px -10px alpha(#000000, 0.24); } .unixnotis-stack-layer-middle { margin: 6px 14px 0; - opacity: 0.86; + opacity: 0.92; + border-color: alpha(#ffffff, 0.09); + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.08) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#29325c, 0.84) 0%, + alpha(#19213f, 0.90) 55%, + alpha(#101531, 0.94) 100% + ); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + 0 6px 10px -10px alpha(#000000, 0.26); } .unixnotis-panel-card.active { - border-color: alpha(@unixnotis-card-border, 0.95); + border-color: alpha(#ffffff, 0.14); } /* Critical state composes after the ordinary active and stack rules */ .unixnotis-panel-card.critical, .unixnotis-panel-card.active.critical { background-image: linear-gradient( - 145deg, - alpha(@unixnotis-critical-surface, 0.82), - alpha(#12182c, 0.97) + 180deg, + alpha(#3a2430, 0.88) 0%, + alpha(#23161f, 0.92) 100% ); - border-color: alpha(@unixnotis-critical-border, 0.48); + border-color: alpha(@unixnotis-critical-border, 0.42); box-shadow: - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 20px -16px alpha(@unixnotis-critical-border, 0.32), - inset 0 1px 0 alpha(#ffffff, 0.05); + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.22), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); } .unixnotis-panel-card.critical .unixnotis-panel-app { diff --git a/crates/unixnotis-core/src/config/panel/config.rs b/crates/unixnotis-core/src/config/panel/config.rs index 162e4f41f..c8b17b767 100644 --- a/crates/unixnotis-core/src/config/panel/config.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -10,6 +10,11 @@ use super::{ PanelClearButtonPlacement, PanelSection, PanelWidgetSection, }; +// Conversation photos are useful context and stay enabled unless explicitly hidden +const fn default_notification_avatars_visible() -> bool { + true +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct PanelConfig { @@ -53,6 +58,9 @@ pub struct PanelConfig { pub notification_metadata: NotificationMetadataConfig, /// Show optional notification image thumbnails in panel rows pub notification_thumbnails_visible: bool, + /// Show bounded conversation avatars in the master-style row image slot + #[serde(default = "default_notification_avatars_visible")] + pub notification_avatars_visible: bool, /// Where the "clear all" action is rendered pub clear_button_placement: PanelClearButtonPlacement, /// Heading shown above toggle-style quick actions @@ -128,6 +136,7 @@ impl Default for PanelConfig { notification_metadata_visible: false, notification_metadata: NotificationMetadataConfig::default(), notification_thumbnails_visible: false, + notification_avatars_visible: true, clear_button_placement: PanelClearButtonPlacement::ActionRow, quick_actions_label: "Quick settings".to_string(), system_status_label: "System health".to_string(), diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs index c438a4a60..3f93d6d25 100644 --- a/crates/unixnotis-core/src/config/panel/tests/config.rs +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -35,6 +35,7 @@ fn default_panel_config_keeps_expected_layout_and_text_contract() { assert_eq!(panel.search_magnifier_icon, "system-search-symbolic"); assert!(panel.action_row_visible); assert!(!panel.reduced_motion); + assert!(panel.notification_avatars_visible); assert!(panel.notification_list_expand); assert!(panel.close_on_click_outside); assert!(panel.respect_work_area); @@ -49,6 +50,7 @@ fn partial_panel_values_use_current_presentation_defaults() { assert_eq!(panel.system_status_label, "System health"); assert_eq!(panel.empty_offset_top, 24); assert!(!panel.reduced_motion); + assert!(panel.notification_avatars_visible); } #[test] diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 2fe0a860b..a31e141ef 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -109,6 +109,8 @@ pub mod panel_card { pub const FOOTER_LEFT: &str = "unixnotis-panel-card-footer-left"; pub const FOOTER_RIGHT: &str = "unixnotis-panel-card-footer-right"; pub const THUMBNAIL: &str = "unixnotis-panel-card-thumbnail"; + pub const CONTENT_IMAGE: &str = "unixnotis-panel-content-image"; + pub const SENDER_VISUAL: &str = "unixnotis-panel-sender-visual"; pub const GROUPED: &str = "unixnotis-panel-card-grouped"; pub const HAS_ACTIONS: &str = "unixnotis-panel-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-panel-card-has-body"; diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 07d5f6381..f2fdeb660 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -278,7 +278,7 @@ fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { assert!(css.contains(".unixnotis-stack-layer-back")); assert!(css.contains(".unixnotis-stack-layer-middle")); assert!(css.contains("margin: 6px 14px 0")); - assert!(css.contains("margin: 12px 8px 10px")); + assert!(css.contains("margin: 12px 8px 8px")); assert!(!css.contains("margin: -58px 14px 0")); assert!(css.contains("margin: 0 20px")); assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 786b7fee2..8ee4c9b25 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -159,6 +159,8 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { #[test] fn notification_surfaces_keep_compact_master_geometry() { + assert!(DEFAULT_PANEL_CSS.contains("min-width: 420px")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-radius)")); assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-notification-card-radius)")); assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-y)")); assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-x)")); @@ -166,11 +168,58 @@ fn notification_surfaces_keep_compact_master_geometry() { assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-y)")); assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-x)")); assert!(DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); - assert!(DEFAULT_PANEL_CSS.contains("margin: 12px 8px 10px")); + assert!(DEFAULT_PANEL_CSS.contains("margin: 12px 8px 8px")); assert!(!DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); assert!(DEFAULT_PANEL_CSS.contains("margin: 0 20px")); } +#[test] +fn panel_group_headers_keep_master_pill_geometry() { + let header = DEFAULT_PANEL_CSS + .split(".unixnotis-group-header {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("group header rules should be present"); + let count = DEFAULT_PANEL_CSS + .split(".unixnotis-group-count {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("group count rules should be present"); + + assert!(header.contains("border-radius: 999px")); + assert!(header.contains("padding: 6px 12px")); + assert!(count.contains("padding: 2px 8px")); + assert!(count.contains("min-width: 22px")); +} + +#[test] +fn panel_avatar_slot_is_plain_but_content_visuals_keep_their_tile() { + let thumbnail = DEFAULT_PANEL_CSS + .split(".unixnotis-panel-card-thumbnail {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("thumbnail rules should be present"); + + assert!(thumbnail.contains("background: transparent")); + assert!(thumbnail.contains("border: 0")); + assert!(thumbnail.contains("border-radius: 0")); + assert!(thumbnail.contains("box-shadow: none")); + assert!(DEFAULT_PANEL_CSS + .contains(".unixnotis-panel-card-thumbnail.unixnotis-panel-content-image,")); + assert!( + DEFAULT_PANEL_CSS.contains(".unixnotis-panel-card-thumbnail.unixnotis-panel-sender-visual") + ); +} + +#[test] +fn bundled_media_defaults_match_the_active_player_surface() { + assert!(DEFAULT_MEDIA_CSS.contains("min-height: 44px")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-nav-prev")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-button:disabled")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-card.playing")); + assert!(DEFAULT_MEDIA_CSS.contains("--unixnotis-media-art-size: 56px")); +} + #[test] fn media_cards_keep_art_and_transport_as_separate_visual_lanes() { assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-art-frame")); diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index a843c1b28..7e5b452f6 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -138,6 +138,8 @@ impl Notification { let mut image = self.image.clone(); image.content_image = Default::default(); image.sender_visual = Default::default(); + // A cleared raster must never retain a role that can select an image slot + image.sender_visual_role = crate::NotificationVisualRole::None; Self { id: self.id, generation: self.generation, diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index d1ed3e0e2..7d75e6a88 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -298,3 +298,28 @@ fn history_projection_drops_raw_hints_and_image_bytes() { assert_eq!(history.sender_start_time, Some(9000)); assert_eq!(history.sender_executable.as_deref(), Some("/usr/bin/mail")); } + +#[test] +fn history_projection_clears_sender_visual_role_with_sender_pixels() { + let notification = notification_with_image(NotificationImage { + sender_visual_role: crate::NotificationVisualRole::ConversationAvatar, + sender_visual: ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }, + ..image_with_raw_bytes() + }); + + let history = notification.to_history(); + + assert_eq!( + history.image.sender_visual_role, + crate::NotificationVisualRole::None + ); + assert!(history.image.sender_visual.data.is_empty()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs index 2fe172f95..e36faa35b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -34,10 +34,15 @@ pub(in crate::daemon::notifications) struct NotificationInput { pub(in crate::daemon::notifications) expire_timeout: i32, } +// Keep message pixels and sender pixels separate until their roles are stored struct ImageBuildInput { + // Explicit message attachment pixels image_data: Option, + // Communication image-data promoted by the trusted daemon sender_visual_data: Option, + // A bounded local sender visual, when attribution allows it sender_visual: Option, + // The semantic role selected from attribution and communication evidence sender_visual_role: SenderVisualRole, } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs index 1a4bdb853..1831a7867 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -272,7 +272,7 @@ fn conversation_avatar_never_changes_badge_or_unresolved_identity() { data: vec![1, 2, 3, 255], }; let notification = build_notification(NotificationInput { - app_name: "Signal".to_string(), + app_name: "Example Chat".to_string(), app_icon: "/tmp/contact.png".to_string(), summary: "New message".to_string(), body: "Hello".to_string(), @@ -386,6 +386,15 @@ fn associated_communication_image_data_becomes_a_bounded_conversation_avatar() { assert_eq!(notification.image.sender_visual.width, 64); assert_eq!(notification.image.sender_visual.height, 64); assert!(notification.image.sender_visual.data.len() <= 64 * 64 * 4); + + // The production view keeps the bounded avatar role and leaves message content empty + let view = notification.to_view(); + assert_eq!( + view.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(!view.image.sender_visual.data.is_empty()); + assert!(view.image.content_image.data.is_empty()); } #[test] diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 67aad92c5..2487d8f7a 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -328,15 +328,21 @@ fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { const fn visual_presentation(notification: &NotificationView) -> VisualPresentation { // The daemon has already materialized safe pixels; clients only select a slot - let sender = match notification.image.sender_visual_role { - unixnotis_core::NotificationVisualRole::ConversationAvatar => { - SenderVisualPresentation::ConversationAvatar - } - unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon => { - SenderVisualPresentation::ApplicationProvidedIcon + let sender = if notification.image.sender_visual.data.is_empty() { + SenderVisualPresentation::None + } else { + match notification.image.sender_visual_role { + unixnotis_core::NotificationVisualRole::ConversationAvatar => { + SenderVisualPresentation::ConversationAvatar + } + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon => { + SenderVisualPresentation::ApplicationProvidedIcon + } + unixnotis_core::NotificationVisualRole::None + | unixnotis_core::NotificationVisualRole::ContentImage => { + SenderVisualPresentation::None + } } - unixnotis_core::NotificationVisualRole::None - | unixnotis_core::NotificationVisualRole::ContentImage => SenderVisualPresentation::None, }; VisualPresentation { sender, diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 9e37219e0..2b615cd37 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -52,6 +52,15 @@ fn shared_model_keeps_verified_communication_content_and_actions_consistent() { fn shared_visual_roles_are_consistent_for_popup_and_panel_clients() { let mut view = notification(); view.image.sender_visual_role = NotificationVisualRole::ConversationAvatar; + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; let avatar = NotificationPresentation::from_view_at(&view, 1_000); assert_eq!( avatar.visuals.sender, @@ -81,6 +90,25 @@ fn shared_visual_roles_are_consistent_for_popup_and_panel_clients() { assert!(content.visuals.content_image); } +#[test] +fn empty_sender_pixels_cannot_select_a_sender_visual_role() { + for role in [ + NotificationVisualRole::ConversationAvatar, + NotificationVisualRole::ApplicationProvidedIcon, + ] { + let mut view = notification(); + view.image.sender_visual_role = role; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!( + presentation.visuals.sender, + SenderVisualPresentation::None, + "role={role:?}" + ); + } +} + #[test] fn native_association_keeps_card_activation_and_confirms_only_extra_buttons() { let mut view = notification(); @@ -220,11 +248,11 @@ fn trusted_relay_claim_never_becomes_the_primary_application_identity() { let mut view = notification(); view.category = "im.received".to_string(); view.attribution = NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); - view.image.badge_icon = "signal-desktop".to_string(); + view.image.badge_icon = "example-chat".to_string(); let presentation = NotificationPresentation::from_view_at(&view, 1_000); @@ -237,7 +265,7 @@ fn trusted_relay_claim_never_becomes_the_primary_application_identity() { ); assert_eq!( presentation.identity.secondary_claim.as_deref(), - Some("App label: Signal") + Some("App label: Example Chat") ); assert_eq!(presentation.identity.badge, BadgePresentation::CommandLine); assert_eq!(presentation.media.thumbnail, ThumbnailKind::None); @@ -297,10 +325,10 @@ fn associated_identity_discloses_a_different_caller_label_only() { fn unresolved_claim_has_no_application_actions_or_reply() { let mut view = notification(); view.attribution = NotificationAttribution::unresolved( - "Signal", + "Example Chat", AttributionReason::NoDesktopCandidate, "sender has no positive application association", - "unresolved:random-script:signal".to_string(), + "unresolved:random-script:example-chat".to_string(), ); view.inline_reply.available = true; view.actions = vec![ @@ -382,9 +410,9 @@ fn media_category_selects_media_layout_without_image_content() { fn untrusted_non_media_notification_cannot_render_content_art() { let mut view = notification(); view.attribution = NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); assert_eq!( diff --git a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs index 758fbd0a9..a24485e08 100644 --- a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs +++ b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs @@ -47,6 +47,17 @@ fn shared_notification_visual_contract_covers_client_surface_matrix() { ..ImageData::default() }; } + if role == NotificationVisualRole::ApplicationProvidedIcon { + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + ..ImageData::default() + }; + } if role == NotificationVisualRole::ContentImage { view.image.content_image = ImageData { width: 1, From 3b4615e37593e44865b60c8138442aaaa4f16d42 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 14:43:09 -0500 Subject: [PATCH 232/275] test(identity): use generic application fixtures Keep attribution, presentation, relay, and desktop-index regressions independent of any named third-party application. Remove the environment-specific package fixture so identity behavior is exercised through generic application records. --- .../src/ui/notifications/row/tests/group.rs | 18 ++--- .../src/daemon/control/tests/action.rs | 6 +- .../identity/desktop_index/tests/scan.rs | 66 +----------------- .../resolver/tests/pipeline/dedicated.rs | 34 +++++----- .../identity/resolver/tests/pipeline/spoof.rs | 68 +++++++++---------- .../identity/resolver/tests/resolution.rs | 2 +- .../src/store/notifications/tests/rules.rs | 6 +- .../src/store/tests/runtime/action_target.rs | 14 ++-- .../src/ui/entry/builders/tests/common.rs | 8 +-- .../src/ui/entry/builders/tests/layout.rs | 4 +- .../src/ui/entry/presentation/tests/kind.rs | 6 +- .../src/ui/entry/presentation/tests/trust.rs | 6 +- .../ui/entry/presentation/tests/view_model.rs | 12 ++-- .../src/ui/state/tests/constructor.rs | 28 ++++---- 14 files changed, 104 insertions(+), 174 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 6cea2925a..9acc19e45 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -99,15 +99,15 @@ fn group_accessible_name_keeps_identity_trust_count_and_state() { group_accessible_label( "Unknown application", "Suspicious", - "Claimed app: Signal", + "Claimed app: Example Chat", 4, true, ), - "Unknown application. Suspicious. Claimed app: Signal. 4 notifications. Expanded" + "Unknown application. Suspicious. Claimed app: Example Chat. 4 notifications. Expanded" ); assert_eq!( - group_accessible_label("Signal", "", "", 1, false), - "Signal. 1 notification. Collapsed" + group_accessible_label("Example Chat", "", "", 1, false), + "Example Chat. 1 notification. Collapsed" ); } @@ -172,14 +172,14 @@ fn relay_group_header_keeps_claim_below_command_line_identity() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); - let mut relayed = notification("Signal").as_ref().clone(); + let mut relayed = notification("Example Chat").as_ref().clone(); relayed.attribution = unixnotis_core::NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); let data = RowData::group_header( - Rc::from("relay:notify-send:signal"), + Rc::from("relay:notify-send:example-chat"), 4, false, Rc::new(relayed), @@ -194,7 +194,7 @@ fn relay_group_header_keeps_claim_below_command_line_identity() { assert_eq!(direct_child_count(&header), 4); assert_eq!(header.spacing(), 8); assert_eq!(widgets.title.text().as_str(), "Command-line notification"); - assert_eq!(widgets.secondary.text().as_str(), "App label: Signal"); + assert_eq!(widgets.secondary.text().as_str(), "App label: Example Chat"); assert!(widgets.secondary.get_visible()); assert!(!widgets.trust_chip.get_visible()); assert!(root.has_css_class("relay")); diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index c947258ce..882163215 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -141,11 +141,11 @@ async fn validated_action_rejects_a_conflicting_application_claim() { let notification = { let mut notification = action_notification(&sender, "open"); notification.attribution = NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", AttributionReason::ApplicationClaimMismatch, "application claim mismatch; source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ); state .store diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs index 994ffc201..14437b6ea 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -1,11 +1,8 @@ use std::fs; use std::os::unix::fs::symlink; -use super::super::launcher::launcher_binding_is_current; -use super::super::model::{LaunchVerification, VerifiedLaunch}; use super::super::scan::{ScanBudget, ScanLimits}; -use super::super::{verify_record_launch, DesktopIdentityIndex}; -use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; +use super::super::DesktopIdentityIndex; use crate::test_support::TempRoot; #[test] @@ -179,64 +176,3 @@ fn exhausted_user_budget_does_not_block_system_desktop_records() { .any(|record| record.system_origin && record.display_name == "System App")); assert_eq!(snapshot.watched_directories.len(), 2); } - -#[test] -fn local_arch_package_launcher_reaches_its_runtime_target() { - let desktop = std::path::Path::new("/usr/share/applications/signal.desktop"); - if !desktop.exists() { - return; - } - - let snapshot = DesktopIdentityIndex::build_snapshot(); - let record = snapshot - .index - .records_for_id("signal") - .into_iter() - .find(|record| record.system_origin) - .expect("installed package desktop record"); - - assert!(record.system_association); - assert_eq!( - record.declared_executable_path.as_deref(), - Some(std::path::Path::new("/usr/bin/signal-desktop")) - ); - assert_eq!( - record.runtime_executable_path.as_deref(), - Some(std::path::Path::new( - "/usr/lib/signal-desktop/signal-desktop" - )) - ); - let binding = record - .launch_spec - .as_ref() - .and_then(|spec| spec.package_launcher.as_ref()) - .expect("installed package launcher binding"); - assert!(launcher_binding_is_current(binding)); - let mut stale_digest = binding.clone(); - stale_digest.launcher_digest[0] ^= 1; - assert!(!launcher_binding_is_current(&stale_digest)); - let mut changed_target = binding.clone(); - changed_target.target_path = "/usr/bin/true".into(); - assert!(!launcher_binding_is_current(&changed_target)); - let runtime_identity = record - .runtime_executable_identity - .expect("installed runtime identity"); - let command_line = CommandLineEvidence { - argv: [ - "/usr/lib/signal-desktop/signal-desktop", - "--password-store=kwallet6", - "--ozone-platform=x11", - "--use-tray-icon", - "--", - ] - .into_iter() - .map(|argument| argument.as_bytes().to_vec()) - .collect(), - quality: CommandLineQuality::Structured, - }; - - assert_eq!( - verify_record_launch(record, &snapshot.index, runtime_identity, &command_line), - LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) - ); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs index a5b2e2d59..c02fc9c00 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs @@ -51,20 +51,20 @@ fn dedicated_system_identity_is_associated_without_inline_reply_authority() { #[test] fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { - let (signal_path, signal_identity) = installed_system_executable(); - let record = system_record("signal", "Signal", &signal_path, signal_identity) - .with_launch_literals(&["--", "sgnl://expected"]); + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("example-chat", "Example Chat", &app_path, app_identity) + .with_launch_literals(&["--", "example-chat://expected"]); let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); let resolution = resolve_with_evidence( AppClaim { - // Signal sends an empty app name and adds Electron flags after desktop activation + // Some Electron applications send an empty name and add runtime flags after activation reported_name: "", desktop_entry: None, }, &sender_with_arguments( - &signal_path, - signal_identity, + &app_path, + app_identity, &["--password-store=kwallet6", "--ozone-platform=x11", "--"], ), &index, @@ -134,34 +134,34 @@ fn empty_dedicated_contract_with_rewritten_argv_is_recognized_not_conflicting() #[test] fn verified_executable_recovers_from_stale_desktop_hint() { - let (signal_path, signal_identity) = installed_system_executable(); + let (app_path, app_identity) = installed_system_executable(); let mut stale_user_entry = DesktopRecord::fixture( - "signal-desktop", - "Signal", + "example-chat", + "Example Chat", "/usr/bin/env", identity(90, 900, 0), false, ); - // An env wrapper cannot associate the user entry with the dedicated Signal process + // An env wrapper cannot associate the user entry with the dedicated application process stale_user_entry.association_eligible = false; stale_user_entry.system_association = false; - let system_entry = system_record("signal-true", "Signal", &signal_path, signal_identity); + let system_entry = system_record("example-chat-true", "Example Chat", &app_path, app_identity); let index = DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); let resolution = resolve_with_evidence( AppClaim { - reported_name: "Signal", - // Electron derives this hint from a differently named local desktop file - desktop_entry: Some("signal-desktop"), + reported_name: "Example Chat", + // Electron can derive this hint from a differently named local desktop file + desktop_entry: Some("example-chat"), }, - &sender(&signal_path, signal_identity), + &sender(&app_path, app_identity), &index, ); assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); - assert_eq!(resolution.attribution.display_name, "Signal"); - assert_eq!(resolution.attribution.desktop_id, "signal-true"); + assert_eq!(resolution.attribution.display_name, "Example Chat"); + assert_eq!(resolution.attribution.desktop_id, "example-chat-true"); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs index 512464cf9..c88fb3892 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs @@ -37,15 +37,15 @@ fn user_shadow_cannot_join_the_system_desktop_group() { let system_identity = identity(30, 300, 0); let user_identity = identity(31, 310, 1000); let system = system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", system_identity, ); let mut user = DesktopRecord::fixture( - "org.signal.Signal", - "Signal", - "/home/user/bin/signal", + "org.example.Chat", + "Example Chat", + "/home/user/bin/example-chat", user_identity, false, ); @@ -54,10 +54,10 @@ fn user_shadow_cannot_join_the_system_desktop_group() { let resolution = resolve_with_evidence( AppClaim { - reported_name: "Signal", - desktop_entry: Some("org.signal.Signal"), + reported_name: "Example Chat", + desktop_entry: Some("org.example.Chat"), }, - &sender("/home/user/bin/signal", user_identity), + &sender("/home/user/bin/example-chat", user_identity), &index, ); @@ -69,7 +69,7 @@ fn user_shadow_cannot_join_the_system_desktop_group() { .starts_with("recognized:user-app:")); assert_ne!( resolution.attribution.group_key, - "verified:system-app:org.signal.Signal" + "verified:system-app:org.example.Chat" ); } @@ -179,14 +179,14 @@ fn ambiguous_protected_records_are_unresolved_not_conflicting() { #[test] fn visually_confusable_system_brand_without_association_is_unresolved() { - let signal_identity = identity(40, 400, 0); + let app_identity = identity(40, 400, 0); let hostile_identity = identity(41, 410, 1000); let index = DesktopIdentityIndex::from_records( vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, )], Vec::new(), ); @@ -209,24 +209,24 @@ fn visually_confusable_system_brand_without_association_is_unresolved() { #[test] fn basename_spoof_without_immutable_owner_is_unresolved_without_actions() { - let signal_identity = identity(1, 10, 0); + let app_identity = identity(1, 10, 0); let hostile_identity = identity(7, 70, 1000); let index = DesktopIdentityIndex::from_records( vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, )], Vec::new(), ); let resolution = resolve_with_evidence( AppClaim { - reported_name: "Signal", + reported_name: "Example Chat", desktop_entry: None, }, - &sender("/tmp/signal-desktop", hostile_identity), + &sender("/tmp/example-chat", hostile_identity), &index, ); @@ -241,10 +241,7 @@ fn basename_spoof_without_immutable_owner_is_unresolved_without_actions() { resolution.diagnostics.verification, LaunchVerificationView::InsufficientEvidence ); - assert_ne!( - resolution.attribution.group_key, - "desktop:org.signal.Signal" - ); + assert_ne!(resolution.attribution.group_key, "desktop:org.example.Chat"); } #[test] @@ -306,21 +303,21 @@ fn exact_system_notify_send_identity_is_a_non_replying_relay() { #[test] fn trusted_relay_uses_command_line_identity() { - let signal_identity = identity(1, 10, 0); + let app_identity = identity(1, 10, 0); let relay_identity = identity(3, 30, 0); let index = DesktopIdentityIndex::from_records( vec![system_record( - "org.signal.Signal", - "Signal", - "/usr/bin/signal-desktop", - signal_identity, + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, )], vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], ); let resolution = resolve_with_evidence( AppClaim { - reported_name: "Signal", + reported_name: "Example Chat", desktop_entry: None, }, &sender("/usr/bin/notify-send", relay_identity), @@ -332,17 +329,14 @@ fn trusted_relay_uses_command_line_identity() { resolution.attribution.display_name, "Command-line notification" ); - assert_eq!(resolution.attribution.claimed_name, "Signal"); + assert_eq!(resolution.attribution.claimed_name, "Example Chat"); assert_eq!( resolution.attribution.badge_icon, "utilities-terminal-symbolic" ); assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); - assert_ne!( - resolution.attribution.group_key, - "desktop:org.signal.Signal" - ); + assert_ne!(resolution.attribution.group_key, "desktop:org.example.Chat"); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs index bbd67e3ef..5dd3012bb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -111,7 +111,7 @@ fn sender_credential_timeout_is_preserved_in_diagnostics() { }; let resolution = unknown_reply_denied( AppClaim { - reported_name: "Signal", + reported_name: "Example Chat", desktop_entry: None, }, &metadata, diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs index db57b83f7..b155d66f0 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs @@ -2,9 +2,9 @@ use super::support::*; #[test] fn contains_ci_matches_ascii() { - assert!(contains_ci("Signal-Desktop", "signal")); - assert!(contains_ci("signal-desktop", "Signal")); - assert!(!contains_ci("signal-desktop", "brave")); + assert!(contains_ci("Example-Chat", "example")); + assert!(contains_ci("example-chat", "Example")); + assert!(!contains_ci("example-chat", "brave")); assert!(contains_ci("mixedCase", "case")); assert!(contains_ci("mixedCase", "")); assert!(contains_ci("same", "same")); diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs index 8a3e9f2b1..ad1d19f05 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -57,22 +57,22 @@ fn active_action_target_denies_every_unverified_sender_class() { "user-app:org.example.UserApplication".to_string(), ), NotificationAttribution::unresolved( - "Signal", + "Example Chat", AttributionReason::NoDesktopCandidate, "source /tmp/fake", - "unknown:signal".to_string(), + "unknown:example-chat".to_string(), ), NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", AttributionReason::ExecutableMismatch, "source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ), NotificationAttribution::relay( - "Signal", + "Example Chat", "trusted relay /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ), ] { let mut store = make_store_with_limits(12, 20); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 27a9b141a..69bf8b296 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -53,11 +53,11 @@ fn title_and_body_builders_keep_text_classes_and_line_limits() { #[gtk::test] fn secondary_claim_stays_on_one_compact_metadata_line() { let mut view = view_model(); - view.secondary_claim = Some("Claimed app: Signal".to_string()); + view.secondary_claim = Some("Claimed app: Example Chat".to_string()); let claim = build_secondary_claim(&view).expect("secondary claim"); - assert_eq!(claim.text().as_str(), "Claimed app: Signal"); + assert_eq!(claim.text().as_str(), "Claimed app: Example Chat"); assert!(claim.is_single_line_mode()); assert_eq!(claim.ellipsize(), gtk::pango::EllipsizeMode::End); assert!(!claim.wraps()); @@ -313,9 +313,9 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); let mut notification = notification(); notification.attribution = NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ); let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs index 84c6c69f2..f6115d08c 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -25,7 +25,7 @@ fn popup_accessible_name_keeps_identity_and_message_context() { fn conflict_accessible_name_includes_trust_claim_and_body() { let mut view = view_model(); view.app_label = "Unknown application".to_string(); - view.secondary_claim = Some("Claimed app: Signal".to_string()); + view.secondary_claim = Some("Claimed app: Example Chat".to_string()); view.badge = BadgePresentation::SuspiciousApplication; view.body = Some("Hey, did this go through?".to_string()); view.trust.level = TrustLevel::Conflict; @@ -33,7 +33,7 @@ fn conflict_accessible_name_includes_trust_claim_and_body() { assert_eq!( popup_accessible_label(&view), - "Unknown application. Suspicious. Claimed app: Signal. Build finished. \ + "Unknown application. Suspicious. Claimed app: Example Chat. Build finished. \ Hey, did this go through?" ); } diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs index 0abe20a5d..b3cc301e7 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs @@ -39,11 +39,11 @@ fn suspicious_provenance_preserves_the_communication_category() { let mut view = notification(); view.category = "im.received".to_string(); view.attribution = NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", AttributionReason::ExecutableMismatch, "source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ); assert_eq!(PopupKind::for_notification(&view), PopupKind::Communication); diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs index da2340b41..56c69bd7e 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -45,11 +45,11 @@ fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { let mut view = notification(); view.attribution = NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", AttributionReason::ExecutableMismatch, "source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ); view.inline_reply.available = true; view.inline_reply_policy = InlineReplyPolicy::Deny; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs index 494ca217b..bdca9ead2 100644 --- a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -90,10 +90,10 @@ fn blank_default_action_is_clickable_without_becoming_a_visible_control() { fn weak_attribution_hides_every_application_directed_action() { let mut view = notification(); view.attribution = NotificationAttribution::unresolved( - "Signal", + "Example Chat", AttributionReason::NoDesktopCandidate, "source /tmp/fake", - "unknown:signal".to_string(), + "unknown:example-chat".to_string(), ); view.actions.push(Action { key: "default".to_string(), @@ -256,11 +256,11 @@ fn conflicting_claim_keeps_communication_layout_and_drops_actions() { let mut view = notification(); view.category = "im.received".to_string(); view.attribution = NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", AttributionReason::ExecutableMismatch, "source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ); view.actions.push(Action { key: "default".to_string(), @@ -272,7 +272,7 @@ fn conflicting_claim_keeps_communication_layout_and_drops_actions() { assert_eq!(model.kind, PopupKind::Communication); assert_eq!( model.secondary_claim.as_deref(), - Some("Claimed app: Signal") + Some("Claimed app: Example Chat") ); assert!(model.primary_actions.is_empty()); assert!(model.overflow_actions.is_empty()); diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index b87601410..84c8d5602 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -222,12 +222,12 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { let notification = NotificationView { id: 3, generation: 3, - app_name: "Signal".to_string(), + app_name: "Example Chat".to_string(), attribution: unixnotis_core::NotificationAttribution::unresolved( - "Signal", + "Example Chat", unixnotis_core::AttributionReason::MissingSenderEvidence, "sender evidence unavailable", - "unknown:signal".to_string(), + "unknown:example-chat".to_string(), ), summary: "John Doe".to_string(), body: "Are you free later?".to_string(), @@ -280,13 +280,13 @@ fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { let notification = NotificationView { id: 4, generation: 4, - app_name: "Signal".to_string(), + app_name: "Example Chat".to_string(), attribution: unixnotis_core::NotificationAttribution::conflict( - "Signal", - "org.signal.Signal", + "Example Chat", + "org.example.Chat", unixnotis_core::AttributionReason::ExecutableMismatch, "application claim mismatch; source /tmp/fake", - "conflict:signal".to_string(), + "conflict:example-chat".to_string(), ), summary: "John Doe".to_string(), body: "Are you free later?".to_string(), @@ -309,7 +309,7 @@ fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { assert!(visible_descendant_has_text(root.upcast_ref(), "Suspicious")); assert!(visible_descendant_has_text( root.upcast_ref(), - "Claimed app: Signal" + "Claimed app: Example Chat" )); assert!(!visible_descendant_has_text( root.upcast_ref(), @@ -318,7 +318,7 @@ fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { } #[gtk::test] -fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { +fn notify_send_claim_uses_one_command_line_avatar_without_app_branding() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupRelayProbe") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -339,11 +339,11 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { let mut notification = NotificationView { id: 5, generation: 5, - app_name: "Signal".to_string(), + app_name: "Example Chat".to_string(), attribution: unixnotis_core::NotificationAttribution::relay( - "Signal", + "Example Chat", "Sent via /usr/bin/notify-send", - "relay:notify-send:signal".to_string(), + "relay:notify-send:example-chat".to_string(), ), summary: "John Doe".to_string(), body: String::new(), @@ -358,7 +358,7 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { popup_decision: unixnotis_core::PopupDecisionRecord::default(), popup_hide_after_ms: 0, }; - notification.image.badge_icon = "signal-desktop".to_string(); + notification.image.badge_icon = "example-chat".to_string(); let root = state.build_popup_root(¬ification); @@ -371,7 +371,7 @@ fn notify_send_claim_uses_one_command_line_avatar_without_signal_branding() { )); assert!(visible_descendant_has_text( root.upcast_ref(), - "App label: Signal" + "App label: Example Chat" )); assert_eq!( visible_descendant_class_count(root.upcast_ref(), "unixnotis-identity-avatar"), From 7b969f2b18afedd6c8e71a631370faa413499d35 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 15:08:52 -0500 Subject: [PATCH 233/275] style(ui): align media defaults and panel action surfaces Use the reviewed media stylesheet as the bundled installation and reset default. Keep conversation avatars plain in the master-sized panel lead slot, while content visuals retain their tile treatment and notification actions share the panel glass hover language. --- crates/unixnotis-core/assets/media.css | 157 +++++++++++------- crates/unixnotis-core/assets/panel.css | 24 ++- .../unixnotis-core/src/embedded/tests/css.rs | 24 ++- 3 files changed, 138 insertions(+), 67 deletions(-) diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 6d675da72..7cc705a1a 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -7,14 +7,14 @@ :root { --unixnotis-media-card-radius: 18px; --unixnotis-media-card-min-height: 82px; - --unixnotis-media-card-padding-x: 10px; + --unixnotis-media-card-padding-x: 8px; --unixnotis-media-card-padding-y: 8px; - --unixnotis-media-card-padding-inline-x: 10px; + --unixnotis-media-card-padding-inline-x: 8px; --unixnotis-media-card-padding-inline-y: 8px; --unixnotis-media-card-padding-stacked: 8px; - --unixnotis-media-card-padding-showcase-x: 10px; + --unixnotis-media-card-padding-showcase-x: 8px; --unixnotis-media-card-padding-showcase-y: 8px; - --unixnotis-media-button-padding-x: 6px; + --unixnotis-media-button-padding-x: 5px; --unixnotis-media-button-padding-y: 4px; --unixnotis-media-art-size: 56px; --unixnotis-media-art-radius: 12px; @@ -59,71 +59,84 @@ background: transparent; } -/* Player switcher: glass chips that hug the card sides, with a count pill so - * switching players reads as an intentional control instead of bare arrows. */ +/* Player switcher: symmetric chevron docks flanking the card. Quiet glass so + * they never compete with the notification list. */ .unixnotis-media-nav { - background: alpha(#ffffff, 0.07); - border: 1px solid alpha(#ffffff, 0.12); - border-radius: 12px; + background: alpha(#ffffff, 0.05); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 999px; padding: 0; margin: 0; min-width: 24px; - min-height: 44px; - -gtk-icon-size: 18px; - font-size: 18px; - font-weight: 600; - color: alpha(#ffffff, 0.92); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.10); + min-height: 52px; + -gtk-icon-size: 16px; + font-size: 16px; + font-weight: 700; + color: alpha(#ffffff, 0.80); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.06), + 0 2px 6px -4px alpha(#000000, 0.4); transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-media-nav:hover { - background: alpha(#ffffff, 0.15); - border-color: alpha(#ffffff, 0.22); + background: alpha(#ffffff, 0.10); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.03); color: #ffffff; box-shadow: - 0 6px 12px -6px alpha(#000000, 0.45), - inset 0 1px 0 alpha(#ffffff, 0.12); + 0 8px 16px -10px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.10); } .unixnotis-media-nav:active { - background: alpha(#ffffff, 0.19); - border-color: alpha(#ffffff, 0.26); + background: alpha(#ffffff, 0.14); color: #ffffff; } -/* Point each tab at the card so it reads as a directional arrow. */ -.unixnotis-media-nav-prev { - border-radius: 999px 6px 6px 999px; +.unixnotis-media-nav:backdrop { + background: alpha(#ffffff, 0.035); + border-color: alpha(#ffffff, 0.05); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04); } -.unixnotis-media-nav-next { - border-radius: 6px 999px 999px 6px; +/* Keep the arrows clear of the rounded chip edges so the glyph never clips. */ +.unixnotis-media-nav image, +.unixnotis-media-nav .icon { + color: inherit; } .unixnotis-media-position { - color: alpha(#ffffff, 0.70); + color: alpha(#ffffff, 0.60); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; - background: alpha(#ffffff, 0.06); - border: 1px solid alpha(#ffffff, 0.10); + background: alpha(#ffffff, 0.05); + border: 1px solid alpha(#ffffff, 0.08); border-radius: 999px; padding: 1px 8px; box-shadow: none; } +/* Glass card: translucent with a soft top sheen, so it reads as a pane of + * frosted glass rather than a solid panel. Deliberately low-contrast so the + * notification feed stays the visual center. */ .unixnotis-media-card { - background: alpha(#ffffff, 0.055); + background-image: linear-gradient(165deg, alpha(#ffffff, 0.07) 0%, alpha(#ffffff, 0.02) 100%); border-top: 1px solid alpha(#ffffff, 0.10); - border-left: 1px solid alpha(#ffffff, 0.08); - border-right: 1px solid alpha(#ffffff, 0.05); + border-left: 1px solid alpha(#ffffff, 0.07); + border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); border-radius: var(--unixnotis-media-card-radius); padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); min-height: var(--unixnotis-media-card-min-height); box-shadow: - 0 6px 16px -10px alpha(#000000, 0.55), + 0 4px 12px -8px alpha(#000000, 0.4), inset 0 1px 0 alpha(#ffffff, 0.05); transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out; } @@ -152,12 +165,40 @@ padding: var(--unixnotis-media-card-padding-showcase-y) var(--unixnotis-media-card-padding-showcase-x); } +/* Playing is a gentle glass brightening, not a colored outline. */ .unixnotis-media-card.playing { - background: alpha(#ffffff, 0.07); - border-color: alpha(@unixnotis-accent, 0.32); + background-image: linear-gradient(165deg, alpha(#ffffff, 0.09) 0%, alpha(#ffffff, 0.03) 100%); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.05); + border-bottom-color: alpha(#ffffff, 0.02); + box-shadow: + 0 8px 16px -12px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.07); +} + +.unixnotis-media-card:hover { + background-image: linear-gradient(165deg, alpha(#ffffff, 0.09) 0%, alpha(#ffffff, 0.03) 100%); + border-top: 1px solid alpha(#ffffff, 0.13); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.05); + border-bottom: 1px solid alpha(#ffffff, 0.02); box-shadow: - 0 8px 18px -12px alpha(#000000, 0.60), - inset 0 1px 0 alpha(#ffffff, 0.08); + 0 8px 16px -12px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.07); +} + +/* Keep depth visible when the panel is unfocused, matching the widget family. */ +.unixnotis-media-card:backdrop { + border-top-color: alpha(#ffffff, 0.07); + border-left-color: alpha(#ffffff, 0.05); + border-right-color: alpha(#ffffff, 0.03); + border-bottom-color: alpha(#ffffff, 0.02); + box-shadow: + 0 12px 24px -22px alpha(#000000, 0.35), + 0 0 0 1px alpha(#ffffff, 0.04), + inset 0 1px 0 alpha(#ffffff, 0.04), + inset 0 -2px 6px -5px alpha(#000000, 0.35); } /* @@ -211,7 +252,7 @@ } .unixnotis-media-source { - color: alpha(@unixnotis-accent, 0.75); + color: alpha(@unixnotis-accent, 0.65); font-weight: 700; font-size: 10px; letter-spacing: 0.12em; @@ -220,7 +261,7 @@ .unixnotis-media-title { color: #ffffff; - font-weight: 800; + font-weight: 750; font-size: var(--unixnotis-media-title-font-size); letter-spacing: -0.01em; line-height: 1.2; @@ -233,7 +274,7 @@ } .unixnotis-media-artist { - color: #cbd5e1; + color: alpha(#cbd5e1, 0.85); font-weight: 500; font-size: 12px; line-height: 1.25; @@ -247,6 +288,7 @@ background: transparent; } +/* Transport buttons share the toggle/action glass. */ .unixnotis-media-button { background: alpha(#ffffff, 0.04); border-top: 1px solid alpha(#ffffff, 0.08); @@ -256,7 +298,7 @@ border-radius: var(--unixnotis-media-button-radius); padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); box-shadow: none; - color: alpha(#ffffff, 0.92); + color: alpha(#ffffff, 0.78); transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } @@ -272,13 +314,15 @@ .unixnotis-media-button:disabled, .unixnotis-media-button:disabled:hover { - background: alpha(#ffffff, 0.05); - border: 1px solid alpha(#ffffff, 0.10); - color: alpha(#ffffff, 0.92); + background: alpha(#ffffff, 0.04); + border: 1px solid alpha(#ffffff, 0.08); + color: alpha(#ffffff, 0.50); -gtk-icon-filter: none; box-shadow: none; } +/* Primary play: a clean white pill so it reads as the main control without + * borrowing the accent hue. */ .unixnotis-media-button.primary { background: @unixnotis-text; border: 1px solid @unixnotis-text; @@ -286,18 +330,6 @@ box-shadow: 0 4px 10px -3px alpha(#000000, 0.3); } -/* Restrained media transport: hover is a gentle lift within the panel family */ -.unixnotis-media-card:hover { - background: alpha(#ffffff, 0.09); - border-top: 1px solid alpha(#ffffff, 0.16); - border-left: 1px solid alpha(#ffffff, 0.12); - border-right: 1px solid alpha(#ffffff, 0.07); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: - 0 10px 22px -12px alpha(#000000, 0.62), - inset 0 1px 0 alpha(#ffffff, 0.08); -} - .unixnotis-media-button.primary:hover { background: alpha(#ffffff, 0.90); border-color: alpha(#ffffff, 0.90); @@ -305,14 +337,14 @@ box-shadow: 0 6px 14px -2px alpha(#000000, 0.45); } -/* Transport icons stay white on every player (browser MPRIS icons can arrive - * dark); the primary play button keeps its dark-on-white glyph. */ +/* Transport icons stay legible on every player (browser MPRIS icons can arrive + * dark); the primary play button keeps its dark glyph. */ .unixnotis-media-button image, .unixnotis-media-button .icon, .unixnotis-media-button:disabled image, .unixnotis-media-button:disabled .icon { - color: #ffffff; - -gtk-icon-palette: success alpha(#ffffff, 0.95), warning alpha(#ffffff, 0.95), error alpha(#ffffff, 0.95); + color: alpha(#ffffff, 0.90); + -gtk-icon-palette: success alpha(#ffffff, 0.90), warning alpha(#ffffff, 0.90), error alpha(#ffffff, 0.90); -gtk-icon-filter: none; -gtk-icon-shadow: 0 0 0 transparent; } @@ -324,6 +356,11 @@ -gtk-icon-shadow: 0 0 0 transparent; } +.unixnotis-media-button.primary:hover image, +.unixnotis-media-button.primary:hover .icon { + color: #020617; +} + .unixnotis-media-button:focus, .unixnotis-media-nav:focus { /* Remove default blue focus rings from GTK button selections */ diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 7fbbb6278..97b15af12 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -790,19 +790,33 @@ entry selection { } .unixnotis-notification-action { - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.9), alpha(@unixnotis-surface, 0.95)); - color: @unixnotis-text; + background: alpha(#ffffff, 0.045); + color: alpha(#ffffff, 0.75); border-radius: 10px; padding: 4px 10px; padding: var(--unixnotis-notification-action-padding-y) var(--unixnotis-notification-action-padding-x); - border: 1px solid alpha(@unixnotis-accent, 0.18); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 28px; font-size: 12px; + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-notification-action:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.2), alpha(@unixnotis-accent-2, 0.2)); - border-color: alpha(@unixnotis-accent, 0.5); + background: alpha(#ffffff, 0.08); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.04); + color: #ffffff; + box-shadow: + 0 10px 18px -14px @unixnotis-shadow-soft, + 0 0 22px -18px @unixnotis-glow-cyan, + 0 0 20px -18px @unixnotis-glow-pink, + inset 0 0 0 1px alpha(#ffffff, 0.05); } .unixnotis-inline-reply { diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 8ee4c9b25..5869c28bf 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -211,10 +211,30 @@ fn panel_avatar_slot_is_plain_but_content_visuals_keep_their_tile() { ); } +#[test] +fn notification_action_hover_matches_panel_glass_controls() { + let action = DEFAULT_PANEL_CSS + .split(".unixnotis-notification-action {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("notification action rules should be present"); + let hover = DEFAULT_PANEL_CSS + .split(".unixnotis-notification-action:hover {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("notification action hover rules should be present"); + + assert!(action.contains("background: alpha(#ffffff, 0.045)")); + assert!(action.contains("border-top: 1px solid alpha(#ffffff, 0.08)")); + assert!(hover.contains("background: alpha(#ffffff, 0.08)")); + assert!(hover.contains("color: #ffffff")); + assert!(!hover.contains("linear-gradient")); +} + #[test] fn bundled_media_defaults_match_the_active_player_surface() { - assert!(DEFAULT_MEDIA_CSS.contains("min-height: 44px")); - assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-nav-prev")); + assert!(DEFAULT_MEDIA_CSS.contains("min-height: 52px")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-nav:hover")); assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-button:disabled")); assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-card.playing")); assert!(DEFAULT_MEDIA_CSS.contains("--unixnotis-media-art-size: 56px")); From 6fe754c050b2d8aaed86571b10232b480a9b2464 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 18:35:45 -0500 Subject: [PATCH 234/275] fix(center): measure and separate notification rows with grid layout Replace overlay-dependent row geometry with measured same-cell grid layers. Keep foreground cards full width independently of rear-layer visibility, store positive offsets and row spacing in GTK layout properties, and invalidate recycled-row geometry after height changes. Cover collapsed, expanded, standalone, adjacent-row, and recycling allocations with mapped tests. --- .../src/ui/notifications/row/group.rs | 12 +- .../src/ui/notifications/row/layout.rs | 4 + .../src/ui/notifications/row/mod.rs | 1 + .../notifications/row/notification/build.rs | 22 +- .../notifications/row/notification/stack.rs | 60 ++++- .../notifications/row/notification/state.rs | 4 + .../row/notification/tests/stack.rs | 107 ++++++++- .../row/notification/update/row.rs | 10 + .../row/notification/update/tests/state.rs | 4 +- .../row/notification/update/visual.rs | 9 +- .../src/ui/notifications/row/tests/group.rs | 1 + .../src/ui/notifications/view/build.rs | 4 - .../src/ui/notifications/view/tests/build.rs | 219 +++++++++++++++++- crates/unixnotis-core/assets/panel.css | 34 +-- .../src/css/hooks/tests/hooks.rs | 14 +- .../unixnotis-core/src/css/tokens/layout.rs | 1 - .../unixnotis-core/src/embedded/tests/css.rs | 16 +- 17 files changed, 463 insertions(+), 59 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/layout.rs diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 61871f04f..5008b2d82 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -19,7 +19,6 @@ use super::super::item::RowData; const GROUP_AVATAR_SIZE: i32 = 26; const GROUP_ICON_SIZE: i32 = 18; - pub(in crate::ui::notifications) struct GroupRowWidgets { pub(super) button: gtk::Button, pub(super) avatar: gtk::Box, @@ -40,14 +39,22 @@ pub(in crate::ui::notifications) fn build_group_row( root.add_css_class(hooks::group_row::ROOT); root.add_css_class(hooks::group_row::CONTAINER); root.add_css_class(hooks::group_row::EXPANDED); + root.set_hexpand(true); + root.set_halign(gtk::Align::Fill); + root.set_vexpand(false); + root.set_margin_bottom(super::layout::NOTIFICATION_LIST_ROW_GAP); let button = gtk::Button::new(); button.add_css_class(hooks::group_row::HEADER); button.set_has_frame(false); button.set_focusable(true); button.set_tooltip_text(Some("Toggle group")); + button.set_hexpand(true); + button.set_halign(gtk::Align::Fill); let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.set_halign(gtk::Align::Fill); let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); avatar.set_halign(gtk::Align::Center); avatar.set_valign(gtk::Align::Center); @@ -198,6 +205,7 @@ pub(in crate::ui::notifications) fn update_group_row( if let Some(notification) = data.notification.as_ref() { let Some(presentation) = presentation else { + root.queue_resize(); return; }; set_widget_visible_if_changed(&group.avatar, true); @@ -244,6 +252,8 @@ pub(in crate::ui::notifications) fn update_group_row( set_class_state(root, hooks::group_row::NO_ICON, true); set_class_state(root, hooks::group_row::HAS_ICON, false); } + // Group identity changes can alter the natural row height when rows are recycled + root.queue_resize(); } fn group_accessible_label( diff --git a/crates/unixnotis-center/src/ui/notifications/row/layout.rs b/crates/unixnotis-center/src/ui/notifications/row/layout.rs new file mode 100644 index 000000000..1fd273071 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/layout.rs @@ -0,0 +1,4 @@ +//! Shared structural measurements for notification list rows + +// Row separation belongs to GTK layout so virtualized rows measure their gap +pub(super) const NOTIFICATION_LIST_ROW_GAP: i32 = 8; diff --git a/crates/unixnotis-center/src/ui/notifications/row/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/mod.rs index 310f09bc2..9f5670830 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/mod.rs @@ -5,4 +5,5 @@ pub(super) mod empty; pub(super) mod group; +pub(super) mod layout; pub(super) mod notification; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 753f94846..523568f12 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -29,15 +29,21 @@ pub(in crate::ui::notifications) fn build_notification_row( let root = gtk::Box::new(gtk::Orientation::Vertical, 0); root.add_css_class(hooks::panel_card::ROW); root.set_hexpand(true); + root.set_halign(gtk::Align::Fill); + root.set_vexpand(false); + root.set_margin_bottom(super::super::layout::NOTIFICATION_LIST_ROW_GAP); // Card uses vertical layout: header, summary, body, then actions let card = gtk::Box::new(gtk::Orientation::Vertical, 6); card.add_css_class("unixnotis-panel-card"); card.set_hexpand(true); + card.set_halign(gtk::Align::Fill); + card.set_vexpand(false); let meta_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta_top.add_css_class(hooks::panel_card::META_TOP); meta_top.set_hexpand(true); + meta_top.set_halign(gtk::Align::Fill); meta_top.set_visible(false); let meta_label = gtk::Label::new(None); @@ -64,6 +70,8 @@ pub(in crate::ui::notifications) fn build_notification_row( // Header owns identity, chronology, and dismiss without covering message content let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); header.add_css_class(hooks::panel_card::HEADER); + header.set_hexpand(true); + header.set_halign(gtk::Align::Fill); let icon = gtk::Image::new(); icon.set_pixel_size(20); icon.add_css_class("unixnotis-panel-icon"); @@ -110,6 +118,8 @@ pub(in crate::ui::notifications) fn build_notification_row( let body_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); body_row.set_hexpand(true); + body_row.set_halign(gtk::Align::Fill); + body_row.set_vexpand(false); let thumbnail = gtk::Image::new(); thumbnail.add_css_class(hooks::panel_card::THUMBNAIL); @@ -120,6 +130,8 @@ pub(in crate::ui::notifications) fn build_notification_row( let text_stack = gtk::Box::new(gtk::Orientation::Vertical, 2); text_stack.add_css_class(hooks::panel_card::TEXT); text_stack.set_hexpand(true); + text_stack.set_halign(gtk::Align::Fill); + text_stack.set_vexpand(false); // Summary is optional, so the update path decides later if the row should exist let summary_label = gtk::Label::new(None); @@ -197,9 +209,12 @@ pub(in crate::ui::notifications) fn build_notification_row( // The wrapper clips the complete styled card while the inner box keeps all CSS hooks let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); + card_plate.set_hexpand(true); + card_plate.set_halign(gtk::Align::Fill); + card_plate.set_vexpand(false); - // One overlay cell keeps positive stack offsets inside the measured row bounds - let stack = gtk::Overlay::new(); + // One grid cell keeps every positive stack offset inside the measured row bounds + let stack = gtk::Grid::new(); stack.add_css_class("unixnotis-panel-notification-stack"); stack.set_hexpand(true); root.append(&stack); @@ -227,10 +242,13 @@ pub(in crate::ui::notifications) fn build_notification_row( // The reusable widget bundle is returned with the root so the list factory // can keep the GTK tree and the cached row state together + let row_root = root.clone(); ( root, NotificationRowWidgets { default_activation, + root: row_root, + stack, card, card_plate, stack_middle, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs index 0330a13c1..bba019bd2 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -9,23 +9,64 @@ pub(super) struct StackLayerVisibility { } pub(super) fn append_stack_layers( - root: >k::Overlay, + root: >k::Grid, foreground: &unixnotis_ui::CutCorner, ) -> (gtk::Box, gtk::Box) { let middle = build_stack_layer("unixnotis-stack-layer-middle"); let back = build_stack_layer("unixnotis-stack-layer-back"); - // One overlay allocation keeps all three layers in the same measured cell - root.set_child(Some(&back)); - root.add_overlay(&middle); - root.add_overlay(foreground); + // A grid measures every visible layer as one ordinary list-row child + // This keeps positive offsets inside the row allocation + root.set_hexpand(true); + root.set_vexpand(false); + root.set_halign(gtk::Align::Fill); + root.set_valign(gtk::Align::Start); + root.attach(&back, 0, 0, 1, 1); + root.attach(&middle, 0, 0, 1, 1); + root.attach(foreground, 0, 0, 1, 1); - // Rear shells never determine row height; the readable card does - root.set_measure_overlay(&middle, false); - root.set_measure_overlay(foreground, true); + // Structural offsets are GTK layout properties, not stylesheet geometry + back.set_halign(gtk::Align::Fill); + back.set_valign(gtk::Align::Start); + back.set_margin_start(20); + back.set_margin_end(20); + middle.set_halign(gtk::Align::Fill); + middle.set_valign(gtk::Align::Start); + middle.set_margin_top(6); + middle.set_margin_start(14); + middle.set_margin_end(14); + foreground.set_halign(gtk::Align::Fill); + foreground.set_hexpand(true); + foreground.set_valign(gtk::Align::Start); + foreground.set_vexpand(false); + foreground.set_margin_bottom(0); (middle, back) } +pub(super) fn set_stack_layer_margins( + foreground: &unixnotis_ui::CutCorner, + middle: >k::Box, + back: >k::Box, + collapsed: bool, + grouped: bool, +) { + // Rear layers keep their measured positive peeks in every row state + back.set_margin_top(0); + back.set_margin_start(20); + back.set_margin_end(20); + back.set_margin_bottom(0); + middle.set_margin_top(6); + middle.set_margin_start(14); + middle.set_margin_end(14); + middle.set_margin_bottom(0); + + // Only a collapsed preview needs the foreground's positive inset + foreground.set_margin_top(if collapsed { 12 } else { 0 }); + foreground.set_margin_start(if grouped { 8 } else { 0 }); + foreground.set_margin_end(if grouped { 8 } else { 0 }); + foreground.set_margin_bottom(0); +} + pub(super) const fn stack_layer_visibility(depth: u8) -> StackLayerVisibility { StackLayerVisibility { middle: depth >= 2, @@ -38,6 +79,9 @@ fn build_stack_layer(position_class: &str) -> gtk::Box { layer.add_css_class("unixnotis-stack-layer"); layer.add_css_class(position_class); layer.set_hexpand(true); + layer.set_halign(gtk::Align::Fill); + layer.set_vexpand(false); + layer.set_valign(gtk::Align::Start); layer.set_can_target(false); layer.set_accessible_role(gtk::AccessibleRole::Presentation); layer.set_visible(false); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index ef9df4a81..33cac2739 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -15,6 +15,10 @@ use super::reply::InlineReplyWidgets; pub(in crate::ui::notifications) struct NotificationRowWidgets { // Active rows use one shared generation-bound card activation binding pub(in crate::ui::notifications) default_activation: DefaultActionBinding, + // The real ListView child owns vertical spacing and recycled-row geometry + pub(super) root: gtk::Box, + // Same-cell grid measures every visible stack layer as one row + pub(super) stack: gtk::Grid, // Styled notification card inside the ListView row wrapper pub(super) card: gtk::Box, // Polygon wrapper clips both visual output and pointer hit testing diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs index 098533cfe..b7fb42ce2 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -1,6 +1,8 @@ use gtk::prelude::*; -use super::{append_stack_layers, stack_layer_visibility, StackLayerVisibility}; +use super::{ + append_stack_layers, set_stack_layer_margins, stack_layer_visibility, StackLayerVisibility, +}; #[test] fn collapsed_stack_depth_maps_to_two_rear_layers() { @@ -24,20 +26,113 @@ fn collapsed_stack_depth_maps_to_two_rear_layers() { #[gtk::test] fn stack_layers_paint_behind_foreground_and_never_accept_input() { - let root = gtk::Overlay::new(); + let root = gtk::Grid::new(); let card = gtk::Box::new(gtk::Orientation::Vertical, 0); let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); let (middle, back) = append_stack_layers(&root, &foreground); + assert_eq!(back.margin_start(), 20); + assert_eq!(middle.margin_top(), 6); + assert_eq!(middle.margin_start(), 14); - assert_eq!(root.child().as_ref(), Some(back.upcast_ref())); - assert_eq!(back.next_sibling().as_ref(), Some(middle.upcast_ref())); + assert_eq!(root.child_at(0, 0).as_ref(), Some(back.upcast_ref())); + assert_eq!( + root.child_at(0, 0) + .expect("back layer should be attached") + .next_sibling() + .as_ref(), + Some(middle.upcast_ref()) + ); assert_eq!( middle.next_sibling().as_ref(), Some(foreground.upcast_ref()) ); - assert!(!root.is_measure_overlay(&middle)); - assert!(root.is_measure_overlay(&foreground)); + assert!(!root.vexpands()); + assert_eq!(root.valign(), gtk::Align::Start); assert!(!middle.can_target()); assert!(!back.can_target()); } + +#[gtk::test] +fn measured_stack_includes_visible_positive_offset_layers() { + let root = gtk::Grid::new(); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + card.set_size_request(-1, 100); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + set_stack_layer_margins(&foreground, &middle, &back, true, true); + middle.set_size_request(-1, 68); + back.set_size_request(-1, 68); + middle.set_visible(true); + back.set_visible(true); + foreground.set_visible(true); + + let (_, natural_height, _, _) = root.measure(gtk::Orientation::Vertical, 320); + + assert!( + natural_height >= 112, + "measured grid height {natural_height} must contain the foreground offset" + ); + + let allocation = gtk::Allocation::new(0, 0, 320, natural_height); + root.size_allocate(&allocation, -1); + + let layers: [>k::Widget; 3] = [ + back.upcast_ref::(), + middle.upcast_ref::(), + foreground.upcast_ref::(), + ]; + for layer in layers { + if !layer.is_visible() { + continue; + } + let layer_bounds = layer + .compute_bounds(&root) + .expect("visible stack layers should have bounds"); + assert!(layer_bounds.y() >= 0.0); + assert!( + layer_bounds.y() + layer_bounds.height() <= natural_height as f32, + "visible stack layer must remain within its measured row" + ); + } + + let foreground_y = foreground + .compute_bounds(&root) + .expect("foreground should have bounds") + .y(); + let foreground_width = foreground.width(); + assert_eq!(foreground_width, 304); + let middle_y = middle + .compute_bounds(&root) + .expect("middle layer should have bounds") + .y(); + let back_y = back + .compute_bounds(&root) + .expect("back layer should have bounds") + .y(); + assert!(foreground_y >= middle_y); + assert!(middle_y >= back_y); +} + +#[gtk::test] +fn foreground_fills_grid_when_rear_layers_are_hidden() { + let root = gtk::Grid::new(); + root.set_hexpand(true); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + card.set_size_request(-1, 80); + card.set_hexpand(true); + card.set_halign(gtk::Align::Fill); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + set_stack_layer_margins(&foreground, &middle, &back, false, false); + foreground.set_visible(true); + + let allocation = gtk::Allocation::new(0, 0, 320, 80); + root.size_allocate(&allocation, -1); + + assert_eq!(root.width(), 320); + assert_eq!(foreground.width(), 320); + assert_eq!(card.width(), 320); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index dfe76f7c0..7b57ae74d 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -77,6 +77,7 @@ pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRow while let Some(child) = row.actions_box.first_child() { row.actions_box.remove(&child); } + queue_row_resize(row); } pub(in crate::ui::notifications) fn update_notification_row( @@ -225,4 +226,13 @@ pub(in crate::ui::notifications) fn update_notification_row( ); set_widget_visible_if_changed(&row.card_plate, true); set_widget_visible_if_changed(&row.card, true); + queue_row_resize(row); +} + +fn queue_row_resize(row: &NotificationRowWidgets) { + // Recycled rows can change natural height when text, media, or stack depth changes + row.card.queue_resize(); + row.card_plate.queue_resize(); + row.stack.queue_resize(); + row.root.queue_resize(); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index 6de91ebb8..c8ec0ee40 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -297,8 +297,8 @@ fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { let stack = root .first_child() - .and_downcast::() - .expect("notification row should use one measured stack overlay"); + .and_downcast::() + .expect("notification row should use one measured stack grid"); let stack_child_count = std::iter::successors(stack.first_child(), gtk::prelude::WidgetExt::next_sibling).count(); assert_eq!(stack_child_count, 3); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 5555be546..0f6d3403f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -5,7 +5,7 @@ use unixnotis_core::{hooks, NotificationView, Urgency}; use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use super::super::super::super::item::RowData; -use super::super::stack::stack_layer_visibility; +use super::super::stack::{set_stack_layer_margins, stack_layer_visibility}; use super::super::state::NotificationRowWidgets; use super::labels::has_visible_text; @@ -59,6 +59,13 @@ pub(super) fn apply_visual_state( data.collapsed_group_preview, ); let layers = stack_layer_visibility(data.stack_depth); + set_stack_layer_margins( + &row.card_plate, + &row.stack_middle, + &row.stack_back, + data.collapsed_group_preview, + data.collapsed_group_preview || data.expanded, + ); set_widget_visible_if_changed(&row.stack_middle, layers.middle); set_widget_visible_if_changed(&row.stack_back, layers.back); let grouped = data.collapsed_group_preview || data.expanded; diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 9acc19e45..6d9412889 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -61,6 +61,7 @@ fn update_group_row_sets_title_count_and_expanded_state() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); + assert_eq!(root.margin_bottom(), 8); let data = RowData::group_header(Rc::from("terminal"), 3, false, notification("Terminal")); update_group_row(&widgets, &root, &data, &IconResolver::new()); diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index f28e17d30..d0025d7e5 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -18,9 +18,6 @@ use super::types::{NotificationList, NotificationListConfig}; use super::widgets::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; use crate::ui::icons::IconResolver; -// Leave a complete-card breathing room at the end of the nested notification viewport -const NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM: i32 = 16; - impl NotificationList { pub fn new( scroller: gtk::ScrolledWindow, @@ -37,7 +34,6 @@ impl NotificationList { list_view.add_css_class("unixnotis-panel-list"); list_view.set_hexpand(true); list_view.set_vexpand(true); - list_view.set_margin_bottom(NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM); let overlay = gtk::Overlay::new(); overlay.add_css_class("unixnotis-panel-list-overlay"); diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index 25858bcaa..73f2e3e9f 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -4,8 +4,6 @@ use unixnotis_core::EmptyStateAlignment; use crate::ui::notifications::test_support as support; -use super::NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM; - #[gtk::test] fn new_list_attaches_overlay_to_scroller() { support::init_gtk(); @@ -33,16 +31,225 @@ fn new_list_attaches_overlay_to_scroller() { .child() .and_downcast::() .expect("overlay should keep the virtualized list as its main child"); - assert_eq!( - list_view.margin_bottom(), - NOTIFICATION_LIST_BOTTOM_BREATHING_ROOM - ); + assert_eq!(list_view.margin_bottom(), 0); assert_eq!(list.empty_text, "No notifications"); assert_eq!(list.no_matching_text, "No matching notifications"); assert_eq!(list.empty_offset_top, 24); assert!(list.empty_overlay.get_visible()); } +#[gtk::test] +fn mapped_rows_keep_adjacent_groups_separated_and_stack_inside_allocation() { + support::init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, event_tx) = support::channels(); + let mut list = crate::ui::notifications::NotificationList::new( + scroller.clone(), + command_tx, + event_tx, + std::rc::Rc::new(crate::ui::icons::IconResolver::new()), + support::list_config(), + ); + + // Two multi-item groups exercise headers, collapsed stacks, and a final row + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + support::notification(4, "Browser"), + support::notification(5, "Editor"), + ], + Vec::new(), + ); + list.flush_rebuild(); + + let window = gtk::Window::new(); + window.set_default_size(520, 760); + window.set_child(Some(&scroller)); + window.present(); + let context = gtk::glib::MainContext::default(); + for _ in 0..8 { + while context.pending() { + context.iteration(false); + } + } + + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should expose a viewport"); + let overlay = viewport + .child() + .and_downcast::() + .expect("viewport should contain the list overlay"); + let list_view = overlay + .child() + .and_downcast::() + .expect("overlay should contain the virtualized list"); + + let mut rows = Vec::new(); + let mut child = list_view.first_child(); + while let Some(item) = child { + rows.push(item); + child = rows.last().and_then(gtk::prelude::WidgetExt::next_sibling); + } + assert!(rows.len() >= 5, "all fixture rows should be mapped"); + + for pair in rows.windows(2) { + let current_item = pair[0] + .first_child() + .expect("mapped row should contain its root"); + let current = current_item + .compute_bounds(&list_view) + .expect("mapped row should have bounds"); + let next = pair[1] + .compute_bounds(&list_view) + .expect("mapped row should have bounds"); + assert!( + next.y() >= current.y() + current.height() + 7.5, + "adjacent ListView rows need the explicit 8px gap: current={current:?}, next={next:?}" + ); + } + + for item in rows { + let Some(root) = item.first_child() else { + continue; + }; + let Some(stack) = root.first_child().and_downcast::() else { + continue; + }; + let stack_bounds = stack + .compute_bounds(&root) + .expect("notification stack should have bounds"); + let root_bounds = root + .compute_bounds(&item) + .expect("notification row should have bounds"); + assert!(stack_bounds.y() >= 0.0); + assert!( + stack_bounds.y() + stack_bounds.height() <= root_bounds.height(), + "stack must remain inside its real ListView row allocation" + ); + } + + window.close(); +} + +#[gtk::test] +fn mapped_notification_foregrounds_fill_every_stack_mode() { + support::init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, event_tx) = support::channels(); + let mut list = crate::ui::notifications::NotificationList::new( + scroller.clone(), + command_tx, + event_tx, + std::rc::Rc::new(crate::ui::icons::IconResolver::new()), + support::list_config(), + ); + + // Two groups cover depth one, depth two, and a standalone row + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + support::notification(4, "Browser"), + support::notification(5, "Browser"), + support::notification(6, "Editor"), + ], + Vec::new(), + ); + list.flush_rebuild(); + + let window = gtk::Window::new(); + window.set_default_size(620, 900); + window.set_child(Some(&scroller)); + window.present(); + pump_gtk_frames(); + + let list_view = mapped_list_view(&scroller); + assert_foregrounds_fill_rows(&list_view, 3); + + // Rebuild the same model with an expanded group so foreground width does not + // depend on a visible rear layer from the previous binding + list.toggle_group("test:Browser"); + list.flush_rebuild(); + pump_gtk_frames(); + assert_foregrounds_fill_rows(&list_view, 5); + + // Return to a collapsed state to exercise another recycled-row transition + list.toggle_group("test:Browser"); + list.flush_rebuild(); + pump_gtk_frames(); + assert_foregrounds_fill_rows(&list_view, 3); + + window.close(); +} + +fn pump_gtk_frames() { + let context = gtk::glib::MainContext::default(); + for _ in 0..8 { + while context.pending() { + context.iteration(false); + } + } +} + +fn mapped_list_view(scroller: >k::ScrolledWindow) -> gtk::ListView { + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should expose a viewport"); + viewport + .child() + .and_downcast::() + .expect("viewport should contain the list overlay") + .child() + .and_downcast::() + .expect("overlay should contain the virtualized list") +} + +fn assert_foregrounds_fill_rows(list_view: >k::ListView, minimum_rows: usize) { + let mut child = list_view.first_child(); + let mut notification_rows = 0; + while let Some(item) = child { + let Some(root) = item.first_child() else { + child = item.next_sibling(); + continue; + }; + let Some(stack) = root.first_child().and_downcast::() else { + child = item.next_sibling(); + continue; + }; + let Some(foreground) = stack.last_child().and_downcast::() else { + panic!("notification grid should end with its foreground card"); + }; + let grouped_inset = + if foreground.has_css_class(unixnotis_core::css::hooks::panel_card::GROUPED) { + 8 + } else { + 0 + }; + let expected = stack.width() - (grouped_inset * 2); + assert!( + foreground.width() >= expected - 1, + "foreground width {} did not fill stack width {} with inset {}", + foreground.width(), + stack.width(), + grouped_inset + ); + assert_eq!(foreground.halign(), gtk::Align::Fill); + assert!(foreground.hexpands()); + notification_rows += 1; + child = item.next_sibling(); + } + assert!( + notification_rows >= minimum_rows, + "mapped fixture should include collapsed, expanded, and standalone notifications" + ); +} + #[gtk::test] fn apply_config_updates_empty_copy_and_offset() { let mut list = support::make_list(); diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 97b15af12..140e2bc93 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -347,6 +347,7 @@ entry selection { .unixnotis-panel-list { background: transparent; + padding-bottom: 20px; } .unixnotis-panel-list row { @@ -360,8 +361,6 @@ entry selection { */ .unixnotis-group { background: transparent; - margin-top: 0; - margin-bottom: 8px; } .unixnotis-group-header { @@ -490,15 +489,16 @@ entry selection { alpha(#121834, 0.93) 100% ); border: 1px solid alpha(#ffffff, 0.10); + border-radius: 18px; border-radius: var(--unixnotis-notification-card-radius); + padding: 9px 11px; padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); margin: 0; box-shadow: inset 0 1px 0 alpha(#ffffff, 0.09), inset 0 2px 0 alpha(#ffffff, 0.03), inset 0 -1px 0 alpha(#000000, 0.20), - 0 1px 2px -1px alpha(#000000, 0.40), - 0 16px 32px -18px alpha(#000000, 0.65); + 0 4px 12px -8px alpha(#000000, 0.65); transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } @@ -541,32 +541,24 @@ entry selection { inset 0 1px 0 alpha(#ffffff, 0.11), inset 0 2px 0 alpha(#ffffff, 0.04), inset 0 -1px 0 alpha(#000000, 0.20), - 0 1px 2px -1px alpha(#000000, 0.45), - 0 20px 40px -24px alpha(#000000, 0.75); -} - -.unixnotis-panel-card-foreground { - margin-bottom: 8px; -} - -.unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { - margin-left: 8px; - margin-right: 8px; - margin-bottom: var(--unixnotis-panel-card-gap); + 0 6px 14px -10px alpha(#000000, 0.72); } .unixnotis-panel-card.unixnotis-panel-card-grouped { + border-radius: 22px; border-radius: var(--unixnotis-notification-card-radius); } -.unixnotis-panel-card-foreground.collapsed-group-preview { - margin: 12px 8px 8px; +.unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { + border-radius: 22px; + border-radius: var(--unixnotis-notification-card-radius); } .unixnotis-stack-layer { min-height: 68px; padding: 0; border: 1px solid alpha(#ffffff, 0.10); + border-radius: 22px; border-radius: var(--unixnotis-notification-card-radius); background-image: linear-gradient(135deg, alpha(#ffffff, 0.10) 0%, alpha(#ffffff, 0) 28%), @@ -583,7 +575,6 @@ entry selection { } .unixnotis-stack-layer-back { - margin: 0 20px; opacity: 0.80; border-color: alpha(#ffffff, 0.07); background-image: @@ -600,7 +591,6 @@ entry selection { } .unixnotis-stack-layer-middle { - margin: 6px 14px 0; opacity: 0.92; border-color: alpha(#ffffff, 0.09); background-image: @@ -634,8 +624,7 @@ entry selection { inset 0 1px 0 alpha(#ffffff, 0.08), inset 0 2px 0 alpha(#ffffff, 0.03), inset 0 -1px 0 alpha(#000000, 0.22), - 0 1px 2px -1px alpha(#000000, 0.40), - 0 16px 32px -18px alpha(#000000, 0.65); + 0 4px 12px -8px alpha(#000000, 0.68); } .unixnotis-panel-card.critical .unixnotis-panel-app { @@ -655,6 +644,7 @@ entry selection { } .unixnotis-panel-card-has-actions .unixnotis-notification-actions { + margin-top: 2px; margin-top: calc(var(--unixnotis-panel-action-gap) - 4px); } diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index f2fdeb660..34113bae4 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -277,10 +277,18 @@ fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { assert!(!css.contains("unixnotis-stack-ghost")); assert!(css.contains(".unixnotis-stack-layer-back")); assert!(css.contains(".unixnotis-stack-layer-middle")); - assert!(css.contains("margin: 6px 14px 0")); - assert!(css.contains("margin: 12px 8px 8px")); + assert!(!css.contains("margin: 6px 14px 0")); + assert!(!css.contains("margin: 12px 8px 0")); + assert!(css.contains(".unixnotis-panel-card-row {\n margin: 0;")); + assert!(css + .contains(".unixnotis-panel-list {\n background: transparent;\n padding-bottom: 20px;")); + assert!(css.contains( + "border-radius: 18px;\n border-radius: var(--unixnotis-notification-card-radius);" + )); + assert!(css.contains("padding: 9px 11px;\n padding: var(--unixnotis-panel-card-padding-y)")); + assert!(!css.contains("0 16px 32px -18px")); assert!(!css.contains("margin: -58px 14px 0")); - assert!(css.contains("margin: 0 20px")); + assert!(!css.contains("margin: 0 20px")); assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); } diff --git a/crates/unixnotis-core/src/css/tokens/layout.rs b/crates/unixnotis-core/src/css/tokens/layout.rs index 6a78ea0e2..12b98bf8b 100644 --- a/crates/unixnotis-core/src/css/tokens/layout.rs +++ b/crates/unixnotis-core/src/css/tokens/layout.rs @@ -17,7 +17,6 @@ pub(super) const fn layout_tokens() -> &'static [(&'static str, &'static str)] { ("--unixnotis-panel-header-padding", "12px"), ("--unixnotis-panel-card-padding-y", "9px"), ("--unixnotis-panel-card-padding-x", "11px"), - ("--unixnotis-panel-card-gap", "8px"), ("--unixnotis-panel-action-gap", "6px"), ("--unixnotis-panel-close-size", "28px"), ("--unixnotis-panel-search-min-height", "34px"), diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 5869c28bf..e381d25d8 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -49,6 +49,16 @@ fn panel_css_keeps_the_dnd_menu_visual_hooks() { } } +#[test] +fn panel_css_leaves_stack_offsets_and_row_gaps_to_gtk_layout() { + assert!(DEFAULT_PANEL_CSS.contains(".unixnotis-panel-card-row {\n margin: 0;")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 12px 8px 0")); + assert!(DEFAULT_PANEL_CSS.contains( + "border-radius: 18px;\n border-radius: var(--unixnotis-notification-card-radius);" + )); +} + #[test] fn dnd_menu_hover_and_keyboard_focus_share_one_visual_rule() { let shared_selector = ".unixnotis-dnd-menu .unixnotis-dnd-menu-choice:hover,\n\ @@ -167,10 +177,10 @@ fn notification_surfaces_keep_compact_master_geometry() { assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-radius)")); assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-y)")); assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-x)")); - assert!(DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); - assert!(DEFAULT_PANEL_CSS.contains("margin: 12px 8px 8px")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 12px 8px 0")); assert!(!DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); - assert!(DEFAULT_PANEL_CSS.contains("margin: 0 20px")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 0 20px")); } #[test] From bef300f958ee42182b84d9d4721d72a0057b9023 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 18:35:55 -0500 Subject: [PATCH 235/275] build(ui): require GTK 4.18 for popup and theme APIs Require the common GTK baseline needed by CSS custom properties and Wayland popup commit handling. Reject unsupported installations before services or configuration are modified. --- .../src/css_check/geometry/check.rs | 5 +- .../src/css_check/geometry/stock/baselines.rs | 9 ++-- .../geometry/tests/custom_properties.rs | 7 +-- .../src/css_check/lint/runner.rs | 4 +- .../src/css_check/lint/tests/scan.rs | 5 +- crates/noticenterctl/src/css_check/policy.rs | 4 +- .../src/css_check/tests/cases.rs | 8 +--- crates/unixnotis-core/assets/base.css | 1 - crates/unixnotis-core/assets/media.css | 2 - crates/unixnotis-core/assets/panel.css | 15 ------ crates/unixnotis-core/assets/popup.css | 5 -- crates/unixnotis-core/assets/widgets.css | 15 ------ crates/unixnotis-core/src/css/features.rs | 17 ++----- .../src/css/hooks/tests/hooks.rs | 6 +-- crates/unixnotis-core/src/css/mod.rs | 2 +- .../unixnotis-core/src/css/tests/features.rs | 17 ++++--- .../unixnotis-core/src/css/tokens/modern.rs | 11 +---- .../src/css/tokens/tests/modern.rs | 48 ++++++------------- .../unixnotis-core/src/embedded/tests/css.rs | 4 +- crates/unixnotis-installer/src/checks/gtk.rs | 38 +++++++-------- .../unixnotis-installer/src/checks/session.rs | 6 +++ .../src/checks/tests/gtk.rs | 21 ++++---- .../src/checks/tests/session.rs | 14 ++++++ crates/unixnotis-ui/src/css/overrides.rs | 19 ++------ .../unixnotis-ui/src/css/tests/overrides.rs | 29 +++-------- 25 files changed, 104 insertions(+), 208 deletions(-) diff --git a/crates/noticenterctl/src/css_check/geometry/check.rs b/crates/noticenterctl/src/css_check/geometry/check.rs index 4a4ae66e4..71830892b 100644 --- a/crates/noticenterctl/src/css_check/geometry/check.rs +++ b/crates/noticenterctl/src/css_check/geometry/check.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; use super::super::files::format_display_path; use super::super::report::{CssCheckCategory, CssCheckDiagnostic}; @@ -31,8 +31,7 @@ pub(in crate::css_check) fn lint_geometry_css_files_with_config( } // Runtime theme overrides inject modern tokens that may never appear in the css files - let generated_tokens = - build_modern_theme_custom_properties(&config.theme, gtk_css_features_for_version(4, 16)); + let generated_tokens = build_modern_theme_custom_properties(&config.theme); // Runtime tokens are stitched in before the file scan so token-only themes do not hide // width pressure from the checker diff --git a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs index c535a68b2..5fa0f7cb7 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::sync::OnceLock; use unixnotis_core::{ - build_modern_theme_custom_properties, gtk_css_features_for_version, Config, DEFAULT_BASE_CSS, - DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, + build_modern_theme_custom_properties, Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, + DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, }; use super::super::super::parse::{ @@ -48,10 +48,7 @@ pub(in crate::css_check) fn stock_geometry_model() -> &'static GeometryModel { static MODEL: OnceLock = OnceLock::new(); MODEL.get_or_init(|| { let mut model = GeometryModel::default(); - let generated_tokens = build_modern_theme_custom_properties( - &stock_config().theme, - gtk_css_features_for_version(4, 16), - ); + let generated_tokens = build_modern_theme_custom_properties(&stock_config().theme); let shared_custom_properties = collect_custom_property_scopes( &std::iter::once(generated_tokens.as_str()) .chain([ diff --git a/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs index 8a5f5e32c..27e4cebf3 100644 --- a/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs +++ b/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs @@ -2,7 +2,7 @@ use super::super::collect_custom_property_scopes; use super::super::model::GeometryModel; use super::super::parse::collect_geometry_from_contents_with_properties; use super::super::test_support::collect_geometry_from_contents; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; #[test] fn geometry_can_follow_custom_property_lengths() { @@ -87,10 +87,7 @@ fn geometry_can_follow_generated_modern_theme_tokens() { // Generated override tokens need to behave the same way as tokens declared in files let css = format!( "{}\n.unixnotis-panel {{ padding: var(--unixnotis-panel-padding); }}\n.unixnotis-toggle {{ min-width: var(--unixnotis-toggle-min-width); padding: 10px calc(var(--unixnotis-panel-action-gap) * 2); border: 1px solid red; }}", - build_modern_theme_custom_properties( - &Config::default().theme, - gtk_css_features_for_version(4, 16), - ) + build_modern_theme_custom_properties(&Config::default().theme) ); let mut model = GeometryModel::default(); diff --git a/crates/noticenterctl/src/css_check/lint/runner.rs b/crates/noticenterctl/src/css_check/lint/runner.rs index 86edc968c..616e7b51f 100644 --- a/crates/noticenterctl/src/css_check/lint/runner.rs +++ b/crates/noticenterctl/src/css_check/lint/runner.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use std::fs; use std::path::{Path, PathBuf}; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; use super::super::files::format_display_path; use super::super::geometry::{collect_custom_property_scopes, CssCustomPropertyScopes}; @@ -73,5 +73,5 @@ pub(in crate::css_check::lint) fn lint_css_contents_with_properties( } fn generated_theme_token_css(config: &Config) -> String { - build_modern_theme_custom_properties(&config.theme, gtk_css_features_for_version(4, 16)) + build_modern_theme_custom_properties(&config.theme) } diff --git a/crates/noticenterctl/src/css_check/lint/tests/scan.rs b/crates/noticenterctl/src/css_check/lint/tests/scan.rs index b6adb464a..c79bdf93d 100644 --- a/crates/noticenterctl/src/css_check/lint/tests/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/tests/scan.rs @@ -89,10 +89,7 @@ fn shipped_css_assets_are_lint_clean() { unixnotis_core::DEFAULT_MEDIA_CSS, ]; let config = unixnotis_core::Config::default(); - let generated = unixnotis_core::build_modern_theme_custom_properties( - &config.theme, - unixnotis_core::gtk_css_features_for_version(4, 16), - ); + let generated = unixnotis_core::build_modern_theme_custom_properties(&config.theme); let combined = std::iter::once(generated.as_str()) .chain(assets) .collect::>() diff --git a/crates/noticenterctl/src/css_check/policy.rs b/crates/noticenterctl/src/css_check/policy.rs index 0cf1d9f98..035d368fe 100644 --- a/crates/noticenterctl/src/css_check/policy.rs +++ b/crates/noticenterctl/src/css_check/policy.rs @@ -1,6 +1,6 @@ //! Shared css-check policy for GTK CSS support and geometry rules -use unixnotis_core::GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL; +use unixnotis_core::GTK_MIN_VERSION_LABEL; pub(super) fn is_horizontal_size_property(name: &str) -> bool { // Only width-driving properties belong here @@ -64,7 +64,7 @@ pub(super) fn parsing_error_hint(line_text: &str) -> Option { if trimmed.contains("var(") { // The minimum version note lives in one shared place so installer and checker stay aligned return Some(format!( - "custom properties need {GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL}, and the referenced token still has to expand to a valid value here" + "custom properties need {GTK_MIN_VERSION_LABEL}, and the referenced token still has to expand to a valid value here" )); } None diff --git a/crates/noticenterctl/src/css_check/tests/cases.rs b/crates/noticenterctl/src/css_check/tests/cases.rs index 25d8ce423..a9d64969a 100644 --- a/crates/noticenterctl/src/css_check/tests/cases.rs +++ b/crates/noticenterctl/src/css_check/tests/cases.rs @@ -2,8 +2,7 @@ use super::lint::test_support::lint_css_contents; use super::parse::{parse_css_declarations, split_selectors}; use super::runtime::panel_width_floor_warning; use unixnotis_core::{ - build_modern_theme_custom_properties, gtk_css_features_for_version, Config, ThemeConfig, - PANEL_RUNTIME_WIDTH_MIN, + build_modern_theme_custom_properties, Config, ThemeConfig, PANEL_RUNTIME_WIDTH_MIN, }; #[path = "files.rs"] @@ -80,10 +79,7 @@ fn lint_css_contents_warns_on_web_length_tokens_in_layout_props() { fn lint_css_contents_accepts_generated_modern_theme_tokens() { let css = format!( "{}\n.unixnotis-panel-card {{ border-radius: var(--unixnotis-card-radius); padding: calc(var(--unixnotis-panel-card-padding-y) + 2px) var(--unixnotis-panel-card-padding-x); }}", - build_modern_theme_custom_properties( - &ThemeConfig::default(), - gtk_css_features_for_version(4, 16), - ) + build_modern_theme_custom_properties(&ThemeConfig::default()) ); let warnings = lint_css_contents(&css); diff --git a/crates/unixnotis-core/assets/base.css b/crates/unixnotis-core/assets/base.css index e3d44724e..895d2a86a 100644 --- a/crates/unixnotis-core/assets/base.css +++ b/crates/unixnotis-core/assets/base.css @@ -105,7 +105,6 @@ .unixnotis-panel-window, .unixnotis-popup-window { background: transparent; - font-family: "Inter", "SF Pro Text", "Noto Sans", sans-serif; font-family: var(--unixnotis-ui-font-family); } diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 7cc705a1a..f60263ee5 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -228,7 +228,6 @@ .unixnotis-media-art { min-width: var(--unixnotis-media-art-size); min-height: var(--unixnotis-media-art-size); - border-radius: 10px; border-radius: var(--unixnotis-media-art-radius); background: alpha(#000000, 0.12); } @@ -236,7 +235,6 @@ .unixnotis-media-art-frame { min-width: var(--unixnotis-media-art-frame-size); min-height: var(--unixnotis-media-art-frame-size); - border-radius: 12px; border-radius: var(--unixnotis-media-art-frame-radius); background: alpha(@unixnotis-surface-strong-base, 0.44); border-top: 1px solid alpha(#ffffff, 0.10); diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 140e2bc93..26d8fe1a3 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -6,9 +6,7 @@ min-width: 420px; background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); color: @unixnotis-text; - border-radius: 30px; border-radius: var(--unixnotis-panel-radius); - padding: 16px; padding: var(--unixnotis-panel-padding); border: 1px solid alpha(#9bb8e8, 0.16); font-family: "Inter", "Manrope", "Noto Sans", sans-serif; @@ -20,9 +18,7 @@ .unixnotis-panel-header { margin-bottom: 12px; - padding: 12px; padding: var(--unixnotis-panel-header-padding); - border-radius: 18px; border-radius: var(--unixnotis-panel-header-radius); background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.7), alpha(@unixnotis-surface, 0.9)); border: 1px solid alpha(@unixnotis-accent, 0.16); @@ -105,7 +101,6 @@ background: alpha(#ffffff, 0.045); color: alpha(#ffffff, 0.75); border-radius: 10px; - padding: 6px 10px; padding: var(--unixnotis-panel-action-gap) calc(var(--unixnotis-panel-action-gap) + 4px); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); @@ -279,7 +274,6 @@ } .unixnotis-panel-search { - min-height: 34px; min-height: var(--unixnotis-panel-search-min-height); border-radius: 12px; background: alpha(#000000, 0.4); @@ -287,7 +281,6 @@ border-left: 1px solid alpha(#ffffff, 0.04); border-right: 1px solid alpha(#ffffff, 0.02); border-bottom: 1px solid alpha(#ffffff, 0.01); - padding: 0 10px; padding: 0 var(--unixnotis-panel-search-padding-x); box-shadow: inset 0 1px 3px alpha(#000000, 0.5); transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; @@ -325,9 +318,7 @@ entry selection { border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); padding: 2px; - min-width: 28px; min-width: var(--unixnotis-panel-close-size); - min-height: 28px; min-height: var(--unixnotis-panel-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; @@ -489,9 +480,7 @@ entry selection { alpha(#121834, 0.93) 100% ); border: 1px solid alpha(#ffffff, 0.10); - border-radius: 18px; border-radius: var(--unixnotis-notification-card-radius); - padding: 9px 11px; padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); margin: 0; box-shadow: @@ -545,12 +534,10 @@ entry selection { } .unixnotis-panel-card.unixnotis-panel-card-grouped { - border-radius: 22px; border-radius: var(--unixnotis-notification-card-radius); } .unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { - border-radius: 22px; border-radius: var(--unixnotis-notification-card-radius); } @@ -558,7 +545,6 @@ entry selection { min-height: 68px; padding: 0; border: 1px solid alpha(#ffffff, 0.10); - border-radius: 22px; border-radius: var(--unixnotis-notification-card-radius); background-image: linear-gradient(135deg, alpha(#ffffff, 0.10) 0%, alpha(#ffffff, 0) 28%), @@ -783,7 +769,6 @@ entry selection { background: alpha(#ffffff, 0.045); color: alpha(#ffffff, 0.75); border-radius: 10px; - padding: 4px 10px; padding: var(--unixnotis-notification-action-padding-y) var(--unixnotis-notification-action-padding-x); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 7aa78c8c3..519b9d595 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -14,9 +14,7 @@ border-radius: 999px; border: 1px solid alpha(#ffffff, 0.10); padding: 3px; - min-width: 26px; min-width: var(--unixnotis-popup-close-size); - min-height: 26px; min-height: var(--unixnotis-popup-close-size); color: alpha(#ffffff, 0.75); /* Touch and keyboard users need a visible resting target */ @@ -49,9 +47,7 @@ /* Popup stack */ .unixnotis-popup-stack { - padding: 8px; padding: var(--unixnotis-popup-stack-padding); - border-radius: 16px; border-radius: calc(var(--unixnotis-popup-card-radius) - 4px); background: transparent; } @@ -245,7 +241,6 @@ /* Actions: hairline separator and quiet minimal buttons */ .unixnotis-popup-actions { - margin-top: 8px; margin-top: var(--unixnotis-popup-actions-gap); } diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index c59b07a8c..3b2906214 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -18,7 +18,6 @@ .unixnotis-quick-slider-volume, .unixnotis-quick-slider-brightness { background: transparent; - border-radius: 18px; border-radius: var(--unixnotis-quick-slider-radius); padding: 4px 6px; border: none; @@ -39,9 +38,7 @@ border-radius: 999px; border: 0; padding: 4px; - min-width: 32px; min-width: var(--unixnotis-quick-slider-icon-size); - min-height: 32px; min-height: var(--unixnotis-quick-slider-icon-size); box-shadow: none; color: alpha(#ffffff, 0.65); @@ -108,16 +105,12 @@ .unixnotis-toggle.unixnotis-toggle-kind-airplane, .unixnotis-toggle.unixnotis-toggle-kind-night { background: alpha(#ffffff, 0.045); - border-radius: 14px; - padding: 10px 12px; padding: var(--unixnotis-toggle-padding-y) var(--unixnotis-toggle-padding-x); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); - min-height: 56px; min-height: var(--unixnotis-toggle-min-height); - min-width: 104px; min-width: var(--unixnotis-toggle-min-width); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); color: alpha(#ffffff, 0.7); @@ -247,14 +240,11 @@ .unixnotis-stat-card { background: alpha(#ffffff, 0.035); - border-radius: 14px; - padding: 10px 12px; padding: var(--unixnotis-stat-card-padding-y) var(--unixnotis-stat-card-padding-x); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); - min-height: 56px; min-height: var(--unixnotis-stat-card-min-height); box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); transition: background-color 0.15s ease-out, border-color 0.15s ease-out; @@ -315,14 +305,11 @@ .unixnotis-info-card, .unixnotis-info-card-weather { background: alpha(#ffffff, 0.035); - border-radius: 16px; - padding: 12px; padding: var(--unixnotis-info-card-padding); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); - min-height: 56px; min-height: var(--unixnotis-info-card-min-height); box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); transition: background-color 0.15s ease-out, border-color 0.15s ease-out; @@ -360,7 +347,6 @@ } .unixnotis-info-card-mono .unixnotis-info-body { - font-family: "CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace; font-family: var(--unixnotis-monospace-font-family); } @@ -377,7 +363,6 @@ background: alpha(#ffffff, 0.02); border: 1px solid alpha(#ffffff, 0.06); border-radius: 12px; - padding: 10px 12px; padding: var(--unixnotis-info-card-padding); color: @unixnotis-text; box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); diff --git a/crates/unixnotis-core/src/css/features.rs b/crates/unixnotis-core/src/css/features.rs index 76c869b90..5513d731f 100644 --- a/crates/unixnotis-core/src/css/features.rs +++ b/crates/unixnotis-core/src/css/features.rs @@ -1,25 +1,18 @@ //! Shared GTK CSS capability checks -pub const GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL: &str = "GTK 4.16+"; +pub const GTK_MIN_VERSION_LABEL: &str = "GTK 4.18+"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct GtkCssFeatures { - // Newer GTK builds can expand var() and custom properties + // The installer uses this capability to enforce the supported baseline pub custom_properties: bool, } -impl GtkCssFeatures { - #[must_use] - pub const fn supports_modern_theme_tokens(self) -> bool { - self.custom_properties - } -} - #[must_use] pub const fn gtk_css_features_for_version(major: u32, minor: u32) -> GtkCssFeatures { - // GTK 4.16 added custom properties and var() + // GTK 4.18 is the common baseline for CSS variables and popup Wayland APIs GtkCssFeatures { - custom_properties: major > 4 || (major == 4 && minor >= 16), + custom_properties: major > 4 || (major == 4 && minor >= 18), } } @@ -38,7 +31,7 @@ fn parse_major_minor(version: &str) -> Option<(u32, u32)> { } fn parse_version_part(part: &str) -> Option { - // Stop at the first non-digit so values like 4.16.0-2 still parse cleanly + // Stop at the first non-digit so values like 4.18.0-2 still parse cleanly let digits = part .trim() .chars() diff --git a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 34113bae4..3e64f818b 100644 --- a/crates/unixnotis-core/src/css/hooks/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -282,10 +282,8 @@ fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { assert!(css.contains(".unixnotis-panel-card-row {\n margin: 0;")); assert!(css .contains(".unixnotis-panel-list {\n background: transparent;\n padding-bottom: 20px;")); - assert!(css.contains( - "border-radius: 18px;\n border-radius: var(--unixnotis-notification-card-radius);" - )); - assert!(css.contains("padding: 9px 11px;\n padding: var(--unixnotis-panel-card-padding-y)")); + assert!(css.contains("border-radius: var(--unixnotis-notification-card-radius);")); + assert!(css.contains("padding: var(--unixnotis-panel-card-padding-y)")); assert!(!css.contains("0 16px 32px -18px")); assert!(!css.contains("margin: -58px 14px 0")); assert!(!css.contains("margin: 0 20px")); diff --git a/crates/unixnotis-core/src/css/mod.rs b/crates/unixnotis-core/src/css/mod.rs index 51d0c68ac..e14655818 100644 --- a/crates/unixnotis-core/src/css/mod.rs +++ b/crates/unixnotis-core/src/css/mod.rs @@ -15,7 +15,7 @@ mod limits; pub use self::features::{ gtk_css_features_for_version, gtk_css_features_from_version_string, GtkCssFeatures, - GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, + GTK_MIN_VERSION_LABEL, }; pub use self::limits::MAX_CSS_FILE_BYTES; pub use self::references::{ diff --git a/crates/unixnotis-core/src/css/tests/features.rs b/crates/unixnotis-core/src/css/tests/features.rs index 0209e93a0..f626d39ba 100644 --- a/crates/unixnotis-core/src/css/tests/features.rs +++ b/crates/unixnotis-core/src/css/tests/features.rs @@ -1,29 +1,28 @@ use super::{ - gtk_css_features_for_version, gtk_css_features_from_version_string, - GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, + gtk_css_features_for_version, gtk_css_features_from_version_string, GTK_MIN_VERSION_LABEL, }; #[test] -fn gtk_css_features_gate_custom_properties_at_gtk_416() { - assert!(!gtk_css_features_for_version(4, 15).custom_properties); - assert!(gtk_css_features_for_version(4, 16).custom_properties); +fn gtk_css_features_gate_common_apis_at_gtk_418() { + assert!(!gtk_css_features_for_version(4, 17).custom_properties); + assert!(gtk_css_features_for_version(4, 18).custom_properties); assert!(gtk_css_features_for_version(5, 0).custom_properties); } #[test] fn gtk_css_features_can_parse_pkg_config_versions() { assert!( - !gtk_css_features_from_version_string("4.15.9") + !gtk_css_features_from_version_string("4.17.9") .expect("version") .custom_properties ); assert!( - gtk_css_features_from_version_string("4.16.3") + gtk_css_features_from_version_string("4.18.3") .expect("version") .custom_properties ); assert!( - gtk_css_features_from_version_string("4.16.0-2") + gtk_css_features_from_version_string("4.18.0-2") .expect("version") .custom_properties ); @@ -31,5 +30,5 @@ fn gtk_css_features_can_parse_pkg_config_versions() { #[test] fn custom_properties_requirement_label_stays_stable() { - assert_eq!(GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, "GTK 4.16+"); + assert_eq!(GTK_MIN_VERSION_LABEL, "GTK 4.18+"); } diff --git a/crates/unixnotis-core/src/css/tokens/modern.rs b/crates/unixnotis-core/src/css/tokens/modern.rs index 4a40e8a89..ddef20fa6 100644 --- a/crates/unixnotis-core/src/css/tokens/modern.rs +++ b/crates/unixnotis-core/src/css/tokens/modern.rs @@ -2,21 +2,12 @@ use crate::config::ThemeConfig; -use super::super::features::GtkCssFeatures; use super::layout::layout_tokens; use super::model::{clamp_alpha, theme_card_style_values}; use super::palette::color_alias_tokens; #[must_use] -pub fn build_modern_theme_custom_properties( - theme: &ThemeConfig, - features: GtkCssFeatures, -) -> String { - // Older GTK builds should see no custom-property output - if !features.supports_modern_theme_tokens() { - return String::new(); - } - +pub fn build_modern_theme_custom_properties(theme: &ThemeConfig) -> String { let surface_alpha = clamp_alpha(theme.surface_alpha); let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); let card_alpha = clamp_alpha(theme.card_alpha); diff --git a/crates/unixnotis-core/src/css/tokens/tests/modern.rs b/crates/unixnotis-core/src/css/tokens/tests/modern.rs index 718af650d..f71d3fba5 100644 --- a/crates/unixnotis-core/src/css/tokens/tests/modern.rs +++ b/crates/unixnotis-core/src/css/tokens/tests/modern.rs @@ -1,17 +1,14 @@ use super::super::build_modern_theme_custom_properties; -use crate::{gtk_css_features_for_version, ThemeConfig}; +use crate::ThemeConfig; #[test] fn modern_theme_custom_properties_stay_additive() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 2, - card_radius: 12, - surface_alpha: 0.88, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); + let overrides = build_modern_theme_custom_properties(&ThemeConfig { + border_width: 2, + card_radius: 12, + surface_alpha: 0.88, + ..ThemeConfig::default() + }); for expected in [ ":root {", @@ -36,31 +33,16 @@ fn modern_theme_custom_properties_stay_additive() { } } -#[test] -fn modern_theme_custom_properties_stay_off_on_older_gtk() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig::default(), - gtk_css_features_for_version(4, 15), - ); - assert!( - overrides.is_empty(), - "GTK versions without custom properties should receive no modern block" - ); -} - #[test] fn modern_theme_tokens_trim_float_values_without_losing_fraction() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 3, - card_radius: 10, - surface_alpha: 0.5, - surface_strong_alpha: 1.0, - card_alpha: 0.125, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); + let overrides = build_modern_theme_custom_properties(&ThemeConfig { + border_width: 3, + card_radius: 10, + surface_alpha: 0.5, + surface_strong_alpha: 1.0, + card_alpha: 0.125, + ..ThemeConfig::default() + }); for expected in [ "--unixnotis-border-width: 3px;", diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index e381d25d8..8357c19f9 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -54,9 +54,7 @@ fn panel_css_leaves_stack_offsets_and_row_gaps_to_gtk_layout() { assert!(DEFAULT_PANEL_CSS.contains(".unixnotis-panel-card-row {\n margin: 0;")); assert!(!DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); assert!(!DEFAULT_PANEL_CSS.contains("margin: 12px 8px 0")); - assert!(DEFAULT_PANEL_CSS.contains( - "border-radius: 18px;\n border-radius: var(--unixnotis-notification-card-radius);" - )); + assert!(DEFAULT_PANEL_CSS.contains("border-radius: var(--unixnotis-notification-card-radius);")); } #[test] diff --git a/crates/unixnotis-installer/src/checks/gtk.rs b/crates/unixnotis-installer/src/checks/gtk.rs index aa4a18732..0af72d053 100644 --- a/crates/unixnotis-installer/src/checks/gtk.rs +++ b/crates/unixnotis-installer/src/checks/gtk.rs @@ -1,40 +1,36 @@ //! GTK capability checks -use unixnotis_core::{ - gtk_css_features_from_version_string, GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, -}; +use unixnotis_core::{gtk_css_features_from_version_string, GTK_MIN_VERSION_LABEL}; use super::system::pkg_config_version; use super::{CheckItem, CheckState}; pub(super) fn gtk4_css_features_check(pkg_config: &CheckItem) -> CheckItem { - // Modern CSS support is additive, so older GTK builds should warn instead of fail + // The shipped CSS contract requires the common GTK 4.18 baseline match pkg_config_version("gtk4") { Ok(Some(version)) => match gtk_css_features_from_version_string(&version) { Some(features) if features.custom_properties => CheckItem::ok( - "GTK4 CSS features", - &format!("found {version}; modern css variables and var() are available"), + "GTK4 (4.18+)", + &format!("found {version}; custom properties and var() are available"), ), - Some(_) => CheckItem::warn( - "GTK4 CSS features", - &format!( - "found {version}; legacy theming still works, but modern css variables need {GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL}" - ), + Some(_) => CheckItem::fail( + "GTK4 (4.18+)", + &format!("found {version}; {GTK_MIN_VERSION_LABEL} is required"), ), - None => CheckItem::warn( - "GTK4 CSS features", - &format!("found {version}; css feature level could not be parsed"), + None => CheckItem::fail( + "GTK4 (4.18+)", + &format!("found {version}; GTK version could not be parsed"), ), }, - Ok(None) if pkg_config.state == CheckState::Fail => CheckItem::warn( - "GTK4 CSS features", - "pkg-config missing; cannot probe GTK4 css feature level", + Ok(None) if pkg_config.state == CheckState::Fail => CheckItem::fail( + "GTK4 (4.18+)", + "pkg-config missing; GTK 4.18 or newer is required", ), - Ok(None) => CheckItem::warn( - "GTK4 CSS features", - "pkg-config gtk4 not found; modern css feature support is unknown", + Ok(None) => CheckItem::fail( + "GTK4 (4.18+)", + "pkg-config gtk4 not found; GTK 4.18 or newer is required", ), - Err(err) => CheckItem::warn("GTK4 CSS features", &format!("check failed: {err}")), + Err(err) => CheckItem::fail("GTK4 (4.18+)", &format!("check failed: {err}")), } } diff --git a/crates/unixnotis-installer/src/checks/session.rs b/crates/unixnotis-installer/src/checks/session.rs index 3b8a54aca..f9ada7dec 100644 --- a/crates/unixnotis-installer/src/checks/session.rs +++ b/crates/unixnotis-installer/src/checks/session.rs @@ -107,6 +107,9 @@ impl Checks { .to_string(), ); } + if self.gtk4_css_features.state == CheckState::Fail { + return Err("GTK 4.18 or newer is required".to_string()); + } } ActionMode::Install => { // Install adds the writable path requirement on top of the runtime checks @@ -125,6 +128,9 @@ impl Checks { .to_string(), ); } + if self.gtk4_css_features.state == CheckState::Fail { + return Err("GTK 4.18 or newer is required".to_string()); + } if self.install_paths.state == CheckState::Fail { return Err("install paths are not writable".to_string()); } diff --git a/crates/unixnotis-installer/src/checks/tests/gtk.rs b/crates/unixnotis-installer/src/checks/tests/gtk.rs index 8ca7ea317..fc14a0b0e 100644 --- a/crates/unixnotis-installer/src/checks/tests/gtk.rs +++ b/crates/unixnotis-installer/src/checks/tests/gtk.rs @@ -9,9 +9,9 @@ use crate::test_support::fs::write_executable; #[test] fn gtk_css_feature_parser_handles_major_and_minor_checks() { - // GTK 4.16 is the first modern CSS feature level needed by the shipped theme path + // GTK 4.18 is the common API baseline needed by the shipped UI assert!( - gtk_css_features_from_version_string("4.16.2") + !gtk_css_features_from_version_string("4.17.2") .expect("version") .custom_properties ); @@ -21,9 +21,9 @@ fn gtk_css_feature_parser_handles_major_and_minor_checks() { .custom_properties ); - // Older GTK4 builds still work with legacy CSS but should not claim var() support + // Older GTK4 builds must not claim support for the common UI contract assert!( - !gtk_css_features_from_version_string("4.14.9") + !gtk_css_features_from_version_string("4.17.9") .expect("version") .custom_properties ); @@ -37,7 +37,7 @@ fn gtk_css_feature_parser_handles_major_and_minor_checks() { } #[test] -fn gtk_css_features_check_warns_for_old_gtk_and_okays_modern_gtk() { +fn gtk_css_features_check_rejects_old_gtk_and_accepts_modern_gtk() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("gtk-css-features"); let fake_bin = root.join("bin"); @@ -48,15 +48,15 @@ fn gtk_css_features_check_warns_for_old_gtk_and_okays_modern_gtk() { let old = gtk4_css_features_check(&pkg); - assert_eq!(old.state, CheckState::Warn); - assert!(old.detail.contains("legacy theming")); + assert_eq!(old.state, CheckState::Fail); + assert!(old.detail.contains("GTK 4.18+ is required")); write_fake_pkg_config(&fake_bin, "4.22.4", None); let modern = gtk4_css_features_check(&pkg); // Modern GTK should advertise the CSS variable support used by shipped themes assert_eq!(modern.state, CheckState::Ok); - assert!(modern.detail.contains("modern css variables")); + assert!(modern.detail.contains("custom properties")); let _ = fs::remove_dir_all(root); } @@ -73,9 +73,8 @@ fn gtk_checks_distinguish_pkg_config_missing_from_package_missing() { let css = gtk4_css_features_check(&pkg_missing); let layer = gtk4_layer_shell_check(&pkg_missing); - // CSS is optional feature detail, but gtk4-layer-shell is required for the UI - assert_eq!(css.state, CheckState::Warn); - assert!(css.detail.contains("pkg-config missing")); + assert_eq!(css.state, CheckState::Fail); + assert!(css.detail.contains("GTK 4.18 or newer is required")); assert_eq!(layer.state, CheckState::Fail); assert!(layer.detail.contains("pkg-config missing")); let _ = fs::remove_dir_all(root); diff --git a/crates/unixnotis-installer/src/checks/tests/session.rs b/crates/unixnotis-installer/src/checks/tests/session.rs index 9ba639c33..d7ebdff93 100644 --- a/crates/unixnotis-installer/src/checks/tests/session.rs +++ b/crates/unixnotis-installer/src/checks/tests/session.rs @@ -108,6 +108,13 @@ fn ready_for_trial_requires_wayland_cargo_and_layer_shell_only() { Err("cargo is required for trial mode".to_string()) ); + checks = passing_checks(); + checks.gtk4_css_features = item("GTK4 (4.18+)", CheckState::Fail); + assert_eq!( + checks.ready_for(ActionMode::Test), + Err("GTK 4.18 or newer is required".to_string()) + ); + checks = passing_checks(); checks.gtk4_layer_shell = item("gtk4-layer-shell", CheckState::Fail); assert_eq!( @@ -139,6 +146,13 @@ fn ready_for_install_requires_runtime_service_manager_and_writable_paths() { Err("cargo is required for installation".to_string()) ); + checks = passing_checks(); + checks.gtk4_css_features = item("GTK4 (4.18+)", CheckState::Fail); + assert_eq!( + checks.ready_for(ActionMode::Install), + Err("GTK 4.18 or newer is required".to_string()) + ); + checks = passing_checks(); checks.gtk4_layer_shell = item("gtk4-layer-shell", CheckState::Fail); assert_eq!( diff --git a/crates/unixnotis-ui/src/css/overrides.rs b/crates/unixnotis-ui/src/css/overrides.rs index ffa547d71..81cfc5fe5 100644 --- a/crates/unixnotis-ui/src/css/overrides.rs +++ b/crates/unixnotis-ui/src/css/overrides.rs @@ -1,21 +1,15 @@ //! Theme-driven CSS overrides used by the UI CSS manager -use gtk::{major_version, minor_version}; use unixnotis_core::{ build_legacy_theme_color_overrides, build_modern_theme_custom_properties, - gtk_css_features_for_version, theme_card_style_values, GtkCssFeatures, ThemeConfig, + theme_card_style_values, ThemeConfig, }; pub fn build_base_overrides(theme: &ThemeConfig) -> String { - // Runtime gating keeps older GTK builds on the legacy-safe token path - build_base_overrides_for_runtime(theme, current_gtk_css_features()) -} - -fn build_base_overrides_for_runtime(theme: &ThemeConfig, features: GtkCssFeatures) -> String { - // Legacy colors stay first so older GTK still has the same theme path + // Legacy color aliases remain first so every generated token has a stable source let mut overrides = build_legacy_theme_color_overrides(theme); - // Modern tokens are additive and only show up on runtimes that can parse them - overrides.push_str(&build_modern_theme_custom_properties(theme, features)); + // GTK 4.18 is the supported baseline for the custom-property theme contract + overrides.push_str(&build_modern_theme_custom_properties(theme)); overrides } @@ -66,11 +60,6 @@ pub fn build_popup_overrides(theme: &ThemeConfig) -> String { ) } -fn current_gtk_css_features() -> GtkCssFeatures { - // Runtime GTK version decides whether custom properties can be emitted safely - gtk_css_features_for_version(major_version(), minor_version()) -} - #[cfg(test)] #[path = "tests/overrides.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/tests/overrides.rs b/crates/unixnotis-ui/src/css/tests/overrides.rs index 9b605e587..ed633996b 100644 --- a/crates/unixnotis-ui/src/css/tests/overrides.rs +++ b/crates/unixnotis-ui/src/css/tests/overrides.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; use std::{env, fs}; use super::{build_panel_overrides, build_popup_overrides, build_widgets_overrides}; -use unixnotis_core::{gtk_css_features_for_version, ThemeConfig}; +use unixnotis_core::ThemeConfig; #[test] fn base_overrides_clamp_alpha_values() { @@ -17,8 +17,7 @@ fn base_overrides_clamp_alpha_values() { ..ThemeConfig::default() }; - let overrides = - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 15)); + let overrides = super::build_base_overrides(&theme); let surface = format!( "alpha(@unixnotis-surface-base, {})", 1.0_f32.clamp(0.0, 1.0) @@ -46,8 +45,7 @@ fn base_overrides_can_emit_modern_custom_properties() { ..ThemeConfig::default() }; - let overrides = - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 16)); + let overrides = super::build_base_overrides(&theme); assert!(overrides.contains(":root {")); assert!(overrides.contains("--unixnotis-border-width: 3px;")); assert!(overrides.contains("--unixnotis-card-radius: 18px;")); @@ -108,23 +106,8 @@ fn popup_overrides_use_theme_values() { } #[test] -fn generated_override_css_loads_without_parse_errors_for_legacy_runtime() { - // Old GTK should still accept the generated fallback path cleanly - let theme = ThemeConfig::default(); - let css = format!( - "{}\n{}\n{}\n{}", - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 15)), - build_panel_overrides(&theme), - build_widgets_overrides(&theme), - build_popup_overrides(&theme), - ); - - assert_css_validates_in_gtk(&css); -} - -#[test] -fn generated_override_css_loads_without_parse_errors_for_modern_runtime() { - // New GTK should also accept the additive custom property path cleanly +fn generated_override_css_loads_without_parse_errors() { + // The supported GTK baseline parses the complete generated token set let theme = ThemeConfig { border_width: 2, card_radius: 18, @@ -133,7 +116,7 @@ fn generated_override_css_loads_without_parse_errors_for_modern_runtime() { }; let css = format!( "{}\n{}\n{}\n{}", - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 16)), + super::build_base_overrides(&theme), build_panel_overrides(&theme), build_widgets_overrides(&theme), build_popup_overrides(&theme), From 8bf87d02f35a07bfd8b7d1d101fb16d2f1ae2b0b Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 20:08:02 -0500 Subject: [PATCH 236/275] fix(notifications): dismiss actioned generations safely Remove the exact active non-resident generation only after the application confirms action delivery. Resident, failed, stale, and confirmation-gated actions remain available, while popup decisions and expiration state are cleaned with the same generation boundary. --- .../src/daemon/control/action.rs | 11 ++- .../src/daemon/control/tests/action.rs | 97 ++++++++++++++++++- .../daemon/state/notification_lifecycle.rs | 35 +++++++ .../state/tests/notification_lifecycle.rs | 52 ++++++++++ .../src/store/notifications/lifecycle.rs | 2 + .../src/store/tests/runtime/popup.rs | 10 ++ 6 files changed, 205 insertions(+), 2 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index 9b687903a..263da1675 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -85,7 +85,16 @@ impl ControlServer { .set_destination(bus_name.to_owned()); NotificationServer::action_invoked(&context, notification.id, action_key) .await - .map_err(to_fdo_error) + .map_err(to_fdo_error)?; + + // A successful action consumes an ordinary notification after delivery + if !target.is_resident { + self.state + .dismiss_actioned_if_current(notification.id, &target) + .await + .map_err(to_fdo_error)?; + } + Ok(()) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index 882163215..0bcc37a6a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -1,10 +1,12 @@ use std::collections::HashMap; +use std::time::Duration; use chrono::Utc; use futures_util::TryStreamExt; use unixnotis_core::{ Action, AttributionReason, Notification, NotificationAttribution, NotificationImage, Urgency, }; +use zbus::fdo::DBusProxy; use zbus::message::Type; use zbus::zvariant::OwnedValue; use zbus::{Connection, MatchRule, MessageStream}; @@ -26,7 +28,7 @@ async fn validated_action_emits_only_an_advertised_live_action() { .key() }; - ControlServer::new(state) + ControlServer::new(state.clone()) .invoke_validated_action_generation(notification, "open", false) .await .expect("invoke advertised action"); @@ -35,6 +37,33 @@ async fn validated_action_emits_only_an_advertised_live_action() { next_action_signal(&mut stream).await, (notification.id, "open".to_string()) ); + let store = state.store.lock().await; + assert!(store.active_notification_view(notification.id).is_none()); + assert!(store.list_history().is_empty()); +} + +#[tokio::test] +async fn successful_action_keeps_a_resident_notification_active() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut resident = action_notification(&sender, "open"); + resident.is_resident = true; + let notification = { + let mut store = state.store.lock().await; + store.insert(resident, 0).notification.key() + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect("resident action should be delivered"); + + assert!(state + .store + .lock() + .await + .active_notification_view(notification.id) + .is_some()); } #[tokio::test] @@ -72,6 +101,72 @@ async fn action_signal_reaches_owner_but_not_unrelated_observer() { ); } +#[tokio::test] +async fn action_keeps_notification_when_the_owner_disappears() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let notification = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&sender, "open"), 0) + .notification + .key() + }; + let sender_name = sender.unique_name().expect("sender unique name").clone(); + sender.close().await.expect("close sender connection"); + let proxy = DBusProxy::new(state.connection()) + .await + .expect("create bus proxy"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if !proxy + .name_has_owner(sender_name.clone().into()) + .await + .expect("query sender ownership") + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("bus should release the closed sender name"); + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect_err("closed sender must reject the action"); + assert!(state + .store + .lock() + .await + .active_notification_view(notification.id) + .is_some()); +} + +#[tokio::test] +async fn unconfirmed_action_does_not_emit_or_dismiss() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut notification = action_notification(&sender, "open"); + notification.attribution.interactions = unixnotis_core::InteractionPolicies::CONFIRM_ACTIONS; + let key = { + let mut store = state.store.lock().await; + store.insert(notification, 0).notification.key() + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(key, "open", false) + .await + .expect_err("confirmation-required action must not run without confirmation"); + assert!(state + .store + .lock() + .await + .active_notification_view(key.id) + .is_some()); +} + #[tokio::test] async fn validated_action_rejects_missing_and_stale_action_generations() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index 4b08a3d30..2d0ce521e 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -102,4 +102,39 @@ impl DaemonState { } Ok(true) } + + pub async fn dismiss_actioned_if_current( + &self, + id: u32, + expected: &Arc, + ) -> zbus::Result { + let removed = { + // Action completion removes only the exact active generation + let mut store = self.store.lock().await; + let removed = store.dismiss_active_if_current(id, expected); + if removed { + self.cancel_expiration(expected.key()); + } + removed + }; + + if !removed { + // A replacement or concurrent close already won the store race + return Ok(false); + } + + // Actioned notifications are dismissed, not archived as expired history + if let Err(error) = self + .publish_notification_dismissed(expected.key(), true) + .await + { + warn!( + ?error, + id, + generation = expected.generation, + "actioned notification was removed but close publication failed" + ); + } + Ok(true) + } } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 73bfdf91c..9f8cfa36a 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -185,6 +185,58 @@ async fn generation_safe_panel_dismiss_removes_and_cancels_the_current_generatio .is_none()); } +#[tokio::test] +async fn action_dismissal_removes_only_the_current_active_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let target = state + .store + .lock() + .await + .insert(notification("action"), 0) + .notification; + + assert!(state + .dismiss_actioned_if_current(target.id, &target) + .await + .expect("action dismissal should succeed")); + assert_eq!(next_cancel_id(&mut receiver).await, target.id); + let store = state.store.lock().await; + assert!(store.active_notification_view(target.id).is_none()); + assert!(store.list_history().is_empty()); +} + +#[tokio::test] +async fn action_dismissal_keeps_a_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (id, original) = { + let mut store = state.store.lock().await; + let original = store.insert(notification("original"), 0).notification; + let replacement = store.insert(notification("replacement"), original.id); + assert!(replacement.replaced); + (original.id, original) + }; + + assert!(!state + .dismiss_actioned_if_current(id, &original) + .await + .expect("stale action dismissal should be a no-op")); + assert!(receiver.try_recv().is_err()); + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active") + .summary, + "replacement" + ); +} + #[tokio::test] async fn close_notification_removes_active_notification_and_cancels_timer() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs index 10702a674..358671770 100644 --- a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -60,6 +60,8 @@ impl NotificationStore { self.active.shift_remove(&id); self.expirations.remove(&id); + // Action cleanup must not leave a replayable popup decision behind + self.prune_popup_decisions(); true } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs index 02914a395..6beed11f8 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -397,3 +397,13 @@ fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_rem store.clear_history(); assert!(store.popup_decisions.is_empty()); } + +#[test] +fn action_dismissal_prunes_the_removed_generation_popup_decision() { + let mut store = make_store_with_limits(10, 10); + let notification = store.insert(make_notification("actioned"), 0).notification; + + assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.dismiss_active_if_current(notification.id, ¬ification)); + assert!(store.popup_decisions.is_empty()); +} From 229bb6498068ed2483dcc3dffa0370f9529a67fa Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 20:08:10 -0500 Subject: [PATCH 237/275] refactor(center): share application headers and compact message actions Give singleton and grouped application blocks one identity header, keep group controls interactive only for multi-notification blocks, and place notification actions in the message column beside lead visuals. This keeps recycled rows on one compact composition without changing their content-driven height. --- .../src/ui/notifications/model/item.rs | 6 ++ .../ui/notifications/model/tests/grouping.rs | 6 +- .../src/ui/notifications/model/tests/item.rs | 4 + .../src/ui/notifications/row/group.rs | 27 +++++-- .../notifications/row/notification/build.rs | 9 ++- .../notifications/row/notification/state.rs | 4 +- .../row/notification/update/row.rs | 17 +++-- .../row/notification/update/tests/state.rs | 51 ++++++++++--- .../row/notification/update/visual.rs | 5 +- .../src/ui/notifications/row/tests/group.rs | 19 ++++- .../src/ui/notifications/store/blocks.rs | 32 ++++---- .../ui/notifications/store/tests/blocks.rs | 14 +++- .../ui/notifications/store/tests/update.rs | 76 +++++++++++++------ 13 files changed, 191 insertions(+), 79 deletions(-) diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 0e2cfe609..2815fdec5 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -66,6 +66,8 @@ pub struct RowData { pub group_key: Rc, pub count: u32, pub expanded: bool, + // Every notification block has a separate application identity header + pub app_header_present: bool, // True when this notification previews a collapsed multi-item group pub collapsed_group_preview: bool, // Rear silhouettes cap at two layers while the count keeps the exact total @@ -84,6 +86,7 @@ impl Default for RowData { group_key: Rc::from(""), count: 0, expanded: false, + app_header_present: false, collapsed_group_preview: false, stack_depth: 0, is_active: false, @@ -107,6 +110,7 @@ impl RowData { group_key, count: count as u32, expanded, + app_header_present: false, collapsed_group_preview: false, stack_depth: 0, is_active: false, @@ -131,6 +135,7 @@ impl RowData { group_key, count: 0, expanded, + app_header_present: true, collapsed_group_preview, stack_depth, is_active, @@ -146,6 +151,7 @@ impl RowData { && Rc::ptr_eq(&self.group_key, &other.group_key) && self.count == other.count && self.expanded == other.expanded + && self.app_header_present == other.app_header_present && self.collapsed_group_preview == other.collapsed_group_preview && self.stack_depth == other.stack_depth && self.is_active == other.is_active diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs index d43ee1e3f..dc999d65d 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs @@ -72,14 +72,14 @@ fn expected_list_len_tracks_collapsed_expanded_and_filtered_groups() { list.flush_rebuild(); let terminal = list.entries.get(&2).expect("terminal").app_key.clone(); - assert_eq!(list.expected_list_len(), 3); + assert_eq!(list.expected_list_len(), 4); list.group_expanded.insert(terminal, true); - assert_eq!(list.expected_list_len(), 4); + assert_eq!(list.expected_list_len(), 5); assert!(list.set_filter_query("browser")); list.flush_rebuild(); - assert_eq!(list.expected_list_len(), 1); + assert_eq!(list.expected_list_len(), 2); } #[gtk::test] diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 9be209026..87859e027 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -145,6 +145,10 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { changed.expanded = true; assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); + changed.app_header_present = false; + assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); changed.collapsed_group_preview = true; assert!(!base.is_equivalent(&changed)); diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 5008b2d82..4502668a1 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -109,7 +109,11 @@ pub(in crate::ui::notifications) fn build_group_row( let group_key: Rc>> = Rc::new(RefCell::new(Rc::from(""))); let event_tx_clone = event_tx; let group_key_clone = group_key.clone(); - button.connect_clicked(move |_| { + button.connect_clicked(move |button| { + if !button.is_sensitive() { + // Programmatic signal emission must respect the same singleton guard + return; + } let group = group_key_clone.borrow().clone(); if group.is_empty() { return; @@ -182,6 +186,14 @@ pub(in crate::ui::notifications) fn update_group_row( set_widget_visible_if_changed(&group.trust_chip, !trust_label.is_empty()); let next_count = data.count.to_string(); set_label_text_if_changed(&group.count, &next_count); + let has_multiple = data.count > 1; + set_widget_visible_if_changed(&group.count, has_multiple); + set_widget_visible_if_changed(&group.chevron, has_multiple); + group.button.set_focusable(has_multiple); + group.button.set_sensitive(has_multiple); + group + .button + .set_tooltip_text(has_multiple.then_some("Toggle notification group")); let accessible_label = group_accessible_label( display_name, trust_label, @@ -197,7 +209,9 @@ pub(in crate::ui::notifications) fn update_group_row( } else { "pan-down-symbolic" }; - set_icon_name_if_changed(&group.chevron, chevron_name); + if has_multiple { + set_icon_name_if_changed(&group.chevron, chevron_name); + } set_class_state(root, hooks::group_row::COLLAPSED, !data.expanded); set_class_state(root, hooks::group_row::EXPANDED, data.expanded); @@ -270,13 +284,14 @@ fn group_accessible_label( if !secondary.trim().is_empty() { parts.push(secondary.trim().to_string()); } - let count_label = if count == 1 { + parts.push(if count == 1 { "1 notification".to_string() } else { format!("{count} notifications") - }; - parts.push(count_label); - parts.push(if expanded { "Expanded" } else { "Collapsed" }.to_string()); + }); + if count > 1 { + parts.push(if expanded { "Expanded" } else { "Collapsed" }.to_string()); + } parts.join(". ") } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index 523568f12..0b5c43c16 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -33,7 +33,7 @@ pub(in crate::ui::notifications) fn build_notification_row( root.set_vexpand(false); root.set_margin_bottom(super::super::layout::NOTIFICATION_LIST_ROW_GAP); - // Card uses vertical layout: header, summary, body, then actions + // Card keeps its header and message column in one measured composition let card = gtk::Box::new(gtk::Orientation::Vertical, 6); card.add_css_class("unixnotis-panel-card"); card.set_hexpand(true); @@ -194,6 +194,7 @@ pub(in crate::ui::notifications) fn build_notification_row( let actions_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); // Action buttons are added on demand during row updates actions_box.add_css_class("unixnotis-notification-actions"); + actions_box.set_visible(false); mark_interactive(&actions_box); let inline_reply = build_inline_reply(command_tx.clone()); @@ -203,10 +204,11 @@ pub(in crate::ui::notifications) fn build_notification_row( card.append(&header); card.append(&body_row); card.append(&footer); - card.append(&actions_box); card.append(&inline_reply.revealer); + // Actions share the message column with the avatar instead of adding a second full row + text_stack.append(&actions_box); - // The wrapper clips the complete styled card while the inner box keeps all CSS hooks + // The wrapper owns the configured corner cut while the inner box keeps all CSS hooks let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); card_plate.add_css_class("unixnotis-panel-card-foreground"); card_plate.set_hexpand(true); @@ -264,6 +266,7 @@ pub(in crate::ui::notifications) fn build_notification_row( meta_label, time_badge, thumbnail, + text_stack, summary_label, body_label, popup_status, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index 33cac2739..bfd241872 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -32,7 +32,7 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) header: gtk::Box, // App name text shown beside the icon pub(super) app_label: gtk::Label, - // Headerless singleton rows retain the caller label and trust state visibly + // Application identity is rendered by the shared block header pub(super) secondary_claim: gtk::Label, pub(super) trust_chip: gtk::Label, // Critical badge remains allocated so urgency changes only toggle visibility @@ -47,6 +47,8 @@ pub(in crate::ui::notifications) struct NotificationRowWidgets { pub(super) time_badge: gtk::Label, // Optional large image preview for notifications with image hints pub(super) thumbnail: gtk::Image, + // Message column keeps text and actions beside a lead visual + pub(super) text_stack: gtk::Box, // Summary line with stronger visual weight pub(super) summary_label: gtk::Label, // Body text section that can span multiple lines diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 7b57ae74d..7881c113f 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -77,7 +77,12 @@ pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRow while let Some(child) = row.actions_box.first_child() { row.actions_box.remove(&child); } - queue_row_resize(row); + // A cleared recycled row must release its previous natural height + row.text_stack.queue_resize(); + row.card.queue_resize(); + row.card_plate.queue_resize(); + row.stack.queue_resize(); + row.root.queue_resize(); } pub(in crate::ui::notifications) fn update_notification_row( @@ -111,9 +116,8 @@ pub(in crate::ui::notifications) fn update_notification_row( // Set this before action-cache early returns so recycled rows cannot retain // a previous notification generation row.default_activation.set_target(default_target); - // Only the collapsed preview delegates identity to the group header - // Expanded groups retain the master-style identity lane on each child - let show_identity = !data.collapsed_group_preview; + // Identity visibility follows block assembly, not stack depth + let show_identity = !data.app_header_present; let has_actions = visible_action_count_from(&presentation, data.is_active) > 0; // The daemon has already assigned the visual role after attribution and safe decoding let lead_visual = panel_lead_visual( @@ -226,11 +230,8 @@ pub(in crate::ui::notifications) fn update_notification_row( ); set_widget_visible_if_changed(&row.card_plate, true); set_widget_visible_if_changed(&row.card, true); - queue_row_resize(row); -} - -fn queue_row_resize(row: &NotificationRowWidgets) { // Recycled rows can change natural height when text, media, or stack depth changes + row.text_stack.queue_resize(); row.card.queue_resize(); row.card_plate.queue_resize(); row.stack.queue_resize(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index c8ec0ee40..eb0c70cc5 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -161,6 +161,33 @@ fn update_notification_row_applies_state_classes_and_text() { assert!(row.icon_sig.borrow().is_none()); } +#[gtk::test] +fn notification_actions_live_inside_the_message_column() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions.push(Action { + key: "open".to_string(), + label: "View".to_string(), + }); + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + let parent = row + .actions_box + .parent() + .expect("actions should have a parent"); + assert!(parent == row.text_stack.clone().upcast::()); + assert!(row.actions_box.get_visible()); +} + #[gtk::test] fn recycled_panel_row_hides_critical_badge_after_urgency_returns_to_normal() { let (_root, row) = notification_row(); @@ -179,23 +206,23 @@ fn recycled_panel_row_hides_critical_badge_after_urgency_returns_to_normal() { } #[gtk::test] -fn single_notification_row_keeps_its_identity_visible_without_a_group_header() { +fn singleton_notification_row_keeps_identity_in_the_shared_header() { let (_root, row) = notification_row(); let data = row_data(Rc::new(sample_notification()), RowFlags::default()); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert!(row.app_label.get_visible()); + assert!(!row.app_label.get_visible()); assert!(row.header.get_visible()); assert!(row.close_button.get_visible()); - assert_eq!(row.card.spacing(), 6); + assert_eq!(row.card.spacing(), 2); assert_eq!(row.app_label.text().as_str(), "demo"); - assert!(row.icon_sig.borrow().is_some()); + assert!(row.icon_sig.borrow().is_none()); } #[gtk::test] -fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { +fn relay_singleton_card_hides_identity_owned_by_its_shared_header() { let (_root, row) = notification_row(); let mut notification = sample_notification(); notification.attribution = unixnotis_core::NotificationAttribution::relay( @@ -213,7 +240,7 @@ fn relay_singleton_shows_authenticated_source_and_secondary_app_label() { row.secondary_claim.text().as_str(), "App label: Example Chat" ); - assert!(row.secondary_claim.get_visible()); + assert!(!row.secondary_claim.get_visible()); assert!(!row.trust_chip.get_visible()); assert!(row.card.has_css_class("relay")); assert!(!row.card.has_css_class("conflict")); @@ -259,7 +286,7 @@ fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { } #[gtk::test] -fn expanded_group_rows_retain_master_identity_lane() { +fn expanded_group_rows_keep_identity_in_the_shared_header() { let (_root, row) = notification_row(); let data = row_data( Rc::new(sample_notification()), @@ -274,9 +301,9 @@ fn expanded_group_rows_retain_master_identity_lane() { update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert!(row.app_label.get_visible()); - assert!(!row.card.has_css_class("group-owned-identity")); - assert_eq!(row.card.spacing(), 6); + assert!(!row.app_label.get_visible()); + assert!(row.card.has_css_class("group-owned-identity")); + assert_eq!(row.card.spacing(), 2); } #[gtk::test] @@ -328,8 +355,8 @@ fn recycled_standalone_row_clears_identity_cache_when_it_becomes_grouped() { update_notification_row(&row, &standalone, &IconResolver::new(), &command_tx); assert!( - row.icon_sig.borrow().is_some(), - "standalone rows should cache their resolved identity icon" + row.icon_sig.borrow().is_none(), + "application identity belongs to the shared header" ); update_notification_row(&row, &grouped, &IconResolver::new(), &command_tx); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs index 0f6d3403f..253c06f51 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -19,9 +19,8 @@ pub(super) fn apply_visual_state( ) { let card = &row.card; let is_critical = notification.urgency == Urgency::Critical as u8; - // Only collapsed previews move identity into the shared group header - // Expanded children retain the normal master-style card composition - let group_owns_identity = data.collapsed_group_preview; + // The application header owns identity whenever block assembly provided one + let group_owns_identity = data.app_header_present; // Removing the hidden identity row also removes its old inter-row breathing room card.set_spacing(if group_owns_identity { 2 } else { 6 }); // Theme changes update recycled rows without rebuilding the GTK child tree diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 6d9412889..571a1fe8b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -108,10 +108,27 @@ fn group_accessible_name_keeps_identity_trust_count_and_state() { ); assert_eq!( group_accessible_label("Example Chat", "", "", 1, false), - "Example Chat. 1 notification. Collapsed" + "Example Chat. 1 notification" ); } +#[gtk::test] +fn singleton_group_header_hides_group_controls_and_is_not_interactive() { + support::init_gtk(); + let (event_tx, event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let data = RowData::group_header(Rc::from("terminal"), 1, false, notification("Terminal")); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert!(!widgets.count.get_visible()); + assert!(!widgets.chevron.get_visible()); + assert!(!widgets.button.is_sensitive()); + assert!(!widgets.button.is_focusable()); + header_button(&root).emit_clicked(); + assert!(event_rx.try_recv().is_err()); +} + #[gtk::test] fn update_group_row_falls_back_to_group_key_without_sample() { support::init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index a49b33082..87a410223 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -22,25 +22,23 @@ impl NotificationList { let mut items = Vec::new(); let mut keys = Vec::new(); - if ids.len() > 1 { - // Multi-item groups own one shared application identity header - let header = self.group_headers.entry(key.clone()).or_insert_with(|| { - RowItem::new(RowData::group_header( - key.clone(), - ids.len(), - expanded, - first_entry.view.clone(), - )) - }); - header.update(RowData::group_header( + // Every application block owns one shared identity header + let header = self.group_headers.entry(key.clone()).or_insert_with(|| { + RowItem::new(RowData::group_header( key.clone(), ids.len(), expanded, first_entry.view.clone(), - )); - items.push(header.clone()); - keys.push(RowKey::GroupHeader { group: key.clone() }); - } + )) + }); + header.update(RowData::group_header( + key.clone(), + ids.len(), + expanded, + first_entry.view.clone(), + )); + items.push(header.clone()); + keys.push(RowKey::GroupHeader { group: key.clone() }); // Collapsed groups render the newest content row under their shared header let collapsed_group_preview = !expanded && ids.len() > 1; @@ -84,8 +82,8 @@ impl NotificationList { ids: &[u32], ) -> usize { let expanded = self.group_expanded.get(key).copied().unwrap_or(false); - if ids.len() <= 1 { - return usize::from(!ids.is_empty()); + if ids.is_empty() { + return 0; } let mut len = 1; // shared header if expanded { diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index acf701baf..94c3034f9 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -87,10 +87,18 @@ fn build_group_block_keeps_single_notification_outside_collapsed_group_preview() let key = list.entries.get(&1).expect("entry").app_key.clone(); let ids = list.grouped_cache.get(&key).expect("group ids").clone(); - let (items, _keys) = list.build_group_block(&key, &ids); + let (items, keys) = list.build_group_block(&key, &ids); - assert_eq!(items.len(), 1); - let visible = items[0].data(); + assert_eq!(items.len(), 2); + assert_eq!( + keys, + vec![ + RowKey::GroupHeader { group: key.clone() }, + RowKey::Notification { id: 1 }, + ] + ); + assert_eq!(items[0].data().count, 1); + let visible = items[1].data(); assert!(!visible.collapsed_group_preview); assert_eq!(visible.stack_depth, 0); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs index 12c811356..d793da012 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs @@ -137,7 +137,7 @@ fn flush_rebuild_builds_seeded_rows_and_hides_empty_overlay() { list.flush_rebuild(); assert!(!list.needs_rebuild()); - assert_eq!(list.store.n_items(), 1); + assert_eq!(list.store.n_items(), 2); assert!(!list.empty_overlay.get_visible()); } @@ -153,15 +153,23 @@ fn flush_rebuild_filters_existing_list_with_minimal_middle_splice() { ); list.flush_rebuild(); let browser = list.entries.get(&2).expect("browser").app_key.clone(); - assert_eq!(list.store.n_items(), 2); + assert_eq!(list.store.n_items(), 4); assert!(list.set_filter_query("browser")); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 1); - assert_eq!(list.current_keys, vec![RowKey::Notification { id: 2 }]); + assert_eq!(list.store.n_items(), 2); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: browser.clone() + }, + RowKey::Notification { id: 2 }, + ] + ); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&browser].len, 1); + assert_eq!(list.group_ranges[&browser].len, 2); } #[gtk::test] @@ -216,17 +224,24 @@ fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 2); + assert_eq!(list.store.n_items(), 4); assert_eq!( list.current_keys, vec![ + RowKey::GroupHeader { + group: editor.clone() + }, RowKey::Notification { id: 3 }, + RowKey::GroupHeader { + group: terminal.clone() + }, RowKey::Notification { id: 1 }, ] ); assert!(!list.group_ranges.contains_key(&browser)); assert_eq!(list.group_ranges[&editor].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 1); + assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].len, 2); assert!(!list.interned.iter().any(|key| key.as_ref() == "stale")); } @@ -248,10 +263,13 @@ fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { list.toggle_group(terminal.as_ref()); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 4); + assert_eq!(list.store.n_items(), 5); assert_eq!( list.current_keys, vec![ + RowKey::GroupHeader { + group: browser.clone() + }, RowKey::Notification { id: 3 }, RowKey::GroupHeader { group: terminal.clone() @@ -261,8 +279,8 @@ fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { ] ); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&browser].len, 1); - assert_eq!(list.group_ranges[&terminal].start, 1); + assert_eq!(list.group_ranges[&browser].len, 2); + assert_eq!(list.group_ranges[&terminal].start, 2); assert_eq!(list.group_ranges[&terminal].len, 3); } @@ -305,10 +323,10 @@ fn flush_rebuild_places_multiple_pending_dirty_groups_before_kept_group() { let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); let browser = list.entries.get(&2).expect("browser").app_key.clone(); let editor = list.entries.get(&3).expect("editor").app_key.clone(); - assert_eq!(list.store.n_items(), 3); + assert_eq!(list.store.n_items(), 6); assert_eq!(list.group_ranges[&editor].start, 0); - assert_eq!(list.group_ranges[&browser].start, 1); - assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&browser].start, 2); + assert_eq!(list.group_ranges[&terminal].start, 4); } #[gtk::test] @@ -330,11 +348,19 @@ fn flush_rebuild_removes_empty_dirty_group_and_keeps_following_ranges_valid() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 1); - assert_eq!(list.current_keys, vec![RowKey::Notification { id: 1 }]); + assert_eq!(list.store.n_items(), 2); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: terminal.clone() + }, + RowKey::Notification { id: 1 }, + ] + ); assert!(!list.group_ranges.contains_key(&browser)); assert_eq!(list.group_ranges[&terminal].start, 0); - assert_eq!(list.group_ranges[&terminal].len, 1); + assert_eq!(list.group_ranges[&terminal].len, 2); } #[gtk::test] @@ -355,9 +381,9 @@ fn flush_rebuild_restores_missing_range_with_full_rebuild_fallback() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 2); + assert_eq!(list.store.n_items(), 4); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 1); + assert_eq!(list.group_ranges[&terminal].start, 2); } #[gtk::test] @@ -376,8 +402,8 @@ fn flush_rebuild_restores_store_length_with_full_rebuild_fallback() { list.request_rebuild(); list.flush_rebuild(); - assert_eq!(list.store.n_items(), 2); - assert_eq!(list.current_keys.len(), 2); + assert_eq!(list.store.n_items(), 4); + assert_eq!(list.current_keys.len(), 4); } #[gtk::test] @@ -391,14 +417,20 @@ fn flush_rebuild_batches_new_dirty_groups_before_kept_groups() { let browser = list.entries.get(&2).expect("browser").app_key.clone(); let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); - assert_eq!(list.store.n_items(), 2); + assert_eq!(list.store.n_items(), 4); assert_eq!( list.current_keys, vec![ + RowKey::GroupHeader { + group: browser.clone() + }, RowKey::Notification { id: 2 }, + RowKey::GroupHeader { + group: terminal.clone() + }, RowKey::Notification { id: 1 }, ] ); assert_eq!(list.group_ranges[&browser].start, 0); - assert_eq!(list.group_ranges[&terminal].start, 1); + assert_eq!(list.group_ranges[&terminal].start, 2); } From 83847be1fe9f859297b888f0760d28cf34683ad9 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 21:55:10 -0500 Subject: [PATCH 238/275] fix(notifications): honor positive protocol expiration Treat a positive expire_timeout as an automatic close for every non-resident notification, as required by the notification protocol. Keep resident records active while preserving the existing popup duration and transient default policy. --- .../src/store/notifications/tests/timeout.rs | 4 ++-- crates/unixnotis-daemon/src/store/notifications/timeout.rs | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs index 1917fd90c..505f11215 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs @@ -20,7 +20,7 @@ fn zero_protocol_timeout_disables_both_clocks() { } #[test] -fn positive_protocol_timeout_controls_popup_and_active_lifetime() { +fn positive_protocol_timeout_closes_nonresident_notifications() { let config = Config::default(); let mut notification = make_notification("bounded"); notification.expire_timeout = 30_000; @@ -29,7 +29,7 @@ fn positive_protocol_timeout_controls_popup_and_active_lifetime() { resolve_timeout_policy(&config, ¬ification), super::super::timeout::ResolvedTimeoutPolicy { popup_hide_after_ms: 30_000, - active_close_after: None, + active_close_after: Some(Duration::from_secs(30)), } ); } diff --git a/crates/unixnotis-daemon/src/store/notifications/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/timeout.rs index 78e306c8c..b6cc5218a 100644 --- a/crates/unixnotis-daemon/src/store/notifications/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/timeout.rs @@ -29,13 +29,12 @@ pub(super) fn resolve_timeout_policy( popup_hide_after_ms: 0, active_close_after: None, }, - // Positive values control the banner; only transient notifications close - // automatically so ordinary panel actions remain available + // Positive protocol values close every non-resident notification timeout if timeout > 0 => { let timeout_ms = timeout as u64; ResolvedTimeoutPolicy { popup_hide_after_ms: timeout_ms, - active_close_after: (notification.is_transient && !notification.is_resident) + active_close_after: (!notification.is_resident) .then(|| Duration::from_millis(timeout_ms)), } } From 61d7d7eccf6ba97387c2b3c6c15f839fa4216ccc Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 3 Aug 2026 21:55:15 -0500 Subject: [PATCH 239/275] fix(installer): restore bundled scripts from reset backups Restore every bundled script from a reset snapshot through no-follow regular-file reads, contained targets, and executable 0755 permissions. Cover all script bytes and modes with an end-to-end reset and installer restore regression. --- .../src/actions/config/backup/restore.rs | 63 +++++++++++++++++- .../actions/config/backup/tests/restore.rs | 65 +++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 65a126aa0..7e3bec9be 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -1,11 +1,12 @@ //! Backup restore helpers and path guards use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::write_file_atomic; -use unixnotis_core::Config; +use unixnotis_core::filesystem::{create_directory_all, open_regular_file, write_file_atomic}; +use unixnotis_core::{Config, DEFAULT_SCRIPTS}; use crate::paths::format_with_home; @@ -113,9 +114,67 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { ); } + // Script backups use their basename because reset stores them directly in the backup root + for script in DEFAULT_SCRIPTS { + let script_name = Path::new(script.relative_path) + .file_name() + .ok_or_else(|| anyhow!("script path has no file name"))?; + let source = backup_dir.join(script_name); + if !source.exists() { + log_line( + ctx, + format!( + "Warning: backup missing {}; leaving current file unchanged", + script.relative_path + ), + ); + continue; + } + + let target = config_dir.join(script.relative_path); + if !is_restore_target_allowed(&config_dir, &target) { + log_line( + ctx, + format!( + "Warning: skipped restoring {} because target escapes config dir ({})", + script.relative_path, + format_with_home(&target) + ), + ); + continue; + } + + if let Some(parent) = target.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create script restore directory {}", parent.display()))?; + } + let contents = read_backup_file(&source) + .with_context(|| format!("failed to read backup {}", script.relative_path))?; + write_file_atomic(&target, &contents, 0o755) + .with_context(|| format!("failed to restore {}", script.relative_path))?; + log_line( + ctx, + format!( + "Restored {} -> {}", + script.relative_path, + format_with_home(&target) + ), + ); + } + Ok(()) } +fn read_backup_file(path: &Path) -> Result> { + // Pin the backup object and reject links or special files before reading it + let mut file = + open_regular_file(path).with_context(|| format!("open backup file {}", path.display()))?; + let mut contents = Vec::new(); + file.read_to_end(&mut contents) + .with_context(|| format!("read backup file {}", path.display()))?; + Ok(contents) +} + pub(in crate::actions::config::backup) fn is_restore_target_allowed( config_dir: &Path, target: &Path, diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs index 46afd30f2..14c544bb7 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs @@ -4,9 +4,11 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; +use unixnotis_core::{reset_config_to_defaults, ResetConfigOptions, DEFAULT_SCRIPTS}; #[test] fn restore_config_uses_restored_theme_paths() { @@ -142,3 +144,66 @@ fn restore_config_skips_absolute_theme_targets() { let _ = fs::remove_file(&escaped_target); let _ = fs::remove_dir_all(&root); } + +#[test] +fn restore_config_restores_all_bundled_scripts_and_executable_modes() { + let _lock = crate::test_support::env::test_env_lock(); + let root = PathBuf::from("target").join(format!( + "unixnotis-installer-script-restore-test-{}", + std::process::id() + )); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(config_dir.join("scripts")).expect("create script directory"); + fs::write(config_dir.join("config.toml"), "custom = true\n").expect("write config"); + + // Seed every bundled script with distinct user content and non-default permissions + for (index, script) in DEFAULT_SCRIPTS.iter().enumerate() { + let path = config_dir.join(script.relative_path); + fs::write(&path, format!("custom script {index}\n")).expect("write custom script"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("set custom script mode"); + } + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: config_dir.clone(), + backup_retention: 1, + }) + .expect("reset should create a restorable script backup"); + let backup_dir = report.backup_dir.expect("reset backup directory"); + + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(16); + let mut ctx = crate::actions::ActionContext { + detection: &detection, + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + restore_config(&mut ctx).expect("restore should restore bundled scripts"); + + for (index, script) in DEFAULT_SCRIPTS.iter().enumerate() { + let path = config_dir.join(script.relative_path); + assert_eq!( + fs::read_to_string(&path).expect("read restored script"), + format!("custom script {index}\n") + ); + assert_eq!( + fs::metadata(&path) + .expect("restored script metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + + let _ = fs::remove_dir_all(&root); +} From 39a29c9e2f254be6c2071044dc736b605342d308 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 21:56:36 -0500 Subject: [PATCH 240/275] perf(theme): localize quick-slider hover styling Apply hover feedback directly to quick-slider thumbs and icons instead of propagating ancestor state through composite widget subtrees. Keep the panel visual treatment unchanged and reject the expensive selector topology in embedded CSS tests. --- crates/unixnotis-core/assets/widgets.css | 34 +++---------------- .../unixnotis-core/src/embedded/tests/css.rs | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index 3b2906214..be699f153 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -25,14 +25,6 @@ margin: 0; } -.unixnotis-quick-slider:hover, -.unixnotis-quick-slider-volume:hover, -.unixnotis-quick-slider-brightness:hover { - background: transparent; - border: none; - box-shadow: none; -} - .unixnotis-quick-slider-icon { background: transparent; border-radius: 999px; @@ -546,7 +538,7 @@ color: alpha(#ffffff, 0.65); } -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-icon { +.unixnotis-quick-slider-volume .unixnotis-quick-slider-icon:hover { color: #00f2fe; } @@ -554,18 +546,10 @@ color: alpha(#ffffff, 0.65); } -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-icon { +.unixnotis-quick-slider-brightness .unixnotis-quick-slider-icon:hover { color: #ffb86b; } -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-value { - color: #ffffff; -} - -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-value { - color: #ffffff; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale highlight { background-image: linear-gradient(90deg, #3b82f6, #00a2ff); } @@ -582,12 +566,12 @@ border-color: alpha(#ff9f0a, 0.6); } -.unixnotis-quick-slider-volume:hover slider { +.unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider:hover { border-color: #00a2ff; box-shadow: 0 0 6px alpha(#00a2ff, 0.5), 0 1.5px 3.5px alpha(#000000, 0.40); } -.unixnotis-quick-slider-brightness:hover slider { +.unixnotis-quick-slider-brightness .unixnotis-quick-slider-scale slider:hover { border-color: #ff9f0a; box-shadow: 0 0 6px alpha(#ff9f0a, 0.5), 0 1.5px 3.5px alpha(#000000, 0.40); } @@ -646,16 +630,6 @@ transition: color 0.15s ease-out; } -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel-min, -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel-max { - color: alpha(#00a2ff, 0.7); -} - -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel-min, -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel-max { - color: alpha(#ff9f0a, 0.7); -} - .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked { background-image: linear-gradient(135deg, #00b4db, #0083b0); border: 1px solid alpha(#ffffff, 0.15); diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 8357c19f9..375e6d973 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -86,6 +86,40 @@ fn stock_panel_hover_styles_avoid_transform_and_geometry_animation() { assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-height")); } +#[test] +fn stock_quick_slider_hover_targets_only_changed_widgets() { + // Composite slider hover rules restyle descendants even when no pixels change on the parent + for selector in [ + ".unixnotis-quick-slider:hover", + ".unixnotis-quick-slider-volume:hover", + ".unixnotis-quick-slider-brightness:hover", + ".unixnotis-quick-slider-volume:hover slider", + ".unixnotis-quick-slider-brightness:hover slider", + ".unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-value", + ".unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-value", + ".unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel", + ".unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel", + ] { + assert!( + !DEFAULT_WIDGETS_CSS.contains(selector), + "quick-slider CSS must not broadcast ancestor hover through {selector}" + ); + } + + // Thumb and icon feedback remain attached to the widgets that actually change appearance + for selector in [ + ".unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider:hover", + ".unixnotis-quick-slider-brightness .unixnotis-quick-slider-scale slider:hover", + ".unixnotis-quick-slider-volume .unixnotis-quick-slider-icon:hover", + ".unixnotis-quick-slider-brightness .unixnotis-quick-slider-icon:hover", + ] { + assert!( + DEFAULT_WIDGETS_CSS.contains(selector), + "quick-slider CSS should keep direct hover feedback on {selector}" + ); + } +} + #[test] fn stock_scrollbar_keeps_master_sizing_without_geometry_animation() { assert!(DEFAULT_PANEL_CSS.contains( From 1caae26d39cb7fc79f87ba45b1a0c8c6fceb85bd Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 21:56:42 -0500 Subject: [PATCH 241/275] perf(daemon): index protected desktop brand records Build a direct normalized-brand index for protected desktop records so claims no longer rescan every desktop entry. Preserve the existing normalization and communication-role checks with focused index regressions. --- .../identity/desktop_index/index/lookup.rs | 15 +++++--------- .../identity/desktop_index/index/mutation.rs | 6 +++++- .../desktop_index/index/tests/mutation.rs | 20 +++++++++++++++++++ .../identity/desktop_index/model.rs | 2 ++ .../identity/desktop_index/names.rs | 18 ++++++++++------- .../identity/desktop_index/tests/parsing.rs | 14 +++++++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs index 7c851e975..e1cd5e512 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs @@ -50,16 +50,11 @@ impl DesktopIdentityIndex { let protected = normalize_brand_name(claim); if !protected.is_empty() { indices.extend( - self.records - .iter() - .enumerate() - .filter_map(|(index, record)| { - (record.system_origin - && [&record.display_name, &record.id] - .iter() - .any(|name| normalize_brand_name(name) == protected)) - .then_some(index) - }), + self.system_brand_records + .get(&protected) + .into_iter() + .flatten() + .copied(), ); } indices.sort_unstable(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs index 578d1b2b5..89611bc2b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs @@ -14,7 +14,11 @@ impl DesktopIdentityIndex { for brand in [&record.display_name, &record.id] { let brand = normalize_brand_name(brand); if !brand.is_empty() { - self.system_brand_names.insert(brand); + self.system_brand_names.insert(brand.clone()); + self.system_brand_records + .entry(brand) + .or_default() + .push(record_index); } } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs index 97f928663..8d6d3fcd3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs @@ -25,6 +25,26 @@ fn executable_index_rebuild_replaces_stale_runtime_identity() { assert_eq!(index.records_for_executable(new).len(), 1); } +#[test] +fn protected_brand_lookup_uses_the_indexed_normalized_record() { + let mut index = DesktopIdentityIndex::default(); + let mut record = record(identity(72)); + record.system_origin = true; + record.display_name = "Example Brand".to_string(); + record.id = "org.example.Brand".to_string(); + index.index_record(record); + + let matched = index.records_for_claim("Example Brand"); + + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].id, "org.example.Brand"); + assert!(index.system_brand_records.values().all(|indices| { + indices + .iter() + .all(|record_index| *record_index < index.records.len()) + })); +} + fn record(runtime: FileIdentity) -> DesktopRecord { DesktopRecord { id: "org.example.App".to_string(), diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index f1b395f1a..b611963ec 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -154,6 +154,8 @@ pub struct DesktopIdentityIndex { pub(super) by_identity: HashMap<(u64, u64), Vec>, pub(super) by_name: HashMap>, pub(super) system_brand_names: HashSet, + // Protected brand keys point directly to records instead of rescanning all desktop entries + pub(super) system_brand_records: HashMap>, pub(super) communication_desktop_ids: HashSet, pub(in crate::daemon::notifications::identity) trusted_relays: Vec, pub(in crate::daemon::notifications::identity) trusted_portals: Vec, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs index 3a9e37057..d0ac49f42 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs @@ -13,17 +13,21 @@ pub(in crate::daemon::notifications::identity) fn normalize_desktop_id(value: &s pub(in crate::daemon::notifications::identity) fn normalize_name(value: &str) -> String { // Punctuation and case do not create separate branding aliases - value + let mut normalized = String::with_capacity(value.len()); + for character in value .chars() .filter(|character| character.is_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() + { + normalized.extend(character.to_lowercase()); + } + normalized } pub(super) fn normalize_brand_name(value: &str) -> String { // UTS 39 skeletons collapse common cross-script lookalikes before comparison - skeleton(value) - .filter(char::is_ascii_alphanumeric) - .map(|character| character.to_ascii_lowercase()) - .collect() + let mut normalized = String::with_capacity(value.len()); + for character in skeleton(value).filter(char::is_ascii_alphanumeric) { + normalized.push(character.to_ascii_lowercase()); + } + normalized } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs index d88e36345..e20b614fe 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -103,4 +103,18 @@ fn desktop_categories_mark_conversation_capable_applications() { index.add_desktop_file(&path, true); assert!(index.desktop_id_has_communication_role("org.example.messages")); + + // A communication marker without an indexed desktop record is not enough evidence + let mut role_only = DesktopIdentityIndex::default(); + role_only + .communication_desktop_ids + .insert("org.example.role-only".to_string()); + assert!(!role_only.desktop_id_has_communication_role("org.example.role-only")); + + // An indexed record without a communication category must not gain the role + let mut record_only = DesktopIdentityIndex::default(); + record_only + .by_id + .insert("org.example.record-only".to_string(), vec![0]); + assert!(!record_only.desktop_id_has_communication_role("org.example.record-only")); } From 0986651ffbd5aef77fa0f667fc482cbaac06f32b Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 21:56:46 -0500 Subject: [PATCH 242/275] test(daemon): align positive timeout lifecycle coverage Update the ingestion regression to assert the protocol-defined expiration of ordinary non-resident positive timeouts, including history placement and preserved action metadata. --- .../daemon/notifications/server/tests/flow.rs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 47c2fc275..34e0391a2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -269,7 +269,7 @@ async fn ingest_notify_schedules_expiration_for_positive_transient_timeout() { } #[tokio::test] -async fn ingest_notify_keeps_ordinary_positive_timeout_active() { +async fn ingest_notify_expires_ordinary_positive_timeout() { let state = daemon_state_for_test(false).await; let scheduler = ExpirationScheduler::start(state.clone()); let server = NotificationServer::new(state.clone(), scheduler); @@ -291,15 +291,23 @@ async fn ingest_notify_keeps_ordinary_positive_timeout_active() { .await .expect("notify should store"); - tokio::time::sleep(Duration::from_millis(40)).await; - let active = state - .store - .lock() - .await - .active_notification_view(id) - .expect("ordinary positive timeout must keep the active record"); - assert_eq!(active.popup_hide_after_ms, 25); - assert_eq!(active.actions.len(), 1); + for _ in 0..30 { + let store = state.store.lock().await; + if store.active_notification_view(id).is_none() { + let history = store.list_history(); + let archived = history + .iter() + .find(|notification| notification.id == id) + .expect("expired notification should be archived"); + assert_eq!(archived.popup_hide_after_ms, 0); + assert_eq!(archived.actions.len(), 1); + return; + } + drop(store); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + panic!("ordinary positive timeout should expire the active record"); } #[tokio::test] From 5bed65222bd653bd8be7e07ccd78de940f8d33d7 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 21:56:50 -0500 Subject: [PATCH 243/275] refactor(installer): split config restore helpers Separate config, theme, and bundled-script restoration into bounded helpers while preserving contained-path checks, executable modes, and warning behavior. --- .../src/actions/config/backup/restore.rs | 236 +++++++++++------- 1 file changed, 141 insertions(+), 95 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 7e3bec9be..9e78bdd49 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -39,129 +39,175 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { ); // Restore config.toml first so restored theme paths drive the rest of the write targets - let config_backup = backup_dir.join("config.toml"); - if config_backup.exists() { - let contents = fs::read_to_string(&config_backup) - .with_context(|| "failed to read backup config.toml")?; - write_file_atomic(&config_path, contents.as_bytes(), 0o644) - .with_context(|| "failed to restore config.toml")?; - log_line( - ctx, - format!("Restored config.toml -> {}", format_with_home(&config_path)), - ); - } else { + restore_config_file(ctx, &backup_dir, &config_path)?; + let config = load_restored_config(ctx, &config_path); + let theme_paths = config + .resolve_theme_paths_from(&config_dir) + .map_err(|err| anyhow!(err.to_string()))?; + + restore_theme_files(ctx, &backup_dir, &config_dir, &theme_paths)?; + restore_bundled_scripts(ctx, &backup_dir, &config_dir)?; + + Ok(()) +} + +fn restore_config_file( + ctx: &mut ActionContext, + backup_dir: &Path, + config_path: &Path, +) -> Result<()> { + let source = backup_dir.join("config.toml"); + if !source.exists() { log_line( ctx, "Warning: backup missing config.toml; leaving current file unchanged".to_string(), ); + return Ok(()); } - let config = if config_path.exists() { - match Config::load_from_path(&config_path) { - Ok(config) => config, - Err(err) => { - log_line( - ctx, - format!( - "Warning: failed to parse restored config.toml ({err:?}); using defaults" - ), - ); - Config::default() - } - } - } else { - Config::default() - }; - let theme_paths = config - .resolve_theme_paths_from(&config_dir) - .map_err(|err| anyhow!(err.to_string()))?; - - let theme_targets = [ - ("base.css", theme_paths.base_css), - ("panel.css", theme_paths.panel_css), - ("popup.css", theme_paths.popup_css), - ("widgets.css", theme_paths.widgets_css), - ("media.css", theme_paths.media_css), - ]; + let contents = + fs::read_to_string(&source).with_context(|| "failed to read backup config.toml")?; + write_file_atomic(config_path, contents.as_bytes(), 0o644) + .with_context(|| "failed to restore config.toml")?; + log_line( + ctx, + format!("Restored config.toml -> {}", format_with_home(config_path)), + ); + Ok(()) +} - for (name, target) in theme_targets { - let source = backup_dir.join(name); - if !source.exists() { - log_line( - ctx, - format!("Warning: backup missing {name}; leaving current file unchanged"), - ); - continue; - } - if !is_restore_target_allowed(&config_dir, &target) { +fn load_restored_config(ctx: &mut ActionContext, config_path: &Path) -> Config { + if !config_path.exists() { + return Config::default(); + } + match Config::load_from_path(config_path) { + Ok(config) => config, + Err(err) => { log_line( ctx, - format!( - "Warning: skipped restoring {} because target escapes config dir ({})", - name, - format_with_home(&target) - ), + format!("Warning: failed to parse restored config.toml ({err:?}); using defaults"), ); - continue; + Config::default() } - let contents = - fs::read_to_string(&source).with_context(|| format!("failed to read backup {name}"))?; - write_file_atomic(&target, contents.as_bytes(), 0o644) - .with_context(|| format!("failed to restore {name}"))?; + } +} + +fn restore_theme_files( + ctx: &mut ActionContext, + backup_dir: &Path, + config_dir: &Path, + theme_paths: &unixnotis_core::ThemePaths, +) -> Result<()> { + let theme_targets = [ + ("base.css", &theme_paths.base_css), + ("panel.css", &theme_paths.panel_css), + ("popup.css", &theme_paths.popup_css), + ("widgets.css", &theme_paths.widgets_css), + ("media.css", &theme_paths.media_css), + ]; + for (name, target) in theme_targets { + restore_theme_file(ctx, backup_dir, config_dir, name, target)?; + } + Ok(()) +} + +fn restore_theme_file( + ctx: &mut ActionContext, + backup_dir: &Path, + config_dir: &Path, + name: &str, + target: &Path, +) -> Result<()> { + let source = backup_dir.join(name); + if !source.exists() { log_line( ctx, - format!("Restored {} -> {}", name, format_with_home(&target)), + format!("Warning: backup missing {name}; leaving current file unchanged"), ); + return Ok(()); } + if !is_restore_target_allowed(config_dir, target) { + log_line( + ctx, + format!( + "Warning: skipped restoring {name} because target escapes config dir ({})", + format_with_home(target) + ), + ); + return Ok(()); + } + let contents = + fs::read_to_string(&source).with_context(|| format!("failed to read backup {name}"))?; + write_file_atomic(target, contents.as_bytes(), 0o644) + .with_context(|| format!("failed to restore {name}"))?; + log_line( + ctx, + format!("Restored {name} -> {}", format_with_home(target)), + ); + Ok(()) +} +fn restore_bundled_scripts( + ctx: &mut ActionContext, + backup_dir: &Path, + config_dir: &Path, +) -> Result<()> { // Script backups use their basename because reset stores them directly in the backup root for script in DEFAULT_SCRIPTS { - let script_name = Path::new(script.relative_path) - .file_name() - .ok_or_else(|| anyhow!("script path has no file name"))?; - let source = backup_dir.join(script_name); - if !source.exists() { - log_line( - ctx, - format!( - "Warning: backup missing {}; leaving current file unchanged", - script.relative_path - ), - ); - continue; - } + restore_bundled_script(ctx, backup_dir, config_dir, script)?; + } + Ok(()) +} - let target = config_dir.join(script.relative_path); - if !is_restore_target_allowed(&config_dir, &target) { - log_line( - ctx, - format!( - "Warning: skipped restoring {} because target escapes config dir ({})", - script.relative_path, - format_with_home(&target) - ), - ); - continue; - } +fn restore_bundled_script( + ctx: &mut ActionContext, + backup_dir: &Path, + config_dir: &Path, + script: &unixnotis_core::DefaultScript, +) -> Result<()> { + let script_name = Path::new(script.relative_path) + .file_name() + .ok_or_else(|| anyhow!("script path has no file name"))?; + let source = backup_dir.join(script_name); + if !source.exists() { + log_line( + ctx, + format!( + "Warning: backup missing {}; leaving current file unchanged", + script.relative_path + ), + ); + return Ok(()); + } - if let Some(parent) = target.parent() { - create_directory_all(parent, 0o700) - .with_context(|| format!("create script restore directory {}", parent.display()))?; - } - let contents = read_backup_file(&source) - .with_context(|| format!("failed to read backup {}", script.relative_path))?; - write_file_atomic(&target, &contents, 0o755) - .with_context(|| format!("failed to restore {}", script.relative_path))?; + let target = config_dir.join(script.relative_path); + if !is_restore_target_allowed(config_dir, &target) { log_line( ctx, format!( - "Restored {} -> {}", + "Warning: skipped restoring {} because target escapes config dir ({})", script.relative_path, format_with_home(&target) ), ); + return Ok(()); } - + if let Some(parent) = target.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create script restore directory {}", parent.display()))?; + } + let contents = read_backup_file(&source) + .with_context(|| format!("failed to read backup {}", script.relative_path))?; + write_file_atomic(&target, &contents, 0o755) + .with_context(|| format!("failed to restore {}", script.relative_path))?; + log_line( + ctx, + format!( + "Restored {} -> {}", + script.relative_path, + format_with_home(&target) + ), + ); Ok(()) } From 0f12f9e8a9700ab244a137e38877016d7ca540af Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 21:57:10 -0500 Subject: [PATCH 244/275] perf(popups): refresh icons without redundant popup rebuilds Colocate popup icon state with the icon subsystem, reuse successful themed paintables, and skip unchanged GTK row rebuilds while preserving materialization acknowledgements. Invalidate all icon-resolution caches when desktop or theme sources change, include every candidate input in the cache key, and advance queued rows without repeatedly rebuilding visible popups. Keep source and generation regressions in the popup test modules. --- crates/unixnotis-popups/src/ui/entry/build.rs | 9 +- crates/unixnotis-popups/src/ui/icons/cache.rs | 6 + crates/unixnotis-popups/src/ui/icons/mod.rs | 5 +- .../unixnotis-popups/src/ui/icons/resolver.rs | 59 ++--- .../src/ui/{icon_state.rs => icons/state.rs} | 191 +++++++++------ .../src/ui/icons/tests/cache.rs | 16 ++ .../src/ui/icons/tests/resolver/candidates.rs | 17 ++ .../ui/icons/tests/resolver/theme_lookup.rs | 11 +- .../src/ui/icons/tests/state.rs | 219 ++++++++++++++++++ .../src/ui/icons/tests/theme_cache.rs | 143 ++++++++++++ .../src/ui/icons/theme_cache.rs | 172 ++++++++++++++ crates/unixnotis-popups/src/ui/mod.rs | 1 - .../src/ui/popups/mutation.rs | 63 ++++- .../src/ui/popups/reconcile.rs | 29 ++- .../src/ui/popups/tests/mutation.rs | 84 ++++++- .../src/ui/popups/tests/reconcile.rs | 162 ++++++++++++- .../src/ui/popups/tests/visibility.rs | 31 ++- .../src/ui/popups/visibility.rs | 30 ++- .../src/ui/state/constructor.rs | 4 +- crates/unixnotis-popups/src/ui/state/mod.rs | 2 +- crates/unixnotis-popups/src/ui/state/model.rs | 19 +- .../src/ui/state/tests/mutation.rs | 155 ++++++++++++- .../src/ui/tests/icon_state.rs | 23 -- 23 files changed, 1294 insertions(+), 157 deletions(-) rename crates/unixnotis-popups/src/ui/{icon_state.rs => icons/state.rs} (53%) create mode 100644 crates/unixnotis-popups/src/ui/icons/tests/state.rs create mode 100644 crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs create mode 100644 crates/unixnotis-popups/src/ui/icons/theme_cache.rs delete mode 100644 crates/unixnotis-popups/src/ui/tests/icon_state.rs diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index b2027d613..822fad2b7 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -22,6 +22,8 @@ use crate::dbus::UiCommand; pub(in crate::ui) struct PopupEntry { // Keep the last payload so seed reconcile can detect real content changes pub(in crate::ui) notification: NotificationView, + // Rows built before an icon-source change must be rebuilt on the next update + pub(in crate::ui) icon_source_generation: u64, // Hidden backlog rows stay lightweight until they enter the visible slice pub(in crate::ui) revealer: Option, pub(in crate::ui) root: Option, @@ -33,10 +35,14 @@ pub(in crate::ui) struct PopupEntry { } impl PopupEntry { - pub(in crate::ui) const fn queued(notification: NotificationView) -> Self { + pub(in crate::ui) const fn queued( + notification: NotificationView, + icon_source_generation: u64, + ) -> Self { // Backlog rows start as plain data and only grow GTK nodes when they become visible Self { notification, + icon_source_generation, revealer: None, root: None, visibility: None, @@ -74,6 +80,7 @@ impl UiState { PopupEntry { // Store the payload used to build this row so later seeds can compare safely notification: notification.clone(), + icon_source_generation: self.icon_source_generation, revealer: Some(revealer), root: Some(root), visibility: Some(visibility), diff --git a/crates/unixnotis-popups/src/ui/icons/cache.rs b/crates/unixnotis-popups/src/ui/icons/cache.rs index 86103331b..a50d9ce1a 100644 --- a/crates/unixnotis-popups/src/ui/icons/cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/cache.rs @@ -186,6 +186,12 @@ impl TextureCache { self.enforce_limit(); } + pub(crate) fn clear(&mut self) { + // Source changes can replace file contents at the same path + self.entries.clear(); + self.order.clear(); + } + fn bump(&mut self, key: &IconRequestKey) { // Move the key to the back to reflect recent use if let Some(pos) = self.order.iter().position(|entry| entry == key) { diff --git a/crates/unixnotis-popups/src/ui/icons/mod.rs b/crates/unixnotis-popups/src/ui/icons/mod.rs index b788e0a7e..ed6d21752 100644 --- a/crates/unixnotis-popups/src/ui/icons/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/mod.rs @@ -4,8 +4,11 @@ mod cache; mod content; mod decode; mod resolver; +mod state; +mod theme_cache; pub(super) use cache::{IconDecodePool, IconDecodeResult, TextureCache}; pub(super) use content::{image_data_texture, image_data_texture_for_data}; pub(super) use decode::{decode_icon_file, RasterIcon}; -pub(super) use resolver::{collect_icon_candidates, file_path_from_hint, resolve_icon_image}; +pub(super) use resolver::{collect_icon_candidates, file_path_from_hint}; +pub(super) use theme_cache::ThemeIconCache; diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index 20cd77ff9..cca2ad637 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -2,7 +2,6 @@ //! //! Separates icon lookup and image decoding from UI state management. -use std::collections::HashSet; use std::path::{Path, PathBuf}; use gio::prelude::FileExt; @@ -28,7 +27,11 @@ pub(in crate::ui) fn file_path_from_hint(path: &str) -> Option { } // Resolve themed icon names while filtering out the missing-icon placeholder. -fn resolve_icon_paintable(name: &str, size: i32) -> Option { +pub(in crate::ui) fn resolve_icon_paintable_with_scale( + name: &str, + size: i32, + scale: i32, +) -> Option { if name.is_empty() { return None; } @@ -38,7 +41,7 @@ fn resolve_icon_paintable(name: &str, size: i32) -> Option { name, &[], size, - 1, + scale.max(1), TextDirection::Ltr, IconLookupFlags::empty(), ); @@ -52,38 +55,40 @@ fn resolve_icon_paintable(name: &str, size: i32) -> Option { Some(paintable) } -pub(in crate::ui) fn resolve_icon_image(name: &str, size: i32) -> Option { - // File-path icons are resolved asynchronously in the UI layer to avoid blocking the GTK thread. - let paintable = resolve_icon_paintable(name, size)?; - let widget = gtk::Image::from_paintable(Some(&paintable)); - widget.set_pixel_size(size); - Some(widget) -} - pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> Vec { - let mut candidates = Vec::new(); - if !notification.attribution.badge_icon.is_empty() { - candidates.push(notification.attribution.badge_icon.clone()); - if let Some(stripped) = notification.attribution.badge_icon.strip_suffix(".desktop") { - candidates.push(stripped.to_string()); + // Candidate lists stay small, so ordered linear deduplication avoids a hash allocation + let mut candidates = Vec::with_capacity(7); + let badge_icon = notification.attribution.badge_icon.as_str(); + if !badge_icon.is_empty() { + push_candidate(&mut candidates, badge_icon); + if let Some(stripped) = badge_icon.strip_suffix(".desktop") { + push_candidate(&mut candidates, stripped); } - candidates.push(notification.attribution.badge_icon.to_lowercase()); + let lowercase = badge_icon.to_lowercase(); + push_candidate(&mut candidates, &lowercase); } - if !notification.attribution.desktop_id.is_empty() { + let desktop_id = notification.attribution.desktop_id.as_str(); + if !desktop_id.is_empty() { // Desktop ids are daemon-associated metadata and safe badge lookup candidates - candidates.push(notification.attribution.desktop_id.clone()); - candidates.push(notification.attribution.desktop_id.to_lowercase()); + push_candidate(&mut candidates, desktop_id); + let lowercase = desktop_id.to_lowercase(); + push_candidate(&mut candidates, &lowercase); } - if is_safe_theme_name(¬ification.image.claimed_theme_icon) { + let claimed_theme_icon = notification.image.claimed_theme_icon.as_str(); + if is_safe_theme_name(claimed_theme_icon) { // Sender input is only a bounded theme lookup hint, never identity evidence - candidates.push(notification.image.claimed_theme_icon.clone()); - candidates.push(notification.image.claimed_theme_icon.to_lowercase()); + push_candidate(&mut candidates, claimed_theme_icon); + let lowercase = claimed_theme_icon.to_lowercase(); + push_candidate(&mut candidates, &lowercase); } - let mut seen = HashSet::new(); candidates - .into_iter() - .filter(|candidate| !candidate.is_empty() && seen.insert(candidate.clone())) - .collect() +} + +fn push_candidate(candidates: &mut Vec, candidate: &str) { + if candidate.is_empty() || candidates.iter().any(|existing| existing == candidate) { + return; + } + candidates.push(candidate.to_owned()); } fn is_safe_theme_name(value: &str) -> bool { diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icons/state.rs similarity index 53% rename from crates/unixnotis-popups/src/ui/icon_state.rs rename to crates/unixnotis-popups/src/ui/icons/state.rs index f1847a33b..b84c36d8f 100644 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ b/crates/unixnotis-popups/src/ui/icons/state.rs @@ -3,6 +3,7 @@ //! Keeps icon decoding, caching, and texture reuse isolated from UI state handling use std::path::PathBuf; +use std::rc::Rc; use std::time::{Duration, Instant}; use gtk::glib::object::Cast; @@ -11,12 +12,12 @@ use gtk::{gdk, glib}; use tracing::debug; use unixnotis_core::NotificationView; -use super::icons::{ +use super::super::state::{IconCacheEntry, IconResolutionKey}; +use super::super::UiState; +use super::{ collect_icon_candidates, file_path_from_hint, image_data_texture, image_data_texture_for_data, - resolve_icon_image, IconDecodePool, IconDecodeResult, + IconDecodePool, IconDecodeResult, TextureCache, ThemeIconCache, }; -use super::state::IconCacheEntry; -use super::UiState; const ICON_CACHE_MAX_ENTRIES: usize = 256; // Skip caching decoded textures above this size to avoid holding large buffers @@ -29,7 +30,7 @@ const POPUP_APPLICATION_VISUAL_SIZE: i32 = 38; const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); impl UiState { - pub(super) fn build_conversation_avatar_widget( + pub(in crate::ui) fn build_conversation_avatar_widget( notification: &NotificationView, size: i32, ) -> Option { @@ -48,7 +49,7 @@ impl UiState { Some(widget) } - pub(super) fn build_sender_visual_widget( + pub(in crate::ui) fn build_sender_visual_widget( notification: &NotificationView, ) -> Option { if notification.image.sender_visual_role @@ -63,7 +64,7 @@ impl UiState { Some(widget) } - pub(super) fn build_content_image_widget( + pub(in crate::ui) fn build_content_image_widget( notification: &NotificationView, ) -> Option { if let Some(texture) = image_data_texture(¬ification.image) { @@ -75,20 +76,27 @@ impl UiState { None } - pub(super) fn build_app_icon_widget( + pub(in crate::ui) fn build_app_icon_widget( &mut self, notification: &NotificationView, size: i32, ) -> Option { self.refresh_icon_sources_if_needed(); // Caller image hints are content, so the header resolves only authenticated badge inputs - let cache_key = format!( - "{}|{}", - notification.app_name, notification.attribution.badge_icon - ); + let cache_key = IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + }; if let Some(cached) = self.icon_cache.get(&cache_key) { - if let Some(icon_name) = &cached.resolved { - return self.resolve_icon_widget(icon_name, size); + if let Some(icon_name) = cached.resolved.as_deref() { + return resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + icon_name, + size, + ); } if negative_cache_is_fresh(cached.cached_at, Instant::now()) { return None; @@ -105,7 +113,12 @@ impl UiState { for candidate in &candidates { if let Some(icon_names) = self.desktop_icons.icons_for(candidate) { for icon_name in icon_names { - if let Some(widget) = self.resolve_icon_widget(icon_name.as_str(), size) { + if let Some(widget) = resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + icon_name.as_str(), + size, + ) { resolved = Some((icon_name, widget)); break; } @@ -118,7 +131,12 @@ impl UiState { if resolved.is_none() { for candidate in candidates { - if let Some(widget) = self.resolve_icon_widget(&candidate, size) { + if let Some(widget) = resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + &candidate, + size, + ) { resolved = Some((candidate, widget)); break; } @@ -134,7 +152,11 @@ impl UiState { } } - fn cache_icon(&mut self, cache_key: String, resolved: Option) { + pub(in crate::ui) fn cache_icon( + &mut self, + cache_key: IconResolutionKey, + resolved: Option, + ) { let cached = IconCacheEntry { resolved, cached_at: Instant::now(), @@ -151,88 +173,105 @@ impl UiState { self.icon_cache_order.push_back(key); } } - while self.icon_cache_order.len() > ICON_CACHE_MAX_ENTRIES { + let excess_entries = self + .icon_cache_order + .len() + .saturating_sub(ICON_CACHE_MAX_ENTRIES); + for _ in 0..excess_entries { if let Some(evicted) = self.icon_cache_order.pop_front() { self.icon_cache.remove(&evicted); } } } - pub(super) fn invalidate_icon_sources(&mut self) { - // Positive names remain useful while misses must be retried against the rebuilt index + pub(in crate::ui) fn invalidate_icon_sources(&mut self) { + // Rebuild both lookup layers so changed desktop entries are resolved again + self.icon_source_generation = self.icon_source_generation.wrapping_add(1); self.desktop_icons.rebuild(); - self.icon_cache.retain(|_, entry| entry.resolved.is_some()); - self.icon_cache_order - .retain(|key| self.icon_cache.contains_key(key)); + self.icon_cache.clear(); + self.icon_cache_order.clear(); + self.theme_icon_cache.clear(); + self.icon_texture_cache.borrow_mut().clear(); self.icon_sources_dirty.set(false); } - fn refresh_icon_sources_if_needed(&mut self) { + pub(in crate::ui) fn refresh_icon_sources_if_needed(&mut self) { if self.icon_sources_dirty.replace(false) { self.invalidate_icon_sources(); } } +} - fn resolve_icon_widget(&self, name: &str, size: i32) -> Option { - if let Some(file_path) = file_path_from_hint(name) { - // Decoded file:// paths allow loading icon files with escaped characters - if file_path.is_file() { - // Reuse a cached texture when available to avoid repeated decode work - if let Some(texture) = self.icon_texture_cache.borrow_mut().get(&file_path, size) { - let widget = gtk::Image::new(); - widget.set_paintable(Some(&texture)); - set_popup_icon_size(&widget, size); - return Some(widget); - } - return Some(self.spawn_file_icon(file_path, size)); +fn resolve_icon_widget( + theme_icon_cache: &mut ThemeIconCache, + icon_texture_cache: &Rc>, + name: &str, + size: i32, +) -> Option { + if let Some(file_path) = file_path_from_hint(name) { + // Decoded file:// paths allow loading icon files with escaped characters + if file_path.is_file() { + // Reuse a cached texture when available to avoid repeated decode work + if let Some(texture) = icon_texture_cache.borrow_mut().get(&file_path, size) { + let widget = gtk::Image::new(); + widget.set_paintable(Some(&texture)); + set_popup_icon_size(&widget, size); + return Some(widget); } + return Some(spawn_file_icon(icon_texture_cache, file_path, size)); } - let widget = resolve_icon_image(name, size)?; - set_popup_icon_size(&widget, size); - Some(widget) } + // Keep the existing lookup scale so caching does not change rendered icon selection + let paintable = theme_icon_cache.get_or_resolve(name, size, 1)?; + let widget = gtk::Image::from_paintable(Some(&paintable)); + set_popup_icon_size(&widget, size); + Some(widget) +} - fn spawn_file_icon(&self, path: PathBuf, size: i32) -> gtk::Image { - let widget = gtk::Image::new(); - set_popup_icon_size(&widget, size); - let (tx, rx) = async_channel::bounded::(1); - let widget_clone = widget.clone(); - let cache = self.icon_texture_cache.clone(); - let path_clone = path.clone(); - let target_size = size.max(1); - // Apply the texture on the main loop to avoid GTK thread violations - glib::MainContext::default().spawn_local(async move { - if let Ok(result) = rx.recv().await { - match result { - Ok(icon) => { - let bytes = glib::Bytes::from(&icon.bytes); - let texture = gdk::MemoryTexture::new( - icon.width, - icon.height, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - icon.stride as usize, - ) - .upcast::(); - widget_clone.set_paintable(Some(&texture)); - set_popup_icon_size(&widget_clone, target_size); - // Cache only modestly sized textures to limit resident memory - if icon.bytes.len() <= ICON_TEXTURE_CACHE_MAX_BYTES { - cache.borrow_mut().insert(path_clone, target_size, texture); - } - } - Err(err) => { - debug!(?err, "popup icon decode failed"); +fn spawn_file_icon( + icon_texture_cache: &Rc>, + path: PathBuf, + size: i32, +) -> gtk::Image { + let widget = gtk::Image::new(); + set_popup_icon_size(&widget, size); + let (tx, rx) = async_channel::bounded::(1); + let widget_clone = widget.clone(); + let cache = Rc::clone(icon_texture_cache); + let path_clone = path.clone(); + let target_size = size.max(1); + // Apply the texture on the main loop to avoid GTK thread violations + glib::MainContext::default().spawn_local(async move { + if let Ok(result) = rx.recv().await { + match result { + Ok(icon) => { + let bytes = glib::Bytes::from(&icon.bytes); + let texture = gdk::MemoryTexture::new( + icon.width, + icon.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + icon.stride as usize, + ) + .upcast::(); + widget_clone.set_paintable(Some(&texture)); + set_popup_icon_size(&widget_clone, target_size); + // Cache only modestly sized textures to limit resident memory + if icon.bytes.len() <= ICON_TEXTURE_CACHE_MAX_BYTES { + cache.borrow_mut().insert(path_clone, target_size, texture); } } + Err(err) => { + debug!(?err, "popup icon decode failed"); + } } - }); + } + }); - // Decode on a background worker pool to avoid spawning unbounded threads - IconDecodePool::global().submit(path, target_size, tx); + // Decode on a background worker pool to avoid spawning unbounded threads + IconDecodePool::global().submit(path, target_size, tx); - widget - } + widget } fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { @@ -240,7 +279,7 @@ fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { } #[cfg(test)] -#[path = "tests/icon_state.rs"] +#[path = "tests/state.rs"] mod tests; fn set_popup_icon_size(widget: >k::Image, size: i32) { diff --git a/crates/unixnotis-popups/src/ui/icons/tests/cache.rs b/crates/unixnotis-popups/src/ui/icons/tests/cache.rs index 7dabcc0ba..b57b2e3cb 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/cache.rs @@ -88,3 +88,19 @@ fn texture_cache_keeps_sizes_separate() { assert!(cache.get(&path, 20).is_some()); assert!(cache.get(&path, 32).is_some()); } + +#[gtk::test] +fn texture_cache_clear_discards_all_path_and_size_entries() { + let mut cache = TextureCache::new(4); + let path = PathBuf::from("icon-test.png"); + let bytes = glib::Bytes::from_owned(vec![255; 4]); + let texture = gdk::MemoryTexture::new(1, 1, gdk::MemoryFormat::R8g8b8a8, &bytes, 4) + .upcast::(); + + cache.insert(path.clone(), 20, texture.clone()); + cache.insert(path.clone(), 32, texture); + cache.clear(); + + assert!(cache.get(&path, 20).is_none()); + assert!(cache.get(&path, 32).is_none()); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index 043f38ec6..5494bd029 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -16,6 +16,23 @@ fn collect_icon_candidates_uses_only_daemon_associated_badge_variants() { ); } +#[test] +fn collect_icon_candidates_includes_a_distinct_desktop_id() { + let mut input = notification("UnixNotis Center", "trusted-badge"); + input.attribution.desktop_id = "org.demo.App.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates, + vec![ + "trusted-badge", + "org.demo.App.desktop", + "org.demo.app.desktop", + ] + ); +} + #[test] fn collect_icon_candidates_dedupes_empty_and_repeated_values() { let candidates = collect_icon_candidates(¬ification("App", "app")); diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs index 4c93f3a87..2b5eddbfa 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use gtk::gdk; use gtk::prelude::FileExt; -use super::super::{is_missing_icon, resolve_icon_image, resolve_icon_paintable}; +use super::super::{is_missing_icon, resolve_icon_paintable_with_scale}; fn available_theme_icon() -> Option<&'static str> { // The GTK test runtime initializes the display on its dedicated thread @@ -30,8 +30,7 @@ fn is_missing_icon_detects_theme_placeholder_stems_only() { #[test] fn resolve_icon_helpers_reject_empty_icon_names() { - assert!(resolve_icon_paintable("", 24).is_none()); - assert!(resolve_icon_image("", 24).is_none()); + assert!(resolve_icon_paintable_with_scale("", 24, 1).is_none()); } #[gtk::test] @@ -41,7 +40,8 @@ fn resolve_icon_image_uses_theme_icon_and_sets_requested_size() { return; }; - let paintable = resolve_icon_paintable(icon_name, 24).expect("theme icon paintable"); + let paintable = + resolve_icon_paintable_with_scale(icon_name, 24, 1).expect("theme icon paintable"); assert!(!is_missing_icon( &paintable .file() @@ -49,7 +49,8 @@ fn resolve_icon_image_uses_theme_icon_and_sets_requested_size() { .unwrap_or_else(|| PathBuf::from(icon_name)) )); - let image = resolve_icon_image(icon_name, 24).expect("theme icon image"); + let image = gtk::Image::from_paintable(Some(&paintable)); + image.set_pixel_size(24); assert_eq!(image.pixel_size(), 24); } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/state.rs b/crates/unixnotis-popups/src/ui/icons/tests/state.rs new file mode 100644 index 000000000..24d232282 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/state.rs @@ -0,0 +1,219 @@ +use super::*; +use image::{ImageBuffer, ImageFormat, Rgba}; +use std::cell::RefCell; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; +use unixnotis_core::{Config, ThemePaths}; +use unixnotis_ui::css::CssManager; + +use crate::ui::state::UiState; + +#[test] +fn negative_icon_cache_expires_at_the_ttl_boundary() { + let now = Instant::now(); + + let fresh = now + .checked_sub(Duration::from_secs(14)) + .expect("fresh timestamp should remain representable"); + let expired = now + .checked_sub(NEGATIVE_ICON_CACHE_TTL) + .expect("expired timestamp should remain representable"); + + assert!(negative_cache_is_fresh(fresh, now)); + assert!(!negative_cache_is_fresh(expired, now)); +} + +#[test] +fn negative_icon_cache_handles_future_timestamp_without_panicking() { + let now = Instant::now(); + + assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); +} + +#[gtk::test] +fn expired_negative_cache_replaces_its_old_order_marker_once() { + let mut state = popup_state("org.unixnotis.PopupExpiredIconCache"); + let notification = icon_notification("dialog-information"); + let cache_key = icon_cache_key(¬ification); + + state.icon_cache.insert( + cache_key.clone(), + IconCacheEntry { + resolved: None, + cached_at: Instant::now() + .checked_sub(NEGATIVE_ICON_CACHE_TTL) + .expect("test timestamp should remain representable"), + }, + ); + state.icon_cache_order.push_back(cache_key.clone()); + + assert!(state.build_app_icon_widget(¬ification, 20).is_some()); + assert_eq!( + state + .icon_cache_order + .iter() + .filter(|key| *key == &cache_key) + .count(), + 1 + ); +} + +#[gtk::test] +fn icon_cache_evicts_only_after_the_configured_limit_is_exceeded() { + let mut state = popup_state("org.unixnotis.PopupIconCacheLimit"); + + for index in 0..ICON_CACHE_MAX_ENTRIES { + state.cache_icon( + test_cache_key(&format!("icon-{index}")), + Some("folder".to_string()), + ); + } + assert_eq!(state.icon_cache.len(), ICON_CACHE_MAX_ENTRIES); + assert!(state.icon_cache.contains_key(&test_cache_key("icon-0"))); + + state.cache_icon( + test_cache_key(&format!("icon-{ICON_CACHE_MAX_ENTRIES}")), + Some("folder".to_string()), + ); + assert_eq!(state.icon_cache.len(), ICON_CACHE_MAX_ENTRIES); + assert!(!state.icon_cache.contains_key(&test_cache_key("icon-0"))); +} + +#[gtk::test] +fn source_invalidation_discards_successful_resolved_icon_names() { + let mut state = popup_state("org.unixnotis.PopupIconSourceCacheClear"); + let notification = icon_notification("old-icon"); + let cache_key = icon_cache_key(¬ification); + + state.cache_icon(cache_key.clone(), Some("old-icon".to_string())); + state.icon_cache_order.push_back(cache_key.clone()); + state.icon_sources_dirty.set(true); + + state.invalidate_icon_sources(); + + assert!(!state.icon_cache.contains_key(&cache_key)); + assert!(state.icon_cache_order.is_empty()); +} + +#[test] +fn icon_resolution_key_includes_all_candidate_inputs() { + let mut first = icon_notification("badge"); + first.attribution.desktop_id = "org.example.First.desktop".to_string(); + first.image.claimed_theme_icon = "first-theme".to_string(); + let mut second = first.clone(); + second.attribution.desktop_id = "org.example.Second.desktop".to_string(); + second.image.claimed_theme_icon = "second-theme".to_string(); + + assert_ne!(icon_cache_key(&first), icon_cache_key(&second)); +} + +#[gtk::test] +fn file_icon_rows_keep_the_requested_size_and_cache_small_decodes() { + let path = test_image_path("spawn-file-icon"); + let image = ImageBuffer::, Vec>::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + image + .save_with_format(&path, ImageFormat::Png) + .expect("save icon fixture"); + + let texture_cache = Rc::new(RefCell::new(TextureCache::new_for_popups())); + let mut theme_cache = ThemeIconCache::new_for_popups(); + let widget = resolve_icon_widget( + &mut theme_cache, + &texture_cache, + path.to_str().expect("fixture path is utf8"), + 20, + ) + .expect("regular file icon should create a widget"); + assert_eq!(widget.pixel_size(), 20); + + for _ in 0..100 { + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + if texture_cache.borrow_mut().get(&path, 20).is_some() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(texture_cache.borrow_mut().get(&path, 20).is_some()); + let _ = fs::remove_file(path); +} + +fn popup_state(application_id: &str) -> UiState { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register icon state test application"); + + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-icon-state"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let theme_paths = ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + }; + let css = CssManager::new_popup(theme_paths, config.theme.clone()); + + UiState::new(&app, config, root.join("config.toml"), command_tx, css) +} + +fn icon_notification(icon_name: &str) -> unixnotis_core::NotificationView { + unixnotis_core::NotificationView { + id: 1, + generation: 1, + app_name: "Icon test".to_string(), + attribution: unixnotis_core::NotificationAttribution { + badge_icon: icon_name.to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + summary: "Icon test".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: unixnotis_core::NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn icon_cache_key(notification: &unixnotis_core::NotificationView) -> IconResolutionKey { + IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + } +} + +fn test_cache_key(name: &str) -> IconResolutionKey { + IconResolutionKey { + app_name: name.to_string(), + badge_icon: String::new(), + desktop_id: String::new(), + claimed_theme_icon: String::new(), + } +} + +fn test_image_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!( + "unixnotis-popups-{name}-{}-{nonce}.png", + std::process::id() + )) +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs new file mode 100644 index 000000000..a97dcf10c --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs @@ -0,0 +1,143 @@ +use super::super::ThemeIconCache; +use super::{ThemeIconCacheMap, ThemeIconKey}; + +#[test] +fn successful_theme_icon_lookup_is_reused_without_re_resolving() { + let mut cache = ThemeIconCacheMap::new(128); + let mut resolves = 0; + + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| { + resolves += 1; + Some(7_u8) + }), + Some(7) + ); + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| { + resolves += 1; + Some(8_u8) + }), + Some(7) + ); + + assert_eq!(resolves, 1); + assert_eq!(cache.entry_count(), 1); +} + +#[test] +fn a_miss_is_not_cached_and_can_be_retried_successfully() { + let mut cache = ThemeIconCacheMap::new(128); + let mut resolves = 0; + + assert_eq!( + cache.get_or_resolve_with("eventual-icon", 24, 1, |_, _, _| { + resolves += 1; + None:: + }), + None + ); + assert!(!cache.contains("eventual-icon", 24, 1)); + + assert_eq!( + cache.get_or_resolve_with("eventual-icon", 24, 1, |_, _, _| { + resolves += 1; + Some(9_u8) + }), + Some(9) + ); + + assert_eq!(resolves, 2); + assert_eq!(cache.entry_count(), 1); +} + +#[test] +fn scale_variants_have_independent_successful_entries() { + let mut cache = ThemeIconCacheMap::new(128); + + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, scale| Some(scale as u8)), + Some(1) + ); + assert_eq!( + cache.get_or_resolve_with("folder", 24, 2, |_, _, scale| Some(scale as u8)), + Some(2) + ); + + assert!(cache.contains("folder", 24, 1)); + assert!(cache.contains("folder", 24, 2)); + assert_eq!(cache.entry_count(), 2); +} + +#[test] +fn invalidation_discards_successful_paintables() { + let mut cache = ThemeIconCacheMap::new(128); + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| Some(1_u8)); + assert_eq!(cache.entry_count(), 1); + + cache.clear(); + + assert_eq!(cache.entry_count(), 0); + assert!(!cache.contains("folder", 24, 1)); +} + +#[test] +fn lru_promotion_keeps_the_recent_success_when_the_limit_is_reached() { + let mut cache = ThemeIconCacheMap::new(2); + + cache.get_or_resolve_with("first", 24, 1, |_, _, _| Some(1_u8)); + cache.get_or_resolve_with("second", 24, 1, |_, _, _| Some(2_u8)); + + // A successful hit moves the first entry behind the second entry + assert_eq!( + cache.get_or_resolve_with("first", 24, 1, |_, _, _| Some(10_u8)), + Some(1) + ); + cache.get_or_resolve_with("third", 24, 1, |_, _, _| Some(3_u8)); + + assert!(cache.contains("first", 24, 1)); + assert!(!cache.contains("second", 24, 1)); + assert!(cache.contains("third", 24, 1)); + assert_eq!(cache.entry_count(), 2); +} + +#[test] +fn failed_lookups_do_not_consume_lru_capacity() { + let mut cache = ThemeIconCacheMap::new(1); + + cache.get_or_resolve_with("missing", 24, 1, |_, _, _| None::); + cache.get_or_resolve_with("present", 24, 1, |_, _, _| Some(1_u8)); + + assert!(!cache.contains("missing", 24, 1)); + assert!(cache.contains("present", 24, 1)); + assert_eq!(cache.entry_count(), 1); +} + +#[test] +fn theme_icon_keys_match_name_size_and_scale_together() { + let key = ThemeIconKey::new("folder", 24, 1); + + assert!(key.matches("folder", 24, 1)); + assert!(!key.matches("dialog-information", 24, 1)); + assert!(!key.matches("folder", 32, 1)); + assert!(!key.matches("folder", 24, 2)); +} + +#[gtk::test] +fn production_theme_cache_clear_removes_successful_entries() { + let mut cache = ThemeIconCache::new_for_popups(); + let Some(_) = cache.get_or_resolve("folder", 24, 1) else { + return; + }; + + assert_eq!(cache.entries.entry_count(), 1); + cache.clear(); + assert_eq!(cache.entries.entry_count(), 0); +} + +#[gtk::test] +fn production_cache_rejects_empty_theme_names_without_creating_an_entry() { + let mut cache = ThemeIconCache::new_for_popups(); + + assert!(cache.get_or_resolve("", 24, 1).is_none()); +} diff --git a/crates/unixnotis-popups/src/ui/icons/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs new file mode 100644 index 000000000..94256f390 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs @@ -0,0 +1,172 @@ +//! Main-thread cache for themed popup icons + +use std::collections::{HashMap, VecDeque}; + +use gtk::IconPaintable; + +use super::resolver::resolve_icon_paintable_with_scale; + +const THEME_ICON_CACHE_MAX_ENTRIES: usize = 128; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ThemeIconSizeKey { + size: i32, + scale: i32, +} + +impl ThemeIconSizeKey { + const fn new(size: i32, scale: i32) -> Self { + Self { size, scale } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) struct ThemeIconKey { + name: String, + size: i32, + scale: i32, +} + +impl ThemeIconKey { + fn new(name: &str, size: i32, scale: i32) -> Self { + Self { + name: name.to_owned(), + size, + scale, + } + } + + const fn size_key(&self) -> ThemeIconSizeKey { + ThemeIconSizeKey::new(self.size, self.scale) + } + + fn matches(&self, name: &str, size: i32, scale: i32) -> bool { + self.size == size && self.scale == scale && self.name == name + } +} + +/// Cache storage is generic so miss and eviction behavior can be tested without +/// depending on the host icon theme +#[derive(Debug)] +struct ThemeIconCacheMap { + entries: HashMap>, + order: VecDeque, + max_entries: usize, +} + +impl ThemeIconCacheMap { + fn new(max_entries: usize) -> Self { + Self { + entries: HashMap::new(), + order: VecDeque::new(), + max_entries, + } + } + + fn get_or_resolve_with(&mut self, name: &str, size: i32, scale: i32, resolve: F) -> Option + where + F: FnOnce(&str, i32, i32) -> Option, + { + let size = size.max(1); + let scale = scale.max(1); + let size_key = ThemeIconSizeKey::new(size, scale); + + // Borrow the caller's name on a hit so no lookup key is allocated + if let Some(value) = self + .entries + .get(&size_key) + .and_then(|bucket| bucket.get(name)) + { + let value = value.clone(); + self.bump(name, size, scale); + return Some(value); + } + + // A miss is deliberately not inserted; the outer negative cache owns retry timing + let value = resolve(name, size, scale)?; + self.entries + .entry(size_key) + .or_default() + .insert(name.to_owned(), value.clone()); + self.order.push_back(ThemeIconKey::new(name, size, scale)); + self.enforce_limit(); + Some(value) + } + + fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + } + + #[cfg(test)] + fn entry_count(&self) -> usize { + self.entries.values().map(HashMap::len).sum() + } + + #[cfg(test)] + fn contains(&self, name: &str, size: i32, scale: i32) -> bool { + let size_key = ThemeIconSizeKey::new(size.max(1), scale.max(1)); + self.entries + .get(&size_key) + .is_some_and(|bucket| bucket.contains_key(name)) + } + + fn bump(&mut self, name: &str, size: i32, scale: i32) { + if let Some(position) = self + .order + .iter() + .position(|entry| entry.matches(name, size, scale)) + { + let key = self.order.remove(position).expect("position was checked"); + self.order.push_back(key); + } + } + + fn enforce_limit(&mut self) { + while self.order.len() > self.max_entries { + let Some(evicted) = self.order.pop_front() else { + break; + }; + let size_key = evicted.size_key(); + let Some(bucket) = self.entries.get_mut(&size_key) else { + continue; + }; + bucket.remove(&evicted.name); + if bucket.is_empty() { + self.entries.remove(&size_key); + } + } + } +} + +/// GTK objects stay on the GTK thread while repeated successful lookups are avoided +#[derive(Debug)] +pub(in crate::ui) struct ThemeIconCache { + entries: ThemeIconCacheMap, +} + +impl ThemeIconCache { + pub(in crate::ui) fn new_for_popups() -> Self { + Self { + entries: ThemeIconCacheMap::new(THEME_ICON_CACHE_MAX_ENTRIES), + } + } + + pub(in crate::ui) fn get_or_resolve( + &mut self, + name: &str, + size: i32, + scale: i32, + ) -> Option { + self.entries + .get_or_resolve_with(name, size, scale, resolve_icon_paintable_with_scale) + } + + pub(in crate::ui) fn clear(&mut self) { + self.entries.clear(); + } +} + +#[cfg(test)] +#[path = "tests/theme_cache.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/mod.rs b/crates/unixnotis-popups/src/ui/mod.rs index a0db07dbf..c45291502 100644 --- a/crates/unixnotis-popups/src/ui/mod.rs +++ b/crates/unixnotis-popups/src/ui/mod.rs @@ -3,7 +3,6 @@ mod config_reload; pub mod css_reload; mod entry; -mod icon_state; mod icons; mod popups; mod state; diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 34b372f78..3c0e5b649 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -27,6 +27,13 @@ pub(super) fn generation_matches(existing: Option, expected: u64) -> bool { existing.is_some_and(|generation| generation == expected) } +pub(super) fn popup_payload_is_unchanged( + existing: &NotificationView, + incoming: &NotificationView, +) -> bool { + existing == incoming +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct VisiblePopupUpdate { // True when stack order, materialization, or reveal state changed @@ -64,7 +71,10 @@ impl UiState { self.hidden_popups.retain(|hidden| hidden.id != id); // Hidden overflow rows stay as plain data until they can actually be shown - self.popups.insert(id, PopupEntry::queued(notification)); + self.popups.insert( + id, + PopupEntry::queued(notification, self.icon_source_generation), + ); self.popup_order.push_front(id); if refresh_visibility { self.update_popup_visibility(false); @@ -114,6 +124,40 @@ impl UiState { return false; } + if self.popups.get(&id).is_some_and(|entry| { + popup_can_skip_rebuild( + &entry.notification, + ¬ification, + entry.icon_source_generation, + self.icon_source_generation, + self.icon_sources_dirty.get(), + ) + }) { + // Duplicate payloads do not rebuild a GTK row, but they still repair + // the daemon acknowledgement if an earlier command was lost + let is_materialized = self + .popups + .get(&id) + .is_some_and(PopupEntry::is_materialized); + if is_materialized { + try_send_command( + &self.command_tx, + UiCommand::Materialized(notification.key()), + ); + } + if refresh_visibility { + // A queued duplicate may now enter the visible slice + self.update_popup_visibility(false); + } + debug!( + id, + generation = notification.generation, + materialized = is_materialized, + "unchanged popup update skipped with acknowledgement repair" + ); + return false; + } + if !self.popups.contains_key(&id) { // Same helper handles late updates for ids that were not present locally self.add_popup_internal(notification, refresh_visibility); @@ -133,6 +177,9 @@ impl UiState { if let Some(entry) = self.popups.get_mut(&id) { // Cached payload stays in sync with the rebuilt or queued row entry.notification = notification; + if !entry.is_materialized() { + entry.icon_source_generation = self.icon_source_generation; + } } if refresh_visibility { @@ -248,6 +295,7 @@ impl UiState { if let Some(entry) = self.popups.get_mut(&id) { entry.root = Some(new_root); + entry.icon_source_generation = self.icon_source_generation; } try_send_command( &self.command_tx, @@ -275,6 +323,7 @@ impl UiState { entry.revealer = built.revealer; entry.root = built.root; entry.visibility = built.visibility; + entry.icon_source_generation = built.icon_source_generation; } pub(super) fn dematerialize_popup(&mut self, id: u32) { @@ -302,6 +351,18 @@ impl UiState { } } +pub(super) fn popup_can_skip_rebuild( + existing: &NotificationView, + incoming: &NotificationView, + entry_icon_source_generation: u64, + icon_source_generation: u64, + icon_sources_dirty: bool, +) -> bool { + popup_payload_is_unchanged(existing, incoming) + && entry_icon_source_generation == icon_source_generation + && !icon_sources_dirty +} + #[cfg(test)] #[path = "tests/mutation.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/popups/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/reconcile.rs index a3389884b..3370f5c1c 100644 --- a/crates/unixnotis-popups/src/ui/popups/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/reconcile.rs @@ -9,6 +9,8 @@ use super::mutation::ReconcilePlan; impl UiState { pub(in super::super) fn reconcile_seed(&mut self, active: Vec) { + // Refresh source indexes before deciding which materialized rows need rebuilding + self.refresh_icon_sources_if_needed(); // Seed is a full snapshot, so desired popups come only from this list let desired = desired_seed_popups(active, &self.control_state) .into_iter() @@ -20,7 +22,27 @@ impl UiState { .iter() .map(|(id, entry)| (*id, &entry.notification)) .collect(); - let plan = build_reconcile_plan(&local, &self.popup_order, &desired); + let refresh_icons = desired.iter().any(|notification| { + self.popups.get(¬ification.id).is_some_and(|entry| { + entry.is_materialized() + && entry.icon_source_generation != self.icon_source_generation + }) + }); + let plan = build_reconcile_plan_with_icon_refresh( + &local, + &self.popup_order, + &desired, + refresh_icons, + ); + + // Queued rows have no GTK tree to rebuild, so advance them to the current source generation + for notification in &desired { + if let Some(entry) = self.popups.get_mut(¬ification.id) { + if !entry.is_materialized() { + entry.icon_source_generation = self.icon_source_generation; + } + } + } // Remove old ids first so inserts and updates work on the final set for id in plan.stale_ids { @@ -59,10 +81,11 @@ impl UiState { } } -pub(super) fn build_reconcile_plan( +fn build_reconcile_plan_with_icon_refresh( local: &HashMap, local_order: &VecDeque, desired: &[NotificationView], + refresh_icons: bool, ) -> ReconcilePlan where T: Borrow, @@ -88,7 +111,7 @@ where .iter() .filter(|notification| match local.get(¬ification.id) { // Identical rows can stay as they are while visibility fixes order later - Some(existing) => existing.borrow() != *notification, + Some(existing) => refresh_icons || existing.borrow() != *notification, // Missing rows must be inserted from seed None => true, }) diff --git a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs index 161893e90..a920c60c1 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs @@ -1,4 +1,8 @@ -use super::{generation_matches, incoming_generation_is_stale, VisiblePopupUpdate}; +use super::{ + generation_matches, incoming_generation_is_stale, popup_can_skip_rebuild, + popup_payload_is_unchanged, VisiblePopupUpdate, +}; +use unixnotis_core::{Action, NotificationImage, NotificationView}; #[test] fn visible_update_starts_without_stack_changes() { @@ -21,3 +25,81 @@ fn popup_close_matches_only_the_exact_generation() { assert!(!generation_matches(Some(8), 7)); assert!(!generation_matches(None, 8)); } + +#[test] +fn identical_same_generation_payloads_do_not_need_a_row_rebuild() { + let notification = NotificationView { + id: 7, + generation: 3, + app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + assert!(popup_payload_is_unchanged( + ¬ification, + ¬ification.clone() + )); + + let mut changed = notification.clone(); + changed.summary = "Changed".to_string(); + assert!(!popup_payload_is_unchanged(¬ification, &changed)); +} + +#[test] +fn identical_payloads_require_rebuild_when_icon_sources_are_stale() { + let notification = NotificationView { + id: 9, + generation: 4, + app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + assert!(!popup_can_skip_rebuild( + ¬ification, + ¬ification, + 2, + 3, + false, + )); + assert!(!popup_can_skip_rebuild( + ¬ification, + ¬ification, + 3, + 3, + true, + )); + assert!(popup_can_skip_rebuild( + ¬ification, + ¬ification, + 3, + 3, + false, + )); +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 0437c47e2..51457c718 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -1,7 +1,12 @@ use std::collections::{HashMap, VecDeque}; -use super::{build_reconcile_plan, desired_seed_popups}; -use unixnotis_core::{Action, ControlState, NotificationImage, NotificationView, Urgency}; +use super::{build_reconcile_plan_with_icon_refresh, desired_seed_popups}; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{ + Action, Config, ControlState, NotificationImage, NotificationView, ThemePaths, Urgency, +}; +use unixnotis_ui::css::CssManager; fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { NotificationView { @@ -76,7 +81,7 @@ fn reconcile_plan_removes_missing_rows_and_updates_changed_payloads() { let local_order = VecDeque::from([7, 5]); let desired = vec![make_view(5, Urgency::Normal, "new")]; - let plan = build_reconcile_plan(&local, &local_order, &desired); + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, false); assert_eq!(plan.stale_ids, vec![7]); assert_eq!(plan.updates.len(), 1); @@ -91,9 +96,158 @@ fn reconcile_plan_preserves_unchanged_rows_without_rebuild() { let local_order = VecDeque::from([1]); let desired = vec![make_view(1, Urgency::Normal, "keep")]; - let plan = build_reconcile_plan(&local, &local_order, &desired); + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, false); assert!(plan.stale_ids.is_empty()); assert!(plan.updates.is_empty()); assert_eq!(plan.desired_order, VecDeque::from([1])); } + +#[test] +fn reconcile_plan_refreshes_unchanged_rows_when_icon_sources_changed() { + let mut local = HashMap::new(); + local.insert(1, make_view(1, Urgency::Normal, "keep")); + let local_order = VecDeque::from([1]); + let desired = vec![make_view(1, Urgency::Normal, "keep")]; + + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, true); + + assert_eq!(plan.updates.len(), 1); + assert_eq!(plan.updates[0].id, 1); +} + +#[gtk::test] +fn reconcile_seed_rebuilds_an_unchanged_row_after_icon_source_invalidation() { + let mut state = popup_state("org.unixnotis.PopupReconcileIconSources"); + let notification = make_view(30, Urgency::Normal, "unchanged"); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("seed fixture should materialize a visible row"); + + state.icon_source_generation = 1; + state.reconcile_seed(vec![notification]); + + let new_root = state + .popups + .get(&30) + .and_then(|entry| entry.root.clone()) + .expect("reconciled row should remain materialized"); + assert_ne!(old_root, new_root); +} + +#[gtk::test] +fn reconcile_seed_refreshes_unchanged_rows_when_sources_are_dirty() { + let mut state = popup_state("org.unixnotis.PopupReconcileDirtySources"); + let notification = make_view(31, Urgency::Normal, "unchanged"); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("seed fixture should materialize a visible row"); + + state.icon_sources_dirty.set(true); + state.reconcile_seed(vec![notification]); + + let new_root = state + .popups + .get(&31) + .and_then(|entry| entry.root.clone()) + .expect("reconciled row should remain materialized"); + assert_ne!(old_root, new_root); +} + +#[gtk::test] +fn reconcile_seed_refreshes_visible_rows_once_and_advances_queued_rows() { + let mut state = popup_state("org.unixnotis.PopupReconcileQueuedIconSources"); + let visible = make_view(40, Urgency::Normal, "visible"); + let queued = make_view(41, Urgency::Normal, "queued"); + + state.add_popup(visible.clone()); + state.add_popup(queued.clone()); + + let visible_id = state + .popups + .iter() + .find_map(|(id, entry)| entry.is_materialized().then_some(*id)) + .expect("one row should be materialized"); + let queued_id = state + .popups + .iter() + .find_map(|(id, entry)| (!entry.is_materialized()).then_some(*id)) + .expect("one row should remain queued"); + let old_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("visible row should have a root"); + let seed = state + .popup_order + .iter() + .map(|id| { + state + .popups + .get(id) + .expect("seed row should exist") + .notification + .clone() + }) + .collect::>(); + + state.icon_sources_dirty.set(true); + state.reconcile_seed(seed.clone()); + + let refreshed_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("refreshed row should have a root"); + assert_ne!(old_root, refreshed_root); + assert_eq!( + state + .popups + .get(&queued_id) + .expect("queued row should remain") + .icon_source_generation, + state.icon_source_generation + ); + + state.reconcile_seed(seed); + + let second_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("visible row should remain materialized"); + assert_eq!(refreshed_root, second_root); +} + +fn popup_state(application_id: &str) -> UiState { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register reconcile test application"); + + let mut config = Config::default(); + config.popups.max_visible = 1; + let root = std::env::temp_dir().join("unixnotis-popup-reconcile"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(16); + let theme_paths = ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + }; + let css = CssManager::new_popup(theme_paths, config.theme.clone()); + + UiState::new(&app, config, root.join("config.toml"), command_tx, css) +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs b/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs index 41d57380c..77a61eebc 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs @@ -1,4 +1,9 @@ -use super::{visible_popup_restack_ids, visible_popup_target}; +use gtk::prelude::*; + +use super::{ + needs_input_region_refresh, set_window_visible_if_changed, visible_popup_restack_ids, + visible_popup_target, +}; #[test] fn visible_target_stays_within_popup_and_runtime_limits() { @@ -7,6 +12,30 @@ fn visible_target_stays_within_popup_and_runtime_limits() { assert_eq!(visible_popup_target(0, 3), 0); } +#[test] +fn input_region_refreshes_when_any_popup_state_changed() { + assert!(!needs_input_region_refresh(false, false, false)); + assert!(needs_input_region_refresh(true, false, false)); + assert!(needs_input_region_refresh(false, true, false)); + assert!(needs_input_region_refresh(false, false, true)); +} + +#[gtk::test] +fn window_visibility_updates_only_when_state_changes() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupVisibilityCache") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register visibility test application"); + let window = gtk::ApplicationWindow::new(&app); + + assert!(!set_window_visible_if_changed(&window, false)); + assert!(set_window_visible_if_changed(&window, true)); + assert!(!set_window_visible_if_changed(&window, true)); + assert!(set_window_visible_if_changed(&window, false)); +} + #[test] fn stable_visible_order_requires_no_restack() { assert!(visible_popup_restack_ids(&[9, 8, 7], &[9, 8, 7]).is_empty()); diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index e11c03167..41409589c 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -18,9 +18,13 @@ impl UiState { // Max-visible of zero disables popups entirely if max_visible == 0 { let update = self.apply_visible_popups(Vec::new()); - self.popup_window.set_visible(false); + let window_changed = set_window_visible_if_changed(&self.popup_window, false); // Keep input region empty when popups are disabled - if force_region_refresh || update.stack_changed { + if needs_input_region_refresh( + force_region_refresh, + update.stack_changed, + window_changed, + ) { refresh_popup_input_region( &self.popup_window, &self.popup_stack, @@ -41,9 +45,9 @@ impl UiState { let update = self.apply_visible_popups(desired_visible); // Window visibility follows the rows GTK actually represents, not just the // logical popup order that was requested upstream - self.popup_window - .set_visible(!self.visible_popups.is_empty()); - if force_region_refresh || update.stack_changed { + let window_changed = + set_window_visible_if_changed(&self.popup_window, !self.visible_popups.is_empty()); + if needs_input_region_refresh(force_region_refresh, update.stack_changed, window_changed) { refresh_popup_input_region( &self.popup_window, &self.popup_stack, @@ -191,6 +195,22 @@ impl UiState { } } +fn set_window_visible_if_changed(window: >k::ApplicationWindow, visible: bool) -> bool { + if window.is_visible() == visible { + return false; + } + window.set_visible(visible); + true +} + +pub(super) const fn needs_input_region_refresh( + force_region_refresh: bool, + stack_changed: bool, + window_changed: bool, +) -> bool { + force_region_refresh || stack_changed || window_changed +} + pub(super) fn visible_popup_target(total_popups: usize, max_visible: usize) -> usize { // Visible slice can never exceed the number of known popups total_popups.min(max_visible) diff --git a/crates/unixnotis-popups/src/ui/state/constructor.rs b/crates/unixnotis-popups/src/ui/state/constructor.rs index 2143c157f..9f8f8babf 100644 --- a/crates/unixnotis-popups/src/ui/state/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/constructor.rs @@ -11,7 +11,7 @@ use unixnotis_ui::icons::DesktopIconIndex; use crate::dbus::UiCommand; -use super::super::icons::TextureCache; +use super::super::icons::{TextureCache, ThemeIconCache}; use super::super::window::build_popup_window; use super::model::UiState; @@ -54,11 +54,13 @@ impl UiState { control_state: ControlState::default(), desktop_icons: DesktopIconIndex::new(), icon_sources_dirty, + icon_source_generation: 0, _app_info_monitor: app_info_monitor, _icon_theme: icon_theme, icon_cache: HashMap::new(), icon_cache_order: VecDeque::new(), icon_texture_cache: Rc::new(RefCell::new(TextureCache::new_for_popups())), + theme_icon_cache: ThemeIconCache::new_for_popups(), } } diff --git a/crates/unixnotis-popups/src/ui/state/mod.rs b/crates/unixnotis-popups/src/ui/state/mod.rs index dd079ab95..cacc33fc1 100644 --- a/crates/unixnotis-popups/src/ui/state/mod.rs +++ b/crates/unixnotis-popups/src/ui/state/mod.rs @@ -4,8 +4,8 @@ mod constructor; mod events; mod model; -pub(super) use model::IconCacheEntry; pub use model::UiState; +pub(super) use model::{IconCacheEntry, IconResolutionKey}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-popups/src/ui/state/model.rs b/crates/unixnotis-popups/src/ui/state/model.rs index d202fd27d..9848c0170 100644 --- a/crates/unixnotis-popups/src/ui/state/model.rs +++ b/crates/unixnotis-popups/src/ui/state/model.rs @@ -13,7 +13,7 @@ use unixnotis_ui::icons::DesktopIconIndex; use crate::dbus::{UiCommand, UiEvent}; use super::super::entry::PopupEntry; -use super::super::icons::TextureCache; +use super::super::icons::{TextureCache, ThemeIconCache}; use super::super::window::PopupInputRegionState; /// Popup-only GTK state for notification toasts @@ -40,14 +40,27 @@ pub struct UiState { pub(in crate::ui) desktop_icons: DesktopIconIndex, // Monitors mark lookup state dirty without rebuilding inside callbacks pub(in crate::ui) icon_sources_dirty: Rc>, + // Each source invalidation advances the generation used by duplicate-update checks + pub(in crate::ui) icon_source_generation: u64, pub(in crate::ui) _app_info_monitor: gtk::gio::AppInfoMonitor, pub(in crate::ui) _icon_theme: Option, // Cache resolved icon names per app to reduce repeated theme lookups - pub(in crate::ui) icon_cache: HashMap, + pub(in crate::ui) icon_cache: HashMap, // FIFO order used to cap icon cache growth - pub(in crate::ui) icon_cache_order: VecDeque, + pub(in crate::ui) icon_cache_order: VecDeque, // Small LRU for decoded textures to avoid repeated PNG decode work pub(in crate::ui) icon_texture_cache: Rc>, + // Themed paintables stay on the GTK thread and are reused by repeated rows + pub(in crate::ui) theme_icon_cache: ThemeIconCache, +} + +/// Inputs that affect desktop and theme icon candidate resolution +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::ui) struct IconResolutionKey { + pub(in crate::ui) app_name: String, + pub(in crate::ui) badge_icon: String, + pub(in crate::ui) desktop_id: String, + pub(in crate::ui) claimed_theme_icon: String, } pub(in crate::ui) struct IconCacheEntry { diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index ff80bc9d9..4c20cab22 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -4,7 +4,7 @@ use unixnotis_core::{ }; use unixnotis_ui::css::CssManager; -use super::super::UiState; +use super::super::{IconCacheEntry, IconResolutionKey, UiState}; use super::support::theme_paths; use crate::dbus::UiEvent; @@ -83,6 +83,79 @@ fn popup_events_preserve_newest_generation_and_exact_close_identity() { assert!(!state.popups.contains_key(&7)); } +#[gtk::test] +fn identical_generation_updates_keep_the_existing_widget() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupDuplicateUpdate", 1); + let original = notification(11, 1, "unchanged"); + + state.add_popup(original.clone()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); + let original_root = state + .popups + .get(&original.id) + .and_then(|entry| entry.root.clone()) + .expect("original popup root"); + + let original_key = original.key(); + state.update_popup(original, true); + + let current_root = state + .popups + .get(&11) + .and_then(|entry| entry.root.clone()) + .expect("unchanged popup root"); + assert_eq!(current_root, original_root); + match command_rx + .try_recv() + .expect("duplicate update should repair materialization acknowledgement") + { + crate::dbus::UiCommand::Materialized(key) => assert_eq!(key, original_key), + command => panic!("unexpected duplicate-update command: {command:?}"), + } + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn queued_duplicate_update_can_materialize_without_rebuilding_payload() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupQueuedDuplicate", 0); + let original = notification(12, 1, "queued"); + + state.add_popup(original.clone()); + assert!(state + .popups + .get(&original.id) + .is_some_and(|entry| !entry.is_materialized())); + assert!(command_rx.try_recv().is_err()); + + // A later visibility change makes the existing queued payload eligible + state.config.popups.max_visible = 1; + state.update_popup(original.clone(), true); + + assert!(state + .popups + .get(&original.id) + .is_some_and(super::super::super::entry::PopupEntry::is_materialized)); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); +} + +#[gtk::test] +fn popup_visibility_tracks_the_materialized_visible_slice() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupVisibilityState", 1); + state.update_popup_visibility(false); + assert!(!state.popup_window.is_visible()); + + let notification = notification(13, 1, "visible"); + state.add_popup(notification.clone()); + assert_materialized_and_visible_commands(&mut command_rx, notification.key()); + assert!(state.popup_window.is_visible()); + + state.remove_popup_if_generation(notification.key()); + assert!(!state.popup_window.is_visible()); +} + #[gtk::test] fn popup_image_builders_distinguish_content_badges_and_missing_sources() { let mut state = popup_state("org.unixnotis.PopupMutationImages"); @@ -124,7 +197,10 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { // A daemon-selected badge remains independent from caller image content missing_content.attribution.badge_icon = "dialog-information".to_string(); - assert!(state.build_app_icon_widget(&missing_content, 20).is_some()); + let badge = state + .build_app_icon_widget(&missing_content, 20) + .expect("known themed badge"); + assert!(badge.paintable().is_some()); let mut decorative = notification(10, 1, "decorative"); decorative.image.sender_visual_role = @@ -149,6 +225,74 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { )); } +#[gtk::test] +fn icon_source_invalidation_clears_the_dirty_marker() { + let mut state = popup_state("org.unixnotis.PopupIconInvalidation"); + state.icon_sources_dirty.set(true); + + state.invalidate_icon_sources(); + + assert!(!state.icon_sources_dirty.get()); +} + +#[gtk::test] +fn identical_update_rebuilds_a_row_after_icon_source_invalidation() { + let (mut state, _command_rx) = + popup_state_with_commands("org.unixnotis.PopupIconSourceGeneration", 1); + let mut notification = notification(32, 1, "icon source changed"); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Example", + "", + "", + "eventual-icon", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "", + "test:icon-source-generation".to_string(), + ); + + // Start with a deterministic miss so the test models a package or theme icon appearing later + assert!(state.build_app_icon_widget(¬ification, 20).is_none()); + let cache_key = IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + }; + assert!(state + .icon_cache + .get(&cache_key) + .is_some_and(|entry| entry.resolved.is_none())); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("initial popup root"); + + // Simulate the icon monitor seeing the newly installed themed icon + state.icon_cache.insert( + cache_key.clone(), + IconCacheEntry { + resolved: Some("dialog-information".to_string()), + cached_at: std::time::Instant::now(), + }, + ); + state.icon_sources_dirty.set(true); + state.update_popup(notification.clone(), true); + + let entry = state + .popups + .get(¬ification.id) + .expect("popup should remain active"); + assert_ne!(entry.root.as_ref(), Some(&old_root)); + assert_eq!(entry.icon_source_generation, 1); + assert!(state + .icon_cache + .get(&cache_key) + .is_some_and(|cached| { cached.resolved.is_none() })); +} + #[gtk::test] fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { let mut state = popup_state("org.unixnotis.PopupWidgetTree"); @@ -201,7 +345,7 @@ fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { #[gtk::test] fn popup_display_timeout_hides_only_the_local_banner_generation() { - let (mut state, _command_rx) = popup_state_with_commands("org.unixnotis.PopupLocalHide", 1); + let (mut state, mut command_rx) = popup_state_with_commands("org.unixnotis.PopupLocalHide", 1); state.config.popups.default_timeout_ms = 1; let (event_tx, event_rx) = async_channel::bounded(2); state.set_popup_event_sender(event_tx); @@ -210,6 +354,7 @@ fn popup_display_timeout_hides_only_the_local_banner_generation() { state.handle_event(UiEvent::NotificationAdded(first.clone(), true)); assert!(state.popups.contains_key(&first.id)); + assert_materialized_and_visible_commands(&mut command_rx, first.key()); std::thread::sleep(std::time::Duration::from_millis(15)); while gtk::glib::MainContext::default().pending() { @@ -228,6 +373,10 @@ fn popup_display_timeout_hides_only_the_local_banner_generation() { // An update for the same live generation must not resurrect its banner state.handle_event(UiEvent::NotificationUpdated(first.clone(), true)); assert!(!state.popups.contains_key(&first.id)); + assert!( + command_rx.try_recv().is_err(), + "hidden generations must not send a fresh materialization acknowledgement" + ); // Closing the active record also releases the local hidden-banner marker state.handle_event(UiEvent::NotificationClosed( diff --git a/crates/unixnotis-popups/src/ui/tests/icon_state.rs b/crates/unixnotis-popups/src/ui/tests/icon_state.rs deleted file mode 100644 index 877fb2188..000000000 --- a/crates/unixnotis-popups/src/ui/tests/icon_state.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::*; - -#[test] -fn negative_icon_cache_expires_at_the_ttl_boundary() { - let now = Instant::now(); - - let fresh = now - .checked_sub(Duration::from_secs(14)) - .expect("fresh timestamp should remain representable"); - let expired = now - .checked_sub(NEGATIVE_ICON_CACHE_TTL) - .expect("expired timestamp should remain representable"); - - assert!(negative_cache_is_fresh(fresh, now)); - assert!(!negative_cache_is_fresh(expired, now)); -} - -#[test] -fn negative_icon_cache_handles_future_timestamp_without_panicking() { - let now = Instant::now(); - - assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); -} From 4d23cc67c6dabfa8c6f1e4fe9a4150a5c6381ab6 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 4 Aug 2026 22:44:24 -0500 Subject: [PATCH 245/275] test(popups): keep test helpers under mirrored test paths Move cache inspection helpers out of production code so the test-placement policy can enforce a clean source boundary. Remove redundant clones from popup reconciliation fixtures while preserving the same test coverage. --- .../src/ui/icons/tests/theme_cache.rs | 52 ++++++++++++------- .../src/ui/icons/theme_cache.rs | 13 ----- .../src/ui/popups/tests/reconcile.rs | 4 +- .../src/ui/state/tests/mutation.rs | 4 +- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs index a97dcf10c..9a05d6eae 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs @@ -1,6 +1,22 @@ use super::super::ThemeIconCache; use super::{ThemeIconCacheMap, ThemeIconKey}; +fn entry_count(cache: &ThemeIconCacheMap) -> usize { + cache + .entries + .values() + .map(std::collections::HashMap::len) + .sum() +} + +fn contains(cache: &ThemeIconCacheMap, name: &str, size: i32, scale: i32) -> bool { + let size_key = super::ThemeIconSizeKey::new(size.max(1), scale.max(1)); + cache + .entries + .get(&size_key) + .is_some_and(|bucket| bucket.contains_key(name)) +} + #[test] fn successful_theme_icon_lookup_is_reused_without_re_resolving() { let mut cache = ThemeIconCacheMap::new(128); @@ -22,7 +38,7 @@ fn successful_theme_icon_lookup_is_reused_without_re_resolving() { ); assert_eq!(resolves, 1); - assert_eq!(cache.entry_count(), 1); + assert_eq!(entry_count(&cache), 1); } #[test] @@ -37,7 +53,7 @@ fn a_miss_is_not_cached_and_can_be_retried_successfully() { }), None ); - assert!(!cache.contains("eventual-icon", 24, 1)); + assert!(!contains(&cache, "eventual-icon", 24, 1)); assert_eq!( cache.get_or_resolve_with("eventual-icon", 24, 1, |_, _, _| { @@ -48,7 +64,7 @@ fn a_miss_is_not_cached_and_can_be_retried_successfully() { ); assert_eq!(resolves, 2); - assert_eq!(cache.entry_count(), 1); + assert_eq!(entry_count(&cache), 1); } #[test] @@ -64,21 +80,21 @@ fn scale_variants_have_independent_successful_entries() { Some(2) ); - assert!(cache.contains("folder", 24, 1)); - assert!(cache.contains("folder", 24, 2)); - assert_eq!(cache.entry_count(), 2); + assert!(contains(&cache, "folder", 24, 1)); + assert!(contains(&cache, "folder", 24, 2)); + assert_eq!(entry_count(&cache), 2); } #[test] fn invalidation_discards_successful_paintables() { let mut cache = ThemeIconCacheMap::new(128); cache.get_or_resolve_with("folder", 24, 1, |_, _, _| Some(1_u8)); - assert_eq!(cache.entry_count(), 1); + assert_eq!(entry_count(&cache), 1); cache.clear(); - assert_eq!(cache.entry_count(), 0); - assert!(!cache.contains("folder", 24, 1)); + assert_eq!(entry_count(&cache), 0); + assert!(!contains(&cache, "folder", 24, 1)); } #[test] @@ -95,10 +111,10 @@ fn lru_promotion_keeps_the_recent_success_when_the_limit_is_reached() { ); cache.get_or_resolve_with("third", 24, 1, |_, _, _| Some(3_u8)); - assert!(cache.contains("first", 24, 1)); - assert!(!cache.contains("second", 24, 1)); - assert!(cache.contains("third", 24, 1)); - assert_eq!(cache.entry_count(), 2); + assert!(contains(&cache, "first", 24, 1)); + assert!(!contains(&cache, "second", 24, 1)); + assert!(contains(&cache, "third", 24, 1)); + assert_eq!(entry_count(&cache), 2); } #[test] @@ -108,9 +124,9 @@ fn failed_lookups_do_not_consume_lru_capacity() { cache.get_or_resolve_with("missing", 24, 1, |_, _, _| None::); cache.get_or_resolve_with("present", 24, 1, |_, _, _| Some(1_u8)); - assert!(!cache.contains("missing", 24, 1)); - assert!(cache.contains("present", 24, 1)); - assert_eq!(cache.entry_count(), 1); + assert!(!contains(&cache, "missing", 24, 1)); + assert!(contains(&cache, "present", 24, 1)); + assert_eq!(entry_count(&cache), 1); } #[test] @@ -130,9 +146,9 @@ fn production_theme_cache_clear_removes_successful_entries() { return; }; - assert_eq!(cache.entries.entry_count(), 1); + assert_eq!(entry_count(&cache.entries), 1); cache.clear(); - assert_eq!(cache.entries.entry_count(), 0); + assert_eq!(entry_count(&cache.entries), 0); } #[gtk::test] diff --git a/crates/unixnotis-popups/src/ui/icons/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs index 94256f390..eae8a46a1 100644 --- a/crates/unixnotis-popups/src/ui/icons/theme_cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs @@ -98,19 +98,6 @@ impl ThemeIconCacheMap { self.order.clear(); } - #[cfg(test)] - fn entry_count(&self) -> usize { - self.entries.values().map(HashMap::len).sum() - } - - #[cfg(test)] - fn contains(&self, name: &str, size: i32, scale: i32) -> bool { - let size_key = ThemeIconSizeKey::new(size.max(1), scale.max(1)); - self.entries - .get(&size_key) - .is_some_and(|bucket| bucket.contains_key(name)) - } - fn bump(&mut self, name: &str, size: i32, scale: i32) { if let Some(position) = self .order diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 51457c718..754e0535f 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -168,8 +168,8 @@ fn reconcile_seed_refreshes_visible_rows_once_and_advances_queued_rows() { let visible = make_view(40, Urgency::Normal, "visible"); let queued = make_view(41, Urgency::Normal, "queued"); - state.add_popup(visible.clone()); - state.add_popup(queued.clone()); + state.add_popup(visible); + state.add_popup(queued); let visible_id = state .popups diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 4c20cab22..14858ac49 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -244,13 +244,13 @@ fn identical_update_rebuilds_a_row_after_icon_source_invalidation() { "Example", "", "", - "eventual-icon", + "", unixnotis_core::AttributionReason::ExactSystemExecutable, "", "test:icon-source-generation".to_string(), ); - // Start with a deterministic miss so the test models a package or theme icon appearing later + // Keep the miss independent of the host icon theme by providing no lookup candidates assert!(state.build_app_icon_widget(¬ification, 20).is_none()); let cache_key = IconResolutionKey { app_name: notification.app_name.clone(), From f67298c7fe1211b77579275d303c573dc9ef1e7e Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 20:01:57 -0500 Subject: [PATCH 246/275] security(core): harden filesystem operations against path races Quarantine exact-name filesystem entries in descriptor-pinned directories before removal or movement, then retain and revalidate object identity before the final pathname operation. Preserve inode metadata for regular-file moves and document the remaining same-UID pathname boundary. Add race regression coverage for replacement entries and symlink/file identity. --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/unixnotis-core/src/filesystem/mod.rs | 1 + .../src/filesystem/quarantine.rs | 203 ++++++++++++ .../unixnotis-core/src/filesystem/regular.rs | 19 +- .../unixnotis-core/src/filesystem/remove.rs | 300 ++++++++++++++---- .../unixnotis-core/src/filesystem/rename.rs | 189 +++++++++-- .../unixnotis-core/src/filesystem/symlink.rs | 52 ++- .../src/filesystem/tests/quarantine.rs | 62 ++++ .../src/filesystem/tests/remove.rs | 29 +- .../src/filesystem/tests/rename.rs | 31 +- 11 files changed, 795 insertions(+), 94 deletions(-) create mode 100644 crates/unixnotis-core/src/filesystem/quarantine.rs create mode 100644 crates/unixnotis-core/src/filesystem/tests/quarantine.rs diff --git a/Cargo.lock b/Cargo.lock index 2370bc3ab..465c1cb3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3702,6 +3702,7 @@ version = "1.2.0" dependencies = [ "anyhow", "crossterm", + "libc", "ratatui", "rustix", "semver", diff --git a/Cargo.toml b/Cargo.toml index bb6783f35..3654477eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ crossterm = "0.29" data-url = "0.3" unicode-width = "0.2.2" unicode-security = "0.1.2" -rustix = { version = "1.1", features = ["event", "fs", "process"] } +rustix = { version = "1.1", features = ["event", "fs", "process", "rand"] } resvg = { version = "0.47.0", default-features = false } semver = "1.0.28" shell-words = "1.1.1" diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index b9a1465a1..b707ed37d 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -6,6 +6,7 @@ mod directory; mod exact; mod install; mod path; +mod quarantine; mod regular; mod remove; mod rename; diff --git a/crates/unixnotis-core/src/filesystem/quarantine.rs b/crates/unixnotis-core/src/filesystem/quarantine.rs new file mode 100644 index 000000000..6b768cd01 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/quarantine.rs @@ -0,0 +1,203 @@ +//! Private same-filesystem quarantine directories for exact entry retirement +//! +//! The first rename is the security boundary for the visible source name. It moves the entry +//! out of the watched basename in one kernel operation, so a later replacement cannot be +//! mistaken for the original source entry +//! +//! The retained quarantine descriptor pins the directory used by later checks and operations. +//! It does not turn the final entry name into a file-descriptor operation: Linux unlinkat still +//! resolves the final entry by pathname. Mode 0700 therefore excludes other UIDs, while a +//! hostile process with the same UID still requires a trusted quarantine directory boundary + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io; +use std::os::fd::OwnedFd; + +use rustix::fs::{fchmod, mkdirat, renameat_with, unlinkat, AtFlags, Mode, RenameFlags}; +use rustix::rand::{getrandom, GetRandomFlags}; + +use super::descriptor::{open_directory_at, sync_directory}; + +const QUARANTINE_ATTEMPTS: usize = 16; +const RANDOM_BYTES: usize = 16; +const QUARANTINE_PREFIX: &str = ".unixnotis-quarantine."; +const ENTRY_PREFIX: &str = ".unixnotis-entry."; + +/// One private quarantine directory retained through a stable descriptor +pub(super) struct Quarantine { + name: OsString, + fd: OwnedFd, +} + +/// One entry moved into a retained quarantine directory +#[derive(Debug)] +pub(super) struct QuarantinedEntry { + name: OsString, +} + +impl Quarantine { + /// Create a mode-0700 directory beside the source entry + pub(super) fn create(parent_fd: &OwnedFd) -> io::Result { + // The quarantine must be beside the source so renameat can keep the original filesystem + // semantics and preserve the exact inode instead of falling back to a data copy + let candidates = random_names(QUARANTINE_PREFIX)?; + for name in candidates { + match mkdirat(parent_fd, &name, Mode::from_raw_mode(0o700)).map_err(io::Error::from) { + Ok(()) => { + // Restore exact permissions after umask processing before any entry is moved + let fd = match open_directory_at(parent_fd, &name) { + Ok(fd) => fd, + Err(error) => { + remove_created_directory(parent_fd, &name); + return Err(error); + } + }; + if let Err(error) = fchmod(&fd, Mode::from_raw_mode(0o700)) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error.into()); + } + if let Err(error) = sync_directory(&fd) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error); + } + if let Err(error) = sync_directory(parent_fd) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error); + } + // Keep this descriptor for the entire claim, validation, and cleanup flow + return Ok(Self { name, fd }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to create a private quarantine directory", + )) + } + + /// Atomically move one source basename into the private directory + pub(super) fn move_entry( + &self, + source_parent: &OwnedFd, + source_name: &OsString, + ) -> io::Result { + for name in random_names(ENTRY_PREFIX)? { + // RENAME_NOREPLACE prevents a pre-existing quarantine name from being overwritten + match renameat_with( + source_parent, + source_name, + &self.fd, + &name, + RenameFlags::NOREPLACE, + ) + .map_err(io::Error::from) + { + Ok(()) => { + // The source basename is now empty, so a watcher can only create a new entry + // there and cannot change the object that this quarantine entry represents + return Ok(QuarantinedEntry { name }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to reserve a quarantine entry name", + )) + } + + /// Restore a quarantined entry without replacing a new source entry + pub(super) fn restore( + &self, + entry: &QuarantinedEntry, + source_parent: &OwnedFd, + source_name: &OsString, + ) -> io::Result<()> { + // Never restore over a replacement that appeared at the original basename + renameat_with( + &self.fd, + &entry.name, + source_parent, + source_name, + RenameFlags::NOREPLACE, + ) + .map_err(io::Error::from) + } + + /// Remove one already-validated quarantined entry + pub(super) fn unlink(&self, entry: &QuarantinedEntry) -> io::Result<()> { + // The caller revalidates the entry against its retained descriptor immediately before + // this operation, which catches replacement objects during the normal claim flow + // unlinkat still resolves entry.name at this syscall; it has no unlink-by-FD mode + unlinkat(&self.fd, &entry.name, AtFlags::empty()).map_err(io::Error::from)?; + // Persist the removal while the quarantine descriptor still identifies its directory + sync_directory(&self.fd) + } + + /// Remove the quarantine directory when no mismatched entry was retained + pub(super) fn cleanup(self, parent_fd: &OwnedFd) -> io::Result<()> { + let Self { name, fd } = self; + // Directory removal is housekeeping after the claimed entry is gone, not the object claim + drop(fd); + match unlinkat(parent_fd, &name, AtFlags::REMOVEDIR).map_err(io::Error::from) { + Ok(()) => sync_directory(parent_fd), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => { + Err(io::Error::other("quarantine retained an unexpected entry")) + } + Err(error) => Err(error), + } + } + + /// Expose the retained descriptor to identity checks without exposing a pathname + pub(super) const fn fd(&self) -> &OwnedFd { + &self.fd + } +} + +fn remove_created_directory(parent_fd: &OwnedFd, name: &OsString) { + // Failed setup must not leave an unused staging directory behind + let _ = unlinkat(parent_fd, name, AtFlags::REMOVEDIR); + let _ = sync_directory(parent_fd); +} + +impl QuarantinedEntry { + /// Return the entry name relative to the retained quarantine descriptor + pub(super) const fn name(&self) -> &OsString { + &self.name + } +} + +fn random_names(prefix: &str) -> io::Result> { + let mut random = [0_u8; RANDOM_BYTES]; + let bytes_read = getrandom(&mut random, GetRandomFlags::empty()).map_err(io::Error::from)?; + if bytes_read != random.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "secure random source returned too few bytes", + )); + } + let mut token = String::with_capacity(RANDOM_BYTES.saturating_mul(2)); + for byte in random { + write!(&mut token, "{byte:02x}") + .map_err(|_| io::Error::other("failed to format quarantine name"))?; + } + let process_id = std::process::id(); + + Ok((0..QUARANTINE_ATTEMPTS) + .map(|attempt| OsString::from(format!("{prefix}{process_id}.{token}.{attempt}"))) + .collect()) +} + +#[cfg(test)] +#[path = "tests/quarantine.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/regular.rs b/crates/unixnotis-core/src/filesystem/regular.rs index be00d2358..446504fd1 100644 --- a/crates/unixnotis-core/src/filesystem/regular.rs +++ b/crates/unixnotis-core/src/filesystem/regular.rs @@ -7,7 +7,7 @@ use std::os::fd::OwnedFd; use std::os::unix::fs::PermissionsExt; use std::path::Path; -use rustix::fs::{openat2, Mode, OFlags}; +use rustix::fs::{fstat, openat2, statat, AtFlags, Mode, OFlags}; use super::descriptor::{contained_resolve_flags, open_parent_existing}; @@ -127,6 +127,23 @@ pub(super) fn open_regular_file_at( Ok(file) } +pub(super) fn revalidate_file_identity( + parent_fd: &OwnedFd, + file_name: &OsString, + file: &fs::File, +) -> io::Result<()> { + // The retained descriptor identifies the object that passed the earlier validation + let retained = fstat(file)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev && retained.st_ino == visible.st_ino { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "regular file changed before the filesystem operation", + )) +} + pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { let read_limit = u64::try_from(expected.len()) .unwrap_or(u64::MAX) diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs index 4ea470ae1..282856ebd 100644 --- a/crates/unixnotis-core/src/filesystem/remove.rs +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -1,15 +1,20 @@ -//! Descriptor-relative removal for regular files and symbolic links +//! Descriptor-relative removal through a private same-filesystem quarantine +//! +//! Each removal first claims the requested basename with an atomic rename. Validation then uses +//! the retained object descriptor inside the quarantine instead of reopening the visible path +//! +//! The final unlink remains pathname-based because Linux has no unlink-by-file-descriptor API. +//! The quarantine directory must therefore be inaccessible to hostile same-UID writers when the +//! caller needs protection beyond the normal other-UID filesystem boundary use std::ffi::OsString; use std::io; -use std::os::fd::OwnedFd; use std::path::{Path, PathBuf}; -use rustix::fs::{fstat, statat, unlinkat, AtFlags}; - -use super::descriptor::{open_parent_existing, sync_directory}; -use super::regular::{file_contents_equal, open_regular_file_at, validate_existing_target}; -use super::symlink::read_symlink_at; +use super::descriptor::open_parent_existing; +use super::quarantine::{Quarantine, QuarantinedEntry}; +use super::regular::{file_contents_equal, open_regular_file_at, revalidate_file_identity}; +use super::symlink::{open_symlink_at, read_symlink_at, revalidate_symlink_identity}; /// Result of removing a symbolic link with an expected target #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,37 +40,50 @@ pub enum RemoveExactFileOutcome { /// Remove a regular file without following links in its path /// +/// The source basename is first moved into a private mode-0700 directory on the same filesystem. +/// Identity checks and physical unlinking then use the retained quarantine directory descriptor +/// rather than a visible claim pathname +/// /// # Errors /// -/// Returns an error when a path component is unsafe, the target is not a regular file, or the -/// unlink or parent-directory synchronization fails +/// Returns an error when a path component is unsafe, the target changes during quarantine, the +/// target is not a regular file, or quarantine cleanup cannot complete pub fn remove_regular_file(path: &Path) -> io::Result { - // Missing parents mean the requested file is already absent let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(false); }; - // Final validation distinguishes regular files from links and special objects - match validate_existing_target(&parent_fd, &file_name) { - Ok(()) => {} + let file = match open_regular_file_at(&parent_fd, &file_name) { + Ok(file) => file, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error), - } + }; + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(false); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; - // Unlink and directory sync use the same retained parent descriptor - unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(&parent_fd)?; - Ok(true) + let result = + unlink_regular_entry(&quarantine, &entry, &parent_fd, &file_name, &file).map(|()| true); + finish_quarantine(quarantine, &parent_fd, result) } /// Remove two same-directory regular files only when both retained payloads match /// -/// This is intended for a shared artifact and its ownership marker. Both files are opened and -/// preflighted through one parent descriptor before either name is unlinked +/// Both names are quarantined before either entry is physically unlinked. A mismatch leaves the +/// quarantined entry in place or restores it without deleting a replacement basename /// /// # Errors /// /// Returns an error when paths have different parents, path traversal is unsafe, either target is -/// not a regular file, retained identities change, or durable unlinking fails +/// not a regular file, retained identities change, or durable quarantine cleanup fails pub fn remove_regular_file_pair_if_contents( path: &Path, expected_contents: &[u8], @@ -103,43 +121,102 @@ pub fn remove_regular_file_pair_if_contents( return Ok(RemoveExactFileOutcome::ContentsMismatch); } - // The marker is removed first so a target-name race fails closed with the shared file intact - revalidate_file_identity(&parent_fd, &marker_name, &marker)?; - unlinkat(&parent_fd, &marker_name, AtFlags::empty())?; - revalidate_file_identity(&parent_fd, &file_name, &file)?; - unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(&parent_fd)?; - Ok(RemoveExactFileOutcome::Removed) + let quarantine = Quarantine::create(&parent_fd)?; + let marker_entry = match quarantine.move_entry(&parent_fd, &marker_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(RemoveExactFileOutcome::Missing); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), marker_entry.name(), &marker) { + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + + let file_entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let result = match quarantine.restore(&marker_entry, &parent_fd, &marker_name) { + Ok(()) => Ok(RemoveExactFileOutcome::Missing), + Err(restore_error) => { + Err(combine_operation_and_restore_error(&error, &restore_error)) + } + }; + return finish_quarantine(quarantine, &parent_fd, result); + } + Err(error) => { + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), file_entry.name(), &file) { + let error = restore_entry_or_error(&quarantine, &file_entry, &parent_fd, &file_name, error); + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + + // The marker is removed first to preserve the existing ownership protocol + if let Err(error) = unlink_regular_entry( + &quarantine, + &marker_entry, + &parent_fd, + &marker_name, + &marker, + ) { + let error = restore_entry_or_error(&quarantine, &file_entry, &parent_fd, &file_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + let result = unlink_regular_entry(&quarantine, &file_entry, &parent_fd, &file_name, &file) + .map(|()| RemoveExactFileOutcome::Removed); + finish_quarantine(quarantine, &parent_fd, result) } /// Remove a symbolic link without requiring a specific target /// /// # Errors /// -/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the -/// unlink or parent-directory synchronization fails +/// Returns an error when a path component is unsafe, the target is not a symbolic link, the +/// quarantined identity changes, or quarantine cleanup fails pub fn remove_symlink(path: &Path) -> io::Result { let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(false); }; - // Reading the stored target proves the final entry is a link without following it - match read_symlink_at(&parent_fd, &file_name) { - Ok(_target) => {} + let link = match open_symlink_at(&parent_fd, &file_name) { + Ok(link) => link, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(error), - } - - unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(&parent_fd)?; - Ok(true) + }; + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(false); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + let result = + unlink_symlink_entry(&quarantine, &entry, &parent_fd, &file_name, &link).map(|()| true); + finish_quarantine(quarantine, &parent_fd, result) } /// Remove a symbolic link only when its stored target matches exactly /// /// # Errors /// -/// Returns an error when a path component is unsafe, the target is not a symbolic link, or the -/// unlink or parent-directory synchronization fails +/// Returns an error when a path component is unsafe, the target is not a symbolic link, the +/// quarantined identity changes, or quarantine cleanup fails pub fn remove_symlink_if_target( path: &Path, expected_target: &Path, @@ -147,7 +224,13 @@ pub fn remove_symlink_if_target( let Some((parent_fd, file_name)) = existing_parent(path)? else { return Ok(RemoveSymlinkOutcome::Missing); }; - // Capture the exact stored bytes before comparing ownership expectations + let link = match open_symlink_at(&parent_fd, &file_name) { + Ok(link) => link, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RemoveSymlinkOutcome::Missing), + _ => return Err(error), + }, + }; let actual_target = match read_symlink_at(&parent_fd, &file_name) { Ok(target) => target, Err(error) => match error.kind() { @@ -156,14 +239,123 @@ pub fn remove_symlink_if_target( }, }; if actual_target != expected_target { - // Mismatched links are user state and remain untouched return Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)); } - // Only an exact target match reaches the unlink boundary - unlinkat(&parent_fd, &file_name, AtFlags::empty())?; - sync_directory(&parent_fd)?; - Ok(RemoveSymlinkOutcome::Removed) + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(RemoveSymlinkOutcome::Missing); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + let quarantined_target = match read_symlink_at(quarantine.fd(), entry.name()) { + Ok(target) => target, + Err(error) => { + let error = restore_entry_or_error(&quarantine, &entry, &parent_fd, &file_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + }; + if quarantined_target != expected_target { + let mismatch_error = io::Error::new( + io::ErrorKind::InvalidInput, + "symbolic-link target changed during quarantine", + ); + let result = match quarantine.restore(&entry, &parent_fd, &file_name) { + Ok(()) => Ok(RemoveSymlinkOutcome::TargetMismatch(quarantined_target)), + Err(restore_error) => Err(combine_operation_and_restore_error( + &mismatch_error, + &restore_error, + )), + }; + return finish_quarantine(quarantine, &parent_fd, result); + } + + let result = unlink_symlink_entry(&quarantine, &entry, &parent_fd, &file_name, &link) + .map(|()| RemoveSymlinkOutcome::Removed); + finish_quarantine(quarantine, &parent_fd, result) +} + +fn unlink_regular_entry( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + file: &std::fs::File, +) -> io::Result<()> { + // Revalidate the object after the atomic claim so a failed claim never authorizes a new file + revalidate_file_identity(quarantine.fd(), entry.name(), file).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + })?; + // The retained quarantine descriptor pins the parent; the final basename is still resolved + // by unlinkat, so a hostile writer must not control this private directory + quarantine.unlink(entry).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + }) +} + +fn unlink_symlink_entry( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + link: &std::os::fd::OwnedFd, +) -> io::Result<()> { + // Symlink identity is checked without following the stored target + revalidate_symlink_identity(quarantine.fd(), entry.name(), link).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + })?; + // As with regular files, unlinkat protects the parent directory but not a hostile same-UID + // replacement of the final quarantine basename between validation and the syscall + quarantine.unlink(entry).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + }) +} + +fn restore_entry_or_error( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + operation_error: io::Error, +) -> io::Error { + match quarantine.restore(entry, source_parent, source_name) { + Ok(()) => operation_error, + Err(restore_error) => combine_operation_and_restore_error(&operation_error, &restore_error), + } +} + +fn finish_quarantine( + quarantine: Quarantine, + parent_fd: &std::os::fd::OwnedFd, + result: io::Result, +) -> io::Result { + let cleanup = quarantine.cleanup(parent_fd); + match result { + Ok(value) => { + cleanup?; + Ok(value) + } + Err(error) => match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(combine_operation_and_restore_error(&error, &cleanup_error)), + }, + } +} + +fn combine_operation_and_restore_error( + operation_error: &io::Error, + restore_error: &io::Error, +) -> io::Error { + io::Error::new( + operation_error.kind(), + format!("{operation_error}; failed to restore quarantine entry: {restore_error}"), + ) } fn existing_parent(path: &Path) -> io::Result> { @@ -175,22 +367,6 @@ fn existing_parent(path: &Path) -> io::Result io::Result<()> { - let retained = fstat(file)?; - let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; - if retained.st_dev == visible.st_dev && retained.st_ino == visible.st_ino { - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "regular file changed during guarded removal", - )) -} - fn file_lookup_is_missing(error: &io::Error) -> bool { // Missing exact-pair members are idempotent while every other error fails closed error.kind() == io::ErrorKind::NotFound diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs index 6b4728a28..def7ac89c 100644 --- a/crates/unixnotis-core/src/filesystem/rename.rs +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -6,7 +6,8 @@ use std::path::Path; use rustix::fs::{renameat_with, RenameFlags}; use super::descriptor::{open_parent_existing, open_target_directory, sync_directory}; -use super::regular::validate_existing_target; +use super::quarantine::{Quarantine, QuarantinedEntry}; +use super::regular::{open_regular_file_at, revalidate_file_identity}; use super::tree::revalidate_directory_identity; /// Result of moving a regular file without replacing another filesystem entry @@ -48,34 +49,89 @@ pub fn rename_regular_file_no_replace( _ => return Err(error), }, }; - // Final-component validation rejects source links, directories, and special files - match validate_existing_target(&source_parent, &source_name) { - Ok(()) => {} + // Retain the validated source so a replacement basename cannot be claimed + let source_file = match open_regular_file_at(&source_parent, &source_name) { + Ok(file) => file, Err(error) => match error.kind() { io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), _ => return Err(error), }, - } + }; let (destination_parent, destination_name) = open_parent_existing(destination)?; - // Kernel no-replace semantics close the check-then-rename destination race + // Claim the source basename before publication so no later operation uses a watched source + // name to identify the object being moved + let quarantine = Quarantine::create(&source_parent)?; + let entry = match quarantine.move_entry(&source_parent, &source_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&source_parent); + return Ok(RenameRegularFileOutcome::SourceMissing); + } + Err(error) => { + let _ = quarantine.cleanup(&source_parent); + return Err(error); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), entry.name(), &source_file) { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + + // Rename the quarantined entry itself so sparse data, metadata, ACLs, timestamps, and hard + // links survive without copy-delete behavior + // The quarantine descriptor pins the parent, while the entry name still relies on the private + // directory boundary described by the quarantine module + // The directory descriptor pins the quarantine parent; the final entry name remains a path + // and therefore uses the same private-directory trust boundary let rename_result = renameat_with( - &source_parent, - &source_name, + quarantine.fd(), + entry.name(), &destination_parent, &destination_name, RenameFlags::NOREPLACE, ) .map_err(Into::into); - match classify_rename_attempt(rename_result)? { - RenameRegularFileOutcome::Renamed => {} - outcome => return Ok(outcome), + let outcome = match classify_rename_attempt(rename_result) { + Ok(outcome) => outcome, + Err(error) => { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + }; + if outcome != RenameRegularFileOutcome::Renamed { + let result = quarantine + .restore(&entry, &source_parent, &source_name) + .map(|()| outcome) + .map_err(|error| { + restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ) + }); + return finish_quarantine(quarantine, &source_parent, result); } - // Both directory entries must reach durable storage even when parents differ - sync_directory(&destination_parent)?; - sync_directory(&source_parent)?; - Ok(RenameRegularFileOutcome::Renamed) + // Both final directory entries must reach durable storage + let result = sync_directory(&destination_parent) + .and(sync_directory(&source_parent)) + .map(|()| RenameRegularFileOutcome::Renamed); + finish_quarantine(quarantine, &source_parent, result) } /// Move a directory without following links or replacing the destination @@ -93,25 +149,110 @@ pub fn rename_directory_no_replace( return Ok(RenameDirectoryOutcome::SourceMissing); }; let (destination_parent, destination_name) = open_parent_existing(destination)?; - // The retained descriptor ensures the visible source name still identifies the staged tree - revalidate_directory_identity(&source_parent, &source_name, &source_directory)?; + // Claim the staged directory basename before publication for the same reason as regular files + let quarantine = Quarantine::create(&source_parent)?; + let entry = match quarantine.move_entry(&source_parent, &source_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&source_parent); + return Ok(RenameDirectoryOutcome::SourceMissing); + } + Err(error) => { + let _ = quarantine.cleanup(&source_parent); + return Err(error); + } + }; + if let Err(error) = + revalidate_directory_identity(quarantine.fd(), entry.name(), &source_directory) + { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } let rename_result = renameat_with( - &source_parent, - &source_name, + quarantine.fd(), + entry.name(), &destination_parent, &destination_name, RenameFlags::NOREPLACE, ) .map_err(Into::into); - let outcome = classify_directory_rename_attempt(rename_result)?; + let outcome = match classify_directory_rename_attempt(rename_result) { + Ok(outcome) => outcome, + Err(error) => { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + }; if outcome != RenameDirectoryOutcome::Renamed { - return Ok(outcome); + let result = quarantine + .restore(&entry, &source_parent, &source_name) + .map(|()| outcome) + .map_err(|error| { + restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ) + }); + return finish_quarantine(quarantine, &source_parent, result); } - sync_directory(&destination_parent)?; - sync_directory(&source_parent)?; - Ok(RenameDirectoryOutcome::Renamed) + let result = sync_directory(&destination_parent) + .and(sync_directory(&source_parent)) + .map(|()| RenameDirectoryOutcome::Renamed); + finish_quarantine(quarantine, &source_parent, result) +} + +fn restore_quarantined_entry_or_error( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + parent_fd: &std::os::fd::OwnedFd, + claimed_name: &std::ffi::OsString, + operation_error: io::Error, +) -> io::Error { + match quarantine.restore(entry, parent_fd, claimed_name) { + Ok(()) => operation_error, + Err(restore_error) => io::Error::new( + operation_error.kind(), + format!("{operation_error}; failed to restore quarantine entry: {restore_error}"), + ), + } +} + +fn finish_quarantine( + quarantine: Quarantine, + parent_fd: &std::os::fd::OwnedFd, + result: io::Result, +) -> io::Result { + let cleanup = quarantine.cleanup(parent_fd); + match result { + Ok(value) => { + cleanup?; + Ok(value) + } + Err(error) => match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(io::Error::new( + error.kind(), + format!("{error}; failed to clean up quarantine: {cleanup_error}"), + )), + }, + } } fn classify_rename_attempt(result: io::Result<()>) -> io::Result { diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs index f09187dba..1fcb241ce 100644 --- a/crates/unixnotis-core/src/filesystem/symlink.rs +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -6,10 +6,15 @@ use std::os::fd::OwnedFd; use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; -use rustix::fs::{readlinkat, renameat, symlinkat, unlinkat, AtFlags}; +use rustix::fs::{ + fstat, openat2, readlinkat, renameat, statat, symlinkat, unlinkat, AtFlags, FileType, Mode, + OFlags, +}; use super::atomic::temp_candidates; -use super::descriptor::{open_parent, open_parent_existing, sync_directory}; +use super::descriptor::{ + contained_resolve_flags, open_parent, open_parent_existing, sync_directory, +}; /// Result of creating a symbolic link without replacing an existing path #[derive(Debug, Clone, PartialEq, Eq)] @@ -137,6 +142,49 @@ pub(super) fn read_symlink_at(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Res Ok(PathBuf::from(OsString::from_vec(target.into_bytes()))) } +pub(super) fn open_symlink_at(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result { + // O_PATH plus NOFOLLOW retains the link itself instead of opening its target + let fd = openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + let stat = fstat(&fd)?; + if FileType::from_raw_mode(stat.st_mode).is_symlink() { + return Ok(fd); + } + Err(not_symlink_error()) +} + +pub(super) fn revalidate_symlink_identity( + parent_fd: &OwnedFd, + file_name: &OsString, + link: &OwnedFd, +) -> io::Result<()> { + // Compare the retained link object with the visible basename immediately before unlinking + let retained = fstat(link)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev + && retained.st_ino == visible.st_ino + && FileType::from_raw_mode(visible.st_mode).is_symlink() + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "symbolic link changed before removal", + )) +} + +fn not_symlink_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to operate on a non-symbolic-link target", + ) +} + fn reserve_temp_symlink( parent_fd: &OwnedFd, candidates: impl IntoIterator, diff --git a/crates/unixnotis-core/src/filesystem/tests/quarantine.rs b/crates/unixnotis-core/src/filesystem/tests/quarantine.rs new file mode 100644 index 000000000..8ccffa8e2 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/quarantine.rs @@ -0,0 +1,62 @@ +//! Private quarantine lifecycle tests + +use std::fs; + +use super::Quarantine; +use crate::filesystem::descriptor::open_parent_existing; +use crate::test_support::unique_temp_path; + +#[test] +fn quarantine_moves_and_restores_an_entry_without_following_a_new_source_name() { + let root = unique_temp_path("quarantine-restore"); + let source = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original"); + + let (parent_fd, source_name) = open_parent_existing(&source).expect("open source parent"); + let quarantine = Quarantine::create(&parent_fd).expect("create quarantine"); + let entry = quarantine + .move_entry(&parent_fd, &source_name) + .expect("move source into quarantine"); + fs::write(&source, "replacement").expect("write replacement source"); + + quarantine + .restore(&entry, &parent_fd, &source_name) + .expect_err("restore must not replace an unrelated destination"); + fs::remove_file(&source).expect("remove test replacement"); + quarantine + .restore(&entry, &parent_fd, &source_name) + .expect("restore original source name"); + + assert_eq!( + fs::read_to_string(&source).expect("read restored source"), + "original" + ); + quarantine + .cleanup(&parent_fd) + .expect("remove empty quarantine"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn quarantine_keeps_an_entry_when_cleanup_is_not_requested() { + let root = unique_temp_path("quarantine-retain"); + let source = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original"); + + let (parent_fd, source_name) = open_parent_existing(&source).expect("open source parent"); + let quarantine = Quarantine::create(&parent_fd).expect("create quarantine"); + let entry = quarantine + .move_entry(&parent_fd, &source_name) + .expect("move source into quarantine"); + quarantine + .unlink(&entry) + .expect("unlink quarantined source"); + quarantine + .cleanup(&parent_fd) + .expect("remove empty quarantine"); + + assert!(!source.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs index 9d8e9f655..709c23a0e 100644 --- a/crates/unixnotis-core/src/filesystem/tests/remove.rs +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -8,10 +8,10 @@ use rustix::fs::{mkfifoat, Mode, CWD}; use super::{ existing_parent, file_lookup_is_missing, remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, - revalidate_file_identity, RemoveExactFileOutcome, RemoveSymlinkOutcome, + RemoveExactFileOutcome, RemoveSymlinkOutcome, }; -use crate::filesystem::regular::open_regular_file_at; -use crate::filesystem::symlink::read_symlink; +use crate::filesystem::regular::{open_regular_file_at, revalidate_file_identity}; +use crate::filesystem::symlink::{open_symlink_at, read_symlink, revalidate_symlink_identity}; use crate::test_support::unique_temp_path; #[test] @@ -48,6 +48,29 @@ fn retained_file_identity_rejects_a_same_directory_replacement() { let _ = fs::remove_dir_all(root); } +#[test] +fn retained_symlink_identity_rejects_a_same_name_regular_replacement() { + let root = unique_temp_path("remove-symlink-identity"); + fs::create_dir_all(&root).expect("create root"); + let target = root.join("enabled"); + let moved = root.join("original-link"); + symlink("service", &target).expect("write original link"); + let (parent_fd, file_name) = existing_parent(&target) + .expect("open parent") + .expect("parent exists"); + let retained = open_symlink_at(&parent_fd, &file_name).expect("open retained link"); + + revalidate_symlink_identity(&parent_fd, &file_name, &retained) + .expect("unchanged link should pass"); + fs::rename(&target, &moved).expect("move original link"); + fs::write(&target, "replacement").expect("write replacement file"); + + revalidate_symlink_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + #[test] fn regular_file_removal_is_idempotent() { let root = unique_temp_path("remove-regular-file"); diff --git a/crates/unixnotis-core/src/filesystem/tests/rename.rs b/crates/unixnotis-core/src/filesystem/tests/rename.rs index 53306763e..3b38e92d3 100644 --- a/crates/unixnotis-core/src/filesystem/tests/rename.rs +++ b/crates/unixnotis-core/src/filesystem/tests/rename.rs @@ -1,12 +1,14 @@ //! No-replace regular-file rename tests use std::fs; -use std::os::unix::fs::symlink; +use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; use super::{ classify_directory_rename_attempt, classify_rename_attempt, rename_directory_no_replace, rename_regular_file_no_replace, RenameDirectoryOutcome, RenameRegularFileOutcome, }; +use crate::filesystem::descriptor::open_parent_existing; +use crate::filesystem::regular::{open_regular_file_at, revalidate_file_identity}; use crate::test_support::unique_temp_path; #[test] @@ -16,11 +18,17 @@ fn regular_file_rename_moves_source_to_an_unused_destination() { let destination = root.join("style.css.bak"); fs::create_dir_all(&root).expect("create root"); fs::write(&source, "legacy theme").expect("write source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o640)).expect("set source mode"); + let source_metadata = fs::metadata(&source).expect("read source metadata"); let outcome = rename_regular_file_no_replace(&source, &destination).expect("rename file"); assert_eq!(outcome, RenameRegularFileOutcome::Renamed); assert!(!source.exists()); + let destination_metadata = fs::metadata(&destination).expect("read destination metadata"); + assert_eq!(destination_metadata.dev(), source_metadata.dev()); + assert_eq!(destination_metadata.ino(), source_metadata.ino()); + assert_eq!(destination_metadata.permissions().mode() & 0o777, 0o640); assert_eq!( fs::read_to_string(destination).expect("read destination"), "legacy theme" @@ -28,6 +36,27 @@ fn regular_file_rename_moves_source_to_an_unused_destination() { let _ = fs::remove_dir_all(root); } +#[test] +fn retained_rename_source_identity_rejects_a_same_name_replacement() { + let root = unique_temp_path("rename-source-identity"); + let source = root.join("style.css"); + let moved = root.join("original.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original source"); + let (parent_fd, file_name) = open_parent_existing(&source).expect("open source parent"); + let retained = open_regular_file_at(&parent_fd, &file_name).expect("open retained source"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect("unchanged source should pass"); + fs::rename(&source, &moved).expect("move original source"); + fs::write(&source, "replacement").expect("write replacement source"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail before rename"); + + let _ = fs::remove_dir_all(root); +} + #[test] fn rename_attempt_result_distinguishes_every_kernel_outcome() { assert_eq!( From faf35cf0ed900d601aa406c2337cc3c8910eb591 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 20:02:11 -0500 Subject: [PATCH 247/275] security(daemon): snapshot trusted executables at startup Build trusted control executable fingerprints while constructing DaemonState, before D-Bus names and control objects are published. Read only the immutable startup map during authorization and remove lazy first-caller trust initialization. Update authorization tests for startup snapshots and missing trusted binaries. --- .../src/daemon/auth/authorization.rs | 18 ++- .../src/daemon/auth/executable_trust/mod.rs | 10 ++ .../src/daemon/auth/executable_trust/paths.rs | 18 +-- .../daemon/auth/executable_trust/snapshots.rs | 68 +---------- .../auth/executable_trust/tests/cache.rs | 111 ------------------ .../daemon/auth/executable_trust/tests/mod.rs | 1 - .../auth/executable_trust/tests/snapshots.rs | 60 ++++++++++ .../auth/executable_trust/tests/strict.rs | 24 ++-- .../unixnotis-daemon/src/daemon/auth/mod.rs | 2 + .../src/daemon/auth/policy.rs | 10 +- .../src/daemon/auth/tests/authorization.rs | 42 +++++-- .../src/daemon/auth/tests/support.rs | 33 ------ .../src/daemon/state/model.rs | 15 +++ 13 files changed, 156 insertions(+), 256 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index 764cb4e94..b283ef004 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -1,5 +1,6 @@ //! Caller authorization flow for control operations +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -15,7 +16,7 @@ use super::executable_trust::is_trusted_control_executable_from_fd; #[cfg(not(target_os = "linux"))] use super::executable_trust::is_trusted_control_executable_path; use super::policy::{ - TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, + TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES, TRUSTED_POPUP_READINESS_EXECUTABLES, }; #[cfg(not(target_os = "linux"))] @@ -130,6 +131,7 @@ async fn authorize_control_call_for_executables( exe_fd.as_ref(), allowed_executables, state.trial_mode(), + state.trusted_executables(), ) { warn!( method, @@ -180,6 +182,7 @@ pub(in crate::daemon) fn control_executable_is_allowed, allowed_executables: &[&str], relaxed: bool, + trusted_snapshots: &HashMap, ) -> bool { // Name allowlist is required; path trust is a separate check that must also pass let Some(path) = path else { @@ -202,13 +205,13 @@ pub(in crate::daemon) fn control_executable_is_allowed( exe_fd: Option<&Fd>, allowed_executables: &[&str], relaxed: bool, + trusted_snapshots: &HashMap, ) -> Option { - if control_executable_is_allowed(path, exe_fd, allowed_executables, relaxed) { + if control_executable_is_allowed( + path, + exe_fd, + allowed_executables, + relaxed, + trusted_snapshots, + ) { return None; } Some(zbus::fdo::Error::AccessDenied( diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs index 2204fc8fe..32dbcf361 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -1,5 +1,7 @@ //! Trusted executable path, metadata, fingerprint, and startup snapshot policy +use std::collections::HashMap; + mod fingerprint; mod metadata; pub(in crate::daemon::auth) mod paths; @@ -10,5 +12,13 @@ pub(super) use paths::is_trusted_control_executable_from_fd; #[cfg(not(target_os = "linux"))] pub(super) use paths::is_trusted_control_executable_path; +pub(in crate::daemon) fn build_trusted_control_snapshots_for_current_executable( +) -> HashMap { + // Resolve the sibling directory before the daemon publishes any D-Bus service + paths::trusted_control_directory().map_or_else(HashMap::new, |trusted_dir| { + snapshots::build_trusted_control_snapshots(&trusted_dir) + }) +} + #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 2fe81bf5e..5701fc06e 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -1,15 +1,12 @@ //! Trusted executable path matching +use std::collections::HashMap; use std::os::unix::io::AsFd; use std::path::{Path, PathBuf}; -#[cfg(target_os = "linux")] -use super::super::policy::TRUSTED_CONTROL_EXECUTABLES; -#[cfg(not(target_os = "linux"))] use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; use super::fingerprint::{file_fingerprint, file_fingerprint_from_fd}; use super::metadata::trusted_control_file_metadata_is_safe; -use super::snapshots::trusted_control_snapshot; pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { // Missing paths remain raw so later trust comparisons fail as ordinary mismatches @@ -17,7 +14,11 @@ pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { } #[cfg(not(target_os = "linux"))] -pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed: bool) -> bool { +pub(in crate::daemon) fn is_trusted_control_executable_path( + path: &Path, + relaxed: bool, + trusted_snapshots: &HashMap, +) -> bool { // Trust only known sibling binaries from the daemon install/build directory let Some(trusted_dir) = trusted_control_directory() else { return false; @@ -35,7 +36,7 @@ pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed return is_trusted_control_executable_path_relaxed_in_dir(&observed, &trusted_dir); } - let Some(snapshot) = trusted_control_snapshot(&trusted_dir, observed_name) else { + let Some(snapshot) = trusted_snapshots.get(observed_name) else { return false; }; trusted_snapshot_matches_observed(&snapshot, &observed) @@ -111,7 +112,7 @@ pub(in crate::daemon) fn trusted_local_bin_matches_executable( canonicalize_best_effort(&candidate) == observed } -fn trusted_control_directory() -> Option { +pub(in crate::daemon::auth) fn trusted_control_directory() -> Option { // The daemon trusts binaries installed next to the running daemon executable let current_exe = std::env::current_exe().ok()?; let current_exe = canonicalize_best_effort(¤t_exe); @@ -136,6 +137,7 @@ pub(in crate::daemon::auth) fn is_trusted_control_executable_from_fd( fd: &Fd, path: &Path, relaxed: bool, + trusted_snapshots: &HashMap, ) -> bool { // Trust only known sibling binaries from the daemon install/build directory let Some(trusted_dir) = trusted_control_directory() else { @@ -168,7 +170,7 @@ pub(in crate::daemon::auth) fn is_trusted_control_executable_from_fd( file_fingerprint(path).is_some_and(|path_fingerprint| path_fingerprint == fingerprint) } else { // Strict mode: the descriptor fingerprint must match the startup snapshot - let Some(snapshot) = trusted_control_snapshot(&trusted_dir, observed_name) else { + let Some(snapshot) = trusted_snapshots.get(observed_name) else { return false; }; fingerprint == snapshot.fingerprint diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs index 9be014ff7..94150f705 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs @@ -2,30 +2,11 @@ use std::collections::HashMap; use std::path::Path; -use std::sync::{Mutex, OnceLock}; -use super::super::policy::{ - TrustedExecutableSnapshot, TrustedSnapshotCacheEntry, TRUSTED_CONTROL_EXECUTABLES, - TRUSTED_SNAPSHOT_CACHE_CAPACITY, -}; +use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; use super::fingerprint::file_fingerprint; use super::paths::canonicalize_best_effort; -pub(in crate::daemon) fn trusted_control_snapshot( - trusted_dir: &Path, - executable: &str, -) -> Option { - if let Some(snapshot) = load_cached_trusted_snapshot(trusted_dir, executable) { - return Some(snapshot); - } - - // Pin the whole sibling trust set together so late file swaps do not sneak in - let snapshots = build_trusted_control_snapshots(trusted_dir); - let snapshot = snapshots.get(executable).cloned()?; - store_cached_trusted_snapshots(trusted_dir, snapshots); - Some(snapshot) -} - pub(in crate::daemon) fn build_trusted_control_snapshots( trusted_dir: &Path, ) -> HashMap { @@ -56,50 +37,3 @@ fn build_trusted_control_snapshot( fingerprint, }) } - -pub(in crate::daemon) fn trusted_snapshot_cache() -> &'static Mutex> -{ - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(Vec::new())) -} - -pub(in crate::daemon) fn load_cached_trusted_snapshot( - trusted_dir: &Path, - executable: &str, -) -> Option { - let cache = trusted_snapshot_cache(); - let cache = match cache.lock() { - Ok(cache) => cache, - Err(poisoned) => poisoned.into_inner(), - }; - cache - .iter() - .find(|entry| entry.trusted_dir == trusted_dir) - .and_then(|entry| entry.snapshots.get(executable).cloned()) -} - -pub(in crate::daemon) fn store_cached_trusted_snapshots( - trusted_dir: &Path, - snapshots: HashMap, -) { - let cache = trusted_snapshot_cache(); - let mut cache = match cache.lock() { - Ok(cache) => cache, - Err(poisoned) => poisoned.into_inner(), - }; - - // Replace existing directory cache before enforcing capacity - if let Some(index) = cache - .iter() - .position(|entry| entry.trusted_dir == trusted_dir) - { - cache.remove(index); - } - if cache.len() >= TRUSTED_SNAPSHOT_CACHE_CAPACITY { - cache.remove(0); - } - cache.push(TrustedSnapshotCacheEntry { - trusted_dir: trusted_dir.to_path_buf(), - snapshots, - }); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs deleted file mode 100644 index a149216db..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/cache.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::collections::HashMap; - -use super::super::fingerprint::{ - fingerprint_cache, load_cached_fingerprint, store_cached_fingerprint, -}; -use super::super::snapshots::{ - load_cached_trusted_snapshot, store_cached_trusted_snapshots, trusted_snapshot_cache, -}; -use crate::daemon::auth::policy::{ - TrustedExecutableSnapshot, FINGERPRINT_CACHE_CAPACITY, TRUSTED_SNAPSHOT_CACHE_CAPACITY, -}; -use crate::daemon::auth::support::{test_fingerprint, test_signature}; -use crate::test_support::{env_lock, TempRoot}; - -#[test] -fn fingerprint_cache_loads_only_same_path_and_signature() { - let _guard = env_lock(); - fingerprint_cache().lock().expect("cache lock").clear(); - let root = TempRoot::new("auth-fingerprint-cache"); - let path = root.join("noticenterctl"); - let other = root.join("unixnotis-center"); - let signature = test_signature(10); - let fingerprint = test_fingerprint(10); - - store_cached_fingerprint(&path, signature, fingerprint.clone()); - - assert_eq!(load_cached_fingerprint(&path, signature), Some(fingerprint)); - assert!(load_cached_fingerprint(&other, signature).is_none()); - assert!(load_cached_fingerprint(&path, test_signature(11)).is_none()); -} - -#[test] -fn fingerprint_cache_replaces_same_path_and_evicts_oldest_entry() { - let _guard = env_lock(); - fingerprint_cache().lock().expect("cache lock").clear(); - let root = TempRoot::new("auth-fingerprint-evict"); - let path = root.join("noticenterctl"); - - store_cached_fingerprint(&path, test_signature(1), test_fingerprint(1)); - store_cached_fingerprint(&path, test_signature(2), test_fingerprint(2)); - assert!(load_cached_fingerprint(&path, test_signature(1)).is_none()); - assert_eq!( - load_cached_fingerprint(&path, test_signature(2)), - Some(test_fingerprint(2)) - ); - - for index in 0..FINGERPRINT_CACHE_CAPACITY { - let entry_path = root.join(format!("tool-{index}")); - store_cached_fingerprint( - &entry_path, - test_signature(100 + index as u64), - test_fingerprint(100 + index as u64), - ); - } - - assert!(load_cached_fingerprint(&path, test_signature(2)).is_none()); -} - -#[test] -fn trusted_snapshot_cache_loads_replaces_and_evicts_by_directory() { - let _guard = env_lock(); - trusted_snapshot_cache() - .lock() - .expect("snapshot cache lock") - .clear(); - let root = TempRoot::new("auth-snapshot-cache"); - let first_dir = root.join("first"); - let second_dir = root.join("second"); - let ctl_path = first_dir.join("noticenterctl"); - let center_path = first_dir.join("unixnotis-center"); - let first_snapshot = TrustedExecutableSnapshot { - canonical_path: ctl_path, - fingerprint: test_fingerprint(1), - }; - let replacement_snapshot = TrustedExecutableSnapshot { - canonical_path: center_path, - fingerprint: test_fingerprint(2), - }; - let mut snapshots = HashMap::new(); - snapshots.insert("noticenterctl".to_string(), first_snapshot.clone()); - - store_cached_trusted_snapshots(&first_dir, snapshots); - assert_eq!( - load_cached_trusted_snapshot(&first_dir, "noticenterctl"), - Some(first_snapshot) - ); - assert!(load_cached_trusted_snapshot(&second_dir, "noticenterctl").is_none()); - - let mut replacement = HashMap::new(); - replacement.insert("noticenterctl".to_string(), replacement_snapshot.clone()); - store_cached_trusted_snapshots(&first_dir, replacement); - assert_eq!( - load_cached_trusted_snapshot(&first_dir, "noticenterctl"), - Some(replacement_snapshot) - ); - - for index in 0..TRUSTED_SNAPSHOT_CACHE_CAPACITY { - let dir = root.join(format!("dir-{index}")); - let mut snapshots = HashMap::new(); - snapshots.insert( - "noticenterctl".to_string(), - TrustedExecutableSnapshot { - canonical_path: dir.join("noticenterctl"), - fingerprint: test_fingerprint(100 + index as u64), - }, - ); - store_cached_trusted_snapshots(&dir, snapshots); - } - - assert!(load_cached_trusted_snapshot(&first_dir, "noticenterctl").is_none()); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs index c42e38cf7..b4c0c749a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs @@ -1,4 +1,3 @@ -mod cache; mod metadata; mod paths; mod snapshots; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs index 9a25bc8da..d428fb249 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs @@ -1,3 +1,63 @@ +#[cfg(target_os = "linux")] +#[test] +fn startup_snapshot_does_not_adopt_a_sibling_replaced_before_first_authorization() { + use super::super::fingerprint::file_fingerprint; + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + + let trusted_dir = TempRoot::new("auth-startup-snapshot-replacement"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + let startup_snapshot = snapshots + .get("noticenterctl") + .expect("trusted sibling should be captured at startup") + .clone(); + + std::fs::remove_file(&trusted).expect("remove original sibling"); + write_executable(&trusted); + let replacement = file_fingerprint(&trusted).expect("replacement should be fingerprinted"); + + // A later authorization reads the immutable startup map instead of adopting this replacement + assert_ne!(startup_snapshot.fingerprint, replacement); + assert_eq!( + snapshots + .get("noticenterctl") + .expect("startup snapshot remains present"), + &startup_snapshot + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn concurrent_authorization_reads_share_one_startup_snapshot() { + use std::sync::Arc; + + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + + let trusted_dir = TempRoot::new("auth-concurrent-startup-snapshot"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = Arc::new(build_trusted_control_snapshots(trusted_dir.path())); + let expected = snapshots + .get("noticenterctl") + .expect("trusted sibling should be captured once") + .clone(); + + std::thread::scope(|scope| { + for _ in 0..8 { + let snapshots = Arc::clone(&snapshots); + let expected = expected.clone(); + scope.spawn(move || { + assert_eq!(snapshots.get("noticenterctl"), Some(&expected)); + }); + } + }); +} + #[cfg(not(target_os = "linux"))] mod strict_snapshot_tests { use super::super::paths::{canonicalize_best_effort, trusted_snapshot_matches_observed}; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs index 837a375fd..8b36f266a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -2,7 +2,7 @@ mod strict_path_tests { use super::super::fingerprint::fingerprint_cache; use super::super::paths::is_trusted_control_executable_path; - use super::super::snapshots::trusted_snapshot_cache; + use super::super::snapshots::build_trusted_control_snapshots; use crate::daemon::auth::authorization::control_executable_is_allowed; use crate::daemon::auth::support::write_executable; use crate::test_support::{env_lock, TempRoot}; @@ -26,37 +26,41 @@ mod strict_path_tests { let foreign = root.join("noticenterctl"); write_executable(&trusted); write_executable(&foreign); - trusted_snapshot_cache() - .lock() - .expect("snapshot cache lock") - .clear(); fingerprint_cache() .lock() .expect("fingerprint cache lock") .clear(); + let snapshots = build_trusted_control_snapshots(&trusted_dir); - assert!(is_trusted_control_executable_path(&trusted, false)); - assert!(!is_trusted_control_executable_path(&foreign, false)); + assert!(is_trusted_control_executable_path( + &trusted, false, &snapshots + )); + assert!(!is_trusted_control_executable_path( + &foreign, false, &snapshots + )); let trusted_fd = open_test_executable(&trusted); let foreign_fd = open_test_executable(&foreign); assert!(control_executable_is_allowed::( Some(&trusted), Some(&trusted_fd), &["noticenterctl"], - false + false, + &snapshots, )); assert!(!control_executable_is_allowed::( Some(&trusted), Some(&trusted_fd), &["unixnotis-center"], - false + false, + &snapshots, )); // Foreign path must be checked with its own fd to verify it's a different executable assert!(!control_executable_is_allowed::( Some(&foreign), Some(&foreign_fd), &["noticenterctl"], - false + false, + &snapshots, )); let _ = std::fs::remove_file(trusted); diff --git a/crates/unixnotis-daemon/src/daemon/auth/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/mod.rs index 44391958b..c7dca9b0a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/mod.rs @@ -22,6 +22,8 @@ pub(super) use authorization::{ authorize_control_call, authorize_interaction_call, authorize_panel_readiness_call, authorize_popup_readiness_call, }; +pub(in crate::daemon) use executable_trust::build_trusted_control_snapshots_for_current_executable; +pub(in crate::daemon) use policy::TrustedExecutableSnapshot; #[cfg(test)] #[path = "tests/authorization.rs"] diff --git a/crates/unixnotis-daemon/src/daemon/auth/policy.rs b/crates/unixnotis-daemon/src/daemon/auth/policy.rs index 7c2dc5a1a..155d71979 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/policy.rs @@ -1,6 +1,5 @@ //! Shared authorization policy constants and small data records -use std::collections::HashMap; use std::path::PathBuf; // Only these sibling binaries may call privileged control methods @@ -21,9 +20,8 @@ pub(in crate::daemon) const TRUSTED_PANEL_READINESS_EXECUTABLES: [&str; 1] = ["u // Only the popup renderer may publish its composite D-Bus and GTK readiness pub(in crate::daemon) const TRUSTED_POPUP_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-popups"]; -// Small bounded caches avoid unbounded growth from repeated forged callers +// Small bounded cache avoids unbounded growth from repeated fingerprint lookups pub(in crate::daemon) const FINGERPRINT_CACHE_CAPACITY: usize = 32; -pub(in crate::daemon) const TRUSTED_SNAPSHOT_CACHE_CAPACITY: usize = 32; #[derive(Clone, Debug, Eq, PartialEq)] pub(in crate::daemon) struct TrustedExecutableSnapshot { @@ -69,9 +67,3 @@ pub(in crate::daemon) struct FingerprintCacheEntry { pub(in crate::daemon) signature: FileFingerprintSignature, pub(in crate::daemon) fingerprint: FileFingerprint, } - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(in crate::daemon) struct TrustedSnapshotCacheEntry { - pub(in crate::daemon) trusted_dir: PathBuf, - pub(in crate::daemon) snapshots: HashMap, -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 96848d90e..27fb32b6b 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::File; use std::os::fd::OwnedFd; use zbus::Message; @@ -102,25 +103,38 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { let trusted_fd = open_test_executable(&trusted); - assert!( - control_executable_error(Some(&trusted), Some(&trusted_fd), &["noticenterctl"], true) - .is_none() - ); - assert!( - control_executable_error::(None, None::<&OwnedFd>, &["noticenterctl"], true) - .is_some() - ); + assert!(control_executable_error( + Some(&trusted), + Some(&trusted_fd), + &["noticenterctl"], + true, + &HashMap::new(), + ) + .is_none()); + assert!(control_executable_error::( + None, + None::<&OwnedFd>, + &["noticenterctl"], + true, + &HashMap::new(), + ) + .is_some()); assert!(control_executable_error( Some(&trusted), Some(&trusted_fd), &["unixnotis-center"], - true + true, + &HashMap::new(), + ) + .is_some()); + assert!(control_executable_error( + Some(&untrusted_name), + Some(&trusted_fd), + &["unknown"], + true, + &HashMap::new(), ) .is_some()); - assert!( - control_executable_error(Some(&untrusted_name), Some(&trusted_fd), &["unknown"], true) - .is_some() - ); } #[test] @@ -142,6 +156,7 @@ fn interaction_executable_policy_excludes_noninteractive_control_clients() { Some(&trusted_fd), &TRUSTED_INTERACTION_EXECUTABLES, true, + &HashMap::new(), ) .is_none()); } @@ -151,6 +166,7 @@ fn interaction_executable_policy_excludes_noninteractive_control_clients() { Some(&cli_fd), &TRUSTED_INTERACTION_EXECUTABLES, true, + &HashMap::new(), ) .is_some()); } diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs index 8a3f1a9fb..518bd8672 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs @@ -1,7 +1,5 @@ use std::path::Path; -use super::policy::{FileFingerprint, FileFingerprintSignature}; - pub(super) fn write_executable(path: &Path) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("create executable parent"); @@ -16,34 +14,3 @@ pub(super) fn write_executable(path: &Path) { std::fs::set_permissions(path, permissions).expect("set executable mode"); } } - -pub(super) fn test_signature(len: u64) -> FileFingerprintSignature { - let signed_len = i64::try_from(len).expect("test length should fit i64"); - FileFingerprintSignature { - len, - #[cfg(unix)] - dev: len + 1, - #[cfg(unix)] - ino: len + 2, - #[cfg(unix)] - mode: 0o755, - #[cfg(unix)] - uid: rustix::process::geteuid().as_raw(), - #[cfg(unix)] - gid: 1000, - #[cfg(unix)] - mtime: signed_len + 3, - #[cfg(unix)] - mtime_nsec: signed_len + 4, - #[cfg(unix)] - ctime: signed_len + 5, - #[cfg(unix)] - ctime_nsec: signed_len + 6, - } -} - -pub(super) fn test_fingerprint(len: u64) -> FileFingerprint { - FileFingerprint { - signature: test_signature(len), - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index e073e3329..b29262fdd 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}; @@ -6,6 +7,9 @@ use tokio::sync::Mutex; use unixnotis_core::Config; use zbus::Connection; +use crate::daemon::auth::{ + build_trusted_control_snapshots_for_current_executable, TrustedExecutableSnapshot, +}; use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; @@ -34,6 +38,8 @@ pub(in crate::daemon::state) struct UiHealthState { /// Shared daemon state guarded behind an async mutex pub struct DaemonState { pub store: Mutex, + // This map is built before the control object is exported and never rebuilt from callers + pub(in crate::daemon) trusted_executables: Arc>, /// Immutable sound settings resolved at startup pub sound: SoundSettings, pub(in crate::daemon::state) connection: Connection, @@ -98,8 +104,11 @@ impl DaemonState { preauthorized_control_owner: Option, ) -> Arc { // One construction path keeps scheduler, signal cache, and popup state in sync + let trusted_executables = + Arc::new(build_trusted_control_snapshots_for_current_executable()); Arc::new(Self { store: Mutex::new(store), + trusted_executables, sound, connection: connection.clone(), ui_health: StdRwLock::new(UiHealthState::default()), @@ -123,6 +132,12 @@ impl DaemonState { &self.connection } + pub(in crate::daemon) fn trusted_executables( + &self, + ) -> &HashMap { + &self.trusted_executables + } + pub(crate) fn set_desktop_index_refresh(&self, handle: DesktopIndexRefreshHandle) { let _ = self.desktop_index_refresh.set(handle); } From e5a38afb5ceed6201735da97b57abe4d75177077 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 20:02:23 -0500 Subject: [PATCH 248/275] security(installer): pin trusted Rust toolchain execution Resolve Cargo, rustc, and rustdoc from the effective account and execute canonical validated paths. Rebuild PATH from trusted toolchain and system directories, pin Rustup state, and remove inherited compiler and linker override variables. Cover poisoned HOME, PATH, and build-environment inputs with installer tests. --- crates/unixnotis-installer/Cargo.toml | 1 + .../src/actions/binaries.rs | 47 +- .../src/actions/build/compile.rs | 15 +- .../src/actions/tests/binaries.rs | 46 +- .../unixnotis-installer/src/checks/system.rs | 9 +- crates/unixnotis-installer/src/main.rs | 1 + .../src/tests/toolchain.rs | 199 ++++++++ crates/unixnotis-installer/src/toolchain.rs | 423 ++++++++++++++++++ crates/unixnotis-installer/src/trial/build.rs | 5 +- 9 files changed, 699 insertions(+), 47 deletions(-) create mode 100644 crates/unixnotis-installer/src/tests/toolchain.rs create mode 100644 crates/unixnotis-installer/src/toolchain.rs diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index 182fead48..d47d5aa92 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] anyhow.workspace = true +libc.workspace = true crossterm.workspace = true ratatui.workspace = true toml.workspace = true diff --git a/crates/unixnotis-installer/src/actions/binaries.rs b/crates/unixnotis-installer/src/actions/binaries.rs index aa89a2eee..25830e584 100644 --- a/crates/unixnotis-installer/src/actions/binaries.rs +++ b/crates/unixnotis-installer/src/actions/binaries.rs @@ -2,24 +2,34 @@ use std::collections::BTreeSet; use std::fs; -use std::path::PathBuf; -use std::process::Command; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use crate::managed_binaries::{is_managed_binary_name, validate_managed_binary_names}; use crate::paths::InstallPaths; -use unixnotis_core::program_in_path; +use crate::toolchain::{cargo_command, resolve_cargo}; pub(super) fn resolve_install_binaries(paths: &InstallPaths) -> Result> { + let cargo = if paths.is_release_archive() { + None + } else { + Some(resolve_cargo()?) + }; + resolve_install_binaries_with_cargo(paths, cargo.as_deref()) +} + +pub(super) fn resolve_install_binaries_with_cargo( + paths: &InstallPaths, + cargo: Option<&Path>, +) -> Result> { // Prefer the installer metadata list when it is present. let metadata_list = load_install_binaries_from_metadata(paths)?; - let cargo_available = program_in_path("cargo"); if !metadata_list.is_empty() { // Validate against cargo metadata when available to catch stale entries. - if cargo_available && !paths.is_release_archive() { + if let Some(cargo) = cargo { // An empty Cargo inventory is an error and cannot widen the declared list - let available = load_install_binaries_from_cargo_metadata(paths)?; + let available = load_install_binaries_from_cargo_metadata(paths, cargo)?; let missing = metadata_list .iter() .filter(|name| !available.contains(*name)) @@ -36,8 +46,8 @@ pub(super) fn resolve_install_binaries(paths: &InstallPaths) -> Result Result // Release archives already contain built binaries under their local bin directory return Ok(paths.repo_root.clone()); } - let metadata = load_cargo_metadata(paths)?; + let cargo = resolve_cargo()?; + resolve_target_directory_with_cargo(paths, &cargo) +} + +pub(super) fn resolve_target_directory_with_cargo( + paths: &InstallPaths, + cargo: &Path, +) -> Result { + let metadata = load_cargo_metadata(paths, cargo)?; Ok(metadata.target_directory) } @@ -183,14 +201,17 @@ struct ReleaseManifest { binaries: Vec, } -fn load_install_binaries_from_cargo_metadata(paths: &InstallPaths) -> Result> { - let metadata = load_cargo_metadata(paths)?; +fn load_install_binaries_from_cargo_metadata( + paths: &InstallPaths, + cargo: &Path, +) -> Result> { + let metadata = load_cargo_metadata(paths, cargo)?; extract_bins_from_metadata(&metadata) } -fn load_cargo_metadata(paths: &InstallPaths) -> Result { +fn load_cargo_metadata(paths: &InstallPaths, cargo: &Path) -> Result { // cargo metadata is the most robust source of workspace targets. - let output = Command::new("cargo") + let output = cargo_command(cargo)? .args(["metadata", "--no-deps", "--format-version", "1"]) .current_dir(&paths.repo_root) .output() diff --git a/crates/unixnotis-installer/src/actions/build/compile.rs b/crates/unixnotis-installer/src/actions/build/compile.rs index 0865df90a..686ea1dfa 100644 --- a/crates/unixnotis-installer/src/actions/build/compile.rs +++ b/crates/unixnotis-installer/src/actions/build/compile.rs @@ -2,7 +2,11 @@ use anyhow::{anyhow, Result}; -use super::super::{binaries::resolve_install_binaries, log_line, run_command, ActionContext}; +use crate::toolchain::{cargo_command, resolve_cargo}; + +use super::super::{ + binaries::resolve_install_binaries_with_cargo, log_line, run_command, ActionContext, +}; pub fn run_build(ctx: &mut ActionContext) -> Result<()> { if ctx.paths.is_release_archive() { @@ -13,15 +17,18 @@ pub fn run_build(ctx: &mut ActionContext) -> Result<()> { // Build release artifacts before copying them into the user bin directory log_line(ctx, "Building release binaries"); + // Resolve once so metadata discovery and the build use the same executable + let cargo = resolve_cargo()?; + // Resolve the managed binary list from installer metadata instead of guessing package names - let binaries = resolve_install_binaries(ctx.paths)?; + let binaries = resolve_install_binaries_with_cargo(ctx.paths, Some(&cargo))?; if binaries.is_empty() { return Err(anyhow!("no installable binaries discovered for build")); } // Installer metadata stores executable names because the same list drives copy and removal // Cargo needs those values as binary targets since a binary can differ from its package name - let mut build = std::process::Command::new("cargo"); + let mut build = cargo_command(&cargo)?; build.args(["build", "--release"]); add_binary_targets(&mut build, &binaries); @@ -45,7 +52,7 @@ fn verify_release_binaries(ctx: &mut ActionContext) -> Result<()> { log_line(ctx, "Using bundled release binaries"); // The same resolver feeds build, install, and uninstall so the managed set cannot drift - let binaries = resolve_install_binaries(ctx.paths)?; + let binaries = super::super::binaries::resolve_install_binaries(ctx.paths)?; if binaries.is_empty() { return Err(anyhow!( "release manifest did not list installable binaries" diff --git a/crates/unixnotis-installer/src/actions/tests/binaries.rs b/crates/unixnotis-installer/src/actions/tests/binaries.rs index ede408d47..8a6d81932 100644 --- a/crates/unixnotis-installer/src/actions/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/tests/binaries.rs @@ -1,16 +1,16 @@ use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ discover_installed_binaries, extract_bins_from_metadata, legacy_binaries, parse_install_binaries_metadata, parse_release_manifest_binaries, resolve_install_binaries, - resolve_install_binaries_best_effort, resolve_target_directory, CargoMetadata, + resolve_install_binaries_best_effort, resolve_install_binaries_with_cargo, + resolve_target_directory, resolve_target_directory_with_cargo, CargoMetadata, }; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; -use crate::test_support::env::EnvGuard; -use crate::test_support::fs::write_executable; #[test] fn parse_install_binaries_metadata_reads_entries() { @@ -118,11 +118,10 @@ fn release_resolution_does_not_compare_archive_names_with_cargo_metadata() { let root = test_root("release-resolution"); let paths = test_paths(&root); write_release_manifest(&paths, &["noticenterctl"]); - let fake_bin = write_fake_cargo( + let _fake_bin = write_fake_cargo( &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"unixnotis-daemon","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); let binaries = resolve_install_binaries(&paths).expect("release manifest should be authoritative"); @@ -138,9 +137,8 @@ fn workspace_resolution_rejects_declared_names_when_cargo_has_no_binary_targets( let paths = test_paths(&root); write_workspace_manifest(&paths, Some(&["noticenterctl"])); let fake_bin = write_fake_cargo(&root, r#"{"target_directory":"target","packages":[]}"#); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths) + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) .expect_err("an empty Cargo inventory must not widen declared names"); assert!(error.to_string().contains("managed binary list")); @@ -157,9 +155,9 @@ fn workspace_resolution_accepts_declared_names_present_in_cargo_metadata() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"noticenterctl","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = resolve_install_binaries(&paths).expect("matching target should be accepted"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("matching target should be accepted"); assert_eq!(binaries, vec!["noticenterctl".to_string()]); let _ = fs::remove_dir_all(root); @@ -175,10 +173,9 @@ fn workspace_resolution_ignores_internal_binary_targets() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"css-check","kind":["bin"]},{"name":"unixnotis-center","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = - resolve_install_binaries(&paths).expect("internal tools must not block source installs"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("internal tools must not block source installs"); assert_eq!(binaries, vec!["unixnotis-center".to_string()]); let _ = fs::remove_dir_all(root); @@ -194,9 +191,9 @@ fn workspace_resolution_rejects_declared_names_missing_from_cargo_metadata() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"unixnotis-daemon","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths).expect_err("missing target should be rejected"); + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect_err("missing target should be rejected"); assert!(error.to_string().contains("noticenterctl")); let _ = fs::remove_dir_all(root); @@ -209,9 +206,9 @@ fn workspace_resolution_rejects_an_empty_declared_and_discovered_set() { let paths = test_paths(&root); write_workspace_manifest(&paths, None); let fake_bin = write_fake_cargo(&root, r#"{"target_directory":"target","packages":[]}"#); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths).expect_err("empty discovery must fail closed"); + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect_err("empty discovery must fail closed"); assert!(error.to_string().contains("no installable binaries")); let _ = fs::remove_dir_all(root); @@ -227,13 +224,14 @@ fn workspace_resolution_uses_cargo_targets_when_the_declared_list_is_missing() { &root, r#"{"target_directory":"build-output","packages":[{"targets":[{"name":"noticenterctl","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = resolve_install_binaries(&paths).expect("cargo targets should provide fallback"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("cargo targets should provide fallback"); assert_eq!(binaries, vec!["noticenterctl".to_string()]); assert_eq!( - resolve_target_directory(&paths).expect("cargo target directory"), + resolve_target_directory_with_cargo(&paths, &fake_bin.join("cargo")) + .expect("cargo target directory"), PathBuf::from("build-output") ); let _ = fs::remove_dir_all(root); @@ -506,11 +504,11 @@ fn write_release_manifest(paths: &InstallPaths, binaries: &[&str]) { } fn write_fake_cargo(root: &std::path::Path, metadata: &str) -> PathBuf { - let fake_bin = root.join("fake-bin"); + let fake_bin = root.join("home").join(".cargo").join("bin"); fs::create_dir_all(&fake_bin).expect("fake cargo directory"); - write_executable( - &fake_bin.join("cargo"), - &format!("#!/bin/sh\nprintf '%s\\n' '{metadata}'\n"), - ); + let cargo = fake_bin.join("cargo"); + fs::write(&cargo, format!("#!/bin/sh\nprintf '%s\\n' '{metadata}'\n")) + .expect("write fake cargo"); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).expect("set fake cargo mode"); fake_bin } diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index ced012702..4a1892375 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -6,8 +6,8 @@ use std::path::Path; use crate::paths::{InstallPaths, ServiceManagerChoice}; use crate::service_manager::{CommandSpec, ReadinessIssue, ServiceManager}; use crate::system_tools; +use crate::toolchain::resolve_cargo; use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; -use unixnotis_core::program_in_path; use super::CheckItem; @@ -113,10 +113,9 @@ pub(super) fn cargo_check(release_archive: bool) -> CheckItem { return CheckItem::ok("cargo", "not required for release archive"); } - if program_in_path("cargo") { - CheckItem::ok("cargo", "available") - } else { - CheckItem::fail("cargo", "not installed") + match resolve_cargo() { + Ok(_) => CheckItem::ok("cargo", "available"), + Err(_) => CheckItem::fail("cargo", "not installed in approved toolchain locations"), } } diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 4913ea698..c38ec0ae0 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -16,6 +16,7 @@ mod terminal; #[cfg(test)] #[path = "tests/support/mod.rs"] mod test_support; +pub(crate) mod toolchain; mod trial; mod ui; mod write_target; diff --git a/crates/unixnotis-installer/src/tests/toolchain.rs b/crates/unixnotis-installer/src/tests/toolchain.rs new file mode 100644 index 000000000..1365c340d --- /dev/null +++ b/crates/unixnotis-installer/src/tests/toolchain.rs @@ -0,0 +1,199 @@ +use std::ffi::OsStr; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::test_support::env::EnvGuard; +use crate::test_support::fs::unique_temp_path; +use unixnotis_core::util::TRUSTED_SYSTEM_TOOL_DIRS; + +use super::{account_home_dir, cargo_command, resolve_cargo}; + +#[test] +fn cargo_resolution_ignores_poisoned_home_and_path_entries() { + let _lock = crate::test_support::env::test_env_lock(); + let root = unique_temp_path("cargo-poisoned-path"); + let poisoned_home = root.join("home"); + let poisoned_home_cargo = poisoned_home.join(".cargo/bin/cargo"); + let poisoned = root.join("poisoned/cargo"); + fs::create_dir_all(poisoned_home_cargo.parent().expect("poisoned HOME parent")) + .expect("poisoned HOME directory"); + fs::create_dir_all(poisoned.parent().expect("poisoned parent")).expect("poisoned directory"); + write_direct_executable(&poisoned_home_cargo, "#!/bin/sh\nexit 41\n"); + write_direct_executable(&poisoned, "#!/bin/sh\nexit 42\n"); + + let _home = EnvGuard::set("HOME", &poisoned_home); + let _path = EnvGuard::set("PATH", poisoned.parent().expect("poisoned parent")); + + let resolved = resolve_cargo().expect("Cargo should resolve from the account home"); + + assert!(resolved.is_absolute()); + assert_eq!( + resolved, + fs::canonicalize(&resolved).expect("resolved path canonicalized") + ); + assert!(!resolved.starts_with(&poisoned_home)); + assert_ne!( + resolved, + fs::canonicalize(poisoned).expect("poisoned path canonicalized") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn cargo_command_removes_compiler_override_environment() { + let _lock = crate::test_support::env::test_env_lock(); + let root = unique_temp_path("cargo-command-environment"); + fs::create_dir_all(&root).expect("create command root"); + let poisoned_path = poisoned_parent(&root); + fs::create_dir_all(&poisoned_path).expect("create poisoned PATH directory"); + let _environment = set_poisoned_cargo_environment(&root, &poisoned_path); + + let cargo = resolve_cargo().expect("trusted Cargo should resolve"); + let command = cargo_command(&cargo).expect("Cargo command should be configured"); + + assert_eq!(command.get_program(), cargo.as_os_str()); + assert_compiler_environment_is_pinned(&command); + assert_path_environment_is_sanitized(&command, &poisoned_path); + assert_account_paths_are_pinned(&command); + + let _ = fs::remove_dir_all(root); +} + +fn set_poisoned_cargo_environment(root: &Path, poisoned_path: &Path) -> Vec { + vec![ + EnvGuard::set("HOME", root.join("attacker-home")), + EnvGuard::set("PATH", poisoned_path), + EnvGuard::set("RUSTC", "/tmp/attacker-rustc"), + EnvGuard::set("RUSTDOC", "/tmp/attacker-rustdoc"), + EnvGuard::set("RUSTC_WRAPPER", "/tmp/attacker-wrapper"), + EnvGuard::set("RUSTC_WORKSPACE_WRAPPER", "/tmp/attacker-workspace-wrapper"), + EnvGuard::set("CARGO_BUILD_RUSTC_WRAPPER", "/tmp/attacker-cargo-wrapper"), + EnvGuard::set( + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "/tmp/attacker-cargo-workspace-wrapper", + ), + EnvGuard::set("RUSTUP_TOOLCHAIN", "/tmp/attacker-toolchain"), + EnvGuard::set("RUSTFLAGS", "--cfg attacker"), + EnvGuard::set("CARGO_ENCODED_RUSTFLAGS", "--cfg\u{1f}attacker"), + EnvGuard::set("CARGO_BUILD_RUSTFLAGS", "--cfg attacker-build"), + EnvGuard::set("CARGO_BUILD_ENCODED_RUSTFLAGS", "--cfg\u{1f}attacker-build"), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", + "/tmp/attacker-linker", + ), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "/tmp/attacker-runner", + ), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + "--cfg attacker-target", + ), + ] +} + +fn assert_compiler_environment_is_pinned(command: &Command) { + for variable in [ + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_ENCODED_RUSTFLAGS", + "RUSTUP_TOOLCHAIN", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + ] { + assert!( + command + .get_envs() + .find(|(name, _value)| *name == OsStr::new(variable)) + .is_some_and(|(_name, value)| value.is_none()), + "{variable} must be removed from the Cargo environment" + ); + } + + for variable in ["RUSTC", "RUSTDOC"] { + let value = command_env(command, variable) + .unwrap_or_else(|| panic!("{variable} must be pinned in the Cargo environment")); + let path = Path::new(value); + assert!(path.is_absolute(), "{variable} must be absolute"); + assert!(path.is_file(), "{variable} must name an executable"); + assert!( + fs::canonicalize(path) + .expect("compiler path should canonicalize") + .is_file(), + "{variable} canonical target must be a file" + ); + } +} + +fn assert_path_environment_is_sanitized(command: &Command, poisoned_path: &Path) { + let path_value = + command_env(command, "PATH").expect("Cargo PATH should be explicitly replaced"); + let path_dirs = std::env::split_paths(path_value).collect::>(); + assert!(!path_dirs.iter().any(|path| { + path.as_path() == poisoned_path + || path + .file_name() + .is_some_and(|name| name == OsStr::new("attacker-home")) + })); + let expected_system_dirs = TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .filter_map(|directory| fs::canonicalize(directory).ok()) + .fold(Vec::new(), |mut directories, directory| { + if !directories.contains(&directory) { + directories.push(directory); + } + directories + }); + assert!(path_dirs.ends_with(&expected_system_dirs)); + + let rustc_directory = fs::canonicalize( + Path::new(command_env(command, "RUSTC").expect("RUSTC should be present")) + .parent() + .expect("RUSTC should have a parent directory"), + ) + .expect("RUSTC parent should be canonicalized"); + assert!( + path_dirs.contains(&rustc_directory), + "PATH should contain the validated rustc directory" + ); +} + +fn assert_account_paths_are_pinned(command: &Command) { + let account_home = account_home_dir().expect("effective account home"); + assert_eq!(command_env(command, "HOME"), Some(account_home.as_os_str())); + assert_eq!( + command_env(command, "CARGO_HOME"), + Some(account_home.join(".cargo").as_os_str()) + ); + assert_eq!( + command_env(command, "RUSTUP_HOME"), + Some(account_home.join(".rustup").as_os_str()) + ); +} + +fn command_env<'a>(command: &'a Command, variable: &str) -> Option<&'a OsStr> { + command + .get_envs() + .find(|(name, _value)| *name == OsStr::new(variable)) + .and_then(|(_name, value)| value) +} + +fn write_direct_executable(path: &Path, contents: &str) { + fs::write(path, contents).expect("write executable"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("set executable mode"); +} + +fn poisoned_parent(root: &Path) -> PathBuf { + root.join("attacker-path") +} diff --git a/crates/unixnotis-installer/src/toolchain.rs b/crates/unixnotis-installer/src/toolchain.rs new file mode 100644 index 000000000..ec2a40421 --- /dev/null +++ b/crates/unixnotis-installer/src/toolchain.rs @@ -0,0 +1,423 @@ +//! Trusted Rust toolchain executable discovery + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(unix)] +use std::ffi::{CStr, OsStr}; +#[cfg(unix)] +use std::io; +#[cfg(unix)] +use std::mem::MaybeUninit; +#[cfg(unix)] +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +#[cfg(unix)] +use std::ptr; + +use anyhow::{anyhow, Result}; + +use unixnotis_core::util::TRUSTED_SYSTEM_TOOL_DIRS; + +#[cfg(unix)] +const PASSWD_BUFFER_START: usize = 1024; +#[cfg(unix)] +const PASSWD_BUFFER_LIMIT: usize = 1024 * 1024; +const CARGO_EXECUTION_ENV_VARS: [&str; 13] = [ + "RUSTC", + "RUSTDOC", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_ENCODED_RUSTFLAGS", + "RUSTUP_TOOLCHAIN", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", +]; + +#[derive(Clone, Debug)] +struct ResolvedToolchain { + cargo: PathBuf, + rustc: PathBuf, + rustdoc: PathBuf, + path: OsString, +} + +struct ValidatedExecutable { + launch_path: PathBuf, + canonical_path: PathBuf, +} + +/// Resolve Cargo without consulting the inherited PATH +pub fn resolve_cargo() -> Result { + Ok(resolve_toolchain()?.cargo) +} + +fn resolve_toolchain() -> Result { + let home = account_home_dir()?; + let cargo = resolve_cargo_path(&home)?; + resolve_toolchain_from_cargo(&home, &cargo) +} + +fn resolve_cargo_path(home: &Path) -> Result { + let mut candidates = vec![home.join(".cargo").join("bin").join("cargo")]; + candidates.extend( + TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .map(|directory| Path::new(directory).join("cargo")), + ); + + for candidate in candidates { + let Ok(validated) = validate_executable(&candidate) else { + continue; + }; + + // Rustup proxy paths need one trusted lookup to bind Cargo to the selected toolchain + if validated + .canonical_path + .file_name() + .is_some_and(|name| name == "rustup") + { + if let Ok(cargo) = rustup_which(&validated.canonical_path, home, "cargo") { + return Ok(cargo); + } + continue; + } + + // Return the canonical executable rather than reopening a candidate symlink later + return Ok(validated.canonical_path); + } + + Err(anyhow!( + "cargo was not found in the approved Rust toolchain locations" + )) +} + +/// Build a Cargo command with a stable argv[0] and a sanitized build environment +pub fn cargo_command(path: &Path) -> Result { + let home = account_home_dir()?; + let tools = resolve_toolchain_from_cargo(&home, path)?; + let mut command = Command::new(&tools.cargo); + + #[cfg(unix)] + { + // Rustup uses argv[0] to distinguish Cargo from the rustup frontend + command.arg0("cargo"); + } + + sanitize_command_environment(&mut command, &home, &tools.path); + // Absolute compiler paths prevent Cargo from resolving rustc or rustdoc through PATH + command.env("RUSTC", &tools.rustc); + command.env("RUSTDOC", &tools.rustdoc); + + Ok(command) +} + +fn resolve_toolchain_from_cargo(home: &Path, cargo: &Path) -> Result { + let cargo = validate_executable(cargo)?.canonical_path; + let rustc = resolve_compiler_tool(home, &cargo, "rustc")?; + let rustdoc = resolve_compiler_tool(home, &cargo, "rustdoc")?; + let path = trusted_tool_path(&cargo, &rustc, &rustdoc)?; + + Ok(ResolvedToolchain { + cargo, + rustc, + rustdoc, + path, + }) +} + +fn resolve_compiler_tool(home: &Path, cargo: &Path, tool: &str) -> Result { + if let Some(parent) = cargo.parent() { + let sibling = parent.join(tool); + if let Ok(validated) = validate_executable(&sibling) { + // Keep proxy basenames such as rustc and rustdoc so rustup dispatches correctly + if validated + .canonical_path + .file_name() + .is_some_and(|name| name == "rustup") + { + return Ok(validated.launch_path); + } + return Ok(validated.canonical_path); + } + } + + let rustup = resolve_rustup(home, cargo)?; + rustup_which(&rustup, home, tool) +} + +fn resolve_rustup(home: &Path, cargo: &Path) -> Result { + let mut candidates = Vec::new(); + if let Some(parent) = cargo.parent() { + candidates.push(parent.join("rustup")); + } + candidates.push(home.join(".cargo").join("bin").join("rustup")); + candidates.extend( + TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .map(|directory| Path::new(directory).join("rustup")), + ); + + candidates + .into_iter() + .find_map(|candidate| validate_executable(&candidate).ok()) + .map(|validated| validated.canonical_path) + .ok_or_else(|| anyhow!("rustup was not found in the approved toolchain locations")) +} + +fn rustup_which(rustup: &Path, home: &Path, tool: &str) -> Result { + let mut command = Command::new(rustup); + #[cfg(unix)] + command.arg0("rustup"); + command.args(["which", tool]); + sanitize_command_environment( + &mut command, + home, + &trusted_tool_path_from_directories(std::iter::empty())?, + ); + + let output = command + .output() + .map_err(|error| anyhow!("failed to resolve rustup {tool}: {error}"))?; + if !output.status.success() { + return Err(anyhow!( + "rustup could not resolve {tool}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let path = PathBuf::from( + String::from_utf8(output.stdout) + .map_err(|error| anyhow!("rustup returned a non-UTF-8 {tool} path: {error}"))? + .trim(), + ); + Ok(validate_executable(&path)?.canonical_path) +} + +fn sanitize_command_environment(command: &mut Command, home: &Path, path: &OsString) { + // Cargo and rustup must not read configuration from environment-selected homes + command.env("HOME", home); + command.env("CARGO_HOME", home.join(".cargo")); + // Rustup must use the account-owned toolchain registry selected by this resolver + command.env("RUSTUP_HOME", home.join(".rustup")); + // PATH is rebuilt from only validated toolchain and fixed system directories + command.env("PATH", path); + for variable in CARGO_EXECUTION_ENV_VARS { + // These variables can replace rustc or wrap every compiler invocation + command.env_remove(variable); + } + + #[cfg(unix)] + for (name, _value) in std::env::vars_os() { + // Target-specific linker, runner, and flags variables are dynamically named + if is_target_execution_variable(&name) { + command.env_remove(name); + } + } +} + +fn trusted_tool_path(cargo: &Path, rustc: &Path, rustdoc: &Path) -> Result { + let directories = [cargo, rustc, rustdoc] + .into_iter() + .filter_map(|path| path.parent()) + .map(validate_tool_directory) + .collect::>>()?; + trusted_tool_path_from_directories(directories) +} + +fn trusted_tool_path_from_directories( + directories: impl IntoIterator, +) -> Result { + let mut paths = Vec::new(); + for directory in directories { + if !paths.contains(&directory) { + paths.push(directory); + } + } + for directory in TRUSTED_SYSTEM_TOOL_DIRS { + // A platform may not provide every FHS directory, so absent system paths are skipped + if let Ok(directory) = validate_tool_directory(Path::new(directory)) { + if !paths.contains(&directory) { + paths.push(directory); + } + } + } + std::env::join_paths(paths).map_err(|error| anyhow!("failed to build trusted PATH: {error}")) +} + +fn validate_tool_directory(path: &Path) -> Result { + let canonical_path = fs::canonicalize(path).map_err(|error| { + anyhow!( + "failed to canonicalize tool directory {}: {error}", + path.display() + ) + })?; + let metadata = fs::metadata(&canonical_path)?; + if !metadata.is_dir() { + return Err(anyhow!( + "tool path parent is not a directory: {}", + canonical_path.display() + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // A writable group or other account could replace a tool after resolution + if metadata.permissions().mode() & 0o022 != 0 || metadata.permissions().mode() & 0o111 == 0 + { + return Err(anyhow!( + "tool directory has unsafe permissions: {}", + canonical_path.display() + )); + } + + // Only the current account or root may own a directory used for execution + let uid = metadata.uid(); + let expected_uid = rustix::process::geteuid().as_raw(); + if uid != expected_uid && uid != 0 { + return Err(anyhow!( + "tool directory has an unexpected owner: {}", + canonical_path.display() + )); + } + } + + Ok(canonical_path) +} + +fn validate_executable(path: &Path) -> Result { + if !path.is_absolute() { + return Err(anyhow!("tool path is not absolute: {}", path.display())); + } + let canonical_path = fs::canonicalize(path) + .map_err(|error| anyhow!("failed to canonicalize tool {}: {error}", path.display()))?; + if !is_acceptable_executable(&canonical_path) { + return Err(anyhow!( + "tool is not an acceptable executable: {}", + path.display() + )); + } + Ok(ValidatedExecutable { + launch_path: path.to_path_buf(), + canonical_path, + }) +} + +#[cfg(unix)] +fn is_target_execution_variable(name: &OsStr) -> bool { + let Some(target_setting) = name.as_bytes().strip_prefix(b"CARGO_TARGET_") else { + return false; + }; + + [b"_LINKER".as_slice(), b"_RUNNER", b"_RUSTFLAGS"] + .into_iter() + .any(|suffix| { + target_setting + .strip_suffix(suffix) + .is_some_and(|target| !target.is_empty()) + }) +} + +fn account_home_dir() -> Result { + #[cfg(unix)] + { + let uid = rustix::process::geteuid().as_raw() as libc::uid_t; + let mut buffer = vec![0_u8; PASSWD_BUFFER_START]; + + loop { + let mut passwd = MaybeUninit::::zeroed(); + let mut result = ptr::null_mut(); + // SAFETY: every pointer targets live storage owned by this scope, the buffer is + // writable for its full length, and libc writes the result pointer into result + let status = unsafe { + // getpwuid_r writes the passwd record and its strings into the caller buffer + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &raw mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return Err(anyhow!("effective UID has no passwd entry")); + } + // SAFETY: libc returned success with a non-null result, so passwd was initialized + let passwd = unsafe { passwd.assume_init() }; + if passwd.pw_dir.is_null() { + return Err(anyhow!("effective UID has no home directory")); + } + // SAFETY: pw_dir is a non-null NUL-terminated field owned by the live passwd + // record and remains valid while the backing buffer stays in scope + let home = unsafe { CStr::from_ptr(passwd.pw_dir).to_bytes().to_vec() }; + let home = PathBuf::from(OsString::from_vec(home)); + if !home.is_absolute() { + return Err(anyhow!("account home directory is not absolute")); + } + return Ok(home); + } + + if status != libc::ERANGE { + return Err(io::Error::from_raw_os_error(status).into()); + } + let next_size = buffer + .len() + .checked_mul(2) + .filter(|size| *size <= PASSWD_BUFFER_LIMIT) + .ok_or_else(|| anyhow!("passwd entry exceeds the supported size limit"))?; + buffer.resize(next_size, 0); + } + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "account home lookup is unsupported on this platform" + )) + } +} + +fn is_acceptable_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // Writable group or other bits would let another account replace the tool + if metadata.permissions().mode() & 0o022 != 0 || metadata.permissions().mode() & 0o111 == 0 + { + return false; + } + + // User toolchains belong to the current account; system tools may belong to root + let uid = metadata.uid(); + let expected_uid = rustix::process::geteuid().as_raw(); + uid == expected_uid || uid == 0 + } + + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +#[path = "tests/toolchain.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/trial/build.rs b/crates/unixnotis-installer/src/trial/build.rs index b939454a9..9abe5ffdf 100644 --- a/crates/unixnotis-installer/src/trial/build.rs +++ b/crates/unixnotis-installer/src/trial/build.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use crate::toolchain::{cargo_command, resolve_cargo}; + // Trial mode needs every runtime binary that may be spawned by the daemon const TRIAL_PACKAGES: [&str; 4] = [ "unixnotis-daemon", @@ -22,7 +24,8 @@ pub(super) struct TrialBinaries { pub(super) fn build_trial_binaries(repo_root: &Path) -> Result { // Build every runtime binary before launch so stale debug outputs are not reused - let mut command = std::process::Command::new("cargo"); + let cargo = resolve_cargo()?; + let mut command = cargo_command(&cargo)?; command.arg("build"); for package in TRIAL_PACKAGES { // Package arguments stay explicit so adding a runtime binary is visible here From 90e9fe1976cca5bc3e567fab02d2be21a1401372 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 21:39:28 -0500 Subject: [PATCH 249/275] fix(notifications): handle large images before storage Resolve an issue where certain notification images could fail to appear. Incoming notification images were being subjected to the smaller retained model limit before they could be downsampled. This caused otherwise valid large sender and content images to be discarded. Separate transient wire-image validation from persistent image storage, validate and downsample images before applying retained-model limits, and keep the existing wire and storage security bounds unchanged. Also fix file-backed visuals so source images can be safely decoded before being reduced to their requested display size. Add regression coverage for large, non-square, padded RGB, oversized, and invalid image inputs. --- Cargo.lock | 1 + .../src/model/image/normalize.rs | 6 + .../src/model/image/tests/model.rs | 4 + crates/unixnotis-daemon/Cargo.toml | 3 + .../notifications/ingress/payload/mod.rs | 2 +- .../ingress/payload/tests/visuals.rs | 24 +++ .../notifications/ingress/payload/visuals.rs | 23 ++- .../src/daemon/notifications/server/flow.rs | 42 +++- .../server/notify_body/limits.rs | 2 + .../notifications/server/notify_body/mod.rs | 5 +- .../server/notify_body/tests/limits.rs | 4 +- .../daemon/notifications/server/tests/flow.rs | 64 ++++++ .../notifications/server/tests/ingress.rs | 14 +- .../notifications/server/wire_hints/decode.rs | 9 +- .../server/wire_hints/image_bytes.rs | 188 +++++++++++++++++- .../notifications/server/wire_hints/mod.rs | 11 +- .../server/wire_hints/tests/image_bytes.rs | 105 ++++++++++ 17 files changed, 463 insertions(+), 44 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs diff --git a/Cargo.lock b/Cargo.lock index 465c1cb3e..cf50847f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3677,6 +3677,7 @@ dependencies = [ "clap", "futures-util", "gio", + "image", "indexmap", "libc", "notify", diff --git a/crates/unixnotis-core/src/model/image/normalize.rs b/crates/unixnotis-core/src/model/image/normalize.rs index 298b25599..0b2527809 100644 --- a/crates/unixnotis-core/src/model/image/normalize.rs +++ b/crates/unixnotis-core/src/model/image/normalize.rs @@ -9,6 +9,12 @@ impl NotificationImage { MAX_IMAGE_BYTES } + /// Returns the maximum dimension retained by the notification model + #[must_use] + pub const fn retained_dimension_limit() -> i32 { + MAX_IMAGE_DIMENSION + } + pub(super) fn is_image_data_usable(data: &ImageData) -> bool { // Hard dimension caps keep texture creation and D-Bus payloads predictable if data.width > MAX_IMAGE_DIMENSION || data.height > MAX_IMAGE_DIMENSION { diff --git a/crates/unixnotis-core/src/model/image/tests/model.rs b/crates/unixnotis-core/src/model/image/tests/model.rs index c73f81312..8f733c990 100644 --- a/crates/unixnotis-core/src/model/image/tests/model.rs +++ b/crates/unixnotis-core/src/model/image/tests/model.rs @@ -12,5 +12,9 @@ fn image_models_default_to_empty_bounded_payloads() { assert_eq!(MAX_IMAGE_BYTES, 256 * 1024); assert_eq!(MAX_IMAGE_DIMENSION, 256); assert_eq!(NotificationImage::retained_byte_limit(), MAX_IMAGE_BYTES); + assert_eq!( + NotificationImage::retained_dimension_limit(), + MAX_IMAGE_DIMENSION + ); assert_eq!(ImageData::default().width, 0); } diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index ef1238ffe..f47b15ce3 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -29,3 +29,6 @@ tree-sitter.workspace = true tree-sitter-bash.workspace = true url.workspace = true wait-timeout.workspace = true + +[dev-dependencies] +image.workspace = true diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs index 7f66c68ee..9379a707b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -8,7 +8,7 @@ pub(in crate::daemon::notifications) use build::{build_notification, Notificatio pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) use visuals::{ materialize_sender_visual, may_materialize_content_image, sender_visual_role, SenderVisualRole, - CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_CONTENT_DIMENSION, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs index a44497832..45c8b1e97 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -2,6 +2,8 @@ use super::super::visuals::{ downsample_avatar, local_avatar_path, valid_percent_escapes, MAX_DECODE_DIMENSION, }; use super::*; +use image::codecs::png::PngEncoder; +use image::{ExtendedColorType, ImageEncoder}; #[test] fn associated_sender_role_accepts_inline_reply_and_message_categories() { let attribution = unixnotis_core::NotificationAttribution::associated( @@ -274,6 +276,28 @@ fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { assert_eq!(avatar.data.len(), 4); } +#[test] +fn large_file_visual_is_decoded_before_avatar_downsampling() { + let pixels = vec![128_u8; 256 * 256 * 4]; + let mut png = Vec::new(); + PngEncoder::new(&mut png) + .write_image(&pixels, 256, 256, ExtendedColorType::Rgba8) + .expect("encode large avatar fixture"); + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unixnotis-large-avatar-{suffix}.png")); + std::fs::write(&path, png).expect("write large avatar fixture"); + + let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); + let _ = std::fs::remove_file(&path); + + let avatar = avatar.expect("large source should be decoded before downsampling"); + assert_eq!((avatar.width, avatar.height), (64, 64)); + assert_eq!(avatar.data.len(), 64 * 64 * 4); +} + #[test] fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { assert!(avatar_file_size_allowed(MAX_SENDER_VISUAL_BYTES)); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs index 376c790d8..52a3b7eb3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -21,7 +21,7 @@ use crate::daemon::notifications::identity::DesktopIdentityIndex; use super::owned_to_string; pub(in crate::daemon::notifications::ingress) const MAX_SENDER_VISUAL_BYTES: u64 = 2_097_152; -const MAX_STORED_AVATAR_DIMENSION: u32 = 64; +pub(in crate::daemon::notifications) const MAX_STORED_AVATAR_DIMENSION: u32 = 64; pub(in crate::daemon::notifications::ingress) const MAX_DECODE_DIMENSION: u32 = MAX_STORED_AVATAR_DIMENSION * 8; pub(in crate::daemon::notifications) const MAX_STORED_CONTENT_DIMENSION: u32 = 256; @@ -124,18 +124,25 @@ pub(in crate::daemon::notifications) fn materialize_sender_visual( } // Keep the decoder bound independent from the UI-requested size - let max_dimension = bounded_decode_dimension(max_dimension); + let target_dimension = bounded_decode_dimension(max_dimension); + let decode_dimension = MAX_DECODE_DIMENSION; + // Encoded and decoded source limits remain fixed while the final target stays role-specific + let decode_pixels = u64::from(decode_dimension).checked_mul(u64::from(decode_dimension))?; let policy = AssetPolicy { max_bytes: MAX_SENDER_VISUAL_BYTES, - max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(max_dimension), - max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(max_dimension), - max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS - .min(u64::from(max_dimension).checked_mul(u64::from(max_dimension))?), + max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(decode_dimension), + max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(decode_dimension), + max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS.min(decode_pixels), allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, }; + // Downsample only after the source has passed the independent decode policy let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; - let (width, height, rgba) = - downsample_avatar(decoded.width, decoded.height, decoded.rgba, max_dimension)?; + let (width, height, rgba) = downsample_avatar( + decoded.width, + decoded.height, + decoded.rgba, + target_dimension, + )?; let width = i32::try_from(width).ok()?; let height = i32::try_from(height).ok()?; let rowstride = width.checked_mul(4)?; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index e7e4f6134..41fa32af3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -13,7 +13,7 @@ use crate::daemon::notifications::identity::{ use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, - MAX_STORED_CONTENT_DIMENSION, + MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::InsertOutcome; @@ -33,7 +33,7 @@ struct WireNotification { body: String, actions: Vec, hints: HashMap, - image_data: Option, + wire_image_data: Option, image_path: Option, expire_timeout: i32, } @@ -62,7 +62,7 @@ impl NotificationServer { replaces_id, expire_timeout, ); - let (hints, image_data, image_path) = hints.into_parts(); + let (hints, wire_image_data, image_path) = hints.into_parts(); let notification = self .notification_from_wire( WireNotification { @@ -72,7 +72,7 @@ impl NotificationServer { body, actions, hints, - image_data, + wire_image_data, image_path, expire_timeout, }, @@ -161,13 +161,11 @@ impl NotificationServer { materialize_sender_visual_for_role(sender_visual_role, input.app_icon.clone()).await; let materialized_content = materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; - // Communication image-data is a bounded conversation visual, not a message attachment - let (image_data, wire_sender_visual) = - if matches!(sender_visual_role, SenderVisualRole::ConversationAvatar) { - (materialized_content, input.image_data) - } else { - (input.image_data.or(materialized_content), None) - }; + let (image_data, wire_sender_visual) = normalize_wire_image_for_role( + sender_visual_role, + input.wire_image_data, + materialized_content, + ); if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -333,6 +331,28 @@ impl NotificationServer { } } +fn normalize_wire_image_for_role( + role: SenderVisualRole, + wire_image_data: Option, + materialized_content: Option, +) -> (Option, Option) { + match role { + SenderVisualRole::ConversationAvatar => { + // Communication artwork becomes a small sender visual before model storage + let sender_visual = wire_image_data + .and_then(|image| image.into_storage_image(MAX_STORED_AVATAR_DIMENSION)); + (materialized_content, sender_visual) + } + // Non-communication artwork uses the larger content-image storage bound + SenderVisualRole::ApplicationProvidedIcon | SenderVisualRole::None => { + let content_image = wire_image_data + .and_then(|image| image.into_storage_image(MAX_STORED_CONTENT_DIMENSION)) + .or(materialized_content); + (content_image, None) + } + } +} + async fn materialize_sender_visual_for_role( role: SenderVisualRole, app_icon: String, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs index 936b25d9a..55979254f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs @@ -3,6 +3,8 @@ // Common native clients send decoded 1024x1024 RGBA application or contact images pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_IMAGE_BYTES: usize = 4 * 1024 * 1024; +// Keep the wire geometry bound explicit even when a sparse row layout uses fewer bytes +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_IMAGE_DIMENSION: u32 = 1024; // The image allowance plus bounded strings, actions, hints, and D-Bus alignment pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_BODY_BYTES: usize = MAX_NOTIFY_WIRE_IMAGE_BYTES + 128 * 1024; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs index 54c0d35a7..56c771a5f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs @@ -6,7 +6,10 @@ mod signature; mod validator; mod value; -pub(super) use limits::{PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES}; +pub(super) use limits::{ + PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, + MAX_NOTIFY_WIRE_IMAGE_DIMENSION, +}; pub(super) use validator::preflight_notify; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs index 4066c1bd4..41cd89bb8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs @@ -1,11 +1,13 @@ use super::super::limits::{ MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, MAX_NON_IMAGE_STRING_BYTES, - MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_SIGNATURE_DEPTH, + MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_NOTIFY_WIRE_IMAGE_DIMENSION, + MAX_SIGNATURE_DEPTH, }; #[test] fn raw_body_limits_keep_the_reviewed_byte_and_depth_boundaries() { assert_eq!(MAX_NOTIFY_WIRE_IMAGE_BYTES, 4_194_304); + assert_eq!(MAX_NOTIFY_WIRE_IMAGE_DIMENSION, 1024); assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 4_325_376); assert_eq!(MAX_NON_IMAGE_ARRAY_BYTES, 16_384); assert_eq!(MAX_NON_IMAGE_STRING_BYTES, 65_536); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 34e0391a2..7f651d086 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -15,6 +15,10 @@ use zbus::message::Type; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, MatchRule, Message, MessageStream}; +use crate::daemon::notifications::identity::SenderMetadata; +use crate::daemon::notifications::ingress::payload::{ + build_notification, NotificationInput, SenderVisualRole, +}; use crate::daemon::{DaemonState, NotificationServer}; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; @@ -171,6 +175,66 @@ fn log_received_notification_reports_true_when_debug_is_enabled() { assert!(logged); } +#[test] +fn conversation_avatar_wire_image_is_stored_with_the_avatar_role_and_bound() { + // Model the validated wire object immediately before notification flow routing + let wire_image = super::super::wire_hints::WireImageData::from_parts( + 320, + 320, + 320 * 4, + true, + 8, + 4, + vec![19_u8; 320 * 320 * 4], + ) + .expect("320x320 communication image should pass wire validation"); + // The communication role must send the wire image down the sender-visual branch + let (content_image, sender_visual_data) = super::normalize_wire_image_for_role( + SenderVisualRole::ConversationAvatar, + Some(wire_image), + None, + ); + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: content_image, + sender_visual_data, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert_eq!( + ( + notification.image.sender_visual.width, + notification.image.sender_visual.height + ), + (64, 64) + ); + assert_eq!(notification.image.sender_visual.data.len(), 64 * 64 * 4); + assert!(notification.image.content_image.data.is_empty()); +} + #[tokio::test] async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index 1a6154702..0a368e83e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -5,6 +5,7 @@ use std::time::Duration; use zbus::zvariant::{OwnedValue, SerializeValue, Structure, Value}; use zbus::{Connection, Message}; +use super::super::notify_body::MAX_NOTIFY_WIRE_IMAGE_BYTES; use super::{ notify_body_is_oversized, notify_has_unix_fds, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES, }; @@ -112,7 +113,7 @@ async fn oversized_hint_map_is_rejected_before_notify_deserialization() { #[tokio::test] async fn oversized_image_array_is_rejected_before_notify_deserialization() { let (state, client) = notification_ingress().await; - let error = send_image_notification(&state, &client, 1, 1, 4, MAX_NOTIFY_WIRE_BODY_BYTES + 1) + let error = send_image_notification(&state, &client, 1, 1, 4, MAX_NOTIFY_WIRE_IMAGE_BYTES + 1) .await .expect_err("image above the wire limit must fail"); @@ -124,7 +125,7 @@ async fn oversized_image_array_is_rejected_before_notify_deserialization() { } #[tokio::test] -async fn native_image_above_retained_limit_keeps_the_text_notification() { +async fn native_image_above_retained_limit_is_downsampled_before_storage() { let (state, client) = notification_ingress().await; let reply = send_image_notification(&state, &client, 1_024, 1_024, 4_096, 1_024 * 1_024 * 4) .await @@ -138,7 +139,14 @@ async fn native_image_above_retained_limit_keeps_the_text_notification() { .expect("notification should be retained"); assert_eq!(active.summary, "summary"); - assert!(active.image.content_image.data.is_empty()); + assert_eq!( + ( + active.image.content_image.width, + active.image.content_image.height + ), + (256, 256) + ); + assert_eq!(active.image.content_image.data.len(), 256 * 256 * 4); } #[tokio::test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs index bac640e11..d0b564d2d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs @@ -4,10 +4,9 @@ use std::collections::HashMap; use serde::de::{DeserializeSeed, Deserializer, Error as _, MapAccess, SeqAccess, Visitor}; use serde::Deserialize; -use unixnotis_core::ImageData; use zbus::zvariant::{OwnedValue, Signature, Value}; -use super::image_bytes::BoundedImageBytes; +use super::image_bytes::{BoundedImageBytes, WireImageData}; use super::WireHints; impl<'de> Deserialize<'de> for WireHints { @@ -77,7 +76,7 @@ impl<'de> Visitor<'de> for WireHintsVisitor { Ok(WireHints { values, - image_data: standard_image.or(legacy_image).or(legacy_icon), + wire_image_data: standard_image.or(legacy_image).or(legacy_icon), image_path, }) } @@ -115,7 +114,7 @@ enum DecodedHint { Text(String), Bool(bool), Urgency(u32), - Image(Option), + Image(Option), } struct HintVariantSeed { @@ -182,7 +181,7 @@ impl<'de> Visitor<'de> for HintVariantVisitor { .ok_or_else(|| A::Error::invalid_length(1, &self))?; let image = raw .6 - .into_image_data(raw.0, raw.1, raw.2, raw.3, raw.4, raw.5); + .into_wire_image(raw.0, raw.1, raw.2, raw.3, raw.4, raw.5); Ok(DecodedHint::Image(image)) } HintKind::Text | HintKind::Bool | HintKind::Urgency | HintKind::Image => Err( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs index d9a1aaad3..0ff0cecd1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs @@ -3,16 +3,153 @@ use serde::de::{SeqAccess, Visitor}; use serde::{Deserialize, Deserializer}; -use unixnotis_core::ImageData; +use unixnotis_core::{ImageData, NotificationImage}; -/// Raw images larger than the retained model limit are consumed but never allocated +use super::super::notify_body::{MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_NOTIFY_WIRE_IMAGE_DIMENSION}; + +/// Raw image bytes retained only under the D-Bus wire budget #[derive(Debug, Default)] pub(super) struct BoundedImageBytes { data: Option>, } +/// Validated wire pixels that have not entered the retained notification model +#[derive(Debug)] +pub(in crate::daemon::notifications::server) struct WireImageData { + width: u32, + height: u32, + rowstride: usize, + channels: u8, + data: Vec, +} + +impl WireImageData { + pub(in crate::daemon::notifications::server) fn from_parts( + width: i32, + height: i32, + rowstride: i32, + _has_alpha: bool, + bits_per_sample: i32, + channels: i32, + data: Vec, + ) -> Option { + // Reject metadata before any pixel index is calculated + if bits_per_sample != 8 { + return None; + } + let width = u32::try_from(width).ok()?; + let height = u32::try_from(height).ok()?; + if width == 0 + || height == 0 + || width > MAX_NOTIFY_WIRE_IMAGE_DIMENSION + || height > MAX_NOTIFY_WIRE_IMAGE_DIMENSION + { + return None; + } + let channels = u8::try_from(channels).ok()?; + if !matches!(channels, 3 | 4) { + return None; + } + if data.is_empty() || data.len() > MAX_NOTIFY_WIRE_IMAGE_BYTES { + return None; + } + + // The stride must cover every visible pixel in every row + let width_usize = usize::try_from(width).ok()?; + let height_usize = usize::try_from(height).ok()?; + let channels_usize = usize::from(channels); + let minimum_rowstride = width_usize.checked_mul(channels_usize)?; + let rowstride = usize::try_from(rowstride).ok()?; + if rowstride < minimum_rowstride { + return None; + } + let required_bytes = rowstride.checked_mul(height_usize)?; + if data.len() < required_bytes { + return None; + } + + // Extra row padding stays transient and is discarded during output sampling + Some(Self { + width, + height, + rowstride, + channels, + data, + }) + } + + pub(in crate::daemon::notifications::server) fn into_storage_image( + self, + requested_dimension: u32, + ) -> Option { + // Clamp the requested output to the persistent model's dimension policy + let model_dimension = u32::try_from(NotificationImage::retained_dimension_limit()).ok()?; + let target_dimension = requested_dimension.min(model_dimension); + if target_dimension == 0 { + return None; + } + + let Self { + width, + height, + rowstride, + channels, + data, + } = self; + let (target_width, target_height) = target_dimensions(width, height, target_dimension)?; + let target_pixels = usize::try_from(target_width) + .ok()? + .checked_mul(usize::try_from(target_height).ok()?)?; + let output_len = target_pixels.checked_mul(4)?; + let mut rgba = vec![0_u8; output_len]; + let channels = usize::from(channels); + let source_width = usize::try_from(width).ok()?; + let source_height = usize::try_from(height).ok()?; + let target_width_usize = usize::try_from(target_width).ok()?; + let target_height_usize = usize::try_from(target_height).ok()?; + + // Sample source pixels directly so a large wire raster never becomes a second full copy + for target_y in 0..target_height_usize { + let source_y = target_y + .checked_mul(source_height)? + .checked_div(target_height_usize)?; + for target_x in 0..target_width_usize { + let source_x = target_x + .checked_mul(source_width)? + .checked_div(target_width_usize)?; + let source_index = source_y + .checked_mul(rowstride)? + .checked_add(source_x.checked_mul(channels)?)?; + let source_end = source_index.checked_add(channels)?; + let source_pixel = data.get(source_index..source_end)?; + let target_index = target_y + .checked_mul(target_width_usize)? + .checked_add(target_x)? + .checked_mul(4)?; + let target_pixel = rgba.get_mut(target_index..target_index + 4)?; + target_pixel[..3].copy_from_slice(&source_pixel[..3]); + target_pixel[3] = if channels == 4 { source_pixel[3] } else { 255 }; + } + } + + // The retained validator remains the final model boundary after downsampling + let width = i32::try_from(target_width).ok()?; + let height = i32::try_from(target_height).ok()?; + let rowstride = width.checked_mul(4)?; + NotificationImage::normalize_image_data(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) + } +} + impl BoundedImageBytes { - pub(super) fn into_image_data( + pub(super) fn into_wire_image( self, width: i32, height: i32, @@ -20,9 +157,9 @@ impl BoundedImageBytes { has_alpha: bool, bits_per_sample: i32, channels: i32, - ) -> Option { + ) -> Option { let data = self.data?; - unixnotis_core::NotificationImage::normalize_image_data(ImageData { + WireImageData::from_parts( width, height, rowstride, @@ -30,7 +167,7 @@ impl BoundedImageBytes { bits_per_sample, channels, data, - }) + ) } } @@ -56,15 +193,16 @@ impl<'de> Visitor<'de> for BoundedImageBytesVisitor { where A: SeqAccess<'de>, { - let retained_limit = unixnotis_core::NotificationImage::retained_byte_limit(); + // The wire limit is separate from the smaller persistent image budget + let wire_image_limit = MAX_NOTIFY_WIRE_IMAGE_BYTES; let mut data = Some(Vec::new()); while let Some(byte) = sequence.next_element::()? { let Some(retained) = data.as_mut() else { continue; }; - if retained.len() == retained_limit { - // Release a partial buffer as soon as the optional image crosses the limit + if retained.len() == wire_image_limit { + // Release a partial buffer as soon as the wire allowance is crossed data = None; continue; } @@ -74,3 +212,35 @@ impl<'de> Visitor<'de> for BoundedImageBytesVisitor { Ok(BoundedImageBytes { data }) } } + +fn target_dimensions(width: u32, height: u32, target_dimension: u32) -> Option<(u32, u32)> { + // Preserve source proportions while keeping both output axes within the target + if width >= height { + Some(( + target_dimension.min(width), + scaled_dimension(height, width, target_dimension.min(width)), + )) + } else { + Some(( + scaled_dimension(width, height, target_dimension.min(height)), + target_dimension.min(height), + )) + } +} + +fn scaled_dimension(value: u32, source_dimension: u32, target_dimension: u32) -> u32 { + if source_dimension <= target_dimension { + return value; + } + // Checked arithmetic keeps future limit changes from turning geometry into a wrap + u64::from(value) + .checked_mul(u64::from(target_dimension)) + .and_then(|scaled| scaled.checked_div(u64::from(source_dimension))) + .and_then(|scaled| u32::try_from(scaled).ok()) + .unwrap_or(1) + .max(1) +} + +#[cfg(test)] +#[path = "tests/image_bytes.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs index 8a4cd6f9a..35a505dbf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs @@ -5,14 +5,15 @@ mod image_bytes; use std::collections::HashMap; -use unixnotis_core::ImageData; use zbus::zvariant::{OwnedValue, Signature, Type}; +pub(super) use self::image_bytes::WireImageData; + /// Hints decoded without expanding large byte arrays into per-byte dynamic values #[derive(Debug, Default)] pub(super) struct WireHints { values: HashMap, - image_data: Option, + wire_image_data: Option, image_path: Option, } @@ -21,10 +22,10 @@ impl WireHints { self, ) -> ( HashMap, - Option, + Option, Option, ) { - (self.values, self.image_data, self.image_path) + (self.values, self.wire_image_data, self.image_path) } } @@ -38,7 +39,7 @@ impl From> for WireHints { .and_then(|value| String::try_from(value).ok()); Self { values, - image_data: None, + wire_image_data: None, image_path, } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs new file mode 100644 index 000000000..752e5f986 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn large_wire_avatar_is_downsampled_before_model_validation() { + let wire = WireImageData::from_parts(320, 320, 320 * 4, true, 8, 4, vec![17_u8; 320 * 320 * 4]) + .expect("320x320 wire avatar should be valid"); + + let image = wire + .into_storage_image(64) + .expect("valid wire avatar should be reduced to storage size"); + + assert_eq!((image.width, image.height), (64, 64)); + assert_eq!(image.data.len(), 64 * 64 * 4); + assert!(image.data.iter().all(|byte| *byte == 17)); +} + +#[test] +fn non_square_wire_images_preserve_aspect_ratio_during_downsampling() { + let wire = WireImageData::from_parts(320, 160, 320 * 4, true, 8, 4, vec![23_u8; 320 * 160 * 4]) + .expect("non-square wire image should be valid"); + + let image = wire + .into_storage_image(64) + .expect("non-square wire image should normalize"); + + assert_eq!((image.width, image.height), (64, 32)); + assert_eq!(image.data.len(), 64 * 32 * 4); +} + +#[test] +fn maximum_wire_raster_is_reduced_to_the_content_model_bound() { + let wire = WireImageData::from_parts( + 1024, + 1024, + 1024 * 4, + true, + 8, + 4, + vec![31_u8; MAX_NOTIFY_WIRE_IMAGE_BYTES], + ) + .expect("maximum documented wire raster should be valid"); + + let image = wire + .into_storage_image(256) + .expect("maximum wire raster should be reduced before storage"); + + assert_eq!((image.width, image.height), (256, 256)); + assert_eq!(image.data.len(), 256 * 256 * 4); +} + +#[test] +fn padded_rgb_wire_rows_are_tightly_packed_as_rgba() { + let mut data = vec![0xee_u8; 2 * 8]; + data[..6].copy_from_slice(&[1, 2, 3, 4, 5, 6]); + data[8..14].copy_from_slice(&[7, 8, 9, 10, 11, 12]); + let wire = WireImageData::from_parts(2, 2, 8, false, 8, 3, data) + .expect("padded RGB rows should be valid"); + + let image = wire + .into_storage_image(2) + .expect("padded RGB rows should normalize"); + + assert_eq!( + image.data, + [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255] + ); +} + +#[test] +fn wire_image_metadata_and_bounds_fail_closed() { + let valid_data = vec![0_u8; 4]; + assert!(WireImageData::from_parts(0, 1, 4, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 0, 4, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1025, 1, 4100, true, 8, 4, vec![0; 4100]).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 16, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 2, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 3, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 4, vec![0; 3]).is_none()); + assert!(WireImageData::from_parts( + 1, + 1, + 4, + true, + 8, + 4, + vec![0; MAX_NOTIFY_WIRE_IMAGE_BYTES + 1] + ) + .is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 4, valid_data) + .expect("valid image") + .into_storage_image(0) + .is_none()); +} + +#[test] +fn byte_array_decoder_reports_the_expected_input_shape() { + let error = BoundedImageBytes::deserialize(serde::de::value::UnitDeserializer::< + serde::de::value::Error, + >::new()) + .expect_err("unit input is not a byte sequence"); + + assert!(error + .to_string() + .contains("a bounded notification image byte array")); +} From b174e77fab91c672c7800bc9d27cce9bcf370d8d Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 21:49:38 -0500 Subject: [PATCH 250/275] ci: align Rust toolchain with effective account home --- .github/workflows/ci.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad78ef214..a7b342d0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,24 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail + account_home="$(getent passwd "$(id -u)" | cut -d: -f6)" + test -n "$account_home" + case "$account_home" in + /*) ;; + *) + echo "effective account home is not absolute: $account_home" >&2 + exit 1 + ;; + esac + # Keep the CI toolchain under the same account-owned home used by the installer + export HOME="$account_home" + export CARGO_HOME="$account_home/.cargo" + export RUSTUP_HOME="$account_home/.rustup" + printf '%s\n' \ + "HOME=$HOME" \ + "CARGO_HOME=$CARGO_HOME" \ + "RUSTUP_HOME=$RUSTUP_HOME" \ + >> "$GITHUB_ENV" rustup_init="${RUNNER_TEMP}/rustup-init" curl --proto '=https' --tlsv1.2 -fsS \ "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ @@ -116,7 +134,7 @@ jobs: "$rustup_init" -y --profile minimal --default-toolchain 1.96.1 \ --component rustfmt,clippy --no-modify-path rm -f "$rustup_init" - echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" + echo "${CARGO_HOME}/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" rustup default 1.96.1 cargo install cargo-audit --locked --version 0.22.0 From 702198006b42a8e49960d65254921ddc9b82dc6a Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 6 Aug 2026 22:08:31 -0500 Subject: [PATCH 251/275] ci: preserve runner home for GitHub actions --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b342d0c..791546384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,6 @@ jobs: export CARGO_HOME="$account_home/.cargo" export RUSTUP_HOME="$account_home/.rustup" printf '%s\n' \ - "HOME=$HOME" \ "CARGO_HOME=$CARGO_HOME" \ "RUSTUP_HOME=$RUSTUP_HOME" \ >> "$GITHUB_ENV" From 7784724920a68bcb09180a519ef174588f54c3f2 Mon Sep 17 00:00:00 2001 From: locainin Date: Fri, 7 Aug 2026 23:51:22 -0500 Subject: [PATCH 252/275] security: harden notification rule identity and cut config v5 Make config v5 a clean schema break and reject older schemas instead of silently migrating changed semantics. Match app= rules against daemon-resolved application identity and add claimed_app= for explicit matching of sender-provided app_name metadata. Use an explicit positive assurance allowlist so future assurance states fail closed. Keep forced urgency and retained urgency hints synchronized, and update config consumers and tests for the v5 schema. --- .../src/css_check/tests/command.rs | 3 + .../src/css_check/theme/tests/helpers.rs | 8 +- .../src/preset/command_rules/tests/cases.rs | 21 +- .../src/preset/command_rules/tests/layout.rs | 3 +- .../src/preset/command_rules/tests/support.rs | 20 +- .../preset/command_rules/tests/validation.rs | 3 +- .../src/preset/export/tests/support.rs | 8 +- .../src/preset/import/review/tests/checks.rs | 45 +-- .../src/preset/import/tests/helpers.rs | 8 +- .../preset/import/transaction/tests/apply.rs | 7 +- .../preset/import/transaction/tests/commit.rs | 21 +- .../import/transaction/tests/prepare.rs | 7 +- .../import/transaction/tests/prepare_exec.rs | 2 +- .../noticenterctl/src/preset/tests/inspect.rs | 16 +- crates/noticenterctl/src/tests/support.rs | 109 ++++++ .../src/config/loading/diagnostics.rs | 33 +- .../src/config/loading/io/load.rs | 18 +- .../src/config/loading/io/tests/load.rs | 44 ++- .../src/config/loading/tests/diagnostics.rs | 65 +--- crates/unixnotis-core/src/config/types.rs | 2 +- .../src/config/validation/mod.rs | 2 +- .../src/config/validation/rules.rs | 4 +- .../src/config/validation/schema.rs | 330 +----------------- .../validation/tests/fixtures/config-v0.toml | 11 - .../tests/fixtures/config-v2-partial.toml | 13 - .../src/config/validation/tests/schema.rs | 272 +++------------ .../unixnotis-core/src/model/notification.rs | 10 + .../unixnotis-core/src/model/tests/types.rs | 7 + crates/unixnotis-core/src/model/types.rs | 5 + .../src/store/notifications/rules.rs | 31 +- .../src/store/notifications/tests/rules.rs | 155 ++++++++ .../actions/config/backup/tests/restore.rs | 13 +- .../src/actions/config/tests/provision.rs | 15 +- .../src/tests/support/mod.rs | 6 + 34 files changed, 562 insertions(+), 755 deletions(-) delete mode 100644 crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml delete mode 100644 crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml diff --git a/crates/noticenterctl/src/css_check/tests/command.rs b/crates/noticenterctl/src/css_check/tests/command.rs index e4fb2738d..e5c366f0b 100644 --- a/crates/noticenterctl/src/css_check/tests/command.rs +++ b/crates/noticenterctl/src/css_check/tests/command.rs @@ -5,6 +5,7 @@ use unixnotis_core::CURRENT_CONFIG_VERSION; use super::load_config_for_path; use crate::config_path::ConfigPathSource; +use crate::test_support::{test_env_lock, EnvGuard}; #[test] fn explicit_existing_config_is_loaded_instead_of_the_default() { @@ -66,7 +67,9 @@ fn missing_environment_config_is_rejected() { #[test] fn absent_default_config_uses_builtin_defaults() { + let _lock = test_env_lock(); let root = temporary_test_directory("missing-default"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); let config_path = root.join("missing.toml"); let config = load_config_for_path(&config_path, ConfigPathSource::Default) diff --git a/crates/noticenterctl/src/css_check/theme/tests/helpers.rs b/crates/noticenterctl/src/css_check/theme/tests/helpers.rs index bda79b365..e81bef48f 100644 --- a/crates/noticenterctl/src/css_check/theme/tests/helpers.rs +++ b/crates/noticenterctl/src/css_check/theme/tests/helpers.rs @@ -3,6 +3,8 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::fixture_file_contents; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(super) struct TempDirGuard { @@ -27,7 +29,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } pub(super) fn path(&self) -> &Path { diff --git a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs index 73b4919c6..48d57a726 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs @@ -1,15 +1,14 @@ -use unixnotis_core::{CommandSpec, Config}; +use unixnotis_core::CommandSpec; use super::super::{ collect_command_references_from_config, collect_host_specific_command_paths, collect_outside_command_paths, rewrite_host_specific_command_paths, - validate_command_paths_in_config_bytes, }; -use super::support::temp_root; +use super::support::{parse_current_config, temp_root, validate_command_paths_in_config_bytes}; #[test] fn collects_widget_command_references() { - let config = Config::parse( + let config = parse_current_config( "\ [theme]\nbase_css = \"base.css\"\n\ [[widgets.toggles]]\nlabel = \"Action\"\nicon = \"applications-system-symbolic\"\ntoggle_cmd = \"scripts/action.sh\"\n\ @@ -36,7 +35,7 @@ fn outside_command_paths_include_absolute_plugin_command() { [[widgets.stats]]\nlabel = \"Probe\"\n\ [widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - let parsed = Config::parse(config).expect("parse config"); + let parsed = parse_current_config(config).expect("parse config"); let outside = collect_outside_command_paths(&config_dir, &parsed); assert_eq!(outside.len(), 1); @@ -99,7 +98,7 @@ fn host_specific_command_paths_include_absolute_path_inside_root() { script_path.display().to_string() ); - let parsed = Config::parse(&config).expect("parse config"); + let parsed = parse_current_config(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); @@ -118,7 +117,7 @@ fn rewrite_host_specific_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed = Config::parse(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); @@ -140,7 +139,7 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_assignments() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env MODE='two words' '{}' --json", script_path.display()) ); - let mut parsed = Config::parse(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); @@ -162,7 +161,7 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_options() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env -u HOME MODE=safe {} --json", script_path.display()) ); - let mut parsed = Config::parse(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); @@ -187,7 +186,7 @@ fn rewrite_host_specific_toggle_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed = Config::parse(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); @@ -211,7 +210,7 @@ fn host_specific_command_paths_include_toggle_command() { script_path.display().to_string() ); - let parsed = Config::parse(&config).expect("parse config"); + let parsed = parse_current_config(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); diff --git a/crates/noticenterctl/src/preset/command_rules/tests/layout.rs b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs index 8e5c12271..b53903f0b 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/layout.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs @@ -1,8 +1,7 @@ use super::super::tokens::{ first_command_token, split_env_assignment, validate_env_command_layout, }; -use super::super::validate_command_paths_in_config_bytes; -use super::support::{parsed_command, temp_root}; +use super::support::{parsed_command, temp_root, validate_command_paths_in_config_bytes}; use unixnotis_core::parse_legacy_command as parse_command; #[test] diff --git a/crates/noticenterctl/src/preset/command_rules/tests/support.rs b/crates/noticenterctl/src/preset/command_rules/tests/support.rs index 7bd43c8fa..235a79e40 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/support.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/support.rs @@ -1,8 +1,12 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use unixnotis_core::{parse_legacy_command, CommandSpec}; +use anyhow::Result; +use unixnotis_core::{parse_legacy_command, CommandSpec, Config, ConfigError}; + +use super::super::validate_command_paths_in_config_bytes as validate_command_paths; +use crate::test_support::{current_config_bytes, current_config_text}; static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); @@ -21,3 +25,15 @@ pub(super) fn temp_root(name: &str) -> PathBuf { pub(super) fn parsed_command(command: &str) -> CommandSpec { parse_legacy_command(command).expect("valid legacy test command") } + +pub(super) fn parse_current_config(contents: &str) -> Result { + Config::parse(¤t_config_text(contents)) +} + +pub(super) fn validate_command_paths_in_config_bytes( + config_dir: &Path, + config_bytes: &[u8], + mode_label: &str, +) -> Result<()> { + validate_command_paths(config_dir, ¤t_config_bytes(config_bytes), mode_label) +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/validation.rs b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs index 6899ec09c..bcda6c412 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/validation.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs @@ -1,5 +1,4 @@ -use super::super::validate_command_paths_in_config_bytes; -use super::support::temp_root; +use super::support::{temp_root, validate_command_paths_in_config_bytes}; #[test] fn validation_rejects_ld_preload_path_that_leaves_root() { diff --git a/crates/noticenterctl/src/preset/export/tests/support.rs b/crates/noticenterctl/src/preset/export/tests/support.rs index dfb0167c7..72f7f5fb8 100644 --- a/crates/noticenterctl/src/preset/export/tests/support.rs +++ b/crates/noticenterctl/src/preset/export/tests/support.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::fixture_file_contents; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(in crate::preset::export) struct TempDirGuard { @@ -29,7 +31,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } } diff --git a/crates/noticenterctl/src/preset/import/review/tests/checks.rs b/crates/noticenterctl/src/preset/import/review/tests/checks.rs index b83b58fde..527037abb 100644 --- a/crates/noticenterctl/src/preset/import/review/tests/checks.rs +++ b/crates/noticenterctl/src/preset/import/review/tests/checks.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::current_config_bytes; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); fn temp_root(name: &str) -> PathBuf { @@ -28,8 +30,9 @@ fn imported_theme_checks_reject_parent_traversal_targets() { let config_dir = temp_root("relative-escape"); let config = b"[theme]\nbase_css = \"../escaped-base.css\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n"; - let error = validate_imported_theme_paths_stay_in_root(&config_dir, config) - .expect_err("reject relative theme escape"); + let error = + validate_imported_theme_paths_stay_in_root(&config_dir, ¤t_config_bytes(config)) + .expect_err("reject relative theme escape"); assert!(error .to_string() @@ -42,8 +45,9 @@ fn imported_command_checks_reject_absolute_plugin_command() { let config_dir = temp_root("outside-command"); let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - let error = validate_imported_command_paths_stay_in_root(&config_dir, config) - .expect_err("reject outside command path"); + let error = + validate_imported_command_paths_stay_in_root(&config_dir, ¤t_config_bytes(config)) + .expect_err("reject outside command path"); assert!(error .to_string() @@ -166,7 +170,8 @@ label = "Probe" cmd = "scripts/check.sh" "#; - let content = collect_imported_exec_content(config, &[]).expect("collect exec content"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &[]) + .expect("collect exec content"); assert_eq!(content.commands.len(), 1); assert_eq!(content.commands[0].slot, "widgets.stats[0].cmd"); @@ -175,13 +180,13 @@ cmd = "scripts/check.sh" #[test] fn imported_exec_collection_ignores_unknown_command_keys_and_keeps_real_command() { - let mut config = String::from("config_version = 2\n"); + let mut config = String::new(); for index in 0..64 { config.push_str(&format!("[aaa{index:02}]\ncmd = \"true\"\n")); } config.push_str("[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"sh assets/payload.dat\"\n"); - let content = collect_imported_exec_content(config.as_bytes(), &[]) + let content = collect_imported_exec_content(¤t_config_bytes(config.as_bytes()), &[]) .expect("collect only typed command fields"); assert_eq!(content.commands.len(), 1); @@ -192,8 +197,6 @@ fn imported_exec_collection_ignores_unknown_command_keys_and_keeps_real_command( #[test] fn imported_exec_collection_covers_every_known_explicit_command_field() { let config = br#" -config_version = 2 - [widgets.volume] get_cmd = "volume-get" set_cmd = "volume-set" @@ -226,7 +229,8 @@ api_version = 1 command = "card-plugin" "#; - let content = collect_imported_exec_content(config, &[]).expect("collect known commands"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &[]) + .expect("collect known commands"); let slots = content .commands .iter() @@ -259,7 +263,7 @@ command = "card-plugin" #[test] fn imported_exec_collection_does_not_include_runtime_defaults() { - let content = collect_imported_exec_content(b"config_version = 2\n", &[]) + let content = collect_imported_exec_content(¤t_config_bytes(b""), &[]) .expect("parse data-only config"); assert!(content.commands.is_empty()); @@ -285,8 +289,8 @@ base_css = "base.css" }, ]; - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect script payload"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &bundle_files) + .expect("collect script payload"); assert!(content.commands.is_empty()); assert_eq!(content.files.len(), 2); @@ -327,8 +331,8 @@ cmd = "scripts/check.sh" }, ]; - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect trusted exec"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &bundle_files) + .expect("collect trusted exec"); assert_eq!(content.commands.len(), 1); assert_eq!(content.files.len(), 3); @@ -351,17 +355,16 @@ fn imported_exec_collection_inventories_plain_payloads_for_each_command_form() { ]; for (command, payload_path) in cases { - let config = format!( - "config_version = 2\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); + let config = format!("[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n"); let files = [BundleFile { relative_path: PathBuf::from(payload_path), contents: b"plain file payload\n".to_vec(), mode: 0o644, }]; - let content = collect_imported_exec_content(config.as_bytes(), &files) - .expect("collect command-backed plain payload"); + let content = + collect_imported_exec_content(¤t_config_bytes(config.as_bytes()), &files) + .expect("collect command-backed plain payload"); assert_eq!(content.commands.len(), 1); assert_eq!(content.files.len(), 1); @@ -377,7 +380,7 @@ fn imported_exec_collection_ignores_plain_assets_without_commands() { mode: 0o644, }]; - let content = collect_imported_exec_content(b"config_version = 2\n", &files) + let content = collect_imported_exec_content(¤t_config_bytes(b""), &files) .expect("collect data-only bundle"); assert!(content.commands.is_empty()); diff --git a/crates/noticenterctl/src/preset/import/tests/helpers.rs b/crates/noticenterctl/src/preset/import/tests/helpers.rs index 356893459..b106a7fa0 100644 --- a/crates/noticenterctl/src/preset/import/tests/helpers.rs +++ b/crates/noticenterctl/src/preset/import/tests/helpers.rs @@ -7,6 +7,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; +use crate::test_support::fixture_file_contents; + pub(in crate::preset::import) use crate::preset::archive::write_bundle; pub(in crate::preset::import) use crate::preset::config_root::{ CollectedConfigFiles, PresetFileSource, @@ -46,7 +48,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write test file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write test file"); } } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs b/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs index 59eeaa69c..4c78a3950 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs @@ -4,6 +4,7 @@ use crate::preset::import::transaction::apply::{ apply_import_plan, finalize_import_transaction, rollback_import_transaction, }; use crate::preset::import::transaction::plan::build_import_plan; +use crate::test_support::current_config_text; #[test] fn applied_import_can_restore_the_exact_previous_file() { @@ -29,7 +30,7 @@ fn applied_import_can_restore_the_exact_previous_file() { rollback_import_transaction(transaction).expect("rollback import"); assert_eq!( fs::read_to_string(root.path.join("config.toml")).expect("read restored file"), - "before" + current_config_text("before") ); } @@ -87,7 +88,7 @@ fn transaction_rejects_a_replaced_live_root_before_finalize() { assert_eq!( fs::read_to_string(moved.join("config.toml")).expect("read rolled-back old root"), - "before" + current_config_text("before") ); fs::remove_dir_all(&root.path).expect("remove replacement root"); fs::rename(&moved, &root.path).expect("restore imported config root"); @@ -119,7 +120,7 @@ fn root_drift_check_rolls_back_files_through_the_pinned_descriptor() { assert_eq!( fs::read_to_string(moved.join("config.toml")).expect("read descriptor-root config"), - "before" + current_config_text("before") ); fs::remove_dir_all(&root.path).expect("remove replacement root"); fs::rename(&moved, &root.path).expect("restore rolled-back config root"); diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs b/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs index 4d907a271..0fc692659 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs @@ -8,11 +8,14 @@ use crate::preset::import::transaction::apply::{ }; use crate::preset::import::transaction::commit::commit_import_plan; use crate::preset::import::transaction::plan::build_import_plan; +use crate::test_support::{current_config_text, fixture_file_contents}; fn bundle_file(relative_path: &str, contents: &str) -> BundleFile { BundleFile { relative_path: PathBuf::from(relative_path), - contents: contents.as_bytes().to_vec(), + contents: fixture_file_contents(relative_path, contents) + .as_bytes() + .to_vec(), mode: 0o644, } } @@ -43,7 +46,7 @@ fn commit_import_plan_writes_files_runs_css_check_and_returns_backup() { assert!(css_result.is_ok()); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read imported config"), - "[panel]\nwidth = 444\n" + current_config_text("[panel]\nwidth = 444\n") ); assert_eq!( fs::read_to_string(import_root.path.join("theme/base.css")).expect("read imported css"), @@ -52,7 +55,7 @@ fn commit_import_plan_writes_files_runs_css_check_and_returns_backup() { let backup_dir = backup_dir.expect("overwritten config should create backup"); assert_eq!( fs::read_to_string(backup_dir.join("config.toml")).expect("read backup config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -79,7 +82,7 @@ fn commit_import_plan_rolls_back_when_imported_config_cannot_load() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -104,7 +107,7 @@ fn apply_failure_on_later_file_rolls_back_earlier_publication() { assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -140,7 +143,7 @@ fn commit_import_plan_rolls_back_when_imported_config_points_outside_root() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert!(!outside_theme.exists()); } @@ -178,7 +181,7 @@ fn commit_import_plan_rolls_back_when_imported_command_points_outside_root() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert!(!import_root.path.join("scripts/probe.sh").exists()); assert!(!outside_command.exists()); @@ -216,7 +219,7 @@ fn commit_import_plan_cleans_partial_backup_and_rolls_back_when_backup_write_fai assert_eq!(writes.load(Ordering::Relaxed), 2); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert_eq!( fs::read_to_string(import_root.path.join("theme/base.css")).expect("read restored css"), @@ -254,6 +257,6 @@ fn commit_import_plan_keeps_import_committed_when_css_check_fails() { .contains("css-check failed for test")); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read committed config"), - "[panel]\nwidth = 444\n" + current_config_text("[panel]\nwidth = 444\n") ); } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs b/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs index 5c9a714eb..2949cdf51 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs @@ -1,4 +1,5 @@ use super::*; +use crate::test_support::current_config_text; #[test] fn run_import_reports_missing_bundle_instead_of_succeeding() { @@ -40,7 +41,7 @@ fn import_dry_run_reports_create_and_overwrite_counts() { assert_eq!(summary.excluded, 1); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "old = true" + current_config_text("old = true") ); } @@ -84,7 +85,7 @@ fn import_writes_files_and_creates_backup_for_overwrites() { assert!(backup_dir.join("config.toml").exists()); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "[theme]\nbase_css = \"base.css\"\n" + current_config_text("[theme]\nbase_css = \"base.css\"\n") ); } @@ -108,7 +109,7 @@ fn import_accepts_bundle_that_contains_only_config_toml() { assert_eq!(summary.created, 1); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "title = \"only config\"\n" + current_config_text("title = \"only config\"\n") ); } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs b/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs index a8ff114ea..2e1390124 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs @@ -135,7 +135,7 @@ cmd = "scripts/probe.sh" #[test] fn import_review_ignores_unknown_command_decoys_and_includes_plain_payload() { let export_root = TempDirGuard::new("command-decoy-export"); - let mut config = String::from("config_version = 2\n[theme]\nbase_css = \"base.css\"\n"); + let mut config = String::from("[theme]\nbase_css = \"base.css\"\n"); for index in 0..64 { config.push_str(&format!("[aaa{index:02}]\ncmd = \"true\"\n")); } diff --git a/crates/noticenterctl/src/preset/tests/inspect.rs b/crates/noticenterctl/src/preset/tests/inspect.rs index 7cf13eed6..5c5468068 100644 --- a/crates/noticenterctl/src/preset/tests/inspect.rs +++ b/crates/noticenterctl/src/preset/tests/inspect.rs @@ -8,6 +8,8 @@ use flate2::write::GzEncoder; use flate2::Compression; use tar::{Builder, Header}; +use crate::test_support::fixture_file_contents; + use super::super::export::flow::export_preset_from; use super::super::inspect::inspect_preset_at; use super::super::manifest::{PresetManifest, PresetManifestFile}; @@ -38,7 +40,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } } @@ -165,6 +171,10 @@ fn inspect_reports_theme_paths_that_leave_config_root() { fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(&str, &str)]) { // Raw bundle writing lets inspect tests model presets that export would reject + let normalized_files = files + .iter() + .map(|(path, contents)| (*path, fixture_file_contents(path, contents))) + .collect::>(); let output = fs::File::create(bundle_path).expect("create test bundle"); let encoder = GzEncoder::new(output, Compression::default()); let mut archive = Builder::new(encoder); @@ -172,7 +182,7 @@ fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(& bundle_name.to_string(), "2026-01-01T00:00:00Z".to_string(), "test".to_string(), - files + normalized_files .iter() .map(|(path, contents)| PresetManifestFile { path: (*path).to_string(), @@ -183,7 +193,7 @@ fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(& let manifest_text = manifest.encode().expect("encode manifest"); append_text_file(&mut archive, "manifest.toml", &manifest_text); - for (path, contents) in files { + for (path, contents) in &normalized_files { append_text_file(&mut archive, &format!("payload/{path}"), contents); } archive.finish().expect("finish archive"); diff --git a/crates/noticenterctl/src/tests/support.rs b/crates/noticenterctl/src/tests/support.rs index 10c23e4e2..008750cd7 100644 --- a/crates/noticenterctl/src/tests/support.rs +++ b/crates/noticenterctl/src/tests/support.rs @@ -1,8 +1,117 @@ //! Shared process-environment guards for CLI tests +use std::borrow::Cow; use std::ffi::{OsStr, OsString}; +use std::path::Path; use std::sync::{Mutex, MutexGuard, OnceLock}; +use unixnotis_core::{parse_legacy_command, CURRENT_CONFIG_VERSION}; + +pub fn current_config_text(contents: &str) -> String { + let Ok(mut document) = toml::from_str::(contents) else { + // Invalid fixtures stay invalid while still crossing the version gate first + return format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}"); + }; + let Some(root) = document.as_table_mut() else { + return format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}"); + }; + root.insert( + "config_version".to_string(), + toml::Value::Integer(i64::from(CURRENT_CONFIG_VERSION)), + ); + normalize_fixture_commands(root); + toml::to_string(&document).expect("serialize current-schema config fixture") +} + +pub fn current_config_bytes(contents: &[u8]) -> Vec { + if let Ok(contents) = std::str::from_utf8(contents) { + current_config_text(contents).into_bytes() + } else { + // Invalid UTF-8 remains visible to the parser after the schema prefix + let mut config = format!("config_version = {CURRENT_CONFIG_VERSION}\n").into_bytes(); + config.extend_from_slice(contents); + config + } +} + +pub fn fixture_file_contents<'a>(relative_path: &str, contents: &'a str) -> Cow<'a, str> { + let is_config = Path::new(relative_path).file_name() == Some(OsStr::new("config.toml")); + let has_version = contents + .lines() + .any(|line| line.trim_start().starts_with("config_version")); + if is_config && !has_version { + // Functional config fixtures always exercise the schema shipped by this test binary + Cow::Owned(current_config_text(contents)) + } else { + Cow::Borrowed(contents) + } +} + +fn normalize_fixture_commands(root: &mut toml::Table) { + let Some(widgets) = root.get_mut("widgets").and_then(toml::Value::as_table_mut) else { + return; + }; + + // Slider command fields live in fixed widget tables + for slider_name in ["volume", "brightness"] { + let Some(slider) = widgets + .get_mut(slider_name) + .and_then(toml::Value::as_table_mut) + else { + continue; + }; + normalize_table_commands(slider, &["get_cmd", "set_cmd", "toggle_cmd", "watch_cmd"]); + } + + normalize_widget_array_commands( + widgets, + "toggles", + &["state_cmd", "toggle_cmd", "on_cmd", "off_cmd", "watch_cmd"], + false, + ); + normalize_widget_array_commands(widgets, "stats", &["cmd"], true); + normalize_widget_array_commands(widgets, "cards", &["cmd"], true); +} + +fn normalize_widget_array_commands( + widgets: &mut toml::Table, + collection_name: &str, + fields: &[&str], + has_plugin: bool, +) { + let Some(items) = widgets + .get_mut(collection_name) + .and_then(toml::Value::as_array_mut) + else { + return; + }; + for item in items { + let Some(table) = item.as_table_mut() else { + continue; + }; + normalize_table_commands(table, fields); + if has_plugin { + let Some(plugin) = table.get_mut("plugin").and_then(toml::Value::as_table_mut) else { + continue; + }; + normalize_table_commands(plugin, &["command"]); + } + } +} + +fn normalize_table_commands(table: &mut toml::Table, fields: &[&str]) { + for field in fields { + let Some(command) = table.get(*field).and_then(toml::Value::as_str) else { + continue; + }; + let Ok(spec) = parse_legacy_command(command) else { + continue; + }; + let value = toml::Value::try_from(spec).expect("serialize command fixture"); + table.insert((*field).to_string(), value); + } +} + pub fn test_env_lock() -> MutexGuard<'static, ()> { // Every test that mutates process environment must share this one lock static LOCK: OnceLock> = OnceLock::new(); diff --git a/crates/unixnotis-core/src/config/loading/diagnostics.rs b/crates/unixnotis-core/src/config/loading/diagnostics.rs index d8dcbe413..949e750ac 100644 --- a/crates/unixnotis-core/src/config/loading/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/diagnostics.rs @@ -6,7 +6,7 @@ use serde::Serialize; use toml::Value; use tracing::{info, warn}; -use super::super::{Config, CURRENT_CONFIG_VERSION}; +use super::super::Config; /// Classification used by configuration diagnostics and doctor output #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -46,37 +46,6 @@ pub struct ConfigLoadReport { pub diagnostics: Vec, } -pub(super) fn migration_diagnostic(contents: &str) -> Option { - // Diagnostics inspect a separate value tree so deserialization behavior stays unchanged - let document = contents.parse::().ok()?; - // Unversioned files are schema zero and follow the explicit legacy migration path - let version = document - .as_table() - .and_then(|root| root.get("config_version")) - .and_then(Value::as_integer) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(0); - (version < CURRENT_CONFIG_VERSION).then(|| ConfigDiagnostic { - code: "config.schema.migrated", - kind: ConfigDiagnosticKind::Note, - path: Some("config_version".to_string()), - message: "Configuration was migrated to the current schema".to_string(), - original: Some(version.to_string()), - effective: Some(CURRENT_CONFIG_VERSION.to_string()), - }) -} - -pub(super) fn migrated_field_diagnostic(path: String) -> ConfigDiagnostic { - ConfigDiagnostic { - code: "config.schema.field-migrated", - kind: ConfigDiagnosticKind::Note, - path: Some(path), - message: "Missing legacy field received its schema-compatible value".to_string(), - original: None, - effective: None, - } -} - pub(super) fn empty_exact_media_policy_diagnostic(contents: &str) -> Option { let document = contents.parse::().ok()?; let root = document.as_table()?; diff --git a/crates/unixnotis-core/src/config/loading/io/load.rs b/crates/unixnotis-core/src/config/loading/io/load.rs index 2dc33084b..2eacca843 100644 --- a/crates/unixnotis-core/src/config/loading/io/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/load.rs @@ -7,12 +7,11 @@ use std::io::Read; use std::path::Path; use crate::config::runtime::{apply_brightness_backend, apply_volume_backend, sanitize_config}; -use crate::config::schema::deserialize_config_with_migrations; +use crate::config::schema::deserialize_current_config; use crate::{log_config_diagnostics, Config, ConfigLoadReport}; use super::super::diagnostics::{ - adjustment_diagnostics, empty_exact_media_policy_diagnostic, migrated_field_diagnostic, - migration_diagnostic, unknown_key_diagnostic, + adjustment_diagnostics, empty_exact_media_policy_diagnostic, unknown_key_diagnostic, }; use super::ConfigError; @@ -41,7 +40,7 @@ impl Config { Self::parse_with_report(&contents) } - /// Parse and migrate configuration text without reading the filesystem + /// Parse current-schema configuration text without reading the filesystem /// /// # Errors /// @@ -52,19 +51,16 @@ impl Config { Ok(report.config) } - /// Parse and migrate configuration text with structured diagnostics + /// Parse current-schema configuration text with structured diagnostics /// /// # Errors /// /// Returns an error for invalid TOML or unsupported schema versions pub fn parse_with_report(contents: &str) -> Result { - let (mut config, ignored_keys, migrated_paths) = - deserialize_config_with_migrations(contents).map_err(ConfigError::ParseFailed)?; - let mut diagnostics = migration_diagnostic(contents) - .into_iter() - .collect::>(); + let (mut config, ignored_keys) = + deserialize_current_config(contents).map_err(ConfigError::ParseFailed)?; + let mut diagnostics = Vec::new(); diagnostics.extend(empty_exact_media_policy_diagnostic(contents)); - diagnostics.extend(migrated_paths.into_iter().map(migrated_field_diagnostic)); diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); let before_runtime = config.clone(); config.apply_runtime_defaults(); diff --git a/crates/unixnotis-core/src/config/loading/io/tests/load.rs b/crates/unixnotis-core/src/config/loading/io/tests/load.rs index fbe4b53b6..1ec6f0fe7 100644 --- a/crates/unixnotis-core/src/config/loading/io/tests/load.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/load.rs @@ -3,7 +3,7 @@ use std::fs; use std::io::Cursor; -use crate::{Config, ConfigError, MAX_CONFIG_BYTES}; +use crate::{Config, ConfigError, CURRENT_CONFIG_VERSION, MAX_CONFIG_BYTES}; use super::super::load::read_config_contents; use super::support::{env_lock, test_root, EnvGuard}; @@ -20,14 +20,17 @@ fn load_from_path_reads_toml_and_applies_runtime_defaults() { // Deliberately use too-small refresh intervals to prove load sanitization still runs fs::write( &path, - r#" + format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} [panel] title = "Loaded Title" [widgets] refresh_interval_ms = 1 refresh_interval_slow_ms = 50 - "#, + "# + ), ) .expect("config file"); @@ -58,27 +61,29 @@ fn load_from_path_returns_parse_error_for_invalid_toml() { #[test] fn parse_returns_the_config_produced_by_the_report_pipeline() { - let config = Config::parse( + let config = Config::parse(&format!( r#" + config_version = {CURRENT_CONFIG_VERSION} [panel] title = "Parsed Title" - "#, - ) + "# + )) .expect("valid config text should parse"); assert_eq!(config.panel.title, "Parsed Title"); } #[test] -fn legacy_theme_mode_is_ignored_and_default_rendering_omits_it() { - let report = Config::parse_with_report( +fn obsolete_theme_mode_is_ignored_and_default_rendering_omits_it() { + let report = Config::parse_with_report(&format!( r#" + config_version = {CURRENT_CONFIG_VERSION} [theme] mode = "stock" popup_css = "popup.css" - "#, - ) - .expect("legacy theme mode should be ignored"); + "# + )) + .expect("obsolete theme mode should be ignored"); assert_eq!(report.config.theme.popup_css, "popup.css"); assert!(!report.diagnostics.iter().any(|diagnostic| { @@ -91,14 +96,16 @@ fn legacy_theme_mode_is_ignored_and_default_rendering_omits_it() { #[test] fn sound_file_hints_require_explicit_configuration() { - let defaults = Config::parse("").expect("default config should parse"); - let enabled = Config::parse( + let defaults = Config::parse(&format!("config_version = {CURRENT_CONFIG_VERSION}\n")) + .expect("default current config should parse"); + let enabled = Config::parse(&format!( r#" + config_version = {CURRENT_CONFIG_VERSION} [sound] allow_file_hints = true allowed_file_hint_dirs = ["sounds", "/srv/notification-sounds"] - "#, - ) + "# + )) .expect("sound hint policy should parse"); assert!(!defaults.sound.allow_file_hints); @@ -195,10 +202,13 @@ fn load_default_reads_config_when_default_file_exists() { fs::create_dir_all(&config_dir).expect("config dir"); fs::write( config_dir.join("config.toml"), - r#" + format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} [panel] title = "Default Path Title" - "#, + "# + ), ) .expect("default config file"); diff --git a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs index 060358b82..d25cd5458 100644 --- a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs @@ -20,50 +20,12 @@ impl Write for CapturedWriter { } } -#[test] -fn migration_diagnostic_reports_unversioned_input_without_exposing_text() { - let diagnostic = migration_diagnostic("[panel]\ntitle = 'private title'\n") - .expect("unversioned config should report migration"); - - assert_eq!(diagnostic.code, "config.schema.migrated"); - assert_eq!(diagnostic.original.as_deref(), Some("0")); - assert_eq!( - diagnostic.effective.as_deref(), - Some(CURRENT_CONFIG_VERSION.to_string().as_str()) - ); - assert!(!diagnostic.message.contains("private title")); -} - -#[test] -fn current_schema_produces_no_migration_diagnostic() { - let input = format!("config_version = {CURRENT_CONFIG_VERSION}\n"); - - assert!(migration_diagnostic(&input).is_none()); -} - #[test] fn current_empty_exact_media_policy_emits_a_warning() { - let report = Config::parse_with_report( - "config_version = 4\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", - ) - .expect("current config should parse"); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "config.media.empty-exact-allowlist" - && diagnostic.kind == ConfigDiagnosticKind::Warning - })); -} - -#[test] -fn legacy_empty_exact_media_policy_emits_a_warning_without_widening_policy() { - let report = Config::parse_with_report( - "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", - ) - .expect("legacy config should parse"); - - assert_eq!( - report.config.media.local_art_policy, - crate::MediaLocalArtPolicy::ExactExecutableOnly + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[media]\nlocal_art_policy = \"exact_executable_only\"\n" ); + let report = Config::parse_with_report(&input).expect("current config should parse"); assert!(report.diagnostics.iter().any(|diagnostic| { diagnostic.code == "config.media.empty-exact-allowlist" && diagnostic.kind == ConfigDiagnosticKind::Warning @@ -107,20 +69,6 @@ fn unknown_key_diagnostic_uses_stable_code_and_warning_kind() { assert_eq!(diagnostic.path.as_deref(), Some("panel.search_visble")); } -#[test] -fn legacy_migration_reports_each_inserted_compatibility_path() { - let report = Config::parse_with_report("").expect("empty legacy config should migrate"); - - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "config.schema.field-migrated" - && diagnostic.path.as_deref() == Some("panel.empty_offset_top") - })); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "config.schema.field-migrated" - && diagnostic.path.as_deref() == Some("media.art_size_px") - })); -} - #[test] fn array_adjustments_report_length_and_changed_items_exactly_once() { let before = Value::Array(vec![Value::Integer(1)]); @@ -248,7 +196,7 @@ fn safe_values_distinguish_finite_and_non_finite_numbers() { } #[test] -fn compatibility_logger_emits_each_diagnostic() { +fn diagnostic_logger_emits_each_current_diagnostic() { let output = Arc::new(Mutex::new(Vec::new())); let writer_output = output.clone(); let subscriber = tracing_subscriber::fmt() @@ -258,7 +206,7 @@ fn compatibility_logger_emits_each_diagnostic() { .finish(); let diagnostics = vec![ unknown_key_diagnostic("panel.unknown".to_string()), - migrated_field_diagnostic("panel.width".to_string()), + unknown_key_diagnostic("media.unknown".to_string()), ]; tracing::subscriber::with_default(subscriber, || { @@ -273,5 +221,6 @@ fn compatibility_logger_emits_each_diagnostic() { ) .expect("diagnostic output should be UTF-8"); assert!(rendered.contains("config.unknown-key")); - assert!(rendered.contains("config.schema.field-migrated")); + assert!(rendered.contains("panel.unknown")); + assert!(rendered.contains("media.unknown")); } diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index 87e1f33e0..84032b20f 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -12,7 +12,7 @@ use super::rules::RuleConfig; use super::theme::ThemeConfig; use super::widgets::WidgetsConfig; -pub const CURRENT_CONFIG_VERSION: u32 = 4; +pub const CURRENT_CONFIG_VERSION: u32 = 5; /// Top-level configuration loaded from config.toml #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/unixnotis-core/src/config/validation/mod.rs b/crates/unixnotis-core/src/config/validation/mod.rs index a7191545b..c5657ad05 100644 --- a/crates/unixnotis-core/src/config/validation/mod.rs +++ b/crates/unixnotis-core/src/config/validation/mod.rs @@ -1,4 +1,4 @@ -//! Notification rule validation and explicit schema migration +//! Notification rule and current schema validation pub(in crate::config) mod rules; pub(in crate::config) mod schema; diff --git a/crates/unixnotis-core/src/config/validation/rules.rs b/crates/unixnotis-core/src/config/validation/rules.rs index 09f21fc3b..cdc53d472 100644 --- a/crates/unixnotis-core/src/config/validation/rules.rs +++ b/crates/unixnotis-core/src/config/validation/rules.rs @@ -121,8 +121,10 @@ impl<'de> Deserialize<'de> for RuleUrgency { pub struct RuleConfig { /// Optional rule name for logging or debugging pub name: Option, - /// Match against the notification app name (case-insensitive substring) + /// Match daemon-resolved application identity (case-insensitive substring) pub app: Option, + /// Match sender-provided freedesktop `app_name` presentation metadata + pub claimed_app: Option, /// Match against the notification summary (case-insensitive substring) pub summary: Option, /// Match against the notification body (case-insensitive substring) diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index c9efbbcc7..305eb6187 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -1,340 +1,48 @@ -//! Explicit configuration schema migrations +//! Current configuration schema deserialization use serde::de::IntoDeserializer; use super::super::{Config, CURRENT_CONFIG_VERSION}; -use crate::{parse_legacy_command, CommandSpec}; -pub(in crate::config) fn deserialize_config_with_migrations( +pub(in crate::config) fn deserialize_current_config( contents: &str, -) -> Result<(Config, Vec, Vec), String> { - // Keep the original tree so migration reporting can describe every inserted field - let mut document = contents +) -> Result<(Config, Vec), String> { + let document = contents .parse::() - .map_err(|err| err.to_string())?; - let original_document = document.clone(); - let migration = migrate_document(&mut document)?; - let mut migrated_paths = Vec::new(); - collect_changed_paths( - "", - Some(&original_document), - Some(&document), - &mut migrated_paths, - ); - if migration.restore_legacy_cards { - // Card restoration changes the typed config after the document migration finishes - // Card restoration happens after deserialization, so record it outside the TOML diff - migrated_paths.push("widgets.cards".to_string()); - } - migrated_paths.sort_unstable(); - migrated_paths.dedup(); + .map_err(|error| error.to_string())?; + validate_current_version(&document)?; + let mut ignored_keys = Vec::new(); let deserializer = document.into_deserializer(); - // Unknown fields are collected without weakening normal serde type validation - let mut config: Config = serde_ignored::deserialize(deserializer, |path| { - // V7 wrote a runtime-only mode that is intentionally obsolete now + // Unknown fields remain visible to diagnostics without weakening serde validation + let config = serde_ignored::deserialize(deserializer, |path| { + // This runtime-only field is intentionally ignored for stock theme compatibility let path = path.to_string(); if path != "theme.mode" { ignored_keys.push(path); } }) - .map_err(|err| err.to_string())?; - - // Older configs enabled the original calendar and weather cards when the key was absent - if migration.restore_legacy_cards { - config.widgets.cards = Config::default().widgets.cards; - for card in &mut config.widgets.cards { - card.enabled = true; - } - } - config.config_version = CURRENT_CONFIG_VERSION; - Ok((config, ignored_keys, migrated_paths)) + .map_err(|error| error.to_string())?; + Ok((config, ignored_keys)) } -fn collect_changed_paths( - path: &str, - before: Option<&toml::Value>, - after: Option<&toml::Value>, - paths: &mut Vec, -) { - if before == after { - return; - } - match (before, after) { - (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => { - // Union traversal catches inserted, removed, and changed child keys - let mut keys = before.keys().chain(after.keys()).collect::>(); - keys.sort_unstable(); - keys.dedup(); - for key in keys { - let child = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - collect_changed_paths(&child, before.get(key), after.get(key), paths); - } - } - (None, Some(toml::Value::Table(after))) => { - // Newly created compatibility tables report their leaf fields instead of one table - for (key, value) in after { - let child = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - collect_changed_paths(&child, None, Some(value), paths); - } - } - _ if !path.is_empty() => paths.push(path.to_string()), - // The root itself is not a useful config-key path - _ => {} - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct MigrationResult { - restore_legacy_cards: bool, -} - -fn migrate_document(document: &mut toml::Value) -> Result { +fn validate_current_version(document: &toml::Value) -> Result<(), String> { let root = document - .as_table_mut() + .as_table() .ok_or_else(|| "configuration root must be a TOML table".to_string())?; let version = match root.get("config_version") { None => 0, - Some(toml::Value::Integer(version)) if *version >= 0 => *version as u32, + Some(toml::Value::Integer(version)) if *version >= 0 => u32::try_from(*version) + .map_err(|_error| format!("unsupported config version {version}"))?, Some(_) => return Err("config_version must be a non-negative integer".to_string()), }; - if version > CURRENT_CONFIG_VERSION { - // Future schemas fail closed because silently dropping fields would corrupt intent - return Err(format!( - "config version {version} is newer than supported version {CURRENT_CONFIG_VERSION}" - )); - } - - let result = match version { - // Schema one used the same legacy layout compatibility values as unversioned files - 0 | 1 => { - let result = migrate_legacy_layout(root); - migrate_legacy_commands(root)?; - result - } - 2 => { - migrate_legacy_commands(root)?; - MigrationResult::default() - } - 3 | CURRENT_CONFIG_VERSION => MigrationResult::default(), - _ => return Err(format!("unsupported config version {version}")), - }; - // Only an absent field receives the current default. An explicit policy, - // including an empty exact allowlist, remains the user's decision. - ensure_media_art_policy_default(root); - root.insert( - "config_version".to_string(), - toml::Value::Integer(i64::from(CURRENT_CONFIG_VERSION)), - ); - Ok(result) -} - -fn ensure_media_art_policy_default(root: &mut toml::Table) { - let Some(media) = root.get_mut("media").and_then(toml::Value::as_table_mut) else { - return; - }; - - // A missing policy receives the current native-art default before serde defaults run - if media.contains_key("local_art_policy") { - return; - } - media.insert( - "local_art_policy".to_string(), - toml::Value::String("all_admitted".to_string()), - ); -} - -fn migrate_legacy_commands(root: &mut toml::Table) -> Result<(), String> { - let Some(widgets) = root.get_mut("widgets").and_then(toml::Value::as_table_mut) else { - return Ok(()); - }; - - for slider_name in ["volume", "brightness"] { - let Some(slider) = widgets - .get_mut(slider_name) - .and_then(toml::Value::as_table_mut) - else { - continue; - }; - for field in ["get_cmd", "set_cmd", "toggle_cmd", "watch_cmd"] { - migrate_command_field(slider, field, &format!("widgets.{slider_name}.{field}"))?; - } - } - - migrate_command_array( - widgets, - "toggles", - &["state_cmd", "toggle_cmd", "on_cmd", "off_cmd", "watch_cmd"], - )?; - for collection in ["stats", "cards"] { - migrate_command_array(widgets, collection, &["cmd"])?; - migrate_plugin_commands(widgets, collection)?; + if version != CURRENT_CONFIG_VERSION { + // A clean schema break prevents old fields from receiving silently changed semantics + return Err(format!("unsupported config version {version}")); } Ok(()) } -fn migrate_command_array( - widgets: &mut toml::Table, - collection: &str, - fields: &[&str], -) -> Result<(), String> { - let Some(entries) = widgets - .get_mut(collection) - .and_then(toml::Value::as_array_mut) - else { - return Ok(()); - }; - for (index, entry) in entries.iter_mut().enumerate() { - let Some(table) = entry.as_table_mut() else { - continue; - }; - for field in fields { - migrate_command_field( - table, - field, - &format!("widgets.{collection}[{index}].{field}"), - )?; - } - } - Ok(()) -} - -fn migrate_plugin_commands(widgets: &mut toml::Table, collection: &str) -> Result<(), String> { - let Some(entries) = widgets - .get_mut(collection) - .and_then(toml::Value::as_array_mut) - else { - return Ok(()); - }; - for (index, entry) in entries.iter_mut().enumerate() { - let Some(plugin) = entry - .as_table_mut() - .and_then(|table| table.get_mut("plugin")) - .and_then(toml::Value::as_table_mut) - else { - continue; - }; - migrate_command_field( - plugin, - "command", - &format!("widgets.{collection}[{index}].plugin.command"), - )?; - } - Ok(()) -} - -fn migrate_command_field(table: &mut toml::Table, field: &str, path: &str) -> Result<(), String> { - let Some(value) = table.get_mut(field) else { - return Ok(()); - }; - let Some(command) = value.as_str() else { - return Ok(()); - }; - let spec = if command.trim().is_empty() { - CommandSpec::direct("", std::iter::empty::<&str>()) - } else { - parse_legacy_command(command) - .map_err(|error| format!("failed to migrate {path}: {error}"))? - }; - *value = toml::Value::try_from(spec) - .map_err(|error| format!("failed to migrate {path}: {error}"))?; - Ok(()) -} - -fn migrate_legacy_layout(root: &mut toml::Table) -> MigrationResult { - // Missing legacy tables still represent omitted old fields, not a request for new defaults - if let Some(panel) = child_table_or_insert(root, "panel") { - insert_string(panel, "quick_actions_label", ""); - insert_string(panel, "system_status_label", ""); - insert_integer(panel, "empty_offset_top", 120); - insert_strings(panel, "section_order", &["widgets", "notifications"]); - insert_strings( - panel, - "widget_order", - &["sliders", "media", "toggles", "stats", "cards"], - ); - } - - let mut restore_legacy_cards = false; - if let Some(widgets) = child_table_or_insert(root, "widgets") { - insert_string(widgets, "density", "comfortable"); - insert_integer(widgets, "toggle_columns", 4); - insert_integer(widgets, "stat_columns", 2); - insert_integer(widgets, "card_columns", 2); - restore_legacy_cards = !widgets.contains_key("cards"); - for slider_name in ["volume", "brightness"] { - // Both sliders existed in the old effective config even when their tables were omitted - if let Some(slider) = child_table_or_insert(widgets, slider_name) { - insert_integer(slider, "segments", 0); - insert_bool(slider, "show_sublabels", false); - insert_string(slider, "sublabel_min", ""); - insert_string(slider, "sublabel_max", ""); - } - } - } - - if let Some(media) = child_table_or_insert(root, "media") { - insert_integer(media, "art_size_px", 50); - insert_integer(media, "text_width_floor_px", 140); - insert_integer(media, "content_spacing_px", 10); - insert_integer(media, "control_spacing_px", 6); - insert_integer(media, "navigation_spacing_px", 6); - } - - MigrationResult { - restore_legacy_cards, - } -} - -fn child_table_or_insert<'a>(table: &'a mut toml::Table, key: &str) -> Option<&'a mut toml::Table> { - // Existing invalid scalar values stay intact so deserialization can report the real type error - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::Table::new())) - .as_table_mut() -} - -fn insert_string(table: &mut toml::Table, key: &str, value: &str) { - // Explicit user values always win over compatibility defaults - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::String(value.to_string())); -} - -fn insert_integer(table: &mut toml::Table, key: &str, value: i64) { - // Entry insertion preserves existing values including values later rejected by serde - table - .entry(key.to_string()) - .or_insert(toml::Value::Integer(value)); -} - -fn insert_bool(table: &mut toml::Table, key: &str, value: bool) { - // Missing booleans receive legacy behavior without rewriting explicit false values - table - .entry(key.to_string()) - .or_insert(toml::Value::Boolean(value)); -} - -fn insert_strings(table: &mut toml::Table, key: &str, values: &[&str]) { - // Ordered arrays preserve the historic panel and widget placement - table.entry(key.to_string()).or_insert_with(|| { - toml::Value::Array( - values - .iter() - .map(|value| toml::Value::String((*value).to_string())) - .collect(), - ) - }); -} - #[cfg(test)] #[path = "tests/schema.rs"] mod tests; diff --git a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml b/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml deleted file mode 100644 index 96307d81e..000000000 --- a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml +++ /dev/null @@ -1,11 +0,0 @@ -[panel] -width = 470 - -[widgets] -refresh_interval_ms = 1000 - -[widgets.volume] -enabled = true - -[media] -enabled = true diff --git a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml b/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml deleted file mode 100644 index 795dcdc17..000000000 --- a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml +++ /dev/null @@ -1,13 +0,0 @@ -config_version = 2 - -[panel] -width = 470 - -[widgets] -refresh_interval_ms = 1000 - -[widgets.volume] -enabled = true - -[media] -enabled = true diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index b0a9a37ee..844dca3f7 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -1,115 +1,16 @@ use super::*; -use crate::{CommandSpec, PanelSection, PanelWidgetSection, WidgetDensity}; - -const LEGACY_FIXTURE: &str = include_str!("fixtures/config-v0.toml"); -const V2_PARTIAL_FIXTURE: &str = include_str!("fixtures/config-v2-partial.toml"); fn deserialize_config(contents: &str) -> Result<(Config, Vec), String> { - let (config, ignored_keys, _migrated_paths) = deserialize_config_with_migrations(contents)?; - Ok((config, ignored_keys)) + deserialize_current_config(contents) } #[test] -fn unversioned_fixture_migrates_to_the_legacy_layout() { - let (config, ignored) = deserialize_config(LEGACY_FIXTURE).expect("migrate legacy config"); +fn current_schema_parses_with_current_defaults() { + let input = format!("config_version = {CURRENT_CONFIG_VERSION}\n[media]\n"); + let (config, ignored) = deserialize_config(&input).expect("parse current config"); assert!(ignored.is_empty()); assert_eq!(config.config_version, CURRENT_CONFIG_VERSION); - assert!(config.panel.quick_actions_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!( - config.panel.section_order, - vec![PanelSection::Widgets, PanelSection::Notifications] - ); - assert_eq!( - config.panel.widget_order, - vec![ - PanelWidgetSection::Sliders, - PanelWidgetSection::Media, - PanelWidgetSection::Toggles, - PanelWidgetSection::Stats, - PanelWidgetSection::Cards, - ] - ); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.volume.segments, 0); - assert!(!config.widgets.volume.show_sublabels); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); - assert_eq!(config.media.art_size_px, 50); -} - -#[test] -fn version_two_partial_fixture_migrates_and_uses_current_defaults() { - let (config, ignored) = - deserialize_config(V2_PARTIAL_FIXTURE).expect("parse version two config"); - - assert!(ignored.is_empty()); - assert_eq!(config.panel.quick_actions_label, "Quick settings"); - assert_eq!(config.panel.empty_offset_top, 24); - assert_eq!(config.widgets.toggle_columns, 2); - assert_eq!(config.widgets.volume.segments, 10); - assert_eq!(config.media.art_size_px, 48); -} - -#[test] -fn version_two_commands_migrate_quoted_punctuation_to_direct_and_operators_to_shell() { - let input = r#" - config_version = 2 - - [widgets.volume] - get_cmd = "printf '%s\\n' 'battery|charging'" - set_cmd = "producer | parser" - "#; - - let (config, ignored) = deserialize_config(input).expect("migrate version two commands"); - - assert!(ignored.is_empty()); - assert_eq!( - config.widgets.volume.get_cmd, - CommandSpec::direct("printf", ["%s\\n", "battery|charging"]) - ); - assert_eq!( - config.widgets.volume.set_cmd, - CommandSpec::shell("producer | parser") - ); -} - -#[test] -fn version_three_requires_explicit_command_mode() { - let legacy = r#" - config_version = 3 - - [widgets.volume] - get_cmd = "printf ready" - "#; - let error = deserialize_config(legacy).expect_err("reject a string command in version three"); - - assert!(error.contains("expected internally tagged enum CommandSpec")); -} - -#[test] -fn version_three_accepts_structured_direct_commands_without_inference() { - let input = r#" - config_version = 3 - - [widgets.volume.get_cmd] - mode = "direct" - program = "printf" - args = ["battery|charging"] - "#; - - let (config, ignored) = deserialize_config(input).expect("parse version three command"); - - assert!(ignored.is_empty()); - assert_eq!( - config.widgets.volume.get_cmd, - CommandSpec::direct("printf", ["battery|charging"]) - ); -} - -#[test] -fn missing_local_art_policy_uses_the_current_default() { - let (config, _) = deserialize_config("config_version = 4\n[media]\n").expect("parse media"); assert_eq!( config.media.local_art_policy, crate::MediaLocalArtPolicy::AllAdmitted @@ -117,24 +18,14 @@ fn missing_local_art_policy_uses_the_current_default() { } #[test] -fn old_explicit_empty_exact_policy_is_preserved() { - let (config, _) = deserialize_config( - "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", - ) - .expect("old media config should migrate"); - assert_eq!( - config.media.local_art_policy, - crate::MediaLocalArtPolicy::ExactExecutableOnly +fn current_schema_preserves_explicit_values() { + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[panel]\nwidth = 517\n[media]\nlocal_art_policy = \"exact_executable_only\"\nlocal_art_executable_allowlist = [\"/usr/bin/player\"]\n" ); - assert!(config.media.local_art_executable_allowlist.is_empty()); -} + let (config, ignored) = deserialize_config(&input).expect("parse explicit current config"); -#[test] -fn old_explicit_allowlist_remains_exact() { - let (config, _) = deserialize_config( - "config_version = 3\n[media]\nlocal_art_policy = \"exact_executable_only\"\nlocal_art_executable_allowlist = [\"/usr/bin/player\"]\n", - ) - .expect("old explicit media config should migrate"); + assert!(ignored.is_empty()); + assert_eq!(config.panel.width, 517); assert_eq!( config.media.local_art_policy, crate::MediaLocalArtPolicy::ExactExecutableOnly @@ -146,134 +37,59 @@ fn old_explicit_allowlist_remains_exact() { } #[test] -fn current_explicit_empty_exact_policy_is_preserved() { - let (config, _) = deserialize_config( - "config_version = 4\n[media]\nlocal_art_policy = \"exact_executable_only\"\n", - ) - .expect("current media config should preserve explicit policy"); - assert_eq!( - config.media.local_art_policy, - crate::MediaLocalArtPolicy::ExactExecutableOnly - ); +fn every_pre_v5_schema_is_rejected_without_migration() { + for version in 0..CURRENT_CONFIG_VERSION { + let input = if version == 0 { + String::new() + } else { + format!("config_version = {version}\n") + }; + let error = deserialize_config(&input).expect_err("reject pre-v5 config"); + assert_eq!(error, format!("unsupported config version {version}")); + } } #[test] -fn future_schema_is_rejected_instead_of_guessed() { +fn future_schema_is_rejected_without_guessing() { let error = deserialize_config("config_version = 999\n").expect_err("reject future config"); - assert!(error.contains("newer than supported")); -} - -#[test] -fn negative_schema_version_is_rejected_instead_of_wrapping() { - let error = deserialize_config("config_version = -1\n").expect_err("reject negative version"); - - assert!(error.contains("non-negative integer")); -} - -#[test] -fn explicit_legacy_values_remain_authoritative_during_migration() { - let text = "[panel]\nquick_actions_label = 'Custom'\nempty_offset_top = 77\n"; - let (config, _) = deserialize_config(text).expect("migrate explicit values"); - - assert_eq!(config.panel.quick_actions_label, "Custom"); - assert_eq!(config.panel.empty_offset_top, 77); + assert_eq!(error, "unsupported config version 999"); } #[test] -fn root_scalar_changes_do_not_report_an_empty_migration_path() { - let before = toml::Value::Integer(1); - let after = toml::Value::Integer(2); - let mut paths = Vec::new(); - - collect_changed_paths("", Some(&before), Some(&after), &mut paths); +fn oversized_schema_version_is_rejected_without_integer_wrapping() { + let error = deserialize_config("config_version = 4294967296\n") + .expect_err("reject schema version larger than u32"); - assert!(paths.is_empty()); + assert_eq!(error, "unsupported config version 4294967296"); } #[test] -fn empty_unversioned_config_receives_complete_legacy_defaults() { - let (config, ignored) = deserialize_config("").expect("migrate empty legacy config"); - - assert!(ignored.is_empty()); - assert!(config.panel.quick_actions_label.is_empty()); - assert!(config.panel.system_status_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!(config.widgets.density, WidgetDensity::Comfortable); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.stat_columns, 2); - assert_eq!(config.widgets.card_columns, 2); - assert_eq!(config.widgets.volume.segments, 0); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); - assert_eq!(config.media.art_size_px, 50); - assert_eq!(config.media.text_width_floor_px, 140); - assert_eq!(config.media.content_spacing_px, 10); - assert_eq!(config.media.control_spacing_px, 6); - assert_eq!(config.media.navigation_spacing_px, 6); -} - -#[test] -fn legacy_widgets_without_slider_tables_receive_slider_compatibility() { - let (config, _) = deserialize_config("[widgets]\ntoggle_columns = 3\n") - .expect("migrate legacy widgets without sliders"); - - // Explicit layout remains authoritative while omitted slider visuals stay historic - assert_eq!(config.widgets.toggle_columns, 3); - assert_eq!(config.widgets.volume.segments, 0); - assert!(!config.widgets.volume.show_sublabels); - assert!(config.widgets.volume.sublabel_min.is_empty()); - assert!(config.widgets.volume.sublabel_max.is_empty()); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(!config.widgets.brightness.show_sublabels); - assert!(config.widgets.brightness.sublabel_min.is_empty()); - assert!(config.widgets.brightness.sublabel_max.is_empty()); +fn negative_or_non_integer_schema_versions_are_rejected() { + for input in [ + "config_version = -1\n", + "config_version = \"5\"\n", + "config_version = true\n", + ] { + let error = deserialize_config(input).expect_err("reject malformed schema version"); + assert_eq!(error, "config_version must be a non-negative integer"); + } } #[test] -fn legacy_config_without_panel_table_receives_panel_compatibility() { - let (config, _) = deserialize_config("[general]\ndnd_default = true\n") - .expect("migrate legacy config without panel"); - - assert!(config.panel.quick_actions_label.is_empty()); - assert!(config.panel.system_status_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!( - config.panel.section_order, - vec![PanelSection::Widgets, PanelSection::Notifications] +fn current_schema_reports_unknown_keys_without_rejecting_valid_fields() { + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[panel]\nwidth = 500\nunknown_panel_key = true\n" ); -} - -#[test] -fn legacy_config_without_media_table_receives_media_compatibility() { - let (config, _) = - deserialize_config("[panel]\nwidth = 480\n").expect("migrate legacy config without media"); - - assert_eq!(config.media.art_size_px, 50); - assert_eq!(config.media.text_width_floor_px, 140); - assert_eq!(config.media.content_spacing_px, 10); - assert_eq!(config.media.control_spacing_px, 6); - assert_eq!(config.media.navigation_spacing_px, 6); -} - -#[test] -fn legacy_config_without_widgets_table_receives_widget_compatibility() { - let (config, _) = deserialize_config("[panel]\nwidth = 480\n") - .expect("migrate legacy config without widgets"); + let (config, ignored) = deserialize_config(&input).expect("parse current config"); - assert_eq!(config.widgets.density, WidgetDensity::Comfortable); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.stat_columns, 2); - assert_eq!(config.widgets.card_columns, 2); - assert_eq!(config.widgets.volume.segments, 0); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); + assert_eq!(config.panel.width, 500); + assert_eq!(ignored, ["panel.unknown_panel_key"]); } #[test] -fn malformed_legacy_table_is_reported_instead_of_replaced() { - let error = deserialize_config("panel = 'not a table'\n") - .expect_err("invalid legacy table should remain a type error"); +fn non_table_configuration_root_is_rejected() { + let error = deserialize_config("[1, 2, 3]").expect_err("reject non-table TOML root"); - assert!(error.contains("invalid type")); + assert!(!error.is_empty()); } diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 7e5b452f6..21315fb99 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -77,6 +77,16 @@ impl Notification { } } + /// Update canonical urgency and any retained protocol projection together + pub fn set_urgency(&mut self, urgency: Urgency) { + self.urgency = urgency; + // Retained hints are projections of the model and never independent policy inputs + if self.hints.contains_key("urgency") { + self.hints + .insert("urgency".to_string(), OwnedValue::from(urgency.as_u32())); + } + } + /// Convert to a lightweight view for UI consumption #[must_use] pub fn to_view(&self) -> NotificationView { diff --git a/crates/unixnotis-core/src/model/tests/types.rs b/crates/unixnotis-core/src/model/tests/types.rs index 7fcf17af7..7127640a8 100644 --- a/crates/unixnotis-core/src/model/tests/types.rs +++ b/crates/unixnotis-core/src/model/tests/types.rs @@ -62,3 +62,10 @@ fn urgency_as_u8_matches_freedesktop_values() { assert_eq!(Urgency::Normal.as_u8(), 1); assert_eq!(Urgency::Critical.as_u8(), 2); } + +#[test] +fn urgency_as_u32_matches_freedesktop_values() { + assert_eq!(Urgency::Low.as_u32(), 0); + assert_eq!(Urgency::Normal.as_u32(), 1); + assert_eq!(Urgency::Critical.as_u32(), 2); +} diff --git a/crates/unixnotis-core/src/model/types.rs b/crates/unixnotis-core/src/model/types.rs index 56147069f..4317089ce 100644 --- a/crates/unixnotis-core/src/model/types.rs +++ b/crates/unixnotis-core/src/model/types.rs @@ -41,6 +41,11 @@ impl Urgency { pub const fn as_u8(self) -> u8 { self as u8 } + + #[must_use] + pub const fn as_u32(self) -> u32 { + self as u32 + } } /// Action pair in the notification protocol diff --git a/crates/unixnotis-daemon/src/store/notifications/rules.rs b/crates/unixnotis-daemon/src/store/notifications/rules.rs index 46782e225..4b6a97c9e 100644 --- a/crates/unixnotis-daemon/src/store/notifications/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/rules.rs @@ -1,4 +1,4 @@ -use unixnotis_core::{Notification, RuleConfig, Urgency}; +use unixnotis_core::{IdentityAssurance, Notification, RuleConfig, Urgency}; use crate::store::NotificationStore; @@ -17,7 +17,21 @@ impl NotificationStore { fn rule_matches(rule: &RuleConfig, notification: &Notification) -> bool { // Every configured filter is ANDed together if let Some(app) = rule.app.as_ref() { - if !contains_ci(¬ification.app_name, app) { + // SECURITY: `Notification::app_name` is sender-controlled protocol metadata + // `app` rules use daemon-resolved attribution only. Matching the raw claim is + // intentionally opt-in through `claimed_app` + let attribution = ¬ification.attribution; + if !assurance_allows_app_rule(attribution.assurance) { + return false; + } + let matches_display = contains_ci(&attribution.display_name, app); + let matches_desktop = contains_ci(&attribution.desktop_id, app); + if !matches_display && !matches_desktop { + return false; + } + } + if let Some(claimed_app) = rule.claimed_app.as_ref() { + if !contains_ci(¬ification.app_name, claimed_app) { return false; } } @@ -46,6 +60,17 @@ fn rule_matches(rule: &RuleConfig, notification: &Notification) -> bool { true } +pub(super) const fn assurance_allows_app_rule(assurance: IdentityAssurance) -> bool { + // Positive enumeration makes new assurance variants fail closed by default + matches!( + assurance, + IdentityAssurance::Authenticated + | IdentityAssurance::SystemAssociated + | IdentityAssurance::PortalAssociated + | IdentityAssurance::UserAssociated + ) +} + fn apply_rule(rule: &RuleConfig, notification: &mut Notification) { // Optional fields mutate only when set in the matching rule if let Some(no_popup) = rule.no_popup { @@ -55,7 +80,7 @@ fn apply_rule(rule: &RuleConfig, notification: &mut Notification) { notification.suppress_sound = silent; } if let Some(force_urgency) = rule.force_urgency { - notification.urgency = Urgency::from(force_urgency); + notification.set_urgency(Urgency::from(force_urgency)); } if let Some(expire_timeout_ms) = rule.expire_timeout_ms { // Clamp protects against large config values that overflow i32 timeout fields diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs index b155d66f0..98f44f2c0 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs @@ -1,4 +1,5 @@ use super::support::*; +use crate::store::notifications::rules::assurance_allows_app_rule; #[test] fn contains_ci_matches_ascii() { @@ -17,6 +18,7 @@ fn rules_require_all_filters_and_apply_every_mutation() { rules: vec![unixnotis_core::RuleConfig { name: Some("test-rule".to_string()), app: Some("test".to_string()), + claimed_app: None, summary: Some("hello".to_string()), body: Some("body".to_string()), category: Some("chat".to_string()), @@ -32,20 +34,173 @@ fn rules_require_all_filters_and_apply_every_mutation() { }; let store = NotificationStore::new(config); let mut notification = make_notification("hello summary"); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Test Application", + "claimed", + "org.example.Test", + "test-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.Test".to_string(), + ); notification.body = "body text".to_string(); notification.category = Some("chat.message".to_string()); notification.urgency = unixnotis_core::Urgency::Normal; + notification.hints.insert( + "urgency".to_string(), + zbus::zvariant::OwnedValue::from(1_u32), + ); store.apply_rules(&mut notification); assert!(notification.suppress_popup); assert!(notification.suppress_sound); assert_eq!(notification.urgency, unixnotis_core::Urgency::Critical); + assert_eq!( + notification + .hints + .get("urgency") + .and_then(|value| value.try_clone().ok()) + .and_then(|value| u32::try_from(value).ok()), + Some(notification.urgency.as_u32()) + ); assert_eq!(notification.expire_timeout, 1234); assert!(notification.is_resident); assert!(notification.is_transient); } +#[test] +fn trusted_app_rule_does_not_match_spoofed_claim_or_escalate_urgency() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + force_urgency: Some(unixnotis_core::RuleUrgency::Critical), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("spoofed claim"); + notification.app_name = "TrustedApp".to_string(); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Unrelated Application", + "TrustedApp", + "org.example.Unrelated", + "unrelated", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.Unrelated".to_string(), + ); + + store.apply_rules(&mut notification); + + assert_eq!(notification.urgency, unixnotis_core::Urgency::Normal); +} + +#[test] +fn trusted_app_rule_matches_resolved_display_name_or_desktop_id() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("trusted identity"); + notification.app_name = "Unrelated Claim".to_string(); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Trusted Application", + "Unrelated Claim", + "org.example.TrustedApp", + "trusted-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.TrustedApp".to_string(), + ); + + store.apply_rules(&mut notification); + + assert!(notification.suppress_popup); +} + +#[test] +fn claimed_app_rule_intentionally_matches_sender_claim() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + claimed_app: Some("TrustedApp".to_string()), + silent: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("claimed identity"); + notification.app_name = "TrustedApp".to_string(); + + store.apply_rules(&mut notification); + + assert!(notification.suppress_sound); +} + +#[test] +fn trusted_app_rule_rejects_unresolved_and_conflicting_attribution() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut unresolved = make_notification("unresolved"); + unresolved.app_name = "TrustedApp".to_string(); + unresolved.attribution = unixnotis_core::NotificationAttribution::unresolved( + "TrustedApp", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "test identity", + "unknown:trusted-app".to_string(), + ); + let mut conflict = make_notification("conflict"); + conflict.app_name = "TrustedApp".to_string(); + conflict.attribution = unixnotis_core::NotificationAttribution::conflict( + "TrustedApp", + "org.example.TrustedApp", + unixnotis_core::AttributionReason::ExecutableMismatch, + "test identity", + "conflict:trusted-app".to_string(), + ); + + store.apply_rules(&mut unresolved); + store.apply_rules(&mut conflict); + + assert!(!unresolved.suppress_popup); + assert!(!conflict.suppress_popup); +} + +#[test] +fn app_rules_accept_only_explicitly_resolved_assurance_levels() { + use unixnotis_core::IdentityAssurance; + + for assurance in [ + IdentityAssurance::Authenticated, + IdentityAssurance::SystemAssociated, + IdentityAssurance::PortalAssociated, + IdentityAssurance::UserAssociated, + ] { + assert!(assurance_allows_app_rule(assurance)); + } + for assurance in [ + IdentityAssurance::Unresolved, + IdentityAssurance::Conflict, + IdentityAssurance::Relay, + ] { + assert!(!assurance_allows_app_rule(assurance)); + } +} + #[test] fn rules_do_not_match_missing_category_or_wrong_urgency() { let config = Config { diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs index 14c544bb7..4f88cba21 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs @@ -3,6 +3,7 @@ use crate::app::events::UiMessage; use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; +use crate::test_support::current_config_text; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; @@ -31,7 +32,11 @@ popup_css = "themes/custom/popup.css" widgets_css = "themes/custom/widgets.css" media_css = "themes/custom/media.css" "#; - fs::write(backup_dir.join("config.toml"), config_toml).expect("write config"); + fs::write( + backup_dir.join("config.toml"), + current_config_text(config_toml), + ) + .expect("write config"); fs::write(backup_dir.join("base.css"), "base").expect("write base"); fs::write(backup_dir.join("panel.css"), "panel").expect("write panel"); fs::write(backup_dir.join("popup.css"), "popup").expect("write popup"); @@ -106,7 +111,11 @@ fn restore_config_skips_absolute_theme_targets() { "[theme]\nbase_css = \"{}\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n", escaped_target.display() ); - fs::write(backup_dir.join("config.toml"), config_toml).expect("write config"); + fs::write( + backup_dir.join("config.toml"), + current_config_text(&config_toml), + ) + .expect("write config"); fs::write(backup_dir.join("base.css"), "base").expect("write base"); fs::write(backup_dir.join("panel.css"), "panel").expect("write panel"); fs::write(backup_dir.join("popup.css"), "popup").expect("write popup"); diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index 7637988cd..99a46fc37 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -10,6 +10,7 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; +use crate::test_support::current_config_text; use crate::test_support::env::{test_env_lock, EnvGuard}; use unixnotis_core::{ Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, @@ -84,12 +85,12 @@ fn ensure_config_provisions_default_css_and_preserves_the_live_config() { assert!(config_dir.join(script.relative_path).is_file()); } - fs::write(&config_path, "custom = true\n").expect("customize live config"); + fs::write(&config_path, current_config_text("custom = true\n")).expect("customize live config"); fs::write(config_dir.join("popup.css"), "/* custom popup */\n").expect("customize popup CSS"); ensure_config(&mut context).expect("existing config should be preserved"); assert_eq!( fs::read_to_string(&config_path).expect("read retained config"), - "custom = true\n" + current_config_text("custom = true\n") ); assert_eq!( fs::read_to_string(config_dir.join("popup.css")).expect("read retained popup CSS"), @@ -115,7 +116,9 @@ fn ensure_config_provisions_the_existing_configured_theme_paths() { fs::create_dir_all(&config_dir).expect("create config directory"); fs::write( config_dir.join("config.toml"), - "[theme]\nbase_css = \"themes/base.css\"\npanel_css = \"themes/panel.css\"\npopup_css = \"themes/popup.css\"\nwidgets_css = \"themes/widgets.css\"\nmedia_css = \"themes/media.css\"\n", + current_config_text( + "[theme]\nbase_css = \"themes/base.css\"\npanel_css = \"themes/panel.css\"\npopup_css = \"themes/popup.css\"\nwidgets_css = \"themes/widgets.css\"\nmedia_css = \"themes/media.css\"\n", + ), ) .expect("write configured theme paths"); fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); @@ -178,14 +181,14 @@ fn ensure_config_preserves_external_theme_files_without_creating_missing_or_unsa fs::create_dir(&external_media).expect("create external special target"); fs::write( config_dir.join("config.toml"), - format!( + current_config_text(&format!( "[theme]\nbase_css = {:?}\npopup_css = {:?}\npanel_css = {:?}\nwidgets_css = {:?}\nmedia_css = {:?}\n", external_base.to_string_lossy(), external_popup.to_string_lossy(), external_panel.to_string_lossy(), external_widgets.to_string_lossy(), external_media.to_string_lossy(), - ), + )), ) .expect("write external theme paths"); @@ -320,7 +323,7 @@ fn ensure_config_rejects_configured_theme_symlinks_without_touching_the_target() fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); fs::write( config_dir.join("config.toml"), - "[theme]\npopup_css = \"themes/popup.css\"\n", + current_config_text("[theme]\npopup_css = \"themes/popup.css\"\n"), ) .expect("write configured popup path"); let target = root.join("outside-popup.css"); diff --git a/crates/unixnotis-installer/src/tests/support/mod.rs b/crates/unixnotis-installer/src/tests/support/mod.rs index f4ab0613d..d027bc3c4 100644 --- a/crates/unixnotis-installer/src/tests/support/mod.rs +++ b/crates/unixnotis-installer/src/tests/support/mod.rs @@ -1,8 +1,14 @@ //! Shared installer test helpers for environment and filesystem fixtures +use unixnotis_core::CURRENT_CONFIG_VERSION; + pub mod env; pub mod fs; mod paths; +pub fn current_config_text(contents: &str) -> String { + format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}") +} + #[cfg(test)] mod tests; From 6c2ee5c1234494e75369d4fc76043ddf001895d6 Mon Sep 17 00:00:00 2001 From: locainin Date: Fri, 7 Aug 2026 23:51:38 -0500 Subject: [PATCH 253/275] security: harden MPRIS discovery and property handling Bound PropertiesChanged messages before dynamic deserialization and propagate timeouts across the complete property snapshot. Replace pass-count fairness with monotonic admission leases and scheduled wakeups so full player inventories cannot starve newcomers indefinitely. Construct replacement players before evicting incumbents, preserve incumbents when candidate construction fails, and add coverage for quiet full-capacity rotation, property limits, timeout handling, and selection behavior. --- .../src/media/mpris/constants.rs | 8 + .../src/media/mpris/discovery.rs | 168 ++++++++----- .../src/media/mpris/fairness.rs | 153 ++++++++++++ .../src/media/mpris/inventory.rs | 95 +++++++ .../src/media/mpris/listener.rs | 69 ++++- .../src/media/mpris/metadata.rs | 71 ++++-- .../unixnotis-center/src/media/mpris/mod.rs | 12 +- .../src/media/mpris/player.rs | 30 +-- .../src/media/mpris/selection.rs | 41 +++ .../src/media/mpris/tests/command.rs | 4 +- .../src/media/mpris/tests/discovery.rs | 141 +---------- .../src/media/mpris/tests/fairness.rs | 235 ++++++++++++++++++ .../src/media/mpris/tests/listener.rs | 96 ++++++- .../src/media/mpris/tests/metadata.rs | 26 +- .../src/media/mpris/tests/mod.rs | 2 + .../src/media/mpris/tests/player.rs | 18 +- .../src/media/mpris/tests/selection.rs | 121 +++++++++ .../src/media/mpris/tests/support.rs | 151 +++++++++-- .../src/media/runtime/dispatch.rs | 4 +- .../src/media/runtime/loop.rs | 19 +- .../src/media/runtime/refresh.rs | 1 + .../src/media/runtime/signal.rs | 3 + .../src/media/runtime/state.rs | 5 +- .../src/media/runtime/tests/dispatch.rs | 13 +- .../src/media/runtime/tests/owner.rs | 4 +- 25 files changed, 1192 insertions(+), 298 deletions(-) create mode 100644 crates/unixnotis-center/src/media/mpris/fairness.rs create mode 100644 crates/unixnotis-center/src/media/mpris/inventory.rs create mode 100644 crates/unixnotis-center/src/media/mpris/selection.rs create mode 100644 crates/unixnotis-center/src/media/mpris/tests/fairness.rs create mode 100644 crates/unixnotis-center/src/media/mpris/tests/selection.rs diff --git a/crates/unixnotis-center/src/media/mpris/constants.rs b/crates/unixnotis-center/src/media/mpris/constants.rs index f0a860c90..726b5ee0f 100644 --- a/crates/unixnotis-center/src/media/mpris/constants.rs +++ b/crates/unixnotis-center/src/media/mpris/constants.rs @@ -13,6 +13,10 @@ pub const MPRIS_APP: &str = "org.mpris.MediaPlayer2"; pub const MPRIS_PROPERTY_TIMEOUT_MS: u64 = 500; /// Reject unusually large property replies before decoding dynamic values pub const MAX_MPRIS_PROPERTY_REPLY_BYTES: usize = 512 * 1024; +/// Reject oversized property-change signals before dynamic value deserialization +pub const MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES: usize = MAX_MPRIS_PROPERTY_REPLY_BYTES; +/// Bound dictionary and invalidation entries after the encoded byte gate +pub const MAX_MPRIS_CHANGED_PROPERTIES: usize = 32; /// Identity is shown in the panel but is never allowed to grow without bound pub const MAX_MPRIS_IDENTITY_BYTES: usize = 512; pub const MPRIS_TIMEOUT_QUARANTINE_AFTER: u8 = 3; @@ -20,6 +24,10 @@ pub const MPRIS_TIMEOUT_QUARANTINE_MS: u64 = 5_000; /// Discovery is capped so one bus connection cannot create unbounded state pub const MAX_MPRIS_PLAYERS: usize = 32; +/// Quiet full-capacity inventories rotate one admission opportunity at this interval +pub const MPRIS_FAIRNESS_LEASE_MS: u64 = 5_000; +/// Failed candidate construction receives a bounded retry without resetting its lease +pub const MPRIS_FAIRNESS_RETRY_MS: u64 = 1_000; /// Candidate owner probes are bounded before any full player construction pub const MAX_MPRIS_CANDIDATES_PER_PASS: usize = 128; diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index 963b757dd..2a9176e5d 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -5,17 +5,30 @@ use std::num::NonZeroUsize; use futures_util::stream::{self, StreamExt}; use tokio::sync::mpsc::Sender; +use tokio::time::Instant; use tracing::warn; use unixnotis_core::{MediaConfig, PanelDebugLevel}; use zbus::fdo::DBusProxy; use zbus::Connection; -use super::constants::{MAX_MPRIS_CANDIDATES_PER_PASS, MAX_MPRIS_PLAYERS, MPRIS_PREFIX}; -use super::player::{build_player_state_for_owner, resolve_player_owner, OwnerProbe}; -use super::{is_allowed_player, spawn_properties_listener, PlayerState}; +use super::constants::MAX_MPRIS_PLAYERS; +use super::fairness::MprisFairnessState; +use super::inventory::{ + admit_fairness_candidate, build_dbus_player_state, insert_player_state, FairnessAdmission, + PlayerStateBuilder, +}; +use super::player::{resolve_player_owner, OwnerProbe}; +use super::selection::{is_discoverable_player, select_player_names}; +use super::PlayerState; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::MediaSignal; +pub(super) struct DiscoveryState<'a> { + pub players: &'a mut HashMap, + pub discovery_cursor: &'a mut usize, + pub fairness: &'a mut MprisFairnessState, +} + pub(in crate::media) async fn refresh_players( connection: &Connection, dbus_proxy: &DBusProxy<'_>, @@ -23,7 +36,36 @@ pub(in crate::media) async fn refresh_players( signal_tx: &Sender, players: &mut HashMap, discovery_cursor: &mut usize, + fairness: &mut MprisFairnessState, +) -> zbus::Result<()> { + refresh_players_with_builder( + connection, + dbus_proxy, + config, + signal_tx, + DiscoveryState { + players, + discovery_cursor, + fairness, + }, + build_dbus_player_state, + ) + .await +} + +pub(in crate::media) async fn refresh_players_with_builder( + connection: &Connection, + dbus_proxy: &DBusProxy<'_>, + config: &MediaConfig, + signal_tx: &Sender, + state: DiscoveryState<'_>, + build_player: PlayerStateBuilder, ) -> zbus::Result<()> { + let DiscoveryState { + players, + discovery_cursor, + fairness, + } = state; let names = dbus_proxy.list_names().await?; let mut allowed = HashSet::new(); for name in names { @@ -81,27 +123,26 @@ pub(in crate::media) async fn refresh_players( probed.sort_unstable_by(|left, right| left.0.cmp(&right.0)); let mut failed_probes = 0usize; let mut capacity_skipped = 0usize; - let mut selected = Vec::<(String, OwnerProbe)>::new(); + let mut eligible = Vec::<(String, OwnerProbe)>::new(); + let mut candidate_owners = HashSet::new(); for (name, owner) in probed { let Some(owner) = owner else { failed_probes = failed_probes.saturating_add(1); continue; }; // Several aliases can resolve to one connection; retain one stable alias - if !owners.insert(owner.unique_owner.clone()) { + if owners.contains(&owner.unique_owner) + || !candidate_owners.insert(owner.unique_owner.clone()) + { continue; } - if owner_capacity_exceeded(owners.len(), MAX_MPRIS_PLAYERS) { - owners.remove(&owner.unique_owner); - capacity_skipped = capacity_skipped.saturating_add(1); - continue; - } - selected.push((name, owner)); + eligible.push((name, owner)); } - // Full construction runs once per selected owner, never once per alias - for (name, owner) in selected { - let state = match build_player_state_for_owner(connection, &name, config, owner).await { + // Build ordinary admissions until successful states fill the owner capacity + while owners.len() < MAX_MPRIS_PLAYERS && !eligible.is_empty() { + let (name, owner) = eligible.remove(0); + let state = match build_player(connection, &name, config, owner).await { Ok(state) => state, Err(err) => { failed_probes = failed_probes.saturating_add(1); @@ -111,17 +152,59 @@ pub(in crate::media) async fn refresh_players( continue; } }; - spawn_properties_listener( - state.properties.clone(), - name.clone(), - signal_tx.clone(), - state.listener_cancel.subscribe(), - ); - players.insert(name.clone(), state); - debug::log(PanelDebugLevel::Info, || { - format!("media player added: {name}") - }); + owners.extend(state.unique_owner.iter().cloned()); + insert_player_state(players, signal_tx, name, state); } + + // Starting the lease after normal admission also covers over-capacity startup inventories + let fairness_rotation_due = fairness.rotation_due( + owners.len() >= MAX_MPRIS_PLAYERS, + !eligible.is_empty(), + Instant::now(), + signal_tx, + ); + if fairness_rotation_due && !eligible.is_empty() { + match admit_fairness_candidate( + connection, + config, + signal_tx, + players, + fairness, + eligible.remove(0), + build_player, + ) + .await + { + FairnessAdmission::Admitted { + victim_name, + candidate_name, + } => { + // Successful admission starts the next bounded opportunity + fairness.complete_rotation(Instant::now(), true, signal_tx); + debug::log(PanelDebugLevel::Info, || { + format!("media player lease rotated: {victim_name} -> {candidate_name}") + }); + } + FairnessAdmission::BuildFailed { + candidate_name, + error, + } => { + failed_probes = failed_probes.saturating_add(1); + // A failed candidate leaves every healthy incumbent untouched + fairness.retry_failed_rotation(Instant::now(), signal_tx); + debug::log(PanelDebugLevel::Verbose, || { + format!( + "failed to build fairness media player state for {candidate_name}: {error}" + ) + }); + } + FairnessAdmission::NoVictim => { + capacity_skipped = capacity_skipped.saturating_add(1); + fairness.retry_failed_rotation(Instant::now(), signal_tx); + } + } + } + capacity_skipped = capacity_skipped.saturating_add(eligible.len()); if failed_probes > 0 { warn!( failed = failed_probes, @@ -138,40 +221,3 @@ pub(in crate::media) async fn refresh_players( Ok(()) } - -pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { - name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) -} - -pub(super) fn select_player_names( - names: HashSet, - tracked: &HashSet, - cursor: &mut usize, -) -> Vec { - let mut names = names.into_iter().collect::>(); - names.sort_unstable(); - - let mut selected = names - .iter() - .filter(|name| tracked.contains(*name)) - .cloned() - .collect::>(); - let remaining = names - .into_iter() - .filter(|name| !tracked.contains(name)) - .collect::>(); - let room = MAX_MPRIS_CANDIDATES_PER_PASS.saturating_sub(selected.len()); - if room == 0 || remaining.is_empty() { - return selected; - } - - let start = *cursor % remaining.len(); - let count = room.min(remaining.len()); - selected.extend((0..count).map(|offset| remaining[(start + offset) % remaining.len()].clone())); - *cursor = (start + count) % remaining.len(); - selected -} - -pub(super) const fn owner_capacity_exceeded(owner_count: usize, capacity: usize) -> bool { - owner_count > capacity -} diff --git a/crates/unixnotis-center/src/media/mpris/fairness.rs b/crates/unixnotis-center/src/media/mpris/fairness.rs new file mode 100644 index 000000000..ac00afb89 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/fairness.rs @@ -0,0 +1,153 @@ +//! Monotonic fairness leases for full MPRIS inventories + +use std::collections::HashSet; +use std::time::Duration; + +use tokio::sync::mpsc::Sender; +use tokio::task::JoinHandle; +use tokio::time::Instant; + +use super::constants::{MPRIS_FAIRNESS_LEASE_MS, MPRIS_FAIRNESS_RETRY_MS}; +use crate::media::runtime::MediaSignal; + +pub(in crate::media) struct MprisFairnessState { + deadline: Option, + wakeup: Option<(u64, JoinHandle<()>)>, + generation: u64, + victim_cursor: usize, + lease_duration: Duration, + retry_duration: Duration, +} + +impl MprisFairnessState { + pub(in crate::media) const fn new() -> Self { + Self::with_durations( + Duration::from_millis(MPRIS_FAIRNESS_LEASE_MS), + Duration::from_millis(MPRIS_FAIRNESS_RETRY_MS), + ) + } + + pub(in crate::media) const fn with_durations( + lease_duration: Duration, + retry_duration: Duration, + ) -> Self { + Self { + deadline: None, + wakeup: None, + generation: 0, + victim_cursor: 0, + lease_duration, + retry_duration, + } + } + + pub(in crate::media) fn rotation_due( + &mut self, + capacity_was_full: bool, + has_untracked: bool, + now: Instant, + signal_tx: &Sender, + ) -> bool { + if !capacity_was_full || !has_untracked { + self.clear_lease(); + return false; + } + let Some(deadline) = self.deadline else { + self.start_lease(now, signal_tx); + return false; + }; + if now >= deadline { + return true; + } + self.ensure_wakeup(deadline, signal_tx); + false + } + + pub(in crate::media) fn complete_rotation( + &mut self, + now: Instant, + has_untracked: bool, + signal_tx: &Sender, + ) { + // Admission completion is the only event that renews an active fairness lease + self.clear_lease(); + if has_untracked { + self.start_lease(now, signal_tx); + } + } + + pub(in crate::media) fn retry_failed_rotation( + &mut self, + now: Instant, + signal_tx: &Sender, + ) { + if self.deadline.is_some() && self.wakeup.is_none() { + self.ensure_wakeup(now + self.retry_duration, signal_tx); + } + } + + pub(in crate::media) fn consume_wakeup(&mut self, generation: u64) -> bool { + let matches_current = self + .wakeup + .as_ref() + .is_some_and(|(scheduled_generation, _task)| *scheduled_generation == generation) + && self.generation == generation + && self.deadline.is_some(); + if matches_current { + self.wakeup.take(); + } + matches_current + } + + pub(in crate::media) fn select_victim(&mut self, tracked: &HashSet) -> Option { + let mut tracked = tracked.iter().collect::>(); + tracked.sort_unstable(); + if tracked.is_empty() { + return None; + } + let victim = (*tracked.get(self.victim_cursor % tracked.len())?).clone(); + self.victim_cursor = (self.victim_cursor + 1) % tracked.len(); + Some(victim) + } + + fn start_lease(&mut self, now: Instant, signal_tx: &Sender) { + self.generation = self.generation.wrapping_add(1); + let deadline = now + self.lease_duration; + self.deadline = Some(deadline); + self.ensure_wakeup(deadline, signal_tx); + } + + fn ensure_wakeup(&mut self, wake_at: Instant, signal_tx: &Sender) { + if self.wakeup.is_some() { + return; + } + let generation = self.generation; + let signal_tx = signal_tx.clone(); + // Exactly one task converts monotonic lease time into an event-loop refresh + let task = tokio::spawn(async move { + tokio::time::sleep_until(wake_at).await; + let _ = signal_tx + .send(MediaSignal::FairnessLeaseExpired { generation }) + .await; + }); + self.wakeup = Some((generation, task)); + } + + fn clear_lease(&mut self) { + if let Some((_generation, task)) = self.wakeup.take() { + task.abort(); + } + if self.deadline.take().is_some() { + // Queued messages from an old lease must not wake the renewed inventory + self.generation = self.generation.wrapping_add(1); + } + } +} + +impl Drop for MprisFairnessState { + fn drop(&mut self) { + if let Some((_generation, task)) = self.wakeup.take() { + task.abort(); + } + } +} diff --git a/crates/unixnotis-center/src/media/mpris/inventory.rs b/crates/unixnotis-center/src/media/mpris/inventory.rs new file mode 100644 index 000000000..226ffe31b --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/inventory.rs @@ -0,0 +1,95 @@ +//! Player-state construction and inventory commits + +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::pin::Pin; + +use tokio::sync::mpsc::Sender; +use unixnotis_core::{MediaConfig, PanelDebugLevel}; +use zbus::Connection; + +use super::player::{build_player_state_for_owner, OwnerProbe}; +use super::{spawn_properties_listener, MprisFairnessState, PlayerState}; +use crate::diagnostics::panel_debug as debug; +use crate::media::runtime::MediaSignal; + +pub(in crate::media) type PlayerStateBuildFuture<'a> = + Pin> + Send + 'a>>; +pub(in crate::media) type PlayerStateBuilder = + for<'a> fn(&'a Connection, &'a str, &'a MediaConfig, OwnerProbe) -> PlayerStateBuildFuture<'a>; + +pub(super) fn build_dbus_player_state<'a>( + connection: &'a Connection, + name: &'a str, + config: &'a MediaConfig, + owner: OwnerProbe, +) -> PlayerStateBuildFuture<'a> { + Box::pin(build_player_state_for_owner( + connection, name, config, owner, + )) +} + +pub(super) fn insert_player_state( + players: &mut HashMap, + signal_tx: &Sender, + name: String, + state: PlayerState, +) { + let properties = state.properties.clone(); + let listener_cancel = state.listener_cancel.subscribe(); + players.insert(name.clone(), state); + spawn_properties_listener(properties, name.clone(), signal_tx.clone(), listener_cancel); + debug::log(PanelDebugLevel::Info, || { + format!("media player added: {name}") + }); +} + +pub(super) enum FairnessAdmission { + Admitted { + victim_name: String, + candidate_name: String, + }, + BuildFailed { + candidate_name: String, + error: zbus::Error, + }, + NoVictim, +} + +pub(super) async fn admit_fairness_candidate( + connection: &Connection, + config: &MediaConfig, + signal_tx: &Sender, + players: &mut HashMap, + fairness: &mut MprisFairnessState, + candidate: (String, OwnerProbe), + build_player: PlayerStateBuilder, +) -> FairnessAdmission { + let (candidate_name, owner) = candidate; + let state = match build_player(connection, &candidate_name, config, owner).await { + Ok(state) => state, + Err(error) => { + return FairnessAdmission::BuildFailed { + candidate_name, + error, + }; + } + }; + + // Victim selection occurs only after the replacement is fully constructible + let tracked_names = players.keys().cloned().collect::>(); + let Some(victim_name) = fairness.select_victim(&tracked_names) else { + return FairnessAdmission::NoVictim; + }; + let Some(victim) = players.remove(&victim_name) else { + return FairnessAdmission::NoVictim; + }; + + // No await separates removal and insertion, so capacity never exposes a partial commit + let _ = victim.listener_cancel.send(true); + insert_player_state(players, signal_tx, candidate_name.clone(), state); + FairnessAdmission::Admitted { + victim_name, + candidate_name, + } +} diff --git a/crates/unixnotis-center/src/media/mpris/listener.rs b/crates/unixnotis-center/src/media/mpris/listener.rs index 92d5fc3d8..ec633b610 100644 --- a/crates/unixnotis-center/src/media/mpris/listener.rs +++ b/crates/unixnotis-center/src/media/mpris/listener.rs @@ -7,8 +7,14 @@ use tokio::sync::{mpsc::Sender, watch}; use tracing::warn; use unixnotis_core::PanelDebugLevel; use zbus::fdo::PropertiesProxy; +use zbus::message::Type; +use zbus::names::InterfaceName; +use zbus::zvariant::Value; +use zbus::{MatchRule, Message, MessageStream}; -use super::constants::MPRIS_PLAYER; +use super::constants::{ + MAX_MPRIS_CHANGED_PROPERTIES, MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES, MPRIS_PLAYER, +}; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::{MediaRefreshOrigin, MediaSignal}; @@ -19,7 +25,24 @@ pub(in crate::media) fn spawn_properties_listener( mut cancel_rx: watch::Receiver, ) { tokio::spawn(async move { - let mut stream = match properties.receive_properties_changed().await { + let connection = properties.inner().connection().clone(); + let destination = properties.inner().destination().to_owned(); + let path = properties.inner().path().to_owned(); + let rule = match MatchRule::builder() + .msg_type(Type::Signal) + .sender(destination) + .and_then(|builder| builder.path(path)) + .and_then(|builder| builder.interface("org.freedesktop.DBus.Properties")) + .and_then(|builder| builder.member("PropertiesChanged")) + .map(zbus::MatchRuleBuilder::build) + { + Ok(rule) => rule, + Err(err) => { + warn!(?err, "failed to build media property signal rule"); + return; + } + }; + let mut stream = match MessageStream::for_match_rule(rule, &connection, Some(32)).await { Ok(stream) => stream, Err(err) => { warn!(?err, "failed to subscribe to media properties"); @@ -38,13 +61,13 @@ pub(in crate::media) fn spawn_properties_listener( let Some(update) = update else { break; }; - let Ok(args) = update.args() else { + let Ok(message) = update else { continue; }; - if args.interface_name != MPRIS_PLAYER { + let Some(relevant) = relevant_media_change_from_message(&message) else { continue; - } - if !is_relevant_media_change(&args.changed_properties, &args.invalidated_properties) { + }; + if !relevant { continue; } debug::log(PanelDebugLevel::Verbose, || { @@ -66,6 +89,40 @@ pub(in crate::media) fn spawn_properties_listener( }); } +pub(super) fn relevant_media_change_from_message(message: &Message) -> Option { + // SECURITY: enforce the encoded signal-body budget before deserializing + // `a{sv}`. Dynamic zvariant values may allocate attacker-controlled memory + if !properties_changed_body_allowed(message.body().len()) { + return None; + } + let body = message.body(); + let (interface_name, changed, invalidated): ( + InterfaceName<'_>, + HashMap<&str, Value<'_>>, + Vec<&str>, + ) = body.deserialize().ok()?; + if interface_name.as_str() != MPRIS_PLAYER + || !changed_property_count_allowed(changed.len(), invalidated.len()) + { + return None; + } + Some(is_relevant_media_change(&changed, &invalidated)) +} + +pub(super) const fn properties_changed_body_allowed(body_len: usize) -> bool { + body_len <= MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES +} + +pub(super) const fn changed_property_count_allowed( + changed_count: usize, + invalidated_count: usize, +) -> bool { + match changed_count.checked_add(invalidated_count) { + Some(count) => count <= MAX_MPRIS_CHANGED_PROPERTIES, + None => false, + } +} + pub(super) fn is_relevant_media_change( changed: &HashMap<&str, zbus::zvariant::Value<'_>>, invalidated: &[&str], diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index f7eec38d9..ea996da59 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -16,6 +16,28 @@ const MAX_ARTIST_BYTES: usize = 256; const MAX_ART_URL_BYTES: usize = 2048; const PLASMA_BRIDGE: &str = "org.mpris.MediaPlayer2.plasma-browser-integration"; +#[derive(Debug)] +pub(super) enum PropertyRead { + Value(T), + Timeout, + Oversize, + Invalid, + BusError, +} + +impl PropertyRead { + pub(super) const fn is_timeout(&self) -> bool { + matches!(self, Self::Timeout) + } + + pub(super) fn into_value(self) -> Option { + match self { + Self::Value(value) => Some(value), + Self::Timeout | Self::Oversize | Self::Invalid | Self::BusError => None, + } + } +} + pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option { if state.timeout.is_quarantined() { return None; @@ -59,7 +81,18 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option Option( interface: &str, property: &str, timeout: std::time::Duration, -) -> Option +) -> PropertyRead where T: TryFrom, { - let reply = tokio::time::timeout(timeout, proxy.call_method("Get", &(interface, property))) - .await - .ok()? - .ok()?; + let reply = + match tokio::time::timeout(timeout, proxy.call_method("Get", &(interface, property))).await + { + Err(_elapsed) => return PropertyRead::Timeout, + Ok(Err(_error)) => return PropertyRead::BusError, + Ok(Ok(reply)) => reply, + }; if !property_reply_body_allowed(reply.body().len()) { - return None; + return PropertyRead::Oversize; + } + let Ok(value) = reply.body().deserialize::() else { + return PropertyRead::Invalid; + }; + match T::try_from(value) { + Ok(value) => PropertyRead::Value(value), + Err(_error) => PropertyRead::Invalid, } - let value: OwnedValue = reply.body().deserialize().ok()?; - T::try_from(value).ok() } pub(super) const fn metadata_entry_count_allowed(count: usize) -> bool { diff --git a/crates/unixnotis-center/src/media/mpris/mod.rs b/crates/unixnotis-center/src/media/mpris/mod.rs index 91fc8c332..1d8a0b364 100644 --- a/crates/unixnotis-center/src/media/mpris/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/mod.rs @@ -5,25 +5,21 @@ mod command; mod constants; mod credentials; mod discovery; +mod fairness; +mod inventory; mod listener; mod metadata; mod player; mod process; +mod selection; pub(in crate::media) use admission::is_allowed_player; pub(in crate::media) use command::handle_command; pub(in crate::media) use constants::MPRIS_PREFIX; pub(in crate::media) use discovery::refresh_players; +pub(in crate::media) use fairness::MprisFairnessState; pub(in crate::media) use listener::spawn_properties_listener; pub(in crate::media) use metadata::{fetch_media_info, is_plasma_browser_bridge}; -#[cfg_attr( - not(test), - expect( - unused_imports, - reason = "the direct builder is retained as an internal integration-test seam" - ) -)] -pub(in crate::media) use player::build_player_state; pub(in crate::media) use player::PlayerState; #[cfg(test)] diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index 5d6749701..0319d95ad 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -87,6 +87,14 @@ impl PlayerTimeoutState { } } + pub(super) fn record_refresh_batch(&self, any_timeout: bool) { + if any_timeout { + self.record_timeout(); + } else { + self.clear_timeout(); + } + } + pub(super) fn clear_timeout(&self) { self.streak.store(0, Ordering::Release); if let Ok(mut until) = self.quarantined_until.lock() { @@ -99,27 +107,6 @@ pub(super) fn quarantine_active(now: Instant, deadline: Instant) -> bool { now < deadline } -#[cfg_attr( - not(test), - expect(dead_code, reason = "direct builder remains available for media tests") -)] -pub(in crate::media) async fn build_player_state( - connection: &Connection, - name: &str, - config: &MediaConfig, -) -> zbus::Result> { - // D-Bus owner data is captured once so snapshots do not need another bus round trip - // The broker-derived PID remains authoritative even when player metadata supplies hints - let Some(owner) = resolve_player_owner(connection, name).await else { - // Ownership changed during probing, so a later bus event should rebuild stable data - return Ok(None); - }; - - Ok(Some( - build_player_state_for_owner(connection, name, config, owner).await?, - )) -} - // Keep credential handling separate so compatibility behavior can be tested without a bus shim pub(super) async fn build_player_state_for_owner( connection: &Connection, @@ -213,6 +200,7 @@ pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Optio std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), ) .await + .into_value() .filter(|identity| identity.len() <= MAX_MPRIS_IDENTITY_BYTES) .map(|identity| identity.trim().to_string()) .filter(|identity| !identity.is_empty()) diff --git a/crates/unixnotis-center/src/media/mpris/selection.rs b/crates/unixnotis-center/src/media/mpris/selection.rs new file mode 100644 index 000000000..c24d0e06f --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/selection.rs @@ -0,0 +1,41 @@ +//! Bounded MPRIS discovery-name selection + +use std::collections::HashSet; + +use unixnotis_core::MediaConfig; + +use super::constants::{MAX_MPRIS_CANDIDATES_PER_PASS, MPRIS_PREFIX}; +use super::is_allowed_player; + +pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { + name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) +} + +pub(super) fn select_player_names( + names: HashSet, + tracked: &HashSet, + cursor: &mut usize, +) -> Vec { + let mut names = names.into_iter().collect::>(); + names.sort_unstable(); + + let mut selected = names + .iter() + .filter(|name| tracked.contains(*name)) + .cloned() + .collect::>(); + let remaining = names + .into_iter() + .filter(|name| !tracked.contains(name)) + .collect::>(); + let room = MAX_MPRIS_CANDIDATES_PER_PASS.saturating_sub(selected.len()); + if room == 0 || remaining.is_empty() { + return selected; + } + + let start = *cursor % remaining.len(); + let count = room.min(remaining.len()); + selected.extend((0..count).map(|offset| remaining[(start + offset) % remaining.len()].clone())); + *cursor = (start + count) % remaining.len(); + selected +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/command.rs b/crates/unixnotis-center/src/media/mpris/tests/command.rs index 060047c2b..1222d79a3 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/command.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/command.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use super::super::command::handle_command; -use super::super::player::{build_player_state, PlayerState}; -use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use super::super::player::PlayerState; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::MediaCommand; use unixnotis_core::MediaConfig; diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index 3d690dd55..30d043ec0 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -1,144 +1,13 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::time::Duration; use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::fdo::DBusProxy; -use super::super::discovery::{ - is_discoverable_player, owner_capacity_exceeded, refresh_players, select_player_names, -}; -use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; - -#[test] -fn discovery_requires_an_mpris_name_that_passes_admission() { - let config = MediaConfig { - denylist: vec!["blocked".to_string()], - ..MediaConfig::default() - }; - - assert!(is_discoverable_player( - "org.mpris.MediaPlayer2.allowed", - &config - )); - assert!(!is_discoverable_player("org.example.allowed", &config)); - assert!(!is_discoverable_player( - "org.mpris.MediaPlayer2.blocked", - &config - )); -} - -#[test] -fn discovery_orders_all_names_before_owner_capacity_is_applied() { - let names = (0..48) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - let mut cursor = 0; - let selected = select_player_names(names, &HashSet::new(), &mut cursor); - - assert_eq!(selected.len(), 48); - assert_eq!( - selected.first().map(String::as_str), - Some("org.mpris.MediaPlayer2.player-000") - ); - assert_eq!( - selected.last().map(String::as_str), - Some("org.mpris.MediaPlayer2.player-047") - ); -} - -#[test] -fn discovery_keeps_all_admitted_names_for_owner_resolution() { - let names = (0..32) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - - let mut cursor = 0; - assert_eq!( - select_player_names(names, &HashSet::new(), &mut cursor).len(), - 32 - ); -} - -#[test] -fn discovery_caps_candidate_work_and_rotates_untracked_names() { - let names = (0..256) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - let mut cursor = 0; - let first = select_player_names(names.clone(), &HashSet::new(), &mut cursor); - let second = select_player_names(names, &HashSet::new(), &mut cursor); - - assert_eq!(first.len(), 128); - assert_eq!(second.len(), 128); - assert!(first.iter().all(|name| !second.contains(name))); -} - -#[test] -fn discovery_rotation_wraps_from_a_nonzero_cursor() { - let names = (0..256) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - let mut cursor = 130; - - let selected = select_player_names(names, &HashSet::new(), &mut cursor); - - assert_eq!( - selected.first().map(String::as_str), - Some("org.mpris.MediaPlayer2.player-130") - ); - assert_eq!(cursor, 2); -} - -#[test] -fn discovery_always_preserves_tracked_names_before_rotation() { - let names = (0..256) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - let tracked = HashSet::from([ - "org.mpris.MediaPlayer2.player-255".to_string(), - "org.mpris.MediaPlayer2.player-254".to_string(), - ]); - let mut cursor = 0; - let selected = select_player_names(names, &tracked, &mut cursor); - - assert!(selected - .iter() - .any(|name| name == "org.mpris.MediaPlayer2.player-254")); - assert!(selected - .iter() - .any(|name| name == "org.mpris.MediaPlayer2.player-255")); - assert_eq!(selected.len(), 128); -} - -#[test] -fn discovery_selection_handles_empty_and_full_tracked_pages() { - let mut cursor = 0; - assert!(select_player_names(HashSet::new(), &HashSet::new(), &mut cursor).is_empty()); - - let names = (0..256) - .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) - .collect::>(); - let tracked = names.iter().take(128).cloned().collect::>(); - let selected = select_player_names(names, &tracked, &mut cursor); - - assert_eq!(selected.len(), 128); - assert!(selected.iter().all(|name| tracked.contains(name))); -} - -#[test] -fn discovery_owner_capacity_rejects_only_values_above_the_limit() { - assert!(!owner_capacity_exceeded(32, 32)); - assert!(owner_capacity_exceeded(33, 32)); -} - -#[test] -fn discovery_owner_capacity_applies_only_above_the_limit() { - assert!(!owner_capacity_exceeded(31, 32)); - assert!(!owner_capacity_exceeded(32, 32)); - assert!(owner_capacity_exceeded(33, 32)); -} +use super::super::discovery::refresh_players; +use super::super::fairness::MprisFairnessState; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; #[tokio::test] async fn discovery_adds_live_players_and_removes_stale_entries() { @@ -157,6 +26,7 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { let mut stale_cancel = stale.listener_cancel.subscribe(); let mut players = HashMap::from([(stale_name.to_string(), stale)]); let mut discovery_cursor = 0; + let mut fairness = MprisFairnessState::new(); tokio::time::timeout( Duration::from_secs(2), @@ -167,6 +37,7 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { &signal_tx, &mut players, &mut discovery_cursor, + &mut fairness, ), ) .await diff --git a/crates/unixnotis-center/src/media/mpris/tests/fairness.rs b/crates/unixnotis-center/src/media/mpris/tests/fairness.rs new file mode 100644 index 000000000..5f7082ee5 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/tests/fairness.rs @@ -0,0 +1,235 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use tokio::sync::mpsc; +use unixnotis_core::MediaConfig; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::constants::MAX_MPRIS_PLAYERS; +use super::super::discovery::{refresh_players, refresh_players_with_builder, DiscoveryState}; +use super::super::fairness::MprisFairnessState; +use super::super::inventory::PlayerStateBuildFuture; +use super::super::player::OwnerProbe; +use super::super::PlayerState; +use super::support::{fleet_player_name, MprisFleetFixture}; + +#[tokio::test] +async fn full_capacity_fairness_becomes_due_at_its_monotonic_deadline() { + let lease = Duration::from_millis(20); + let mut fairness = MprisFairnessState::with_durations(lease, lease); + let (signal_tx, mut signal_rx) = mpsc::channel(1); + let admitted_at = tokio::time::Instant::now(); + + assert!(!fairness.rotation_due(true, true, admitted_at, &signal_tx)); + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + assert!(fairness.rotation_due(true, true, tokio::time::Instant::now(), &signal_tx)); +} + +#[tokio::test] +async fn fairness_never_schedules_below_capacity_or_without_untracked_candidates() { + let lease = Duration::from_millis(10); + let mut fairness = MprisFairnessState::with_durations(lease, lease); + let (signal_tx, mut signal_rx) = mpsc::channel(1); + + assert!(!fairness.rotation_due(false, true, tokio::time::Instant::now(), &signal_tx)); + assert!(!fairness.rotation_due(true, false, tokio::time::Instant::now(), &signal_tx)); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(signal_rx.try_recv().is_err()); +} + +#[test] +fn fairness_victim_selection_rotates_across_incumbents() { + let mut fairness = MprisFairnessState::new(); + let tracked = HashSet::from([ + "org.mpris.MediaPlayer2.a".to_string(), + "org.mpris.MediaPlayer2.b".to_string(), + ]); + + assert_eq!( + fairness.select_victim(&tracked).as_deref(), + Some("org.mpris.MediaPlayer2.a") + ); + assert_eq!( + fairness.select_victim(&tracked).as_deref(), + Some("org.mpris.MediaPlayer2.b") + ); +} + +async fn receive_fairness_wakeup( + fairness: &mut MprisFairnessState, + signal_rx: &mut mpsc::Receiver, +) { + let signal = tokio::time::timeout(Duration::from_secs(2), signal_rx.recv()) + .await + .expect("quiet capacity should receive its fairness wakeup") + .expect("fairness signal channel should remain open"); + let crate::media::runtime::MediaSignal::FairnessLeaseExpired { generation } = signal else { + panic!("quiet MPRIS players emitted an unrelated signal"); + }; + assert!(fairness.consume_wakeup(generation)); +} + +fn cancel_all_listeners(players: &HashMap) { + for player in players.values() { + let _ = player.listener_cancel.send(true); + } +} + +async fn discover_incumbent_fleet( + fixture: &MprisFleetFixture, + proxy: &DBusProxy<'_>, + config: &MediaConfig, + signal_tx: &mpsc::Sender, + players: &mut HashMap, + cursor: &mut usize, + fairness: &mut MprisFairnessState, +) { + refresh_players( + &fixture.client, + proxy, + config, + signal_tx, + players, + cursor, + fairness, + ) + .await + .expect("discover the incumbent fleet"); + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); +} + +#[tokio::test] +async fn quiet_full_capacity_inventory_wakes_and_admits_the_next_player() { + let mut fixture = MprisFleetFixture::start(MAX_MPRIS_PLAYERS).await; + let config = MediaConfig::default(); + let proxy = DBusProxy::new(&fixture.client) + .await + .expect("create private bus proxy"); + let (signal_tx, mut signal_rx) = mpsc::channel(64); + let mut players = HashMap::new(); + let mut cursor = 0; + let mut fairness = + MprisFairnessState::with_durations(Duration::from_millis(25), Duration::from_millis(25)); + discover_incumbent_fleet( + &fixture, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await; + + let candidate_name = fleet_player_name(MAX_MPRIS_PLAYERS); + fixture.add_player(MAX_MPRIS_PLAYERS).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("observe the over-capacity candidate"); + assert!(!players.contains_key(&candidate_name)); + + // The lease task is the only event that requests this second discovery pass + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("admit the fairness candidate after its deadline"); + + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); + assert!(players.contains_key(&candidate_name)); + cancel_all_listeners(&players); +} + +fn fail_player_state_build<'a>( + _connection: &'a Connection, + _name: &'a str, + _config: &'a MediaConfig, + _owner: OwnerProbe, +) -> PlayerStateBuildFuture<'a> { + Box::pin(async { + Err(zbus::Error::Failure( + "intentional candidate build failure".to_string(), + )) + }) +} + +#[tokio::test] +async fn failed_fairness_candidate_build_keeps_the_incumbent_and_listener_alive() { + let mut fixture = MprisFleetFixture::start(MAX_MPRIS_PLAYERS).await; + let config = MediaConfig::default(); + let proxy = DBusProxy::new(&fixture.client) + .await + .expect("create private bus proxy"); + let (signal_tx, mut signal_rx) = mpsc::channel(64); + let mut players = HashMap::new(); + let mut cursor = 0; + let mut fairness = + MprisFairnessState::with_durations(Duration::from_millis(25), Duration::from_millis(100)); + discover_incumbent_fleet( + &fixture, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await; + fixture.add_player(MAX_MPRIS_PLAYERS).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("start the fairness lease"); + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + let victim_name = fleet_player_name(0); + let mut victim_cancel = players[&victim_name].listener_cancel.subscribe(); + + refresh_players_with_builder( + &fixture.client, + &proxy, + &config, + &signal_tx, + DiscoveryState { + players: &mut players, + discovery_cursor: &mut cursor, + fairness: &mut fairness, + }, + fail_player_state_build, + ) + .await + .expect("candidate build failure should not fail discovery"); + + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); + assert!(players.contains_key(&victim_name)); + assert!(!players.contains_key(&fleet_player_name(MAX_MPRIS_PLAYERS))); + assert!( + tokio::time::timeout(Duration::from_millis(30), victim_cancel.changed()) + .await + .is_err(), + "failed admission must not cancel the selected incumbent" + ); + cancel_all_listeners(&players); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/listener.rs b/crates/unixnotis-center/src/media/mpris/tests/listener.rs index 59a3f9553..e4370b517 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/listener.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/listener.rs @@ -3,12 +3,43 @@ use std::time::Duration; use tokio::sync::mpsc; use unixnotis_core::MediaConfig; +use zbus::Message; -use super::super::listener::{is_relevant_media_change, spawn_properties_listener}; -use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use super::super::constants::{ + MAX_MPRIS_CHANGED_PROPERTIES, MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES, MPRIS_PATH, MPRIS_PLAYER, +}; +use super::super::listener::{ + changed_property_count_allowed, is_relevant_media_change, properties_changed_body_allowed, + relevant_media_change_from_message, spawn_properties_listener, +}; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::runtime::{MediaRefreshOrigin, MediaSignal}; +fn properties_changed_message(body: &T) -> Message +where + T: serde::Serialize + zbus::zvariant::DynamicType, +{ + Message::signal( + MPRIS_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + ) + .expect("property signal builder") + .build(body) + .expect("property signal message") +} + +fn signal_with_exact_body_len(target_len: usize) -> Message { + let empty = properties_changed_message(&(Vec::::new(),)); + let overhead = empty.body().len(); + let payload_len = target_len + .checked_sub(overhead) + .expect("target body must exceed the encoded array overhead"); + let message = properties_changed_message(&(vec![0_u8; payload_len],)); + assert_eq!(message.body().len(), target_len); + message +} + #[test] fn relevant_media_change_detects_updates_and_invalidations() { let mut changed = HashMap::new(); @@ -29,6 +60,65 @@ fn relevant_media_change_ignores_unrelated_properties() { assert!(!is_relevant_media_change(&changed, &["Position"])); } +#[test] +fn properties_changed_encoded_body_limit_accepts_only_the_exact_budget() { + assert!(properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES - 1 + )); + assert!(properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + )); + assert!(!properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1 + )); +} + +#[test] +fn raw_properties_changed_gate_handles_encoded_bodies_on_both_sides_of_limit() { + let below = signal_with_exact_body_len(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES - 1); + let above = signal_with_exact_body_len(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1); + + assert!(properties_changed_body_allowed(below.body().len())); + assert!(!properties_changed_body_allowed(above.body().len())); + assert_eq!(relevant_media_change_from_message(&above), None); +} + +#[test] +fn properties_changed_entry_limit_bounds_changes_and_invalidations_together() { + assert!(changed_property_count_allowed( + MAX_MPRIS_CHANGED_PROPERTIES, + 0 + )); + assert!(changed_property_count_allowed(16, 16)); + assert!(!changed_property_count_allowed(16, 17)); + assert!(!changed_property_count_allowed(usize::MAX, 1)); +} + +#[test] +fn raw_properties_changed_decoder_accepts_normal_media_signals() { + let changed = HashMap::from([("Metadata", zbus::zvariant::Value::from("track"))]); + let message = properties_changed_message(&(MPRIS_PLAYER, changed, Vec::<&str>::new())); + + assert_eq!(relevant_media_change_from_message(&message), Some(true)); +} + +#[test] +fn raw_properties_changed_decoder_rejects_oversized_irrelevant_data_before_decode() { + let oversized = "x".repeat(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1_024); + let changed = HashMap::from([("Unrelated", zbus::zvariant::Value::from(oversized.as_str()))]); + let message = properties_changed_message(&(MPRIS_PLAYER, changed, Vec::<&str>::new())); + + assert!(message.body().len() > MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES); + assert_eq!(relevant_media_change_from_message(&message), None); +} + +#[test] +fn raw_properties_changed_decoder_rejects_malformed_body() { + let message = properties_changed_message(&("wrong shape",)); + + assert_eq!(relevant_media_change_from_message(&message), None); +} + #[tokio::test] async fn property_listener_forwards_relevant_live_player_changes() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index d1a85fd43..27f834cac 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -4,8 +4,7 @@ use super::super::metadata::{ bound_string, is_plasma_browser_bridge, metadata_artist, metadata_entry_count_allowed, metadata_pid, metadata_string, property_reply_body_allowed, }; -use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_BRIDGE_PLAYER_NAME, TEST_PLAYER_NAME}; +use super::support::{build_player_state, MprisFixture, TEST_BRIDGE_PLAYER_NAME, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; use zbus::zvariant::{OwnedValue, Value}; @@ -137,6 +136,29 @@ async fn oversized_metadata_reply_is_rejected_before_dynamic_decode() { assert!(info.artist.is_empty()); } +#[tokio::test] +async fn fast_playback_status_cannot_clear_five_sibling_property_timeouts() { + let fixture = MprisFixture::start_with_slow_non_status_properties().await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build slow-property fixture player") + .expect("fixture owner should remain stable"); + + for _ in 0..3 { + let info = fetch_media_info(&player) + .await + .expect("fast PlaybackStatus should still construct a partial snapshot"); + assert_eq!(info.playback_status, "Playing"); + assert!(!info.can_play); + assert!(!info.can_pause); + assert!(!info.can_next); + assert!(!info.can_prev); + } + + assert!(player.timeout.is_quarantined()); + assert_eq!(fetch_media_info(&player).await, None); +} + #[tokio::test] async fn oversized_art_url_is_not_retained() { let fixture = MprisFixture::start_with_art_url_bytes(2_049).await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/mod.rs b/crates/unixnotis-center/src/media/mpris/tests/mod.rs index 75c3ee429..6a14eaf17 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/mod.rs @@ -1,7 +1,9 @@ mod admission; mod command; mod discovery; +mod fairness; mod listener; mod metadata; mod player; +mod selection; pub(in crate::media) mod support; diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index bc0ecc3d8..758dafc8f 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -4,10 +4,10 @@ use unixnotis_core::MediaConfig; use super::super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PREFIX}; use super::super::player::{ - build_player_state, build_player_state_for_owner, fetch_identity, owner_probe_is_stable, - quarantine_active, read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, + build_player_state_for_owner, fetch_identity, owner_probe_is_stable, quarantine_active, + read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, }; -use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; #[test] fn owner_probe_accepts_only_one_stable_unique_owner() { @@ -63,6 +63,18 @@ fn player_timeout_state_clear_releases_a_quarantine() { assert!(!state.is_quarantined()); } +#[test] +fn refresh_batch_with_fast_status_and_other_timeouts_reaches_quarantine() { + let state = PlayerTimeoutState::new(); + + // PlaybackStatus succeeded in each modeled batch, while five sibling calls timed out + for _ in 0..3 { + state.record_refresh_batch(true); + } + + assert!(state.is_quarantined()); +} + #[tokio::test] async fn player_state_uses_live_identity_owner_and_process_details() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/selection.rs b/crates/unixnotis-center/src/media/mpris/tests/selection.rs new file mode 100644 index 000000000..69999c806 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/tests/selection.rs @@ -0,0 +1,121 @@ +use std::collections::HashSet; + +use unixnotis_core::MediaConfig; + +use super::super::selection::{is_discoverable_player, select_player_names}; + +#[test] +fn discovery_requires_an_mpris_name_that_passes_admission() { + let config = MediaConfig { + denylist: vec!["blocked".to_string()], + ..MediaConfig::default() + }; + + assert!(is_discoverable_player( + "org.mpris.MediaPlayer2.allowed", + &config + )); + assert!(!is_discoverable_player("org.example.allowed", &config)); + assert!(!is_discoverable_player( + "org.mpris.MediaPlayer2.blocked", + &config + )); +} + +#[test] +fn discovery_orders_all_names_before_owner_capacity_is_applied() { + let names = (0..48) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 0; + let selected = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!(selected.len(), 48); + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-000") + ); + assert_eq!( + selected.last().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-047") + ); +} + +#[test] +fn discovery_keeps_all_admitted_names_for_owner_resolution() { + let names = (0..32) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + + let mut cursor = 0; + assert_eq!( + select_player_names(names, &HashSet::new(), &mut cursor).len(), + 32 + ); +} + +#[test] +fn discovery_caps_candidate_work_and_rotates_untracked_names() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 0; + let first = select_player_names(names.clone(), &HashSet::new(), &mut cursor); + let second = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!(first.len(), 128); + assert_eq!(second.len(), 128); + assert!(first.iter().all(|name| !second.contains(name))); +} + +#[test] +fn discovery_rotation_wraps_from_a_nonzero_cursor() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 130; + + let selected = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-130") + ); + assert_eq!(cursor, 2); +} + +#[test] +fn discovery_always_preserves_tracked_names_before_rotation() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = HashSet::from([ + "org.mpris.MediaPlayer2.player-255".to_string(), + "org.mpris.MediaPlayer2.player-254".to_string(), + ]); + let mut cursor = 0; + let selected = select_player_names(names, &tracked, &mut cursor); + + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-254")); + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-255")); + assert_eq!(selected.len(), 128); +} + +#[test] +fn discovery_selection_handles_empty_and_full_tracked_pages() { + let mut cursor = 0; + assert!(select_player_names(HashSet::new(), &HashSet::new(), &mut cursor).is_empty()); + + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = names.iter().take(128).cloned().collect::>(); + let selected = select_player_names(names, &tracked, &mut cursor); + + assert_eq!(selected.len(), 128); + assert!(selected.iter().all(|name| tracked.contains(name))); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index 9ceb4760d..c451fedb8 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -9,13 +9,33 @@ use zbus::zvariant::OwnedValue; use zbus::{Connection, ConnectionBuilder}; use super::super::constants::MPRIS_PATH; +use super::super::player::{build_player_state_for_owner, resolve_player_owner, PlayerState}; use crate::test_support::broker::read_broker_address; +use unixnotis_core::MediaConfig; pub(in crate::media) const TEST_PLAYER_NAME: &str = "org.mpris.MediaPlayer2.unixnotis_test"; pub(in crate::media) const TEST_BRIDGE_PLAYER_NAME: &str = "org.mpris.MediaPlayer2.plasma-browser-integration"; pub(in crate::media) const TEST_PLAYER_IDENTITY: &str = "UnixNotis Test Player"; +pub(in crate::media) async fn build_player_state( + connection: &Connection, + name: &str, + config: &MediaConfig, +) -> zbus::Result> { + // Resolve one stable owner before constructing proxies bound to that owner + let Some(owner) = resolve_player_owner(connection, name).await else { + return Ok(None); + }; + Ok(Some( + build_player_state_for_owner(connection, name, config, owner).await?, + )) +} + +pub(in crate::media) fn fleet_player_name(index: usize) -> String { + format!("org.mpris.MediaPlayer2.unixnotis_fleet_{index:03}") +} + // Parallel fixtures need distinct socket directories even inside one process static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); @@ -110,6 +130,13 @@ struct TestMprisPlayer { metadata_bytes: usize, art_url_bytes: usize, metadata_pid: Option, + slow_non_status: bool, +} + +async fn delay_non_status_property(slow: bool) { + if slow { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + } } #[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] @@ -128,7 +155,8 @@ impl TestMprisPlayer { } #[zbus(property)] - fn metadata(&self) -> HashMap { + async fn metadata(&self) -> HashMap { + delay_non_status_property(self.slow_non_status).await; // The optional payload exercises the raw reply budget without a real player let mut metadata = HashMap::new(); if self.metadata_bytes > 0 { @@ -159,22 +187,26 @@ impl TestMprisPlayer { } #[zbus(property)] - fn can_play(&self) -> bool { + async fn can_play(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_pause(&self) -> bool { + async fn can_pause(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_go_next(&self) -> bool { + async fn can_go_next(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_go_previous(&self) -> bool { + async fn can_go_previous(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } } @@ -201,13 +233,17 @@ impl MprisFixture { } pub(in crate::media) async fn start_with_kde_pid(pid: u32) -> Self { - Self::start_with_payload_for_name(TEST_BRIDGE_PLAYER_NAME, 0, 0, 0, Some(pid)).await + Self::start_with_payload_for_name(TEST_BRIDGE_PLAYER_NAME, 0, 0, 0, Some(pid), false).await } pub(in crate::media) async fn start_with_identity_bytes(identity_bytes: usize) -> Self { Self::start_with_payload(0, 0, identity_bytes).await } + pub(in crate::media) async fn start_with_slow_non_status_properties() -> Self { + Self::start_with_payload_for_name(TEST_PLAYER_NAME, 0, 0, 0, None, true).await + } + async fn start_with_payload( metadata_bytes: usize, art_url_bytes: usize, @@ -219,6 +255,7 @@ impl MprisFixture { art_url_bytes, identity_bytes, None, + false, ) .await } @@ -229,34 +266,24 @@ impl MprisFixture { art_url_bytes: usize, identity_bytes: usize, metadata_pid: Option, + slow_non_status: bool, ) -> Self { let broker = PrivateBroker::start(); - let commands = Arc::new(CommandCounts::default()); let identity = if identity_bytes == 0 { TEST_PLAYER_IDENTITY.to_string() } else { "x".repeat(identity_bytes) }; - // The service exports both MPRIS interfaces at the standard object path - let server = ConnectionBuilder::address(broker.address.as_str()) - .expect("parse private broker address") - .name(name) - .expect("request test MPRIS name") - .serve_at(MPRIS_PATH, TestMprisRoot { identity }) - .expect("register test MPRIS root") - .serve_at( - MPRIS_PATH, - TestMprisPlayer { - commands: commands.clone(), - metadata_bytes, - art_url_bytes, - metadata_pid, - }, - ) - .expect("register test MPRIS player") - .build() - .await - .expect("connect test MPRIS service"); + let (server, commands) = build_test_player_service( + &broker.address, + name, + identity, + metadata_bytes, + art_url_bytes, + metadata_pid, + slow_non_status, + ) + .await; // A separate client connection exercises normal bus routing and owner lookup let client = ConnectionBuilder::address(broker.address.as_str()) .expect("parse private broker address") @@ -292,3 +319,73 @@ impl MprisFixture { .expect("emit playback status change"); } } + +pub(in crate::media) struct MprisFleetFixture { + pub(in crate::media) client: Connection, + servers: Vec, + broker: PrivateBroker, +} + +impl MprisFleetFixture { + pub(in crate::media) async fn start(player_count: usize) -> Self { + let broker = PrivateBroker::start(); + let client = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .build() + .await + .expect("connect fleet test client"); + let mut fixture = Self { + client, + servers: Vec::with_capacity(player_count.saturating_add(1)), + broker, + }; + for index in 0..player_count { + fixture.add_player(index).await; + } + fixture + } + + pub(in crate::media) async fn add_player(&mut self, index: usize) { + let name = fleet_player_name(index); + let identity = format!("UnixNotis Fleet Player {index:03}"); + let (server, _commands) = + build_test_player_service(&self.broker.address, &name, identity, 0, 0, None, false) + .await; + // Keeping the connection alive preserves the unique owner and all exported interfaces + self.servers.push(server); + } +} + +async fn build_test_player_service( + address: &str, + name: &str, + identity: String, + metadata_bytes: usize, + art_url_bytes: usize, + metadata_pid: Option, + slow_non_status: bool, +) -> (Connection, Arc) { + let commands = Arc::new(CommandCounts::default()); + // The service exports both MPRIS interfaces at the standard object path + let server = ConnectionBuilder::address(address) + .expect("parse private broker address") + .name(name) + .expect("request test MPRIS name") + .serve_at(MPRIS_PATH, TestMprisRoot { identity }) + .expect("register test MPRIS root") + .serve_at( + MPRIS_PATH, + TestMprisPlayer { + commands: commands.clone(), + metadata_bytes, + art_url_bytes, + metadata_pid, + slow_non_status, + }, + ) + .expect("register test MPRIS player") + .build() + .await + .expect("connect test MPRIS service"); + (server, commands) +} diff --git a/crates/unixnotis-center/src/media/runtime/dispatch.rs b/crates/unixnotis-center/src/media/runtime/dispatch.rs index 5245e42af..6dd3b23a1 100644 --- a/crates/unixnotis-center/src/media/runtime/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/dispatch.rs @@ -44,10 +44,10 @@ pub(super) async fn handle_runtime_signal( state: &mut MediaRuntimeState, signal_tx: &mpsc::Sender, sender: &async_channel::Sender, - signal: MediaSignal, + bus_name: String, + origin: MediaRefreshOrigin, ) { // Signal payloads name the one player that changed, avoiding a full cache rebuild - let MediaSignal::PropertiesChanged { bus_name, origin } = signal; refresh_player_cache( &state.players, &mut state.cache, diff --git a/crates/unixnotis-center/src/media/runtime/loop.rs b/crates/unixnotis-center/src/media/runtime/loop.rs index 0c8d1d2a8..b5c7d51bb 100644 --- a/crates/unixnotis-center/src/media/runtime/loop.rs +++ b/crates/unixnotis-center/src/media/runtime/loop.rs @@ -120,7 +120,24 @@ async fn run_connection_once( // Property listeners belong to this connection and must be rebuilt together return false; }; - handle_runtime_signal(&mut state, &signal_tx, sender, signal).await; + match signal { + MediaSignal::FairnessLeaseExpired { generation } => { + // Only the current lease may request a full discovery pass + if state.mpris_fairness.consume_wakeup(generation) { + refresh = true; + } + } + MediaSignal::PropertiesChanged { bus_name, origin } => { + handle_runtime_signal( + &mut state, + &signal_tx, + sender, + bus_name, + origin, + ) + .await; + } + } } retry = owner_retry_rx.recv() => { if retry.is_none() { diff --git a/crates/unixnotis-center/src/media/runtime/refresh.rs b/crates/unixnotis-center/src/media/runtime/refresh.rs index 139d4b600..1b3135ca5 100644 --- a/crates/unixnotis-center/src/media/runtime/refresh.rs +++ b/crates/unixnotis-center/src/media/runtime/refresh.rs @@ -32,6 +32,7 @@ pub(super) async fn refresh_all_players( signal_tx, &mut state.players, &mut state.discovery_cursor, + &mut state.mpris_fairness, ) .await { diff --git a/crates/unixnotis-center/src/media/runtime/signal.rs b/crates/unixnotis-center/src/media/runtime/signal.rs index efd2bde78..cc65773ac 100644 --- a/crates/unixnotis-center/src/media/runtime/signal.rs +++ b/crates/unixnotis-center/src/media/runtime/signal.rs @@ -14,4 +14,7 @@ pub(in crate::media) enum MediaSignal { bus_name: String, origin: MediaRefreshOrigin, }, + FairnessLeaseExpired { + generation: u64, + }, } diff --git a/crates/unixnotis-center/src/media/runtime/state.rs b/crates/unixnotis-center/src/media/runtime/state.rs index 81466ec92..7823d49f2 100644 --- a/crates/unixnotis-center/src/media/runtime/state.rs +++ b/crates/unixnotis-center/src/media/runtime/state.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use super::schedule::DelayedRefreshTasks; -use crate::media::mpris::PlayerState; +use crate::media::mpris::{MprisFairnessState, PlayerState}; use crate::media::MediaInfo; pub(super) struct MediaRuntimeState { @@ -17,6 +17,8 @@ pub(super) struct MediaRuntimeState { pub(super) delayed_refreshes: DelayedRefreshTasks, // Rotates bounded candidate probes so names outside the first sorted page get a turn pub(super) discovery_cursor: usize, + // A monotonic lease wakes quiet full-capacity inventories without polling + pub(super) mpris_fairness: MprisFairnessState, } impl MediaRuntimeState { @@ -28,6 +30,7 @@ impl MediaRuntimeState { last_snapshot: Vec::new(), delayed_refreshes: HashMap::new(), discovery_cursor: 0, + mpris_fairness: MprisFairnessState::new(), } } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs index 45fb5893c..b55fbae62 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs @@ -7,8 +7,7 @@ use super::super::state::MediaRuntimeState; use super::super::{MediaRefreshOrigin, MediaSignal}; use super::support::receive_ui_event; use crate::control::UiEvent; -use crate::media::mpris::build_player_state; -use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; +use crate::media::mpris::tests::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::{MediaCommand, MediaInfo}; use unixnotis_core::MediaConfig; @@ -19,7 +18,9 @@ fn property_signal_preserves_player_and_refresh_origin() { origin: MediaRefreshOrigin::Fallback, }; - let MediaSignal::PropertiesChanged { bus_name, origin } = signal; + let MediaSignal::PropertiesChanged { bus_name, origin } = signal else { + panic!("property test signal changed variant"); + }; assert_eq!(bus_name, "org.mpris.MediaPlayer2.test"); assert_eq!(origin, MediaRefreshOrigin::Fallback); } @@ -116,10 +117,8 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { &mut state, &signal_tx, &event_tx, - MediaSignal::PropertiesChanged { - bus_name: TEST_PLAYER_NAME.to_string(), - origin: MediaRefreshOrigin::Bus, - }, + TEST_PLAYER_NAME.to_string(), + MediaRefreshOrigin::Bus, ) .await; diff --git a/crates/unixnotis-center/src/media/runtime/tests/owner.rs b/crates/unixnotis-center/src/media/runtime/tests/owner.rs index 491228ed3..9532e46c5 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/owner.rs @@ -5,8 +5,8 @@ use super::super::owner::{ use super::super::state::MediaRuntimeState; use super::support::receive_ui_event; use crate::control::UiEvent; -use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; -use crate::media::mpris::{build_player_state, fetch_media_info}; +use crate::media::mpris::fetch_media_info; +use crate::media::mpris::tests::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; async fn live_runtime_state(fixture: &MprisFixture) -> MediaRuntimeState { From 994309d37bf5f62e8142d6c8662b59e9be2a8485 Mon Sep 17 00:00:00 2001 From: locainin Date: Fri, 7 Aug 2026 23:51:53 -0500 Subject: [PATCH 254/275] security: harden notification ingress and lifecycle Harden notification ownership, callback routing, quotas, protocol validation, and lifecycle handling around stable Linux process identity. Prevent D-Bus name churn from resetting per-sender quotas, preserve callback delivery across reconnects, restrict replacement to active notifications, and make failed CloseNotification requests indistinguishable. Preserve absolute popup deadlines across renderer downtime, correct resident and critical expiration behavior, and give DropAll notifications a content-free lifecycle with reply-before-close ordering. Validate raw image channel and row layout, advertise only implemented capabilities, canonicalize stored urgency, and consume sound cooldown only after a sound request is actually accepted. Add integration coverage for D-Bus lifecycle ordering, ownership, reconnects, quota principals, images, sounds, expiration, and popup replay. --- .../src/daemon/control/action.rs | 27 +-- .../src/daemon/control/reply.rs | 31 +-- .../src/daemon/control/tests/action.rs | 25 +- .../src/daemon/control/tests/reply.rs | 25 +- .../src/daemon/control/tests/server.rs | 34 ++- .../daemon/notifications/identity/delivery.rs | 94 ++++++++ .../src/daemon/notifications/identity/mod.rs | 5 +- .../daemon/notifications/identity/sender.rs | 6 +- .../notifications/identity/sender_cache.rs | 32 ++- .../notifications/identity/tests/delivery.rs | 155 +++++++++++++ .../notifications/identity/tests/sender.rs | 13 ++ .../identity/tests/sender_cache.rs | 34 +++ .../notifications/ingress/payload/build.rs | 2 +- .../notifications/ingress/payload/sanitize.rs | 17 +- .../ingress/payload/tests/build.rs | 37 +++ .../ingress/payload/tests/mod.rs | 3 +- .../ingress/payload/tests/sanitize.rs | 15 +- .../src/daemon/notifications/ingress/quota.rs | 127 +++++++---- .../notifications/ingress/tests/quota.rs | 167 ++++++++------ .../notifications/server/capabilities.rs | 4 +- .../src/daemon/notifications/server/close.rs | 52 +++-- .../src/daemon/notifications/server/flow.rs | 153 +++++++++---- .../daemon/notifications/server/ingress.rs | 24 +- .../daemon/notifications/server/interface.rs | 60 +++-- .../src/daemon/notifications/server/mod.rs | 16 ++ .../notifications/server/reply_lifecycle.rs | 72 ++++++ .../server/tests/capabilities.rs | 20 +- .../daemon/notifications/server/tests/flow.rs | 151 +++++++++++- .../notifications/server/tests/ingress.rs | 63 +++++ .../notifications/server/tests/interface.rs | 2 +- .../server/tests/quota_principal.rs | 33 +++ .../server/tests/reply_lifecycle.rs | 115 ++++++++++ .../server/wire_hints/image_bytes.rs | 19 +- .../server/wire_hints/tests/image_bytes.rs | 12 + .../daemon/state/notification_lifecycle.rs | 29 +-- .../src/daemon/state/tests/mod.rs | 1 + .../state/tests/notification_lifecycle.rs | 25 +- .../src/daemon/state/tests/support.rs | 37 +++ .../src/runtime/tests/dbus_lifecycle.rs | 108 ++++++++- crates/unixnotis-daemon/src/sound/command.rs | 24 +- crates/unixnotis-daemon/src/sound/settings.rs | 61 +++-- .../src/sound/tests/settings.rs | 89 ++++++-- .../src/store/inhibitors/tests/model.rs | 15 +- crates/unixnotis-daemon/src/store/mod.rs | 7 +- crates/unixnotis-daemon/src/store/model.rs | 55 ++++- .../src/store/notifications/insertion.rs | 50 ++-- .../src/store/notifications/ownership.rs | 42 +++- .../src/store/notifications/tests/history.rs | 14 +- .../store/notifications/tests/lifecycle.rs | 42 ++-- .../store/notifications/tests/ownership.rs | 177 +++++++++++--- .../src/store/notifications/tests/timeout.rs | 32 ++- .../src/store/notifications/timeout.rs | 12 +- crates/unixnotis-daemon/src/store/runtime.rs | 75 +++++- .../src/store/test_support.rs | 2 +- .../unixnotis-daemon/src/store/tests/mod.rs | 1 + .../src/store/tests/runtime/action_target.rs | 10 +- .../src/store/tests/runtime/config.rs | 4 +- .../src/store/tests/runtime/inline_reply.rs | 14 +- .../src/store/tests/runtime/lifecycle.rs | 8 +- .../src/store/tests/runtime/popup.rs | 215 ++++++++++++++++-- .../src/store/tests/support.rs | 40 ++++ crates/unixnotis-daemon/src/tests/expire.rs | 10 +- 62 files changed, 2254 insertions(+), 590 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/support.rs create mode 100644 crates/unixnotis-daemon/src/store/tests/support.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index 263da1675..85b50d203 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -3,9 +3,9 @@ use std::future::Future; use unixnotis_core::NotificationKey; -use zbus::fdo::DBusProxy; use zbus::SignalContext; +use crate::daemon::notifications::identity::resolve_callback_destination; use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; use super::ControlServer; @@ -48,22 +48,15 @@ impl ControlServer { ) })? }; - let sender = target - .sender_name - .as_deref() - .ok_or_else(application_unavailable_error)?; - let bus_name = zbus::names::BusName::try_from(sender) - .map_err(|_error| application_unavailable_error())?; - let proxy = DBusProxy::new(self.state.connection()) - .await - .map_err(to_fdo_error)?; - if !proxy - .name_has_owner(bus_name.clone()) - .await - .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))? - { - return Err(application_unavailable_error()); - } + let bus_name = resolve_callback_destination( + &self.state.sender_metadata_cache, + self.state.connection(), + target.sender_name.as_deref(), + target.sender_pid, + target.sender_start_time, + ) + .await + .ok_or_else(application_unavailable_error)?; // The test seam models replacement after the external liveness query pre_emit().await; diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index 137882fff..5901090a4 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -3,10 +3,10 @@ use std::future::Future; use unixnotis_core::Notification; -use zbus::fdo::DBusProxy; use zbus::names::BusName; use zbus::SignalContext; +use crate::daemon::notifications::identity::resolve_callback_destination; use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; use super::ControlServer; @@ -79,26 +79,15 @@ impl ControlServer { &self, target: &Notification, ) -> zbus::fdo::Result> { - let sender = target - .sender_name - .as_deref() - .ok_or_else(application_unavailable_error)?; - let bus_name = BusName::try_from(sender).map_err(|error| { - // Stored sender names should always be unique D-Bus names from message headers - tracing::debug!(?error, "inline reply target has an invalid sender name"); - application_unavailable_error() - })?; - let proxy = DBusProxy::new(self.state.connection()) - .await - .map_err(to_fdo_error)?; - let has_owner = proxy - .name_has_owner(bus_name.clone()) - .await - .map_err(|err| zbus::fdo::Error::Failed(err.to_string()))?; - if !has_owner { - return Err(application_unavailable_error()); - } - Ok(bus_name.to_owned()) + resolve_callback_destination( + &self.state.sender_metadata_cache, + self.state.connection(), + target.sender_name.as_deref(), + target.sender_pid, + target.sender_start_time, + ) + .await + .ok_or_else(application_unavailable_error) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index 0bcc37a6a..564cfa545 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -24,7 +24,7 @@ async fn validated_action_emits_only_an_advertised_live_action() { let mut store = state.store.lock().await; store .insert(action_notification(&sender, "open"), 0) - .notification + .active_notification() .key() }; @@ -50,7 +50,7 @@ async fn successful_action_keeps_a_resident_notification_active() { resident.is_resident = true; let notification = { let mut store = state.store.lock().await; - store.insert(resident, 0).notification.key() + store.insert(resident, 0).active_notification().key() }; ControlServer::new(state.clone()) @@ -77,7 +77,7 @@ async fn action_signal_reaches_owner_but_not_unrelated_observer() { let mut store = state.store.lock().await; store .insert(action_notification(&owner, "open"), 0) - .notification + .active_notification() .key() }; @@ -109,7 +109,7 @@ async fn action_keeps_notification_when_the_owner_disappears() { let mut store = state.store.lock().await; store .insert(action_notification(&sender, "open"), 0) - .notification + .active_notification() .key() }; let sender_name = sender.unique_name().expect("sender unique name").clone(); @@ -152,7 +152,7 @@ async fn unconfirmed_action_does_not_emit_or_dismiss() { notification.attribution.interactions = unixnotis_core::InteractionPolicies::CONFIRM_ACTIONS; let key = { let mut store = state.store.lock().await; - store.insert(notification, 0).notification.key() + store.insert(notification, 0).active_notification().key() }; ControlServer::new(state.clone()) @@ -175,7 +175,7 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { let mut store = state.store.lock().await; let notification = store .insert(action_notification(&sender, "open"), 0) - .notification; + .active_notification(); (notification.id, notification.key()) }; let server = ControlServer::new(state.clone()); @@ -209,11 +209,11 @@ async fn stale_action_does_not_target_same_id_replacement() { let mut store = state.store.lock().await; let first = store .insert(action_notification(&sender, "delete"), 0) - .notification; + .active_notification(); let stale_key = first.key(); let second = store .insert(action_notification(&sender, "delete"), first.id) - .notification; + .active_notification(); (stale_key, second.key()) }; @@ -247,7 +247,7 @@ async fn validated_action_rejects_a_conflicting_application_claim() { .lock() .await .insert(notification, 0) - .notification + .active_notification() .key() }; @@ -292,8 +292,11 @@ fn action_notification(sender: &Connection, key: &str) -> Notification { expire_timeout: 0, received_at: Utc::now(), sender_name: sender.unique_name().map(ToString::to_string), - sender_pid: None, - sender_start_time: None, + sender_pid: Some(std::process::id()), + sender_start_time: Some( + crate::daemon::notifications::identity::read_process_start_time(std::process::id()) + .expect("test process should expose a start time"), + ), sender_executable: None, } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 7e1b8c4d5..190ebac79 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -64,7 +64,7 @@ async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(false, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; @@ -89,7 +89,7 @@ async fn submit_inline_reply_keeps_resident_notification_live() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(true, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; @@ -112,10 +112,10 @@ async fn stale_reply_generation_cannot_target_a_same_id_replacement() { let mut store = state.store.lock().await; let original = store .insert(reply_notification(false, &sender), 0) - .notification; + .active_notification(); let replacement = store .insert(reply_notification(false, &sender), original.id) - .notification; + .active_notification(); (original.id, original.generation, replacement.generation) }; @@ -152,7 +152,7 @@ async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(true, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; @@ -176,7 +176,7 @@ async fn reply_listener_replacement_survives_generation_safe_dismissal() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(false, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; let replacement_state = state.clone(); @@ -213,7 +213,7 @@ async fn reply_listener_close_removes_replied_notification_without_history() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(false, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; let closing_state = state.clone(); @@ -243,7 +243,7 @@ async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(false, &sender), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; let sender_name = sender.unique_name().expect("sender unique name").clone(); @@ -293,7 +293,7 @@ async fn inline_reply_signal_reaches_owner_but_not_unrelated_observer() { let mut store = state.store.lock().await; let notification = store .insert(reply_notification(true, &owner), 0) - .notification; + .active_notification(); (notification.id, notification.generation) }; @@ -353,8 +353,11 @@ fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { .expect("sender connection unique name") .to_string(), ), - sender_pid: Some(1234), - sender_start_time: Some(555), + sender_pid: Some(std::process::id()), + sender_start_time: Some( + crate::daemon::notifications::identity::read_process_start_time(std::process::id()) + .expect("test process should expose a start time"), + ), sender_executable: Some("/usr/bin/test-app".to_string()), } } diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index dfcdcfd69..f1dd194dc 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -72,8 +72,14 @@ async fn drain_active_notifications_returns_ids_and_cancels_expirations() { let server = ControlServer::new(state.clone()); let keys = { let mut store = state.store.lock().await; - let first = store.insert(notification("first"), 0).notification.key(); - let second = store.insert(notification("second"), 0).notification.key(); + let first = store + .insert(notification("first"), 0) + .active_notification() + .key(); + let second = store + .insert(notification("second"), 0) + .active_notification() + .key(); vec![second, first] }; @@ -91,7 +97,10 @@ async fn clear_saved_history_removes_archived_notifications() { let server = ControlServer::new(state.clone()); let id = { let mut store = state.store.lock().await; - let id = store.insert(notification("history"), 0).notification.id; + let id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(id, CloseReason::Undefined); id }; @@ -155,7 +164,10 @@ async fn authorized_snapshot_is_one_store_consistent_read() { { let mut store = state.store.lock().await; store.insert(notification("active"), 0); - let history_id = store.insert(notification("history"), 0).notification.id; + let history_id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(history_id, CloseReason::Undefined); } @@ -177,7 +189,10 @@ async fn authorized_clear_all_removes_active_and_history_together() { { let mut store = state.store.lock().await; store.insert(notification("active"), 0); - let history_id = store.insert(notification("history"), 0).notification.id; + let history_id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(history_id, CloseReason::Undefined); } @@ -216,7 +231,10 @@ async fn clear_history_rejects_unauthorized_sender_before_mutating_state() { let server = ControlServer::new(state.clone()); let id = { let mut store = state.store.lock().await; - let id = store.insert(notification("history"), 0).notification.id; + let id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(id, CloseReason::Undefined); id }; @@ -244,7 +262,7 @@ async fn generation_dismiss_rejects_unauthorized_sender_before_mutating_state() .lock() .await .insert(notification("protected generation"), 0) - .notification + .active_notification() .key(); let server = ControlServer::new(state.clone()); let message = control_header_message("DismissGeneration"); @@ -286,7 +304,7 @@ async fn popup_render_acknowledgement_rejects_unauthorized_sender() { .lock() .await .insert(notification("render acknowledgement"), 0) - .notification + .active_notification() .key(); let server = ControlServer::new(state.clone()); let message = control_header_message("MarkPopupVisible"); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs new file mode 100644 index 000000000..bf2d38d22 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs @@ -0,0 +1,94 @@ +//! Reconnect-safe callback destination resolution + +use zbus::fdo::DBusProxy; +use zbus::names::{BusName, UniqueName}; +use zbus::Connection; + +use super::{read_process_start_time, SenderMetadataCache, SENDER_CREDENTIAL_TIMEOUT}; + +pub(in crate::daemon) async fn resolve_callback_destination( + cache: &SenderMetadataCache, + connection: &Connection, + retained_bus_name: Option<&str>, + pid: Option, + start_time: Option, +) -> Option> { + let proxy = DBusProxy::new(connection).await.ok()?; + + if let (Some(pid), Some(start_time)) = (pid, start_time) { + // Stable lifetime evidence permits reconnect-safe address rebinding + if let Some(retained) = retained_bus_name { + if let Some(destination) = verified_destination(&proxy, retained, pid, start_time).await + { + return Some(destination); + } + } + + // A unique bus name is an ephemeral delivery address, not ownership + // Every cached address is verified before callback delivery + for current in cache.sender_candidates_for_process(pid, start_time, retained_bus_name) { + if let Some(destination) = verified_destination(&proxy, ¤t, pid, start_time).await + { + return Some(destination); + } + } + return None; + } + + // Weak evidence may retain one exact live address but can never authorize rebinding + let retained = retained_bus_name?; + let bus_name = BusName::try_from(retained).ok()?.to_owned(); + tokio::time::timeout( + SENDER_CREDENTIAL_TIMEOUT, + proxy.name_has_owner(bus_name.clone()), + ) + .await + .ok()? + .ok()? + .then_some(bus_name) +} + +async fn verified_destination( + proxy: &DBusProxy<'_>, + candidate: &str, + expected_pid: u32, + expected_start_time: u64, +) -> Option> { + let unique_name = UniqueName::try_from(candidate).ok()?.to_owned(); + let start_before = read_process_start_time(expected_pid); + let bus_pid = tokio::time::timeout( + SENDER_CREDENTIAL_TIMEOUT, + proxy.get_connection_unix_process_id(unique_name.clone().into()), + ) + .await + .ok()? + .ok()?; + let start_after = read_process_start_time(expected_pid); + if !credentials_match_lifetime( + bus_pid, + expected_pid, + start_before, + start_after, + expected_start_time, + ) { + return None; + } + Some(unique_name.into()) +} + +fn credentials_match_lifetime( + bus_pid: u32, + expected_pid: u32, + start_before: Option, + start_after: Option, + expected_start_time: u64, +) -> bool { + // Both samples must identify the retained lifetime so PID reuse cannot race delivery + bus_pid == expected_pid + && start_before == Some(expected_start_time) + && start_after == Some(expected_start_time) +} + +#[cfg(test)] +#[path = "tests/delivery.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs index 55d493abb..bc5ff2eca 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -1,5 +1,6 @@ //! Daemon-owned application association from process and desktop metadata +mod delivery; mod desktop_index; mod executable; mod policy; @@ -7,6 +8,7 @@ mod resolver; mod sender; mod sender_cache; +pub(in crate::daemon) use delivery::resolve_callback_destination; pub use desktop_index::DesktopIndexRefreshHandle; pub use desktop_index::DesktopIndexSnapshot; pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; @@ -15,6 +17,7 @@ pub(in crate::daemon) use resolver::resolve_attribution_owned; pub(in crate::daemon::notifications) use resolver::resolve_attribution_with_deadline; pub(super) use sender::SenderMetadata; pub(in crate::daemon) use sender::{ - resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, + read_process_start_time, resolve_sender_metadata, SenderMetadataStatus, + SENDER_CREDENTIAL_TIMEOUT, }; pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index cd610a244..df889f4cd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -104,6 +104,8 @@ fn metadata_from_credentials( SenderMetadata { sender_name, sender_pid: process_id, + // Process start time turns the reusable pid into one lifetime identity + sender_start_time: process_id.and_then(read_process_start_time), sender_uid: user_id, status, ..SenderMetadata::default() @@ -244,7 +246,7 @@ fn process_lifetime_matches( } #[cfg(target_os = "linux")] -fn read_process_start_time(pid: u32) -> Option { +pub(in crate::daemon) fn read_process_start_time(pid: u32) -> Option { // /proc//stat keeps the process lifetime tick count in field 22 let path = format!("/proc/{pid}/stat"); let contents = std::fs::read_to_string(path).ok()?; @@ -350,7 +352,7 @@ fn parse_process_cmdline(mut bytes: Vec) -> Option>> { } #[cfg(not(target_os = "linux"))] -fn read_process_start_time(_pid: u32) -> Option { +pub(in crate::daemon) fn read_process_start_time(_pid: u32) -> Option { // Non-Linux builds fall back to bus-name ownership only None } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs index b92e6997a..ce1f9306d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs @@ -40,7 +40,7 @@ impl SenderMetadataCache { Some(entry.metadata.clone()) } - pub(super) fn insert(&self, sender: String, metadata: SenderMetadata) { + pub(in crate::daemon) fn insert(&self, sender: String, metadata: SenderMetadata) { let Ok(mut state) = self.state.lock() else { return; }; @@ -63,6 +63,36 @@ impl SenderMetadataCache { state.entries.remove(sender); } } + + pub(in crate::daemon) fn sender_candidates_for_process( + &self, + pid: u32, + start_time: u64, + excluded: Option<&str>, + ) -> Vec { + let Ok(state) = self.state.lock() else { + return Vec::new(); + }; + // Try every matching address because a newer cache entry may already be stale + let mut candidates = state + .entries + .iter() + .filter(|(sender, entry)| { + excluded != Some(sender.as_str()) + && entry.metadata.sender_pid == Some(pid) + && entry.metadata.sender_start_time == Some(start_time) + }) + .map(|(sender, entry)| (entry.last_used, sender.clone())) + .collect::>(); + // Newest-first lookup prefers reconnects while retaining older valid fallbacks + candidates.sort_unstable_by(|left, right| { + right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)) + }); + candidates + .into_iter() + .map(|(_last_used, sender)| sender) + .collect() + } } impl CacheState { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs new file mode 100644 index 000000000..d4215ba98 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs @@ -0,0 +1,155 @@ +use super::*; +use crate::daemon::notifications::identity::{SenderMetadata, SenderMetadataStatus}; + +fn cache_sender(cache: &SenderMetadataCache, connection: &Connection, start_time: u64) { + let sender = connection + .unique_name() + .expect("test connection should have a unique name") + .to_string(); + cache.insert( + sender.clone(), + SenderMetadata { + sender_name: Some(sender), + sender_pid: Some(std::process::id()), + sender_start_time: Some(start_time), + sender_uid: Some(rustix::process::geteuid().as_raw()), + status: SenderMetadataStatus::Complete, + ..SenderMetadata::default() + }, + ); +} + +#[test] +fn callback_credentials_require_every_process_lifetime_component() { + assert!(credentials_match_lifetime(42, 42, Some(7), Some(7), 7)); + assert!(!credentials_match_lifetime(41, 42, Some(7), Some(7), 7)); + assert!(!credentials_match_lifetime(42, 42, Some(6), Some(7), 7)); + assert!(!credentials_match_lifetime(42, 42, Some(7), Some(6), 7)); +} + +#[tokio::test] +async fn callback_destination_follows_same_process_to_a_new_bus_name() { + let cache = SenderMetadataCache::new(); + let first = Connection::session() + .await + .expect("first session connection"); + let retained = first + .unique_name() + .expect("first connection unique name") + .to_string(); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &first, start_time); + first.close().await.expect("close first connection"); + + let second = Connection::session() + .await + .expect("second session connection"); + cache_sender(&cache, &second, start_time); + let destination = resolve_callback_destination( + &cache, + &second, + Some(&retained), + Some(std::process::id()), + Some(start_time), + ) + .await + .expect("same process lifetime should resolve its new address"); + + assert_eq!( + destination.as_str(), + second + .unique_name() + .expect("second connection unique name") + .as_str() + ); +} + +#[tokio::test] +async fn callback_destination_rejects_a_different_process_lifetime() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + + let destination = resolve_callback_destination( + &cache, + &connection, + connection.unique_name().map(|name| name.as_str()), + Some(std::process::id()), + Some(start_time.saturating_add(1)), + ) + .await; + + assert!(destination.is_none()); +} + +#[tokio::test] +async fn callback_destination_keeps_the_exact_live_name_without_lifetime_evidence() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let retained = connection + .unique_name() + .expect("session connection unique name") + .to_string(); + + let destination = + resolve_callback_destination(&cache, &connection, Some(&retained), None, None) + .await + .expect("an exact live address should remain usable"); + + assert_eq!(destination.as_str(), retained); +} + +#[tokio::test] +async fn callback_destination_without_lifetime_evidence_never_rebinds() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + + let destination = + resolve_callback_destination(&cache, &connection, Some(":1.999999"), None, None).await; + + assert!(destination.is_none()); +} + +#[tokio::test] +async fn callback_destination_tries_older_process_candidates_after_a_stale_newest_entry() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + cache.insert( + ":1.999999".to_string(), + SenderMetadata { + sender_name: Some(":1.999999".to_string()), + sender_pid: Some(std::process::id()), + sender_start_time: Some(start_time), + sender_uid: Some(rustix::process::geteuid().as_raw()), + status: SenderMetadataStatus::Complete, + ..SenderMetadata::default() + }, + ); + + let destination = resolve_callback_destination( + &cache, + &connection, + Some(":1.retired"), + Some(std::process::id()), + Some(start_time), + ) + .await + .expect("an older verified address should survive a stale cache entry"); + + assert_eq!( + destination.as_str(), + connection + .unique_name() + .expect("session connection unique name") + .as_str() + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index bd1af302c..3262c1c9e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -49,6 +49,19 @@ fn credential_metadata_keeps_sender_identity_and_failure_stage() { assert_eq!(failed.install_provenance, InstallProvenance::Unknown); } +#[cfg(target_os = "linux")] +#[test] +fn credential_metadata_captures_the_complete_current_process_lifetime() { + let pid = std::process::id(); + let expected_start = + read_process_start_time(pid).expect("current process start time should exist"); + + let metadata = metadata_from_credentials(Some(":1.44".to_string()), Some(pid), Some(1_000)); + + assert_eq!(metadata.sender_pid, Some(pid)); + assert_eq!(metadata.sender_start_time, Some(expected_start)); +} + #[test] fn status_metadata_preserves_sender_name_and_failure_status() { let metadata = metadata_with_status( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs index 66ec1fc93..e0cef4806 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -52,3 +52,37 @@ fn sender_cache_evicts_least_recently_used_entry_at_capacity() { assert!(cache.get(":1.0").is_some()); assert!(cache.get(":1.replacement").is_some()); } + +#[test] +fn sender_candidates_require_both_pid_and_process_start_time() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.exact".to_string(), metadata(":1.exact", 42)); + let mut wrong_start = metadata(":1.wrong-start", 42); + wrong_start.sender_start_time = Some(43); + cache.insert(":1.wrong-start".to_string(), wrong_start); + let mut wrong_pid = metadata(":1.wrong-pid", 99); + wrong_pid.sender_start_time = Some(42); + cache.insert(":1.wrong-pid".to_string(), wrong_pid); + + assert_eq!( + cache.sender_candidates_for_process(42, 42, None), + [":1.exact"] + ); +} + +#[test] +fn sender_candidates_exclude_the_retained_address_and_are_newest_first() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.stale".to_string(), metadata(":1.stale", 42)); + cache.insert(":1.current".to_string(), metadata(":1.current", 42)); + cache.insert(":1.newest".to_string(), metadata(":1.newest", 42)); + + assert_eq!( + cache.sender_candidates_for_process(42, 42, Some(":1.stale")), + [":1.newest", ":1.current"] + ); + assert_eq!( + cache.sender_candidates_for_process(42, 42, Some(":1.newest")), + [":1.current", ":1.stale"] + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs index e36faa35b..8817c7eb2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -130,7 +130,7 @@ pub(in crate::daemon::notifications) fn build_notification( actions, inline_reply, inline_reply_policy, - hints: sanitize_hints_for_storage(hints), + hints: sanitize_hints_for_storage(hints, urgency), urgency, category, is_transient, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs index 67fc2a871..5018832f7 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use unixnotis_core::util; +use unixnotis_core::{util, Urgency}; use zbus::zvariant::{OwnedValue, Value}; use super::super::limits::{ @@ -12,6 +12,7 @@ use super::super::limits::{ pub(in crate::daemon::notifications) fn sanitize_hints_for_storage( hints: HashMap, + canonical_urgency: Urgency, ) -> HashMap { let mut sanitized = HashMap::with_capacity(hints.len().min(MAX_HINT_ENTRIES)); @@ -35,7 +36,9 @@ pub(in crate::daemon::notifications) fn sanitize_hints_for_storage( "transient" | "resident" | "suppress-sound" => { bool::try_from(&value).ok().map(OwnedValue::from) } - "urgency" => parse_urgency_hint(&value).map(OwnedValue::from), + // `Notification::urgency` is the single source of truth. The retained wire + // hint is reconstructed from that canonical value so policy cannot diverge + "urgency" => Some(OwnedValue::from(canonical_urgency.as_u32())), _ => None, }; @@ -52,16 +55,6 @@ pub(in crate::daemon::notifications) fn string_to_owned_value(value: &str) -> Op OwnedValue::try_from(Value::from(value)).ok() } -pub(in crate::daemon::notifications) fn parse_urgency_hint(value: &OwnedValue) -> Option { - if let Ok(raw) = u8::try_from(value) { - return Some(u32::from(raw).min(2)); - } - if let Ok(raw) = u32::try_from(value) { - return Some(raw.min(2)); - } - None -} - pub(in crate::daemon::notifications) fn owned_to_string(value: &OwnedValue) -> Option { value .try_clone() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs index 1831a7867..9886a64d1 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -1,4 +1,41 @@ use super::*; + +#[test] +fn retained_urgency_hint_always_matches_canonical_notification_urgency() { + for raw in 0..=u8::MAX { + let notification = build_notification(NotificationInput { + app_name: "app".to_string(), + app_icon: String::new(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::from([("urgency".to_string(), OwnedValue::from(raw))]), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + let stored = u32::try_from( + notification + .hints + .get("urgency") + .expect("canonical urgency hint should be retained"), + ) + .expect("canonical urgency hint should use an unsigned integer"); + + assert_eq!( + stored, + notification.urgency.as_u32(), + "raw urgency {raw} must not diverge from the canonical field" + ); + } +} + #[test] fn build_notification_clamps_summary_and_body_sizes() { let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs index f7f669fc3..4b559f7c2 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -7,8 +7,7 @@ pub(super) use super::super::super::identity::SenderMetadata; pub(super) use super::super::limits::{MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES}; pub(super) use super::build::{build_notification, NotificationInput}; pub(super) use super::sanitize::{ - owned_to_string, parse_actions, parse_urgency_hint, sanitize_hints_for_storage, - string_to_owned_value, + owned_to_string, parse_actions, sanitize_hints_for_storage, string_to_owned_value, }; pub(super) use super::visuals::{ avatar_buffer_size_allowed, avatar_file_size_allowed, bounded_decode_dimension, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs index afb603e91..7004f08f8 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs @@ -53,13 +53,13 @@ fn sanitize_hints_drops_untrusted_and_bounds_strings() { string_to_owned_value("custom").expect("custom"), ); - let sanitized = sanitize_hints_for_storage(hints); + let sanitized = sanitize_hints_for_storage(hints, unixnotis_core::Urgency::Normal); assert_eq!(sanitized.len(), 3); assert!(sanitized.contains_key("transient")); assert!(sanitized.contains_key("sound-name")); assert_eq!( u32::try_from(sanitized.get("urgency").expect("urgency")), - Ok(2) + Ok(1) ); let sound_name = owned_to_string( @@ -71,17 +71,6 @@ fn sanitize_hints_drops_untrusted_and_bounds_strings() { assert!(sound_name.len() <= 2048); } -#[test] -fn parse_urgency_hint_accepts_byte_and_integer_values_with_cap() { - assert_eq!(parse_urgency_hint(&OwnedValue::from(0u8)), Some(0)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(1u32)), Some(1)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(99u32)), Some(2)); - assert_eq!( - parse_urgency_hint(&string_to_owned_value("high").expect("string")), - None - ); -} - #[test] fn owned_to_string_accepts_only_string_values() { assert_eq!( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs index 6513df3a4..23b63746e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs @@ -12,9 +12,30 @@ const CLOSE_GLOBAL_BURST: f64 = 480.0; const CLOSE_GLOBAL_REFILL_PER_SECOND: f64 = 240.0; const CLOSE_SENDER_BURST: f64 = 160.0; const CLOSE_SENDER_REFILL_PER_SECOND: f64 = 80.0; -const MAX_TRACKED_SENDERS: usize = 256; -const SENDER_IDLE_TTL_SECONDS: u64 = 60; -const UNKNOWN_SENDER: &str = ""; +const OVERFLOW_BURST: f64 = 10.0; +const OVERFLOW_REFILL_PER_SECOND: f64 = 5.0; +const CLOSE_OVERFLOW_BURST: f64 = 40.0; +const CLOSE_OVERFLOW_REFILL_PER_SECOND: f64 = 20.0; +const MAX_TRACKED_PRINCIPALS: usize = 256; +const PRINCIPAL_IDLE_TTL_SECONDS: u64 = 60; + +/// Stable process-lifetime identity used for per-caller ingress fairness +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] +pub(in crate::daemon::notifications) struct QuotaPrincipal { + uid: u32, + pid: u32, + start_time: u64, +} + +impl QuotaPrincipal { + pub(in crate::daemon::notifications) const fn new(uid: u32, pid: u32, start_time: u64) -> Self { + Self { + uid, + pid, + start_time, + } + } +} pub(in crate::daemon::notifications) struct NotificationQuota { state: Mutex, @@ -27,14 +48,17 @@ struct QuotaPolicy { global_refill_per_second: f64, sender_burst: f64, sender_refill_per_second: f64, + overflow_burst: f64, + overflow_refill_per_second: f64, } struct QuotaState { global: TokenBucket, - senders: HashMap, + principals: HashMap, + overflow: TokenBucket, } -struct SenderBucket { +struct PrincipalBucket { bucket: TokenBucket, last_seen: Instant, } @@ -63,6 +87,8 @@ impl NotificationQuota { global_refill_per_second: GLOBAL_REFILL_PER_SECOND, sender_burst: SENDER_BURST, sender_refill_per_second: SENDER_REFILL_PER_SECOND, + overflow_burst: OVERFLOW_BURST, + overflow_refill_per_second: OVERFLOW_REFILL_PER_SECOND, }, ) } @@ -75,6 +101,8 @@ impl NotificationQuota { global_refill_per_second: CLOSE_GLOBAL_REFILL_PER_SECOND, sender_burst: CLOSE_SENDER_BURST, sender_refill_per_second: CLOSE_SENDER_REFILL_PER_SECOND, + overflow_burst: CLOSE_OVERFLOW_BURST, + overflow_refill_per_second: CLOSE_OVERFLOW_REFILL_PER_SECOND, }, ) } @@ -83,34 +111,53 @@ impl NotificationQuota { Self { state: Mutex::new(QuotaState { global: TokenBucket::new(policy.global_burst, policy.global_refill_per_second, now), - senders: HashMap::new(), + principals: HashMap::new(), + overflow: TokenBucket::new( + policy.overflow_burst, + policy.overflow_refill_per_second, + now, + ), }), policy, } } - pub(in crate::daemon::notifications) fn admit( - &self, - sender: Option<&str>, - now: Instant, - ) -> bool { + pub(in crate::daemon::notifications) fn admit_global(&self, now: Instant) -> bool { let Ok(mut state) = self.state.lock() else { // A poisoned limiter fails closed instead of disabling ingress control return false; }; state.global.refill(now); - if !state.global.has_token() { + state.global.take_token() + } + + pub(in crate::daemon::notifications) fn admit_principal( + &self, + principal: Option, + now: Instant, + ) -> bool { + let Ok(mut state) = self.state.lock() else { return false; + }; + state.prune_principal_buckets(now); + let Some(principal) = principal else { + state.overflow.refill(now); + return state.overflow.take_token(); + }; + if !state.principals.contains_key(&principal) + && state.principals.len() >= MAX_TRACKED_PRINCIPALS + { + // D-Bus unique names are ephemeral transport addresses, not stable principals + // Unknown or overflow identities share a restricted bucket rather than receiving + // a fresh burst + state.overflow.refill(now); + return state.overflow.take_token(); } - - let sender = sender.unwrap_or(UNKNOWN_SENDER); - state.prune_sender_buckets(now); - state.ensure_sender_capacity(sender); - let sender_bucket = + let principal_bucket = state - .senders - .entry(sender.to_string()) - .or_insert_with(|| SenderBucket { + .principals + .entry(principal) + .or_insert_with(|| PrincipalBucket { bucket: TokenBucket::new( self.policy.sender_burst, self.policy.sender_refill_per_second, @@ -118,38 +165,22 @@ impl NotificationQuota { ), last_seen: now, }); - sender_bucket.last_seen = now; - sender_bucket.bucket.refill(now); - if !sender_bucket.bucket.take_token() { - return false; - } - - // The global token is consumed only after the sender also passes - state.global.take_token() + principal_bucket.last_seen = now; + principal_bucket.bucket.refill(now); + principal_bucket.bucket.take_token() } } impl QuotaState { - fn prune_sender_buckets(&mut self, now: Instant) { - self.senders.retain(|_sender, bucket| { - now.saturating_duration_since(bucket.last_seen).as_secs() < SENDER_IDLE_TTL_SECONDS + fn prune_principal_buckets(&mut self, now: Instant) { + self.principals.retain(|_principal, bucket| { + bucket.bucket.refill(now); + let idle = now.saturating_duration_since(bucket.last_seen).as_secs() + >= PRINCIPAL_IDLE_TTL_SECONDS; + // Only a fully restored idle principal may release its bounded map slot + !(idle && bucket.bucket.is_full()) }); } - - fn ensure_sender_capacity(&mut self, sender: &str) { - if self.senders.contains_key(sender) || self.senders.len() < MAX_TRACKED_SENDERS { - return; - } - // A bounded linear scan is cheaper than unbounded attacker-controlled state - if let Some(oldest) = self - .senders - .iter() - .min_by_key(|(_sender, bucket)| bucket.last_seen) - .map(|(sender, _bucket)| sender.clone()) - { - self.senders.remove(&oldest); - } - } } impl TokenBucket { @@ -175,6 +206,10 @@ impl TokenBucket { self.tokens >= 1.0 } + fn is_full(&self) -> bool { + self.tokens >= self.capacity + } + fn take_token(&mut self) -> bool { if !self.has_token() { return false; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs index cf1694ecf..0ea20021c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs @@ -1,14 +1,18 @@ -use std::time::{Duration, Instant}; - use std::collections::HashMap; +use std::time::{Duration, Instant}; use super::{ - NotificationQuota, QuotaState, SenderBucket, TokenBucket, CLOSE_SENDER_BURST, GLOBAL_BURST, - MAX_TRACKED_SENDERS, SENDER_BURST, SENDER_IDLE_TTL_SECONDS, + NotificationQuota, PrincipalBucket, QuotaPrincipal, QuotaState, TokenBucket, + CLOSE_SENDER_BURST, GLOBAL_BURST, MAX_TRACKED_PRINCIPALS, OVERFLOW_BURST, + PRINCIPAL_IDLE_TTL_SECONDS, SENDER_BURST, }; -fn sender_bucket(now: Instant) -> SenderBucket { - SenderBucket { +fn principal(index: u32) -> QuotaPrincipal { + QuotaPrincipal::new(1_000, index, u64::from(index) + 10) +} + +fn principal_bucket(now: Instant) -> PrincipalBucket { + PrincipalBucket { bucket: TokenBucket::new(SENDER_BURST, 1.0, now), last_seen: now, } @@ -17,126 +21,143 @@ fn sender_bucket(now: Instant) -> SenderBucket { fn quota_state(now: Instant) -> QuotaState { QuotaState { global: TokenBucket::new(GLOBAL_BURST, 1.0, now), - senders: HashMap::new(), + principals: HashMap::new(), + overflow: TokenBucket::new(OVERFLOW_BURST, 1.0, now), } } #[test] -fn sender_bucket_rejects_a_burst_and_refills_over_time() { +fn principal_bucket_rejects_a_burst_and_refills_over_time() { let now = Instant::now(); let quota = NotificationQuota::new_at(now); + let caller = principal(10); for _ in 0..SENDER_BURST as usize { - assert!(quota.admit(Some(":1.10"), now)); + assert!(quota.admit_principal(Some(caller), now)); } - assert!(!quota.admit(Some(":1.10"), now)); - assert!(quota.admit(Some(":1.10"), now + Duration::from_millis(50))); + assert!(!quota.admit_principal(Some(caller), now)); + assert!(quota.admit_principal(Some(caller), now + Duration::from_millis(50))); } #[test] -fn global_bucket_limits_many_independent_senders() { +fn global_bucket_limits_requests_before_identity_resolution() { let now = Instant::now(); let quota = NotificationQuota::new_at(now); - for index in 0..GLOBAL_BURST as usize { - assert!(quota.admit(Some(&format!(":1.{index}")), now)); + for _ in 0..GLOBAL_BURST as usize { + assert!(quota.admit_global(now)); } - assert!(!quota.admit(Some(":1.blocked"), now)); - assert!(quota.admit(Some(":1.allowed"), now + Duration::from_millis(17))); + assert!(!quota.admit_global(now)); + assert!(quota.admit_global(now + Duration::from_millis(17))); } #[test] -fn close_requests_use_a_separate_higher_budget() { +fn close_requests_use_a_separate_higher_principal_budget() { let now = Instant::now(); let notify = NotificationQuota::new_at(now); let close = NotificationQuota::new_close_at(now); + let caller = principal(10); for _ in 0..SENDER_BURST as usize { - assert!(notify.admit(Some(":1.10"), now)); - assert!(close.admit(Some(":1.10"), now)); + assert!(notify.admit_principal(Some(caller), now)); + assert!(close.admit_principal(Some(caller), now)); } - assert!(!notify.admit(Some(":1.10"), now)); + assert!(!notify.admit_principal(Some(caller), now)); for _ in SENDER_BURST as usize..CLOSE_SENDER_BURST as usize { - assert!(close.admit(Some(":1.10"), now)); + assert!(close.admit_principal(Some(caller), now)); } - assert!(!close.admit(Some(":1.10"), now)); + assert!(!close.admit_principal(Some(caller), now)); } #[test] -fn sender_tracking_stays_bounded_and_unknown_callers_share_one_bucket() { +fn unknown_and_overflow_principals_share_one_restricted_bucket() { let now = Instant::now(); let quota = NotificationQuota::new_at(now); - for index in 0..MAX_TRACKED_SENDERS + 20 { - let at = now + Duration::from_secs(index as u64); - assert!(quota.admit(Some(&format!(":1.{index}")), at)); + for index in 0..MAX_TRACKED_PRINCIPALS { + assert!(quota.admit_principal(Some(principal(index as u32)), now)); } - assert!(quota.state.lock().expect("quota state").senders.len() <= MAX_TRACKED_SENDERS); - - let later = now + Duration::from_secs((MAX_TRACKED_SENDERS + 21) as u64); - for _ in 0..SENDER_BURST as usize { - assert!(quota.admit(None, later)); + for index in 0..OVERFLOW_BURST as usize { + let admitted = if index % 2 == 0 { + quota.admit_principal(None, now) + } else { + quota.admit_principal( + Some(principal((MAX_TRACKED_PRINCIPALS + index) as u32)), + now, + ) + }; + assert!(admitted); } - assert!(!quota.admit(None, later)); + assert!(!quota.admit_principal(None, now)); + assert!(!quota.admit_principal(Some(principal(u32::MAX)), now)); + assert_eq!( + quota.state.lock().expect("quota state").principals.len(), + MAX_TRACKED_PRINCIPALS + ); } #[test] -fn sender_pruning_removes_entries_at_the_idle_boundary_only() { +fn principal_pruning_removes_only_fully_refilled_idle_entries() { let now = Instant::now(); let mut state = quota_state(now); + let expired_idle = principal(1); + let throttled = principal(2); + let recent = principal(3); + state.principals.insert(expired_idle, principal_bucket(now)); + let mut depleted = principal_bucket(now); + depleted.bucket.tokens = 0.0; + depleted.bucket.refill_per_second = 0.0; + state.principals.insert(throttled, depleted); state - .senders - .insert("stale".to_string(), sender_bucket(now)); - state.senders.insert( - "recent".to_string(), - sender_bucket(now + Duration::from_secs(1)), - ); + .principals + .insert(recent, principal_bucket(now + Duration::from_secs(1))); - state.prune_sender_buckets(now + Duration::from_secs(SENDER_IDLE_TTL_SECONDS)); + state.prune_principal_buckets(now + Duration::from_secs(PRINCIPAL_IDLE_TTL_SECONDS)); - assert!(!state.senders.contains_key("stale")); - assert!(state.senders.contains_key("recent")); + assert!(!state.principals.contains_key(&expired_idle)); + assert!(state.principals.contains_key(&throttled)); + assert!(state.principals.contains_key(&recent)); } #[test] -fn sender_capacity_preserves_existing_and_below_limit_sets() { +fn capacity_never_evicts_a_live_throttled_principal_for_a_new_burst() { let now = Instant::now(); - let mut state = quota_state(now); - state - .senders - .insert("existing".to_string(), sender_bucket(now)); - - state.ensure_sender_capacity("new"); - assert_eq!(state.senders.len(), 1); - assert!(state.senders.contains_key("existing")); - - for index in 1..MAX_TRACKED_SENDERS { - state.senders.insert( - format!("sender-{index}"), - sender_bucket(now + Duration::from_secs(index as u64)), - ); + let quota = NotificationQuota::new_at(now); + let protected = principal(0); + + for _ in 0..SENDER_BURST as usize { + assert!(quota.admit_principal(Some(protected), now)); + } + for index in 1..MAX_TRACKED_PRINCIPALS { + assert!(quota.admit_principal(Some(principal(index as u32)), now)); + } + assert!(!quota.admit_principal(Some(protected), now)); + + for index in MAX_TRACKED_PRINCIPALS..MAX_TRACKED_PRINCIPALS + 20 { + let _admitted = quota.admit_principal(Some(principal(index as u32)), now); } - let before = state.senders.len(); - state.ensure_sender_capacity("existing"); - assert_eq!(state.senders.len(), before); - assert!(state.senders.contains_key("existing")); + assert!(!quota.admit_principal(Some(protected), now)); + assert!(quota + .state + .lock() + .expect("quota state") + .principals + .contains_key(&protected)); } #[test] -fn sender_capacity_evicts_the_oldest_entry_at_the_exact_limit() { +fn reconnect_address_churn_does_not_reset_a_process_principal_bucket() { let now = Instant::now(); - let mut state = quota_state(now); - for index in 0..MAX_TRACKED_SENDERS { - state.senders.insert( - format!("sender-{index}"), - sender_bucket(now + Duration::from_secs(index as u64)), - ); - } + let quota = NotificationQuota::new_at(now); + let same_process = QuotaPrincipal::new(1_000, 42, 99); - state.ensure_sender_capacity("new"); + let mut admitted = 0usize; + for _transport_connection in 0..MAX_TRACKED_PRINCIPALS + 64 { + admitted += usize::from(quota.admit_principal(Some(same_process), now)); + } - assert_eq!(state.senders.len(), MAX_TRACKED_SENDERS - 1); - assert!(!state.senders.contains_key("sender-0")); - assert!(state.senders.contains_key("sender-1")); + assert_eq!(admitted, SENDER_BURST as usize); + assert!(!quota.admit_principal(Some(same_process), now)); + assert_eq!(quota.state.lock().expect("quota state").principals.len(), 1); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs index d3a633ca5..80b0fe2eb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs @@ -1,10 +1,10 @@ pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { - // Capabilities are static except for optional sound support + // Advertise only semantics preserved by normalization. Notification bodies + // are intentionally sanitized to display text, so body-markup is unsupported let mut caps = vec![ "actions".to_string(), "inline-reply".to_string(), "body".to_string(), - "body-markup".to_string(), "icon-static".to_string(), ]; if supports_sound { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs index ef93978f8..02aa4c024 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -1,11 +1,11 @@ +use std::time::Instant; use tracing::debug; use unixnotis_core::CloseReason; use zbus::message::Header; -use crate::daemon::to_fdo_error; - use super::NotificationServer; use crate::daemon::notifications::identity::resolve_sender_metadata; +use crate::daemon::notifications::ingress::metrics::RejectedRequest; impl NotificationServer { pub(super) async fn close_notification_if_owned( @@ -22,33 +22,49 @@ impl NotificationServer { header, ) .await; - let Some(sender_name) = sender.sender_name.as_deref() else { - return Ok(()); - }; - - let owned = { - let store = self.state.store.lock().await; - // Ownership check allows reconnect-safe close by same sender pid - store.is_notification_owned_by( + if !self + .close_quota + .admit_principal(super::quota_principal(&sender), Instant::now()) + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::CloseQuota); + debug!(rejected, "close request rejected by principal quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification close quota exceeded".to_string(), + )); + } + let removed = { + let mut store = self.state.store.lock().await; + // Ownership and removal share one lock so a same-ID replacement cannot race the close + store.close_owned_active( id, - sender_name, + sender.sender_name.as_deref(), sender.sender_pid, sender.sender_start_time, + CloseReason::ClosedByCall, ) }; - if !owned { + let Some(removed) = removed else { debug!( id, - sender = sender_name, + sender = sender.sender_name.as_deref().unwrap_or("unknown"), sender_pid = sender.sender_pid, - "ignoring close for unowned notification" + "notification close target is not closable" ); - return Ok(()); - } + // Missing, foreign, historical, and otherwise non-closable IDs are indistinguishable + return Err(generic_close_error()); + }; + self.state.cancel_expiration(removed.key()); self.state - .close_notification(id, CloseReason::ClosedByCall) + .publish_notification_closed(removed.key(), CloseReason::ClosedByCall) .await - .map_err(to_fdo_error) + .map_err(crate::daemon::to_fdo_error) } } + +const fn generic_close_error() -> zbus::fdo::Error { + // One empty generic failure prevents existence and ownership disclosure + zbus::fdo::Error::Failed(String::new()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 41fa32af3..9af43e561 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; use tracing::{debug, warn}; use unixnotis_core::{ImageData, Notification, NotificationKey}; use zbus::message::Header; @@ -10,15 +12,17 @@ use crate::daemon::notifications::identity::{ use crate::daemon::notifications::identity::{ resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, }; +use crate::daemon::notifications::ingress::metrics::RejectedRequest; use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; -use crate::store::InsertOutcome; +use crate::store::{CommitDisposition, InsertOutcome, SuppressedNotification}; use super::avatar::run_avatar_worker; +use super::reply_lifecycle::NotifyCompletion; use super::wire_hints::WireHints; use super::NotificationServer; @@ -43,7 +47,7 @@ impl NotificationServer { clippy::too_many_arguments, reason = "the freedesktop notification method defines this wire-level argument list" )] - pub(super) async fn ingest_notify( + pub(super) async fn ingest_notify_deferred( &self, app_name: String, replaces_id: u32, @@ -54,7 +58,7 @@ impl NotificationServer { hints: WireHints, header: &Header<'_>, expire_timeout: i32, - ) -> zbus::fdo::Result { + ) -> zbus::fdo::Result { let _ = Self::log_received_notification( &app_name, &summary, @@ -62,6 +66,19 @@ impl NotificationServer { replaces_id, expire_timeout, ); + let sender = self.resolve_sender(header).await; + if !self + .notify_quota + .admit_principal(super::quota_principal(&sender), Instant::now()) + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyQuota); + debug!(rejected, "notification request rejected by principal quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification ingress quota exceeded".to_string(), + )); + } let (hints, wire_image_data, image_path) = hints.into_parts(); let notification = self .notification_from_wire( @@ -76,7 +93,7 @@ impl NotificationServer { image_path, expire_timeout, }, - header, + sender, ) .await; let stored = self.store_notification(notification, replaces_id).await; @@ -111,13 +128,9 @@ impl NotificationServer { true } - async fn notification_from_wire( - &self, - input: WireNotification, - header: &Header<'_>, - ) -> Notification { + async fn resolve_sender(&self, header: &Header<'_>) -> SenderMetadata { // Sender metadata helps with ownership checks and diagnostics - let sender = if let Ok(sender) = tokio::time::timeout( + if let Ok(sender) = tokio::time::timeout( SENDER_CREDENTIAL_TIMEOUT, resolve_sender_metadata( &self.state.sender_metadata_cache, @@ -134,7 +147,14 @@ impl NotificationServer { status: SenderMetadataStatus::CredentialLookupTimedOut, ..SenderMetadata::default() } - }; + } + } + + async fn notification_from_wire( + &self, + input: WireNotification, + sender: SenderMetadata, + ) -> Notification { let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); let desktop_identity_index = self.state.desktop_identity_index.load_full(); // This is the only attribution deadline, including package enrichment @@ -222,16 +242,13 @@ impl NotificationServer { // Sample renderer health immediately before the serialized commit let ui_health = self.state.ui_health(); let outcome = store.insert_with_ui_health(notification, replaces_id, &ui_health); - if !outcome.dropped { + if let CommitDisposition::Active(notification) = &outcome.disposition { // The store resolved both clocks after applying rules and committing the generation let expiration = outcome.expiration; - store.set_expiration(&outcome.notification, expiration); + store.set_expiration(notification, expiration); // Unbounded send is synchronous, so commit order is preserved without an await - self.scheduler.schedule( - outcome.notification.id, - outcome.notification.generation, - expiration, - ); + self.scheduler + .schedule(notification.id, notification.generation, expiration); } // Eviction cancellation is committed in the same order as the insertion for key in &outcome.evicted { @@ -242,66 +259,84 @@ impl NotificationServer { StoredNotification { outcome } } - fn handle_dropped_notification(outcome: &InsertOutcome) -> Option { - if !outcome.dropped { - return None; - } + fn suppressed_notification(outcome: &InsertOutcome) -> Option { + let suppressed = outcome.suppressed()?; debug!( - id = outcome.notification.id, - app = %outcome.notification.app_name, - "notification dropped due to active inhibitor" + id = suppressed.id, + generation = suppressed.generation, + owner_pid = suppressed.owner.map(|owner| owner.pid), + "notification content dropped due to active inhibitor" ); - Some(outcome.notification.id) + Some(suppressed) } - fn play_sound(&self, outcome: &InsertOutcome) { + fn play_sound(&self, notification: &Notification, allow_sound: bool) -> bool { // Sound is best-effort and decided by rules and per-notification hints self.state .sound - .play_from_hints(&outcome.notification.hints, outcome.allow_sound); + .play_from_hints(¬ification.hints, allow_sound) } - async fn emit_notification_change(&self, outcome: &InsertOutcome) -> zbus::fdo::Result<()> { + async fn emit_notification_change( + &self, + notification: &Notification, + replaced: bool, + ) -> zbus::fdo::Result<()> { let mode = self .state - .notification_signal_mode(outcome.notification.sender_name.as_deref()); + .notification_signal_mode(notification.sender_name.as_deref()); if mode == NotificationSignalMode::SnapshotOnly { debug!( - id = outcome.notification.id, - sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), + id = notification.id, + sender = notification.sender_name.as_deref().unwrap_or("unknown"), "notification burst detected; using snapshot invalidation instead of per-row signal" ); } self.state - .publish_notification_change(mode, outcome.notification.key(), outcome.replaced) + .publish_notification_change(mode, notification.key(), replaced) .await .map_err(to_fdo_error) } - async fn finish_notification_change(&self, outcome: InsertOutcome) -> zbus::fdo::Result { - if let Some(id) = Self::handle_dropped_notification(&outcome) { - return Ok(id); - } - - self.play_sound(&outcome); + async fn finish_notification_change( + &self, + outcome: InsertOutcome, + ) -> zbus::fdo::Result { + let notification = match &outcome.disposition { + CommitDisposition::Active(notification) => Arc::clone(notification), + CommitDisposition::SuppressedDropAll(suppressed) => { + let suppressed = *suppressed; + let _ = Self::suppressed_notification(&outcome); + return Ok(NotifyCompletion { + id: suppressed.id, + suppressed: Some(suppressed), + }); + } + }; + let _sound_accepted = self.play_sound(¬ification, outcome.allow_sound); debug!( - id = outcome.notification.id, + id = notification.id, decision = ?outcome.popup_admission, "notification popup admission decided" ); if outcome.popup_admission.should_show() && self.state.should_warn_popups_unready() { warn!( - id = outcome.notification.id, + id = notification.id, "popup admitted while popup renderer is not ready" ); } - let id = outcome.notification.id; - if let Err(error) = self.emit_notification_change(&outcome).await { + let id = notification.id; + let key = notification.key(); + if let Err(error) = self + .emit_notification_change(¬ification, outcome.replaced) + .await + { warn!(?error, id, "notification committed but live fanout failed"); - self.state.store.lock().await.record_popup_delivery_stage( - outcome.notification.key(), - unixnotis_core::PopupDeliveryStage::FanoutFailed, - ); + self.state + .store + .lock() + .await + .record_popup_delivery_stage(key, unixnotis_core::PopupDeliveryStage::FanoutFailed); // Snapshot invalidation gives connected clients one best-effort recovery route let _ = self.state.publish_snapshot_invalidated().await; } @@ -316,7 +351,29 @@ impl NotificationServer { warn!(?error, id, "notification committed but state fanout failed"); } - Ok(id) + Ok(NotifyCompletion { + id, + suppressed: None, + }) + } + + pub(super) async fn publish_suppressed_close(&self, suppressed: SuppressedNotification) { + let key = NotificationKey { + id: suppressed.id, + generation: suppressed.generation, + }; + if let Err(error) = self + .state + .publish_notification_closed(key, unixnotis_core::CloseReason::Undefined) + .await + { + warn!( + ?error, + id = suppressed.id, + generation = suppressed.generation, + "suppressed notification close fanout failed" + ); + } } async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs index 43c173b7a..415489338 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -9,6 +9,7 @@ use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, Message, ObjectServer}; use super::notify_body::{preflight_notify, PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES}; +use super::reply_lifecycle::PostReplyKey; use super::NotificationServer; /// Object-server adapter that rejects oversized Notify bodies before typed allocation @@ -96,7 +97,28 @@ impl Interface for NotificationIngress { }); } } - self.inner.call(server, connection, message, name) + let is_notify = name.as_bytes() == b"Notify"; + let dispatch = self.inner.call(server, connection, message, name); + if !is_notify { + return dispatch; + } + + let request = PostReplyKey::from_header(&message.header()); + match dispatch { + DispatchResult::Async(future) => DispatchResult::Async(Box::pin(async move { + // The generated handler sends the method reply before this future completes + let reply_result = future.await; + let suppressed = self.inner.post_reply_lifecycle.take(&request).await; + if reply_result.is_ok() { + if let Some(suppressed) = suppressed { + // The signal now enters the connection after the successful reply + self.inner.publish_suppressed_close(suppressed).await; + } + } + reply_result + })), + other => other, + } } fn call_mut<'call>( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index 4ff19483e..5e1410d3b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -11,6 +11,7 @@ use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; +use super::reply_lifecycle::{PostReplyKey, PostReplyLifecycle, RetainError}; use super::wire_hints::WireHints; use crate::daemon::notifications::ingress::metrics::{IngressMetrics, RejectedRequest}; use crate::daemon::notifications::ingress::quota::NotificationQuota; @@ -25,13 +26,15 @@ pub struct NotificationServer { // Scheduler handles expiration deadlines without blocking D-Bus handlers pub(super) scheduler: ExpirationScheduler, // Shared token buckets reject sustained sender and process-wide floods - notify_quota: NotificationQuota, + pub(super) notify_quota: NotificationQuota, // Close requests are cheaper but still trigger sender identity and store work - close_quota: NotificationQuota, + pub(super) close_quota: NotificationQuota, // Expensive sender and payload work has a fixed concurrency ceiling notify_slots: Semaphore, // Counters expose pressure without retaining attacker-controlled labels - ingress_metrics: IngressMetrics, + pub(super) ingress_metrics: IngressMetrics, + // DropAll lifecycle records wait here until the matching reply is sent + pub(super) post_reply_lifecycle: PostReplyLifecycle, } impl NotificationServer { @@ -44,6 +47,7 @@ impl NotificationServer { close_quota: NotificationQuota::new_close(), notify_slots: Semaphore::const_new(MAX_CONCURRENT_NOTIFY_HANDLERS), ingress_metrics: IngressMetrics::new(), + post_reply_lifecycle: PostReplyLifecycle::default(), } } } @@ -51,8 +55,8 @@ impl NotificationServer { #[interface(name = "org.freedesktop.Notifications")] impl NotificationServer { pub(super) async fn get_capabilities(&self) -> Vec { - // Advertise sound support only when the configured backend can deliver it - notification_capabilities(self.state.sound.supports_sound()) + // Advertise sender sound support only when every promised hint is implemented + notification_capabilities(self.state.sound.supports_fdo_sound_capability()) } #[expect( @@ -71,8 +75,7 @@ impl NotificationServer { #[zbus(header)] header: Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { - let sender = header.sender().map(zbus::names::UniqueName::as_str); - if !self.notify_quota.admit(sender, Instant::now()) { + if !self.notify_quota.admit_global(Instant::now()) { let rejected = self .ingress_metrics .record_rejection(RejectedRequest::NotifyQuota); @@ -95,18 +98,34 @@ impl NotificationServer { })?; let _activity = self.ingress_metrics.enter_handler(); // The interface adapter forwards the authenticated header with the exact wire payload - self.ingest_notify( - app_name, - replaces_id, - app_icon, - summary, - body, - actions, - hints, - &header, - expire_timeout, - ) - .await + let completion = self + .ingest_notify_deferred( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + &header, + expire_timeout, + ) + .await?; + if let Some(suppressed) = completion.suppressed { + let request = PostReplyKey::from_header(&header); + self.post_reply_lifecycle + .retain(request, suppressed) + .await + .map_err(|error| match error { + RetainError::CapacityExceeded => zbus::fdo::Error::LimitsExceeded( + "notification lifecycle queue is full".to_string(), + ), + RetainError::DuplicateSerial => zbus::fdo::Error::Failed( + "notification lifecycle request collision".to_string(), + ), + })?; + } + Ok(completion.id) } pub(super) async fn close_notification( @@ -114,8 +133,7 @@ impl NotificationServer { id: u32, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - let sender = header.sender().map(zbus::names::UniqueName::as_str); - if !self.close_quota.admit(sender, Instant::now()) { + if !self.close_quota.admit_global(Instant::now()) { let rejected = self .ingress_metrics .record_rejection(RejectedRequest::CloseQuota); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index b0ff0684d..1193220e3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -7,11 +7,27 @@ mod flow; mod ingress; mod interface; mod notify_body; +mod reply_lifecycle; mod wire_hints; pub use ingress::NotificationIngress; pub use interface::NotificationServer; +use super::identity::SenderMetadata; +use super::ingress::quota::QuotaPrincipal; + +fn quota_principal(sender: &SenderMetadata) -> Option { + Some(QuotaPrincipal::new( + sender.sender_uid?, + sender.sender_pid?, + sender.sender_start_time?, + )) +} + #[cfg(test)] #[path = "tests/interface.rs"] mod tests; + +#[cfg(test)] +#[path = "tests/quota_principal.rs"] +mod quota_principal_tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs new file mode 100644 index 000000000..b213a9d0c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; +use std::num::NonZeroU32; + +use tokio::sync::Mutex; +use zbus::message::Header; + +use crate::store::SuppressedNotification; + +const MAX_PENDING_SUPPRESSED_CLOSES: usize = 128; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RetainError { + CapacityExceeded, + DuplicateSerial, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) struct PostReplyKey { + sender: Option, + serial: NonZeroU32, +} + +impl PostReplyKey { + pub(super) fn from_header(header: &Header<'_>) -> Self { + Self { + // The bus name is transport correlation only and grants no ownership + sender: header.sender().map(ToString::to_string), + serial: header.primary().serial_num(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct NotifyCompletion { + pub(super) id: u32, + pub(super) suppressed: Option, +} + +/// Content-free lifecycle work held until its method reply crosses D-Bus +#[derive(Default)] +pub(super) struct PostReplyLifecycle { + // Sender and serial together identify one in-flight method reply + pending: Mutex>, +} + +impl PostReplyLifecycle { + pub(super) async fn retain( + &self, + request: PostReplyKey, + suppressed: SuppressedNotification, + ) -> Result<(), RetainError> { + let mut pending = self.pending.lock().await; + // An in-flight request must never identify two returned IDs + if pending.contains_key(&request) { + return Err(RetainError::DuplicateSerial); + } + // A stalled transport cannot grow deferred lifecycle memory without bound + if pending.len() >= MAX_PENDING_SUPPRESSED_CLOSES { + return Err(RetainError::CapacityExceeded); + } + pending.insert(request, suppressed); + Ok(()) + } + + pub(super) async fn take(&self, request: &PostReplyKey) -> Option { + self.pending.lock().await.remove(request) + } +} + +#[cfg(test)] +#[path = "tests/reply_lifecycle.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs index 696b1a021..6303b50e4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs @@ -6,16 +6,7 @@ use super::notification_capabilities; fn notification_capabilities_without_sound_keeps_static_contract() { let caps = notification_capabilities(false); - assert_eq!( - caps, - [ - "actions", - "inline-reply", - "body", - "body-markup", - "icon-static" - ] - ); + assert_eq!(caps, ["actions", "inline-reply", "body", "icon-static"]); } #[test] @@ -24,13 +15,6 @@ fn notification_capabilities_adds_sound_only_when_backend_supports_it() { assert_eq!( caps, - [ - "actions", - "inline-reply", - "body", - "body-markup", - "icon-static", - "sound" - ] + ["actions", "inline-reply", "body", "icon-static", "sound"] ); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 7f651d086..867f3034d 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -11,7 +11,7 @@ use tracing_subscriber::filter::LevelFilter; use unixnotis_core::{ CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, }; -use zbus::message::Type; +use zbus::message::{Header, Type}; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, MatchRule, Message, MessageStream}; @@ -22,9 +22,49 @@ use crate::daemon::notifications::ingress::payload::{ use crate::daemon::{DaemonState, NotificationServer}; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; -use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; +use crate::store::{ + CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, +}; use crate::test_support::daemon_state_for_test; +impl NotificationServer { + #[expect( + clippy::too_many_arguments, + reason = "the freedesktop notification method defines this wire-level argument list" + )] + async fn ingest_notify( + &self, + app_name: String, + replaces_id: u32, + app_icon: String, + summary: String, + body: String, + actions: Vec, + hints: super::super::wire_hints::WireHints, + header: &Header<'_>, + expire_timeout: i32, + ) -> zbus::fdo::Result { + let completion = self + .ingest_notify_deferred( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + header, + expire_timeout, + ) + .await?; + if let Some(suppressed) = completion.suppressed { + self.publish_suppressed_close(suppressed).await; + } + Ok(completion.id) + } +} + fn notification_with_id(id: u32) -> Arc { Arc::new(Notification { id, @@ -56,8 +96,20 @@ fn notification_with_id(id: u32) -> Arc { } fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { + let disposition = if dropped { + CommitDisposition::SuppressedDropAll(SuppressedNotification { + id, + generation: 1, + owner: Some(StableProcessIdentity { + pid: 42, + start_time: 77, + }), + }) + } else { + CommitDisposition::Active(notification_with_id(id)) + }; InsertOutcome { - notification: notification_with_id(id), + disposition, replaced: false, popup_admission: if dropped { PopupAdmission::Suppressed(PopupSuppressionReason::DropAllInhibitor) @@ -66,7 +118,6 @@ fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { }, allow_sound: !dropped, evicted: Vec::new(), - dropped, expiration: None, } } @@ -100,6 +151,34 @@ async fn daemon_state_with_config(config: Config) -> Arc { ) } +#[tokio::test(flavor = "current_thread")] +async fn notification_server_sound_dispatch_reports_accepted_and_blocked_outcomes() { + use std::os::unix::fs::PermissionsExt; + + use crate::system_tools::routing::use_fake_tool_bin; + use crate::test_support::TempRoot; + + let root = TempRoot::new("notification-flow-sound"); + let player = root.join("canberra-gtk-play"); + std::fs::write(&player, "#!/bin/sh\nexit 0\n").expect("write fake sound player"); + let mut permissions = std::fs::metadata(&player) + .expect("fake sound player metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(player, permissions).expect("make fake sound player executable"); + let _tools = use_fake_tool_bin(root.path()); + let mut config = Config::default(); + config.sound.enabled = true; + config.sound.default_name = Some("message-new".to_string()); + let state = daemon_state_with_config(config).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state, scheduler); + let notification = notification_with_id(9); + + assert!(server.play_sound(¬ification, true)); + assert!(!server.play_sound(¬ification, false)); +} + async fn control_signal_stream(state: &DaemonState, member: &str) -> MessageStream { let receiver = Connection::session().await.expect("receiver session bus"); let sender = state @@ -132,21 +211,27 @@ async fn next_signal(stream: &mut MessageStream) -> Message { } #[test] -fn handle_dropped_notification_returns_id_for_dropped_payload() { +fn suppressed_notification_returns_content_free_lifecycle_for_dropped_payload() { let outcome = insert_outcome(9, true); - let id = NotificationServer::handle_dropped_notification(&outcome); + let suppressed = NotificationServer::suppressed_notification(&outcome) + .expect("DropAll outcome should retain lifecycle identity"); - assert_eq!(id, Some(9)); + assert_eq!(suppressed.id, 9); + assert_eq!(suppressed.generation, 1); + assert!(matches!( + outcome.disposition, + CommitDisposition::SuppressedDropAll(_) + )); } #[test] -fn handle_dropped_notification_returns_none_for_stored_payload() { +fn suppressed_notification_returns_none_for_stored_payload() { let outcome = insert_outcome(9, false); - let id = NotificationServer::handle_dropped_notification(&outcome); + let suppressed = NotificationServer::suppressed_notification(&outcome); - assert_eq!(id, None); + assert_eq!(suppressed, None); } #[test] @@ -283,6 +368,52 @@ async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { assert_eq!(active.category, "im.received"); } +#[tokio::test] +async fn drop_all_returns_an_id_then_emits_one_content_free_close_lifecycle() { + let mut config = Config::default(); + config.inhibit.mode = unixnotis_core::InhibitMode::DropAll; + let state = daemon_state_with_config(config).await; + state + .store + .lock() + .await + .add_inhibitor("test-owner".to_string(), "privacy".to_string(), 0); + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationClosed").await; + + let id = server + .ingest_notify( + "sensitive app".to_string(), + 0, + String::new(), + "secret summary".to_string(), + "secret body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("DropAll Notify should return its lifecycle ID"); + + { + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); + } + let signal = next_signal(&mut stream).await; + let (closed_id, generation, reason) = signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("content-free close body"); + assert_eq!(closed_id, id); + assert_ne!(generation, 0); + assert_eq!(reason, CloseReason::Undefined); +} + #[tokio::test] async fn ingest_notify_schedules_expiration_for_positive_transient_timeout() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index 0a368e83e..9d188d958 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -11,6 +11,7 @@ use super::{ }; use crate::daemon::{NotificationServer, NOTIFICATIONS_OBJECT_PATH}; use crate::expire::ExpirationScheduler; +use crate::store::test_support::make_notification_with_sender; use crate::test_support::daemon_state_for_test; const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; @@ -376,6 +377,68 @@ async fn notification_ingress() -> (std::sync::Arc, (state, client) } +#[tokio::test] +async fn close_errors_hide_missing_foreign_and_history_only_id_existence() { + let (state, client) = notification_ingress().await; + let (foreign_id, history_id) = { + let mut store = state.store.lock().await; + let foreign = store + .insert( + make_notification_with_sender("foreign", ":1.foreign", 999_998, 41), + 0, + ) + .active_notification(); + let history = store + .insert( + make_notification_with_sender("history", ":1.foreign", 999_999, 42), + 0, + ) + .active_notification(); + store.close(history.id, unixnotis_core::CloseReason::Expired); + (foreign.id, history.id) + }; + + let missing = close_method_error(&state, &client, u32::MAX).await; + let foreign = close_method_error(&state, &client, foreign_id).await; + let history = close_method_error(&state, &client, history_id).await; + + assert_eq!(missing, foreign); + assert_eq!(foreign, history); + assert_eq!( + missing, + ( + "org.freedesktop.DBus.Error.Failed".to_string(), + Some(String::new()) + ) + ); +} + +async fn close_method_error( + state: &crate::daemon::DaemonState, + client: &Connection, + id: u32, +) -> (String, Option) { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let error = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "CloseNotification", + &id, + ) + .await + .expect_err("non-closable notification should return a D-Bus error"); + match error { + zbus::Error::MethodError(name, message, _reply) => (name.to_string(), message), + other => panic!("expected a D-Bus method error, got {other:?}"), + } +} + async fn send_image_notification( state: &crate::daemon::DaemonState, client: &Connection, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs index 3b28d9455..d7c697c2b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs @@ -27,7 +27,7 @@ async fn get_capabilities_returns_freedesktop_capability_contract() { assert!(capabilities.contains(&"actions".to_string())); assert!(capabilities.contains(&"body".to_string())); - assert!(capabilities.contains(&"body-markup".to_string())); + assert!(!capabilities.contains(&"body-markup".to_string())); assert!(capabilities.contains(&"icon-static".to_string())); assert!(!capabilities.contains(&"xyzzy".to_string())); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs new file mode 100644 index 000000000..2e129a300 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs @@ -0,0 +1,33 @@ +use super::{quota_principal, QuotaPrincipal, SenderMetadata}; + +#[test] +fn quota_principal_requires_and_preserves_the_complete_process_lifetime() { + let complete = SenderMetadata { + sender_uid: Some(1_000), + sender_pid: Some(42), + sender_start_time: Some(77), + ..SenderMetadata::default() + }; + + assert_eq!( + quota_principal(&complete), + Some(QuotaPrincipal::new(1_000, 42, 77)) + ); + + for incomplete in [ + SenderMetadata { + sender_uid: None, + ..complete.clone() + }, + SenderMetadata { + sender_pid: None, + ..complete.clone() + }, + SenderMetadata { + sender_start_time: None, + ..complete + }, + ] { + assert_eq!(quota_principal(&incomplete), None); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs new file mode 100644 index 000000000..0ddd311ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs @@ -0,0 +1,115 @@ +use std::num::NonZeroU32; + +use crate::store::{StableProcessIdentity, SuppressedNotification}; + +use super::{PostReplyKey, PostReplyLifecycle, RetainError, MAX_PENDING_SUPPRESSED_CLOSES}; + +fn suppressed(id: u32, generation: u64) -> SuppressedNotification { + SuppressedNotification { + id, + generation, + owner: Some(StableProcessIdentity { + pid: id, + start_time: generation, + }), + } +} + +fn request(sender: &str, serial: u32) -> PostReplyKey { + PostReplyKey { + sender: Some(sender.to_string()), + serial: NonZeroU32::new(serial).expect("non-zero request serial"), + } +} + +#[tokio::test] +async fn retained_lifecycle_is_removed_only_by_its_request_serial() { + let lifecycle = PostReplyLifecycle::default(); + let first_request = request(":1.11", 11); + let second_request = request(":1.11", 12); + let first = suppressed(21, 31); + let second = suppressed(22, 32); + + lifecycle + .retain(first_request.clone(), first) + .await + .expect("first request serial should be vacant"); + lifecycle + .retain(second_request.clone(), second) + .await + .expect("second request serial should be vacant"); + + assert_eq!(lifecycle.take(&second_request).await, Some(second)); + assert_eq!(lifecycle.take(&first_request).await, Some(first)); + assert_eq!(lifecycle.take(&first_request).await, None); +} + +#[tokio::test] +async fn duplicate_in_flight_serial_keeps_the_original_lifecycle() { + let lifecycle = PostReplyLifecycle::default(); + let request = request(":1.41", 41); + let original = suppressed(51, 61); + let duplicate = suppressed(52, 62); + + lifecycle + .retain(request.clone(), original) + .await + .expect("first request serial should be vacant"); + assert_eq!( + lifecycle.retain(request.clone(), duplicate).await, + Err(RetainError::DuplicateSerial) + ); + assert_eq!(lifecycle.take(&request).await, Some(original)); +} + +#[tokio::test] +async fn equal_serials_from_different_senders_keep_independent_lifecycles() { + let lifecycle = PostReplyLifecycle::default(); + let first_request = request(":1.51", 1); + let second_request = request(":1.52", 1); + let first = suppressed(71, 81); + let second = suppressed(72, 82); + + lifecycle + .retain(first_request.clone(), first) + .await + .expect("first sender should have an independent serial space"); + lifecycle + .retain(second_request.clone(), second) + .await + .expect("second sender should have an independent serial space"); + + assert_eq!(lifecycle.take(&first_request).await, Some(first)); + assert_eq!(lifecycle.take(&second_request).await, Some(second)); +} + +#[tokio::test] +async fn pending_lifecycle_capacity_is_hard_bounded_and_reusable() { + let lifecycle = PostReplyLifecycle::default(); + + for serial in 1..=MAX_PENDING_SUPPRESSED_CLOSES { + let serial = u32::try_from(serial).expect("test capacity fits u32"); + let request = request(":1.capacity", serial); + lifecycle + .retain(request, suppressed(serial, u64::from(serial))) + .await + .expect("exact queue capacity should be accepted"); + } + + let overflow_serial = + u32::try_from(MAX_PENDING_SUPPRESSED_CLOSES + 1).expect("test overflow serial fits u32"); + let overflow_request = request(":1.capacity", overflow_serial); + assert_eq!( + lifecycle + .retain(overflow_request.clone(), suppressed(overflow_serial, 1)) + .await, + Err(RetainError::CapacityExceeded) + ); + + let released_request = request(":1.capacity", 1); + assert!(lifecycle.take(&released_request).await.is_some()); + lifecycle + .retain(overflow_request, suppressed(overflow_serial, 2)) + .await + .expect("released capacity should admit the next lifecycle"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs index 0ff0cecd1..b48d02d98 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs @@ -28,7 +28,7 @@ impl WireImageData { width: i32, height: i32, rowstride: i32, - _has_alpha: bool, + has_alpha: bool, bits_per_sample: i32, channels: i32, data: Vec, @@ -47,23 +47,30 @@ impl WireImageData { return None; } let channels = u8::try_from(channels).ok()?; - if !matches!(channels, 3 | 4) { + // The protocol's alpha flag and channel count describe the same pixel layout + // Reject contradictory metadata rather than guessing how to reinterpret it + let expected_channels = if has_alpha { 4 } else { 3 }; + if channels != expected_channels { return None; } if data.is_empty() || data.len() > MAX_NOTIFY_WIRE_IMAGE_BYTES { return None; } - // The stride must cover every visible pixel in every row + // The stride must cover every visible pixel in each non-final row let width_usize = usize::try_from(width).ok()?; let height_usize = usize::try_from(height).ok()?; let channels_usize = usize::from(channels); - let minimum_rowstride = width_usize.checked_mul(channels_usize)?; + let row_bytes = width_usize.checked_mul(channels_usize)?; let rowstride = usize::try_from(rowstride).ok()?; - if rowstride < minimum_rowstride { + if rowstride < row_bytes { return None; } - let required_bytes = rowstride.checked_mul(height_usize)?; + // `rowstride` is the distance between consecutive row starts. Padding after + // the final visible row is not required, so validate through its last pixel + let required_bytes = (height_usize - 1) + .checked_mul(rowstride)? + .checked_add(row_bytes)?; if data.len() < required_bytes { return None; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs index 752e5f986..50af429a5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs @@ -66,6 +66,18 @@ fn padded_rgb_wire_rows_are_tightly_packed_as_rgba() { ); } +#[test] +fn alpha_flag_and_channel_count_must_describe_the_same_layout() { + assert!(WireImageData::from_parts(1, 1, 4, false, 8, 4, vec![0; 4]).is_none()); + assert!(WireImageData::from_parts(1, 1, 3, true, 8, 3, vec![0; 3]).is_none()); +} + +#[test] +fn final_wire_row_does_not_require_trailing_stride_padding() { + assert!(WireImageData::from_parts(1, 2, 4, false, 8, 3, vec![0; 7]).is_some()); + assert!(WireImageData::from_parts(1, 2, 4, false, 8, 3, vec![0; 6]).is_none()); +} + #[test] fn wire_image_metadata_and_bounds_fail_closed() { let valid_data = vec![0_u8; 4]; diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs index 2d0ce521e..c654b5a55 100644 --- a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -1,38 +1,11 @@ use std::sync::Arc; use tracing::warn; -use unixnotis_core::{CloseReason, Notification, NotificationKey}; +use unixnotis_core::{Notification, NotificationKey}; use super::DaemonState; impl DaemonState { - pub async fn close_notification(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { - let removed = { - let mut store = self.store.lock().await; - let removed = store.close(id, reason); - if let Some(notification) = removed.as_ref() { - // Cancellation is ordered before a replacement can acquire the store lock - self.cancel_expiration(notification.key()); - } - removed - }; - let Some(removed) = removed else { - return Ok(()); - }; - if let Err(err) = self - .publish_notification_closed(removed.key(), reason) - .await - { - warn!( - ?err, - id, - reason = reason as u32, - "notification close committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } - pub async fn dismiss_generation(&self, key: NotificationKey) -> zbus::Result<()> { let outcome = { let mut store = self.store.lock().await; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs index f1b8567db..9dc4c09db 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -1,3 +1,4 @@ mod notification_lifecycle; mod scheduler; mod status; +mod support; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs index 9f8cfa36a..39973332a 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -59,7 +59,7 @@ async fn generation_dismiss_removes_matching_history_without_canceling_timer() { let key = { let mut store = state.store.lock().await; let inserted = store.insert(notification("history"), 0); - let key = inserted.notification.key(); + let key = inserted.active_notification().key(); store.close(key.id, CloseReason::Expired); key }; @@ -103,7 +103,9 @@ async fn generation_safe_dismiss_keeps_replacement_and_its_timer() { state.set_scheduler(scheduler); let (id, original) = { let mut store = state.store.lock().await; - let original = store.insert(notification("original"), 0).notification; + let original = store + .insert(notification("original"), 0) + .active_notification(); let id = original.id; let replacement = store.insert(notification("replacement"), id); assert!(replacement.replaced); @@ -133,10 +135,12 @@ async fn generation_safe_panel_dismiss_rejects_a_stale_same_id_generation() { state.set_scheduler(scheduler); let (stale_key, replacement_key) = { let mut store = state.store.lock().await; - let original = store.insert(notification("original"), 0).notification; + let original = store + .insert(notification("original"), 0) + .active_notification(); let replacement = store .insert(notification("replacement"), original.id) - .notification; + .active_notification(); (original.key(), replacement.key()) }; @@ -168,7 +172,7 @@ async fn generation_safe_panel_dismiss_removes_and_cancels_the_current_generatio .lock() .await .insert(notification("current"), 0) - .notification + .active_notification() .key(); state @@ -195,7 +199,7 @@ async fn action_dismissal_removes_only_the_current_active_generation() { .lock() .await .insert(notification("action"), 0) - .notification; + .active_notification(); assert!(state .dismiss_actioned_if_current(target.id, &target) @@ -214,7 +218,9 @@ async fn action_dismissal_keeps_a_same_id_replacement() { state.set_scheduler(scheduler); let (id, original) = { let mut store = state.store.lock().await; - let original = store.insert(notification("original"), 0).notification; + let original = store + .insert(notification("original"), 0) + .active_notification(); let replacement = store.insert(notification("replacement"), original.id); assert!(replacement.replaced); (original.id, original) @@ -244,7 +250,10 @@ async fn close_notification_removes_active_notification_and_cancels_timer() { state.set_scheduler(scheduler); let id = { let mut store = state.store.lock().await; - store.insert(notification("close"), 0).notification.id + store + .insert(notification("close"), 0) + .active_notification() + .id }; state diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/support.rs b/crates/unixnotis-daemon/src/daemon/state/tests/support.rs new file mode 100644 index 000000000..6f5b4abdb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/support.rs @@ -0,0 +1,37 @@ +use tracing::warn; +use unixnotis_core::CloseReason; + +use crate::daemon::DaemonState; + +impl DaemonState { + pub(crate) async fn close_notification( + &self, + id: u32, + reason: CloseReason, + ) -> zbus::Result<()> { + let removed = { + let mut store = self.store.lock().await; + let removed = store.close(id, reason); + if let Some(notification) = removed.as_ref() { + // Cancellation is ordered before a replacement can acquire the store lock + self.cancel_expiration(notification.key()); + } + removed + }; + let Some(removed) = removed else { + return Ok(()); + }; + if let Err(error) = self + .publish_notification_closed(removed.key(), reason) + .await + { + warn!( + ?error, + id, + reason = reason as u32, + "notification close committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs index 40e326e36..e94efb88d 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -5,9 +5,10 @@ use clap::Parser; use futures_util::StreamExt; use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; use zbus::fdo::DBusProxy; +use zbus::message::Type; use zbus::names::BusName; use zbus::zvariant::OwnedValue; -use zbus::{Connection, ConnectionBuilder}; +use zbus::{Connection, ConnectionBuilder, MatchRule, MessageStream}; use super::super::{run_with_builder, run_with_builder_inner}; use crate::cli::Args; @@ -64,6 +65,31 @@ fn spawn_daemon_with_trusted_sender( }) } +fn spawn_daemon_with_config_and_trusted_sender( + address: String, + run_seconds: u64, + config: Config, + trusted_sender: String, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder_inner( + &args, + config, + builder, + Some(trusted_sender), + )) + .await + }) +} + async fn owner(dbus: &DBusProxy<'_>, name: &'static str) -> Option { let name = BusName::try_from(name).expect("static bus name"); dbus.get_name_owner(name) @@ -252,6 +278,86 @@ async fn private_session_bus_accepts_full_notification_view_after_added_signal() .expect("bounded daemon run"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drop_all_notify_reply_precedes_its_notification_closed_signal_on_the_bus() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let trusted_sender = client + .unique_name() + .expect("private session bus assigns a unique client name") + .to_string(); + let mut config = Config::default(); + config.inhibit.mode = unixnotis_core::InhibitMode::DropAll; + let daemon = + spawn_daemon_with_config_and_trusted_sender(bus.address.clone(), 3, config, trusted_sender); + let (notifications_owner, _control_owner) = wait_for_both_owners(&client).await; + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + control + .inhibit("DropAll ordering test", 0) + .await + .expect("activate DropAll inhibitor"); + + let close_rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(notifications_owner.as_str()) + .expect("notification daemon sender") + .path("/org/freedesktop/Notifications") + .expect("notification object path") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("NotificationClosed") + .expect("notification close member") + .build(); + let mut closed = MessageStream::for_match_rule(close_rule, &client, Some(4)) + .await + .expect("subscribe to freedesktop close signals before Notify"); + let payload = ( + "DropAll wire test", + 0_u32, + "", + "discarded summary", + "discarded body", + Vec::::new(), + HashMap::::new(), + 0_i32, + ); + + let reply = client + .call_method( + Some(NOTIFICATIONS_BUS_NAME), + "/org/freedesktop/Notifications", + Some("org.freedesktop.Notifications"), + "Notify", + &payload, + ) + .await + .expect("DropAll Notify should return a method reply"); + let id = reply.body().deserialize::().expect("notification id"); + let close = tokio::time::timeout(Duration::from_secs(2), closed.next()) + .await + .expect("NotificationClosed should arrive promptly") + .expect("NotificationClosed stream should remain open") + .expect("NotificationClosed message should decode"); + let (closed_id, reason) = close + .body() + .deserialize::<(u32, u32)>() + .expect("freedesktop close arguments"); + + assert_eq!(closed_id, id); + assert_eq!(reason, unixnotis_core::CloseReason::Undefined as u32); + assert!( + reply.recv_position() < close.recv_position(), + "Notify reply must cross the bus before NotificationClosed" + ); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn session_bus_loss_makes_the_daemon_exit_with_failure() { let mut bus = PrivateBus::start(); diff --git a/crates/unixnotis-daemon/src/sound/command.rs b/crates/unixnotis-daemon/src/sound/command.rs index c8e41bdb1..e21873e05 100644 --- a/crates/unixnotis-daemon/src/sound/command.rs +++ b/crates/unixnotis-daemon/src/sound/command.rs @@ -18,7 +18,7 @@ const SOUND_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); // Small cap prevents unbounded process fanout during notification bursts const SOUND_MAX_CONCURRENT: usize = 2; -pub(super) fn play_with_canberra(source: SoundSource) { +pub(super) fn play_with_canberra(source: SoundSource) -> bool { // canberra supports both symbolic names and direct files let mut args = Vec::new(); let mut display_args = Vec::new(); @@ -43,14 +43,14 @@ pub(super) fn play_with_canberra(source: SoundSource) { &args, &display_args, keepalive, - ); + ) } -pub(super) fn play_with_pw_play(source: SoundSource) { +pub(super) fn play_with_pw_play(source: SoundSource) -> bool { // pw-play accepts only direct file playback let SoundSource::File(file) = source else { warn!("pw-play backend does not support sound-name hints"); - return; + return false; }; let args = vec![file.playback_path().into_os_string()]; let display_args = vec![file.path().as_os_str().to_os_string()]; @@ -60,14 +60,14 @@ pub(super) fn play_with_pw_play(source: SoundSource) { &args, &display_args, Some(file.keepalive()), - ); + ) } -pub(super) fn play_with_paplay(source: SoundSource) { +pub(super) fn play_with_paplay(source: SoundSource) -> bool { // paplay accepts only direct file playback let SoundSource::File(file) = source else { warn!("paplay backend does not support sound-name hints"); - return; + return false; }; let args = vec![file.playback_path().into_os_string()]; let display_args = vec![file.path().as_os_str().to_os_string()]; @@ -77,7 +77,7 @@ pub(super) fn play_with_paplay(source: SoundSource) { &args, &display_args, Some(file.keepalive()), - ); + ) } fn sound_semaphore() -> &'static Arc { @@ -92,14 +92,14 @@ fn spawn_sound_command( args: &[OsString], display_args: &[OsString], keepalive: Option>, -) { +) -> bool { let limiter = sound_semaphore().clone(); // try_acquire keeps this call non-blocking on hot paths let permit = if let Ok(permit) = limiter.try_acquire_owned() { permit } else { debug!(backend, "sound command skipped (concurrency limit reached)"); - return; + return false; }; let command_str = sound_command_display(program, display_args); let command_snip = util::log_snippet(&command_str); @@ -112,7 +112,7 @@ fn spawn_sound_command( ?err, "trusted sound backend is unavailable" ); - return; + return false; } }; match command.spawn() { @@ -131,6 +131,7 @@ fn spawn_sound_command( let _keepalive = keepalive; reap_sound_child(backend, command_snip, pid, child).await; }); + true } Err(err) => { warn!( @@ -139,6 +140,7 @@ fn spawn_sound_command( ?err, "failed to spawn sound command" ); + false } } } diff --git a/crates/unixnotis-daemon/src/sound/settings.rs b/crates/unixnotis-daemon/src/sound/settings.rs index 4b2d087c8..afb85ecc6 100644 --- a/crates/unixnotis-daemon/src/sound/settings.rs +++ b/crates/unixnotis-daemon/src/sound/settings.rs @@ -62,11 +62,20 @@ impl SoundSettings { } } - /// Return true when sound playback is enabled and a backend is available - pub fn supports_sound(&self) -> bool { + /// Return true when internal notification playback can use a configured backend + pub fn has_playback_backend(&self) -> bool { self.enabled && self.backend != SoundBackend::None } + /// Return true when sender-requested freedesktop sound semantics are available + pub fn supports_fdo_sound_capability(&self) -> bool { + // The specification requires `sound-file` and `suppress-sound` support when + // advertising `sound`, so an empty or disabled file policy must fail closed + self.has_playback_backend() + && self.allow_file_hints + && !self.allowed_file_hint_dirs.is_empty() + } + /// Resolve a sound source from hints or defaults and play if allowed pub fn play_from_hints(&self, hints: &HashMap, allow_sound: bool) -> bool { // Hard gates first to keep the common no-sound path fast @@ -77,18 +86,13 @@ impl SoundSettings { if hint_bool(hints, "suppress-sound").unwrap_or(false) { return false; } - // Small cooldown avoids noisy bursts when apps spam fast updates - if !self.should_play_now() { - return false; - } - // Hint source wins, then fallback source from config let source = resolve_hint_sound(hints, self.allow_file_hints, &self.allowed_file_hint_dirs) .or_else(|| self.default_source()); - if let Some(source) = source { - return self.play(source); - } - false + let Some(source) = source else { + return false; + }; + self.play_with_cooldown(source, Instant::now()) } fn should_warn_missing_backend(sound_enabled: bool, backend: SoundBackend) -> bool { @@ -108,30 +112,18 @@ impl SoundSettings { fn play(&self, source: SoundSource) -> bool { // Backend-specific launcher keeps this method tiny and testable match self.backend { - SoundBackend::Canberra => { - play_with_canberra(source); - true - } - SoundBackend::PwPlay => { - play_with_pw_play(source); - true - } - SoundBackend::PaPlay => { - play_with_paplay(source); - true - } + SoundBackend::Canberra => play_with_canberra(source), + SoundBackend::PwPlay => play_with_pw_play(source), + SoundBackend::PaPlay => play_with_paplay(source), SoundBackend::None => false, } } - fn should_play_now(&self) -> bool { - self.should_play_at(Instant::now()) - } - - fn should_play_at(&self, now: Instant) -> bool { - let Ok(mut guard) = self.last_played.lock() else { - // A poisoned lock should not disable alerts forever - return true; + fn play_with_cooldown(&self, source: SoundSource, now: Instant) -> bool { + let mut guard = match self.last_played.lock() { + Ok(guard) => guard, + // Recover the timestamp so a prior panic cannot disable alerts forever + Err(poisoned) => poisoned.into_inner(), }; if let Some(last) = *guard { // Skip playback if requests are too close together @@ -139,7 +131,12 @@ impl SoundSettings { return false; } } - // Record now only when the request is accepted + // Cooldown measures accepted playback, not notification attempts + // Missing, unsupported, concurrency-rejected, and spawn-failed sources must + // not suppress the next legitimate sound + if !self.play(source) { + return false; + } *guard = Some(now); true } diff --git a/crates/unixnotis-daemon/src/sound/tests/settings.rs b/crates/unixnotis-daemon/src/sound/tests/settings.rs index d7247007b..359a621ef 100644 --- a/crates/unixnotis-daemon/src/sound/tests/settings.rs +++ b/crates/unixnotis-daemon/src/sound/tests/settings.rs @@ -1,5 +1,6 @@ use super::*; use crate::sound::SoundFile; +use crate::system_tools::routing::use_fake_tool_bin; use crate::test_support::TempRoot; use zbus::zvariant::{OwnedValue, Value}; @@ -34,11 +35,45 @@ fn last_played_is_set(settings: &SoundSettings) -> bool { .is_some() } +fn install_fake_canberra(root: &TempRoot) { + use std::os::unix::fs::PermissionsExt; + + let path = root.join("canberra-gtk-play"); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake canberra tool"); + let mut permissions = std::fs::metadata(&path) + .expect("fake canberra metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("make fake canberra executable"); +} + +fn sound_name_hints(name: &str) -> HashMap { + HashMap::from([( + "sound-name".to_string(), + Value::from(name) + .try_into() + .expect("sound name should convert"), + )]) +} + #[test] -fn supports_sound_requires_enabled_config_and_backend() { - assert!(settings(true, SoundBackend::Canberra).supports_sound()); - assert!(!settings(false, SoundBackend::Canberra).supports_sound()); - assert!(!settings(true, SoundBackend::None).supports_sound()); +fn playback_backend_requires_enabled_config_and_available_tool() { + assert!(settings(true, SoundBackend::Canberra).has_playback_backend()); + assert!(!settings(false, SoundBackend::Canberra).has_playback_backend()); + assert!(!settings(true, SoundBackend::None).has_playback_backend()); +} + +#[test] +fn fdo_sound_capability_requires_allowed_file_hints_and_backend() { + let mut sound = settings(true, SoundBackend::Canberra); + assert!(!sound.supports_fdo_sound_capability()); + + sound.allow_file_hints = true; + sound.allowed_file_hint_dirs = vec![PathBuf::from("/allowed")]; + assert!(sound.supports_fdo_sound_capability()); + + sound.backend = SoundBackend::None; + assert!(!sound.supports_fdo_sound_capability()); } #[test] @@ -96,9 +131,12 @@ fn play_from_hints_does_not_consume_throttle_when_global_or_notification_gate_bl assert!(!last_played_is_set(&suppressed)); } -#[test] -fn play_from_hints_uses_default_source_and_records_allowed_attempt() { - let sound = settings(true, SoundBackend::PwPlay); +#[tokio::test(flavor = "current_thread")] +async fn play_from_hints_uses_default_source_and_records_allowed_attempt() { + let root = TempRoot::new("sound-settings-play"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); + let sound = settings(true, SoundBackend::Canberra); assert!(sound.play_from_hints(&HashMap::new(), true)); @@ -110,7 +148,7 @@ fn play_from_hints_reports_false_when_no_backend_is_available() { let sound = settings(true, SoundBackend::None); assert!(!sound.play_from_hints(&HashMap::new(), true)); - assert!(last_played_is_set(&sound)); + assert!(!last_played_is_set(&sound)); } #[test] @@ -119,32 +157,37 @@ fn play_from_hints_returns_false_when_no_source_is_available() { sound.default_name = None; assert!(!sound.play_from_hints(&HashMap::new(), true)); - assert!(last_played_is_set(&sound)); + assert!(!last_played_is_set(&sound)); } -#[test] -fn should_play_now_records_first_request_and_throttles_immediate_repeat() { +#[tokio::test(flavor = "current_thread")] +async fn accepted_playback_records_cooldown_and_throttles_immediate_repeat() { + let root = TempRoot::new("sound-settings-cooldown"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); let sound = settings(true, SoundBackend::Canberra); - assert!(sound.should_play_now()); - assert!(!sound.should_play_now()); + assert!(sound.play_from_hints(&HashMap::new(), true)); + assert!(!sound.play_from_hints(&HashMap::new(), true)); } -#[test] -fn should_play_now_accepts_when_last_play_is_older_than_interval() { - let sound = settings(true, SoundBackend::Canberra); - let now = Instant::now(); - *sound.last_played.lock().expect("last_played lock") = Some( - now.checked_sub(SOUND_MIN_INTERVAL) - .expect("test clock should represent the previous playback window"), - ); +#[tokio::test(flavor = "current_thread")] +async fn unusable_request_does_not_suppress_next_valid_sound() { + let root = TempRoot::new("sound-settings-failed-then-valid"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); + let mut sound = settings(true, SoundBackend::Canberra); + sound.default_name = None; - assert!(sound.should_play_at(now)); + assert!(!sound.play_from_hints(&HashMap::new(), true)); + assert!(!last_played_is_set(&sound)); + assert!(sound.play_from_hints(&sound_name_hints("message-new"), true)); + assert!(last_played_is_set(&sound)); } #[test] fn play_reports_whether_backend_dispatch_was_available() { - assert!(settings(true, SoundBackend::PwPlay) + assert!(!settings(true, SoundBackend::PwPlay) .play(SoundSource::Name("message-new-instant".to_string()))); assert!(!settings(true, SoundBackend::None) .play(SoundSource::Name("message-new-instant".to_string()))); diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs index d5f3b64d7..5221f46ae 100644 --- a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs @@ -1,7 +1,7 @@ use unixnotis_core::{Config, InhibitMode}; use crate::store::test_support::make_notification; -use crate::store::NotificationStore; +use crate::store::{CommitDisposition, NotificationStore}; #[test] fn inhibit_no_popups_suppresses_show_popup() { @@ -11,7 +11,7 @@ fn inhibit_no_popups_suppresses_show_popup() { store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); let outcome = store.insert(make_notification("inhibited"), 0); - assert!(!outcome.dropped); + assert!(outcome.suppressed().is_none()); assert!(!outcome.popup_admission.should_show()); assert!(!outcome.allow_sound); assert_eq!(store.list_active().len(), 1); @@ -25,7 +25,16 @@ fn inhibit_drop_all_skips_storage() { store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); let outcome = store.insert(make_notification("inhibited"), 0); - assert!(outcome.dropped); + let suppressed = outcome + .suppressed() + .expect("DropAll must retain only lifecycle identity"); + assert_eq!(suppressed.id, 1); + assert_eq!(suppressed.generation, 1); + assert_eq!(suppressed.owner.expect("stable test owner").pid, 1234); + assert!(matches!( + outcome.disposition, + CommitDisposition::SuppressedDropAll(_) + )); assert!(store.list_active().is_empty()); assert_eq!(store.history_len(), 0); } diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index 56da7b251..ae5970bda 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -7,11 +7,12 @@ mod notifications; mod runtime; pub use model::{ - DeliveryStageUpdate, DismissOutcome, DndWrite, ExpirationTicket, InsertOutcome, - NotificationStore, PopupAdmission, PopupSuppressionReason, + CloseAuthorization, CommitDisposition, DeliveryStageUpdate, DismissOutcome, DndWrite, + ExpirationTicket, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, }; #[cfg(test)] -mod test_support; +pub mod test_support; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs index 3b6a1a056..5664627f9 100644 --- a/crates/unixnotis-daemon/src/store/model.rs +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -25,6 +25,8 @@ pub struct NotificationStore { pub(super) history: HistoryStore, // Arrival-time popup decisions outlive active state while history retains the generation pub(super) popup_decisions: HashMap, + // Monotonic popup deadlines stay daemon-local and are never serialized + pub(super) popup_timings: HashMap, // Exact expiration identity per active notification generation pub(super) expirations: HashMap, // Effective DND switch after loading persisted state @@ -45,8 +47,8 @@ pub struct NotificationStore { } pub struct InsertOutcome { - // Stored notification instance returned to callers - pub notification: Arc, + // Commit kind keeps content-bearing and content-free lifecycles structurally distinct + pub disposition: CommitDisposition, // True when insertion replaced an existing id pub replaced: bool, // Structured popup policy keeps suppression causes available to diagnostics @@ -55,12 +57,57 @@ pub struct InsertOutcome { pub allow_sound: bool, // Active ids evicted because max_active was exceeded pub evicted: Vec, - // True when payload was intentionally dropped by inhibit mode - pub dropped: bool, // Commit-time daemon deadline for this exact generation pub expiration: Option, } +/// Result of committing one protocol notification request +pub enum CommitDisposition { + // Ordinary notifications retain their normalized content in active storage + Active(Arc), + // DropAll retains no sender-controlled presentation content + SuppressedDropAll(SuppressedNotification), +} + +/// Content-free lifecycle identity for a `DropAll` notification +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SuppressedNotification { + pub id: u32, + pub generation: u64, + // Stable process identity is retained only when both components are established + pub owner: Option, +} + +/// Process-lifetime ownership principal independent of a D-Bus unique name +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct StableProcessIdentity { + pub pid: u32, + pub start_time: u64, +} + +/// Deliberately collapsed result of authorizing a protocol close request +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloseAuthorization { + OwnedActive(NotificationKey), + NotClosable, +} + +impl InsertOutcome { + pub const fn suppressed(&self) -> Option { + match &self.disposition { + CommitDisposition::Active(_) => None, + CommitDisposition::SuppressedDropAll(suppressed) => Some(*suppressed), + } + } +} + +/// Daemon-only popup lifetime for one committed generation +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct PopupTiming { + // None keeps the popup eligible until delivery because zero disables automatic hiding + pub(super) deadline: Option, +} + /// Exact identity required to expire one committed notification #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ExpirationTicket { diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index b61517f74..c31ea99e9 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -5,7 +5,10 @@ use unixnotis_core::{ Notification, NotificationKey, UiHealth, Urgency, }; -use crate::store::{InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason}; +use crate::store::{ + CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, +}; use super::timeout::resolve_timeout_policy; @@ -13,19 +16,6 @@ use super::timeout::resolve_timeout_policy; const ACTIVE_HARD_CAP: usize = 12; impl NotificationStore { - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "legacy in-crate test fixtures use the neutral health wrapper" - ) - )] - pub(crate) fn insert(&mut self, notification: Notification, replaces_id: u32) -> InsertOutcome { - // Test and legacy in-crate callers use the neutral health snapshot - // Production notification ingress calls insert_with_ui_health directly - self.insert_with_ui_health(notification, replaces_id, &UiHealth::default()) - } - pub fn insert_with_ui_health( &mut self, mut notification: Notification, @@ -36,19 +26,30 @@ impl NotificationStore { self.apply_rules(&mut notification); let timeout_policy = resolve_timeout_policy(&self.config, ¬ification); if self.should_drop_inhibited() { - // DropAll mode still assigns an ID so call sites can log consistent metadata + // DropAll discards notification content, not protocol lifecycle + // Only process-lifetime identity survives long enough to close the returned ID let assigned_id = self.next_id(); - notification.id = assigned_id; - let notification = Arc::new(notification); + let generation = self.next_generation; + self.next_generation = self + .next_generation + .checked_add(1) + .expect("notification generation space must not be exhausted"); + let owner = notification + .sender_pid + .zip(notification.sender_start_time) + .map(|(pid, start_time)| StableProcessIdentity { pid, start_time }); return InsertOutcome { popup_admission: PopupAdmission::Suppressed( PopupSuppressionReason::DropAllInhibitor, ), allow_sound: false, - notification, + disposition: CommitDisposition::SuppressedDropAll(SuppressedNotification { + id: assigned_id, + generation, + owner, + }), replaced: false, evicted: Vec::new(), - dropped: true, expiration: None, }; } @@ -82,10 +83,13 @@ impl NotificationStore { self.expirations.remove(&assigned_id); self.popup_decisions .retain(|key, _decision| key.id != assigned_id); + self.popup_timings + .retain(|key, _timing| key.id != assigned_id); + let admitted_at = std::time::Instant::now(); let expiration = timeout_policy .active_close_after - .map(|duration| std::time::Instant::now() + duration); + .map(|duration| admitted_at.checked_add(duration).unwrap_or(admitted_at)); let notification = Arc::new(notification); // Active map keeps insertion order so oldest eviction is deterministic self.active.insert(assigned_id, notification.clone()); @@ -93,19 +97,19 @@ impl NotificationStore { let evicted = self.enforce_active_limit(); let popup_admission = self.popup_admission(¬ification); - self.record_popup_commit_environment( + self.record_popup_commit_environment_at( notification.key(), popup_admission, ui_health, timeout_policy.popup_hide_after_ms, + admitted_at, ); InsertOutcome { popup_admission, allow_sound: self.should_play_sound(¬ification), - notification, + disposition: CommitDisposition::Active(notification), replaced, evicted, - dropped: false, expiration, } } diff --git a/crates/unixnotis-daemon/src/store/notifications/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/ownership.rs index f6c0ec154..746ad5f1a 100644 --- a/crates/unixnotis-daemon/src/store/notifications/ownership.rs +++ b/crates/unixnotis-daemon/src/store/notifications/ownership.rs @@ -1,21 +1,42 @@ +use std::sync::Arc; use tracing::warn; -use unixnotis_core::Notification; -use crate::store::NotificationStore; +use unixnotis_core::{CloseReason, Notification}; + +use crate::store::{CloseAuthorization, NotificationStore}; impl NotificationStore { - pub fn is_notification_owned_by( + pub fn close_authorization( &self, id: u32, - sender: &str, + sender: Option<&str>, sender_pid: Option, sender_start_time: Option, - ) -> bool { - // Ownership checks are valid only against active notifications + ) -> CloseAuthorization { let Some(notification) = self.active.get(&id) else { - return false; + return CloseAuthorization::NotClosable; }; - notification_is_owned_by(notification, Some(sender), sender_pid, sender_start_time) + if notification_is_owned_by(notification, sender, sender_pid, sender_start_time) { + CloseAuthorization::OwnedActive(notification.key()) + } else { + CloseAuthorization::NotClosable + } + } + + pub fn close_owned_active( + &mut self, + id: u32, + sender: Option<&str>, + sender_pid: Option, + sender_start_time: Option, + reason: CloseReason, + ) -> Option> { + // SECURITY: missing and foreign-owned IDs collapse before leaving the store + // CloseNotification therefore cannot become a notification-existence oracle + match self.close_authorization(id, sender, sender_pid, sender_start_time) { + CloseAuthorization::OwnedActive(_key) => self.close(id, reason), + CloseAuthorization::NotClosable => None, + } } pub(super) fn next_id(&mut self) -> u32 { @@ -57,8 +78,9 @@ impl NotificationStore { sender_pid: Option, sender_start_time: Option, ) -> bool { - // Replacement is allowed only for the sender that owns the original notification - let Some(existing) = self.active.get(&id).or_else(|| self.history.get(&id)) else { + // Protocol replacement authority ends when an ID leaves `active` + // History is presentation-only state and cannot resurrect a closed object + let Some(existing) = self.active.get(&id) else { return false; }; notification_is_owned_by(existing, sender, sender_pid, sender_start_time) diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs index faa26844b..eba514cc2 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs @@ -5,7 +5,7 @@ fn max_entries_zero_drops_history_on_close() { let mut store = make_store_with_limits(10, 0); let outcome = store.insert(make_notification("first"), 0); - store.close(outcome.notification.id, CloseReason::Expired); + store.close(outcome.active_notification().id, CloseReason::Expired); assert_eq!(store.history_len(), 0); } @@ -29,13 +29,13 @@ fn history_reinsert_replaces_existing_order_entry() { let mut store = make_store_with_limits(0, 10); let first = store.insert(make_notification("first"), 0); let mut replacement = make_notification("replacement"); - replacement.id = first.notification.id; + replacement.id = first.active_notification().id; store.history.insert(Arc::new(replacement)); let history = store.list_history(); assert_eq!(history.len(), 1); - assert_eq!(history[0].id, first.notification.id); + assert_eq!(history[0].id, first.active_notification().id); assert_eq!(history[0].summary, "replacement"); } @@ -49,7 +49,7 @@ fn transient_close_obeys_the_history_policy() { notification.is_transient = true; let outcome = store.insert(notification, 0); - store.close(outcome.notification.id, CloseReason::Expired); + store.close(outcome.active_notification().id, CloseReason::Expired); assert_eq!(store.history_len(), expected); } @@ -59,7 +59,7 @@ fn transient_close_obeys_the_history_policy() { fn clear_history_removes_archived_notifications() { let mut store = make_store_with_limits(10, 10); let first = store.insert(make_notification("first"), 0); - store.close(first.notification.id, CloseReason::Expired); + store.close(first.active_notification().id, CloseReason::Expired); store.clear_history(); @@ -70,7 +70,9 @@ fn clear_history_removes_archived_notifications() { #[test] fn history_generation_checks_and_removal_require_the_exact_commit_key() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("archived"), 0).notification; + let notification = store + .insert(make_notification("archived"), 0) + .active_notification(); let current = notification.key(); let stale = unixnotis_core::NotificationKey { id: current.id, diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs index 5203346f3..022870b89 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -12,16 +12,19 @@ fn drain_active_keys_returns_newest_first_and_clears_expirations() { let first = store.insert(make_notification("first"), 0); let second = store.insert(make_notification("second"), 0); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - store.set_expiration(&first.notification, Some(deadline)); + store.set_expiration(&first.active_notification(), Some(deadline)); let keys = store.drain_active_keys(); assert_eq!( keys, - vec![second.notification.key(), first.notification.key()] + vec![ + second.active_notification().key(), + first.active_notification().key() + ] ); assert!(store.list_active().is_empty()); - assert_eq!(expiration_for(&store, first.notification.id), None); + assert_eq!(expiration_for(&store, first.active_notification().id), None); } #[test] @@ -32,23 +35,26 @@ fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { let second = std::time::Instant::now() + std::time::Duration::from_secs(2); let first_ticket = store - .set_expiration(&outcome.notification, Some(first)) + .set_expiration(&outcome.active_notification(), Some(first)) .expect("positive deadline should create a ticket"); assert_eq!( - expiration_for(&store, outcome.notification.id), + expiration_for(&store, outcome.active_notification().id), Some(first_ticket) ); let second_ticket = store - .set_expiration(&outcome.notification, Some(second)) + .set_expiration(&outcome.active_notification(), Some(second)) .expect("replacement deadline should create a ticket"); assert_eq!( - expiration_for(&store, outcome.notification.id), + expiration_for(&store, outcome.active_notification().id), Some(second_ticket) ); - store.set_expiration(&outcome.notification, None); - assert_eq!(expiration_for(&store, outcome.notification.id), None); + store.set_expiration(&outcome.active_notification(), None); + assert_eq!( + expiration_for(&store, outcome.active_notification().id), + None + ); } #[test] @@ -60,7 +66,7 @@ fn generation_safe_reply_dismissal_keeps_same_id_replacement() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let original = store.insert(original, 0).notification; + let original = store.insert(original, 0).active_notification(); let id = original.id; let replacement = store.insert(make_notification("replacement"), id); @@ -74,18 +80,20 @@ fn generation_safe_reply_dismissal_keeps_same_id_replacement() { .summary, "replacement" ); - assert!(store.dismiss_active_if_current(id, &replacement.notification)); + assert!(store.dismiss_active_if_current(id, &replacement.active_notification())); assert!(store.active_notification_view(id).is_none()); } #[test] fn stale_panel_dismissal_keeps_same_id_replacement() { let mut store = make_store_with_limits(12, 20); - let original = store.insert(make_notification("original"), 0).notification; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); let stale_key = original.key(); let replacement = store .insert(make_notification("replacement"), original.id) - .notification; + .active_notification(); let outcome = store.dismiss_generation(stale_key); @@ -102,7 +110,9 @@ fn stale_panel_dismissal_keeps_same_id_replacement() { #[test] fn replied_generation_is_removed_after_sender_archives_it() { let mut store = make_store_with_limits(12, 20); - let original = store.insert(make_notification("original"), 0).notification; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); let id = original.id; store.close(id, CloseReason::ClosedByCall); assert_eq!(store.list_history().len(), 1); @@ -117,7 +127,9 @@ fn replied_generation_is_removed_after_sender_archives_it() { #[test] fn replied_generation_cleanup_keeps_archived_same_id_replacement() { let mut store = make_store_with_limits(12, 20); - let original = store.insert(make_notification("original"), 0).notification; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); let id = original.id; let replacement = store.insert(make_notification("replacement"), id); assert!(replacement.replaced); diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs index 96d568754..3dcd6d2de 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs @@ -1,28 +1,87 @@ use super::support::*; +fn is_notification_owned_by( + store: &NotificationStore, + id: u32, + sender: &str, + sender_pid: Option, + sender_start_time: Option, +) -> bool { + matches!( + store.close_authorization(id, Some(sender), sender_pid, sender_start_time), + crate::store::CloseAuthorization::OwnedActive(_) + ) +} + #[test] -fn replace_id_in_history_reuses_id_and_clears_entry() { +fn replace_id_in_history_allocates_new_id_and_preserves_history() { let mut store = make_store_with_limits(2, 10); let first = store.insert(make_notification("first"), 0); - store.close(first.notification.id, CloseReason::Expired); + store.close(first.active_notification().id, CloseReason::Expired); assert_eq!(store.history_len(), 1); - // Replacement should reuse the original ID and remove the history entry - let replaced = store.insert(make_notification("replacement"), first.notification.id); - assert!(replaced.replaced); - assert_eq!(replaced.notification.id, first.notification.id); - assert_eq!(store.history_len(), 0); + // History cannot restore replacement authority for an inactive protocol ID + let replaced = store.insert( + make_notification("replacement"), + first.active_notification().id, + ); + assert!(!replaced.replaced); + assert_ne!( + replaced.active_notification().id, + first.active_notification().id + ); + assert_eq!(store.history_len(), 1); let active = store.list_active(); assert_eq!(active.len(), 1); assert_eq!(active[0].summary, "replacement"); - // Closing the replacement should re-add a single history entry for the updated notification - store.close(replaced.notification.id, CloseReason::Expired); + // Closing the new notification archives it independently from the original ID + store.close(replaced.active_notification().id, CloseReason::Expired); let history = store.list_history(); - assert_eq!(history.len(), 1); + assert_eq!(history.len(), 2); assert_eq!(history[0].summary, "replacement"); + assert_eq!(history[1].summary, "first"); +} + +#[test] +fn active_owned_id_replaces_while_active_foreign_and_missing_ids_do_not() { + let mut store = make_store_with_limits(5, 10); + let owned = store.insert( + make_notification_with_sender("owned", ":1.owner", 101, 11), + 0, + ); + let foreign = store.insert( + make_notification_with_sender("foreign", ":1.foreign", 202, 22), + 0, + ); + + let owned_replacement = store.insert( + make_notification_with_sender("owned replacement", ":1.owner", 101, 11), + owned.active_notification().id, + ); + let foreign_attempt = store.insert( + make_notification_with_sender("foreign attempt", ":1.owner", 101, 11), + foreign.active_notification().id, + ); + let missing_attempt = store.insert( + make_notification_with_sender("missing attempt", ":1.owner", 101, 11), + u32::MAX, + ); + + assert!(owned_replacement.replaced); + assert_eq!( + owned_replacement.active_notification().id, + owned.active_notification().id + ); + assert!(!foreign_attempt.replaced); + assert_ne!( + foreign_attempt.active_notification().id, + foreign.active_notification().id + ); + assert!(!missing_attempt.replaced); + assert_ne!(missing_attempt.active_notification().id, u32::MAX); } #[test] @@ -33,16 +92,19 @@ fn replace_id_rejected_for_different_sender() { make_notification_with_sender("first", ":1.sender-a", 101, 1), 0, ); - store.close(first.notification.id, CloseReason::Expired); + store.close(first.active_notification().id, CloseReason::Expired); assert_eq!(store.history_len(), 1); // Cross-sender replacement must allocate a fresh id and keep prior history intact let replaced = store.insert( make_notification_with_sender("replacement", ":1.sender-b", 202, 2), - first.notification.id, + first.active_notification().id, ); assert!(!replaced.replaced); - assert_ne!(replaced.notification.id, first.notification.id); + assert_ne!( + replaced.active_notification().id, + first.active_notification().id + ); assert_eq!(store.history_len(), 1); } @@ -53,14 +115,16 @@ fn is_notification_owned_by_matches_sender() { make_notification_with_sender("owned", ":1.owner", 1234, 55), 0, ); - assert!(store.is_notification_owned_by( - outcome.notification.id, + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.owner", Some(1234), Some(55) )); - assert!(!store.is_notification_owned_by( - outcome.notification.id, + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.other", Some(5678), Some(66) @@ -76,8 +140,9 @@ fn is_notification_owned_by_accepts_exact_sender_without_process_match() { ); // Bus names are stronger than pid metadata, which may be absent or stale - assert!(store.is_notification_owned_by( - outcome.notification.id, + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.owner", Some(5678), Some(66) @@ -92,8 +157,9 @@ fn is_notification_owned_by_accepts_same_process_after_reconnect() { 0, ); // A new bus name from the same process lifetime should still be treated as owner - assert!(store.is_notification_owned_by( - outcome.notification.id, + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.owner-b", Some(1234), Some(55) @@ -108,8 +174,9 @@ fn is_notification_owned_by_rejects_reused_pid_with_new_start_time() { 0, ); // Same pid is not enough once the original process lifetime has ended - assert!(!store.is_notification_owned_by( - outcome.notification.id, + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.owner-b", Some(1234), Some(77) @@ -125,14 +192,67 @@ fn is_notification_owned_by_rejects_pid_match_without_start_time() { ); // Pid reuse is common enough that start time must be part of process ownership - assert!(!store.is_notification_owned_by( - outcome.notification.id, + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, ":1.owner-b", Some(1234), None )); } +#[test] +fn close_authorization_collapses_missing_foreign_and_history_only_ids() { + let mut store = make_store_with_limits(10, 10); + let active = store + .insert( + make_notification_with_sender("active", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + let archived = store + .insert( + make_notification_with_sender("archived", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + store.close(archived.id, CloseReason::Expired); + + let missing = store.close_authorization(u32::MAX, Some(":1.owner"), Some(1234), Some(55)); + let foreign = store.close_authorization(active.id, Some(":1.foreign"), Some(9876), Some(66)); + let history = store.close_authorization(archived.id, Some(":1.owner"), Some(1234), Some(55)); + + assert_eq!(missing, crate::store::CloseAuthorization::NotClosable); + assert_eq!(foreign, crate::store::CloseAuthorization::NotClosable); + assert_eq!(history, crate::store::CloseAuthorization::NotClosable); + assert_eq!(missing, foreign); + assert_eq!(foreign, history); +} + +#[test] +fn close_owned_active_removes_only_the_authorized_live_object() { + let mut store = make_store_with_limits(10, 10); + let active = store + .insert( + make_notification_with_sender("active", ":1.owner-a", 1234, 55), + 0, + ) + .active_notification(); + + let removed = store + .close_owned_active( + active.id, + Some(":1.owner-b"), + Some(1234), + Some(55), + CloseReason::ClosedByCall, + ) + .expect("same process lifetime should close after reconnect"); + + assert_eq!(removed.key(), active.key()); + assert!(store.list_active().is_empty()); +} + #[test] fn replacement_allows_same_process_after_bus_reconnect() { let mut store = make_store_with_limits(2, 10); @@ -144,12 +264,15 @@ fn replacement_allows_same_process_after_bus_reconnect() { let replacement = store.insert( make_notification_with_sender("replacement", ":1.owner-b", 1234, 55), - first.notification.id, + first.active_notification().id, ); // Same process lifetime can replace after the bus name changes assert!(replacement.replaced); - assert_eq!(replacement.notification.id, first.notification.id); + assert_eq!( + replacement.active_notification().id, + first.active_notification().id + ); } #[test] diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs index 505f11215..38718487d 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs @@ -51,13 +51,26 @@ fn positive_protocol_timeout_closes_transient_notifications() { } #[test] -fn resident_positive_timeout_only_hides_the_banner() { +fn resident_positive_timeout_still_expires_the_active_notification() { let config = Config::default(); let mut notification = make_notification("resident"); notification.expire_timeout = 30_000; notification.is_resident = true; let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 30_000); + assert_eq!(policy.active_close_after, Some(Duration::from_secs(30))); +} + +#[test] +fn critical_positive_timeout_hides_popup_without_expiring_active_notification() { + let config = Config::default(); + let mut notification = make_notification("critical bounded popup"); + notification.expire_timeout = 30_000; + notification.urgency = Urgency::Critical; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 30_000); assert_eq!(policy.active_close_after, None); } @@ -99,3 +112,20 @@ fn transient_default_timeout_closes_without_history_by_default() { Some(Duration::from_millis(config.popups.default_timeout_ms)) ); } + +#[test] +fn resident_transient_default_timeout_uses_time_policy_independent_of_actions() { + let config = Config::default(); + let mut notification = make_notification("resident transient"); + notification.expire_timeout = -1; + notification.is_transient = true; + notification.is_resident = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!( + policy.active_close_after, + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/timeout.rs index b6cc5218a..a41b11125 100644 --- a/crates/unixnotis-daemon/src/store/notifications/timeout.rs +++ b/crates/unixnotis-daemon/src/store/notifications/timeout.rs @@ -29,19 +29,23 @@ pub(super) fn resolve_timeout_policy( popup_hide_after_ms: 0, active_close_after: None, }, - // Positive protocol values close every non-resident notification + // Positive protocol values close normal notifications regardless of resident state timeout if timeout > 0 => { let timeout_ms = timeout as u64; ResolvedTimeoutPolicy { popup_hide_after_ms: timeout_ms, - active_close_after: (!notification.is_resident) + // `resident` controls post-action dismissal. It does not override the + // notification's explicit expiration timeout + active_close_after: (notification.urgency != Urgency::Critical) .then(|| Duration::from_millis(timeout_ms)), } } // The default protocol value uses UnixNotis display policy _ => { - let active_close_after = if notification.is_transient - && !notification.is_resident + // Critical popup visibility and active-notification lifetime are separate + // Critical alerts may leave the screen, but stay active until explicitly closed + let active_close_after = if notification.urgency != Urgency::Critical + && notification.is_transient && configured_popup_ms > 0 { Some(Duration::from_millis(configured_popup_ms)) diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index c111b0ac5..eb6103697 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::{Duration, Instant}; use indexmap::IndexMap; use tracing::{debug, warn}; @@ -10,7 +11,7 @@ use unixnotis_core::{ }; use super::dnd::{DndStateStore, DND_STATE_VERSION}; -use super::model::{DeliveryStageUpdate, NotificationStore}; +use super::model::{DeliveryStageUpdate, NotificationStore, PopupTiming}; use super::notifications::HistoryStore; impl NotificationStore { @@ -74,6 +75,7 @@ impl NotificationStore { active: IndexMap::new(), history: HistoryStore::new(), popup_decisions: HashMap::new(), + popup_timings: HashMap::new(), expirations: HashMap::new(), dnd_state_store, next_inhibitor_id: 1, @@ -117,6 +119,7 @@ impl NotificationStore { } pub fn list_popup_candidates(&self) -> Vec { + let now = Instant::now(); // Newest-first ordering matches ListActive while excluding persistent no-popup rules self.active .values() @@ -131,9 +134,10 @@ impl NotificationStore { decision.admission_at_commit, PopupAdmissionView::Show | PopupAdmissionView::RendererUnavailable ) && decision.delivery_stage.rank() < PopupDeliveryStage::Visible.rank() + && self.popup_deadline_is_current(notification.key(), now) }) }) - .map(|notification| self.list_view_with_popup_decision(notification)) + .map(|notification| self.list_view_with_popup_timing(notification, now)) .collect() } @@ -146,6 +150,7 @@ impl NotificationStore { } pub fn popup_candidate(&mut self, id: u32) -> Option { + let now = Instant::now(); // Payload and its arrival-time policy are read from one store-lock snapshot let notification = self.active.get(&id)?; let key = notification.key(); @@ -155,8 +160,13 @@ impl NotificationStore { if decision.delivery_stage.rank() >= PopupDeliveryStage::Visible.rank() { return None; } + // Popup lifetime begins at daemon admission, not renderer availability + // Renderer downtime must never make stale content a fresh full-duration popup + if !self.popup_deadline_is_current(key, now) { + return None; + } let admission = decision.admission_at_commit; - let view = self.view_with_popup_decision(notification); + let view = self.view_with_popup_timing(notification, now); if admission.should_show() { self.record_popup_delivery_stage(key, PopupDeliveryStage::RendererFetched); } @@ -191,12 +201,13 @@ impl NotificationStore { }) } - pub(crate) fn record_popup_commit_environment( + pub(super) fn record_popup_commit_environment_at( &mut self, key: NotificationKey, admission: super::PopupAdmission, ui_health: &UiHealth, popup_hide_after_ms: u64, + admitted_at: Instant, ) { let max_visible = u32::try_from(self.config.popups.max_visible).unwrap_or(u32::MAX); let effective_admission = if !admission.should_show() { @@ -226,6 +237,16 @@ impl NotificationStore { popup_hide_after_ms, }, ); + let deadline = if popup_hide_after_ms == 0 { + None + } else { + Some( + admitted_at + .checked_add(Duration::from_millis(popup_hide_after_ms)) + .unwrap_or(admitted_at), + ) + }; + self.popup_timings.insert(key, PopupTiming { deadline }); } pub fn record_popup_delivery_stage( @@ -251,6 +272,12 @@ impl NotificationStore { .is_some_and(|notification| notification.generation == key.generation) || self.history.contains_generation(*key) }); + self.popup_timings.retain(|key, _timing| { + self.active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation) + || self.history.contains_generation(*key) + }); } fn view_with_popup_decision(&self, notification: &Notification) -> NotificationView { @@ -271,6 +298,46 @@ impl NotificationStore { view } + pub(super) fn popup_deadline_is_current(&self, key: NotificationKey, now: Instant) -> bool { + self.popup_timings + .get(&key) + .is_some_and(|timing| timing.deadline.is_none_or(|deadline| now < deadline)) + } + + fn view_with_popup_timing( + &self, + notification: &Notification, + now: Instant, + ) -> NotificationView { + let mut view = self.view_with_popup_decision(notification); + view.popup_hide_after_ms = self.remaining_popup_ms(notification.key(), now); + view + } + + fn list_view_with_popup_timing( + &self, + notification: &Notification, + now: Instant, + ) -> NotificationView { + let mut view = self.list_view_with_popup_decision(notification); + view.popup_hide_after_ms = self.remaining_popup_ms(notification.key(), now); + view + } + + fn remaining_popup_ms(&self, key: NotificationKey, now: Instant) -> u64 { + let Some(timing) = self.popup_timings.get(&key) else { + return 0; + }; + let Some(deadline) = timing.deadline else { + return 0; + }; + let remaining = deadline.saturating_duration_since(now); + // Sub-millisecond positive durations must not become the renderer's no-timeout sentinel + u64::try_from(remaining.as_millis()) + .unwrap_or(u64::MAX) + .max(1) + } + pub fn active_inline_reply_target( &self, id: u32, diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs index 8d87376c1..fe888baf8 100644 --- a/crates/unixnotis-daemon/src/store/test_support.rs +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -56,7 +56,7 @@ pub(in crate::store) fn make_notification(summary: &str) -> Notification { } } -pub(in crate::store) fn make_notification_with_sender( +pub fn make_notification_with_sender( summary: &str, sender: &str, pid: u32, diff --git a/crates/unixnotis-daemon/src/store/tests/mod.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs index af9952687..db5908f25 100644 --- a/crates/unixnotis-daemon/src/store/tests/mod.rs +++ b/crates/unixnotis-daemon/src/store/tests/mod.rs @@ -1,2 +1,3 @@ mod model; mod runtime; +mod support; diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs index ad1d19f05..0c0adc6aa 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -23,7 +23,7 @@ fn active_action_target_requires_an_exact_action_on_the_live_generation() { key: "open".to_string(), label: "Open".to_string(), }); - let original = store.insert(notification, 0).notification; + let original = store.insert(notification, 0).active_notification(); let id = original.id; let key = original.key(); @@ -82,7 +82,7 @@ fn active_action_target_denies_every_unverified_sender_class() { key: "default".to_string(), label: "Open".to_string(), }); - let key = store.insert(notification, 0).notification.key(); + let key = store.insert(notification, 0).active_notification().key(); assert!( store @@ -118,7 +118,7 @@ fn native_association_allows_default_but_requires_confirmation_for_buttons() { label: "Archive".to_string(), }, ]; - let key = store.insert(notification, 0).notification.key(); + let key = store.insert(notification, 0).active_notification().key(); assert!( store @@ -165,7 +165,7 @@ fn portal_association_requires_confirmation_for_default_and_buttons() { label: "Open".to_string(), }, ]; - let key = store.insert(notification, 0).notification.key(); + let key = store.insert(notification, 0).active_notification().key(); for action_key in ["default", "open"] { assert!( @@ -204,7 +204,7 @@ fn active_action_target_rejects_inline_reply_even_when_confirmed() { key: "open".to_string(), label: "Open".to_string(), }); - let key = store.insert(notification, 0).notification.key(); + let key = store.insert(notification, 0).active_notification().key(); assert!( store diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs index 33412a868..409390652 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs @@ -20,9 +20,9 @@ fn active_notification_view_returns_current_active_payload() { let outcome = store.insert(make_notification("visible"), 0); let view = store - .active_notification_view(outcome.notification.id) + .active_notification_view(outcome.active_notification().id) .expect("active notification should be visible"); - assert_eq!(view.id, outcome.notification.id); + assert_eq!(view.id, outcome.active_notification().id); assert_eq!(view.summary, "visible"); } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs index aaf33c271..9c437960a 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs @@ -8,7 +8,9 @@ use crate::store::test_support::{make_notification, make_store_with_limits}; #[test] fn active_inline_reply_target_requires_a_live_explicit_reply_action() { let mut store = make_store_with_limits(12, 20); - let ordinary = store.insert(make_notification("ordinary"), 0).notification; + let ordinary = store + .insert(make_notification("ordinary"), 0) + .active_notification(); let mut reply = make_notification("reply"); reply.inline_reply = InlineReply { available: true, @@ -19,7 +21,7 @@ fn active_inline_reply_target_requires_a_live_explicit_reply_action() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let reply = store.insert(reply, 0).notification; + let reply = store.insert(reply, 0).active_notification(); assert!(store .active_inline_reply_target(ordinary.id, ordinary.generation) @@ -44,7 +46,7 @@ fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { label: "Reply".to_string(), }); reply.is_resident = true; - let reply = store.insert(reply, 0).notification; + let reply = store.insert(reply, 0).active_notification(); assert!( store @@ -74,7 +76,7 @@ fn inline_reply_metadata_without_the_protocol_action_is_rejected() { let mut store = make_store_with_limits(12, 20); let mut malformed = make_notification("metadata only"); malformed.inline_reply.available = true; - let malformed = store.insert(malformed, 0).notification; + let malformed = store.insert(malformed, 0).active_notification(); assert!(store .active_inline_reply_target(malformed.id, malformed.generation) @@ -91,7 +93,7 @@ fn inline_reply_policy_denies_a_complete_reply_action() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let notification = store.insert(notification, 0).notification; + let notification = store.insert(notification, 0).active_notification(); assert!(store .active_inline_reply_target(notification.id, notification.generation) @@ -119,7 +121,7 @@ fn native_association_denies_reply_even_if_protocol_metadata_claims_allow() { key: "inline-reply".to_string(), label: "Reply".to_string(), }); - let notification = store.insert(notification, 0).notification; + let notification = store.insert(notification, 0).active_notification(); assert!( store diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs index 5a7ffdd14..aaa2eb7c5 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs @@ -5,8 +5,12 @@ use crate::store::test_support::{make_notification, make_store_with_limits}; #[test] fn clear_all_removes_active_history_and_expiration_state_together() { let mut store = make_store_with_limits(10, 10); - let active = store.insert(make_notification("active"), 0).notification; - let archived = store.insert(make_notification("archived"), 0).notification; + let active = store + .insert(make_notification("active"), 0) + .active_notification(); + let archived = store + .insert(make_notification("archived"), 0) + .active_notification(); store.close(archived.id, CloseReason::Expired); store.set_expiration(&active, Some(std::time::Instant::now())); diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs index 6beed11f8..bc3ef11b6 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use unixnotis_core::{CloseReason, Config, PopupAdmissionView}; use crate::store::test_support::{make_notification, make_store_with_limits}; @@ -6,10 +8,12 @@ use crate::store::NotificationStore; #[test] fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("allowed"), 0).notification; + let original = store + .insert(make_notification("allowed"), 0) + .active_notification(); let mut suppressed = make_notification("rule suppressed"); suppressed.suppress_popup = true; - let replacement = store.insert(suppressed, original.id).notification; + let replacement = store.insert(suppressed, original.id).active_notification(); let candidate = store .popup_candidate(original.id) @@ -23,11 +27,13 @@ fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { #[test] fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("allowed"), 0).notification; + let original = store + .insert(make_notification("allowed"), 0) + .active_notification(); store.set_dnd(true); let replacement = store .insert(make_notification("dnd suppressed"), original.id) - .notification; + .active_notification(); let candidate = store .popup_candidate(original.id) @@ -41,7 +47,9 @@ fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { #[test] fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() { let mut store = make_store_with_limits(10, 10); - let visible = store.insert(make_notification("visible"), 0).notification; + let visible = store + .insert(make_notification("visible"), 0) + .active_notification(); let unavailable = store .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) .expect("active notification diagnostics"); @@ -56,7 +64,7 @@ fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() store.set_dnd(true); let dnd_suppressed = store .insert(make_notification("DND suppressed"), 0) - .notification; + .active_notification(); store.set_dnd(false); let ready = unixnotis_core::UiHealth { popups_process_running: true, @@ -86,7 +94,9 @@ fn notification_diagnostics_require_both_renderer_process_and_readiness() { popups_ready: ready, ..unixnotis_core::UiHealth::default() }; - let visible = store.insert(make_notification("visible"), 0).notification; + let visible = store + .insert(make_notification("visible"), 0) + .active_notification(); store.record_popup_commit_environment( visible.key(), crate::store::PopupAdmission::Show, @@ -115,7 +125,7 @@ fn popup_diagnostics_keep_the_readiness_revision_sampled_at_commit() { }; let notification = store .insert_with_ui_health(make_notification("revision"), 0, &health) - .notification; + .active_notification(); let diagnostics = store .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) @@ -129,7 +139,9 @@ fn disabled_popups_are_recorded_when_max_visible_is_zero() { let mut config = Config::default(); config.popups.max_visible = 0; let mut store = NotificationStore::new(config); - let notification = store.insert(make_notification("disabled"), 0).notification; + let notification = store + .insert(make_notification("disabled"), 0) + .active_notification(); let ready = unixnotis_core::UiHealth { popups_process_running: true, popups_ready: true, @@ -159,7 +171,7 @@ fn archived_notification_keeps_its_arrival_popup_explanation() { store.set_dnd(true); let notification = store .insert(make_notification("archived DND"), 0) - .notification; + .active_notification(); store.close(notification.id, CloseReason::Expired); store.set_dnd(false); @@ -174,7 +186,9 @@ fn archived_notification_keeps_its_arrival_popup_explanation() { #[test] fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); let ready = unixnotis_core::UiHealth { popups_process_running: true, popups_ready: true, @@ -220,7 +234,7 @@ fn visible_popup_candidate_cannot_be_fetched_again_after_reconnect() { let mut store = make_store_with_limits(10, 10); let notification = store .insert(make_notification("visible once"), 0) - .notification; + .active_notification(); assert!(store.popup_candidate(notification.id).is_some()); assert_eq!( @@ -239,7 +253,9 @@ fn visible_popup_candidate_cannot_be_fetched_again_after_reconnect() { #[test] fn delivery_stage_never_moves_backward() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); assert_eq!( store.record_popup_delivery_stage( @@ -269,7 +285,9 @@ fn delivery_stage_never_moves_backward() { #[test] fn duplicate_popup_stage_acknowledgement_is_idempotent() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("delivery"), 0).notification; + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); assert_eq!( store.record_popup_delivery_stage( @@ -291,10 +309,12 @@ fn duplicate_popup_stage_acknowledgement_is_idempotent() { #[test] fn popup_stage_acknowledgement_rejects_a_missing_generation() { let mut store = make_store_with_limits(10, 10); - let original = store.insert(make_notification("original"), 0).notification; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); let _replacement = store .insert(make_notification("replacement"), original.id) - .notification; + .active_notification(); assert_eq!( store.record_popup_delivery_stage( @@ -317,7 +337,7 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( let mut rule_suppressed = make_notification("persistent suppression"); rule_suppressed.suppress_popup = true; - let rule_suppressed = store.insert(rule_suppressed, 0).notification; + let rule_suppressed = store.insert(rule_suppressed, 0).active_notification(); store.record_popup_commit_environment( rule_suppressed.key(), crate::store::PopupAdmission::Show, @@ -327,7 +347,7 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( let arrival_suppressed = store .insert(make_notification("arrival suppression"), 0) - .notification; + .active_notification(); store.record_popup_commit_environment( arrival_suppressed.key(), crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), @@ -335,7 +355,9 @@ fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering( 0, ); - let admitted = store.insert(make_notification("admitted"), 0).notification; + let admitted = store + .insert(make_notification("admitted"), 0) + .active_notification(); store.record_popup_commit_environment( admitted.key(), crate::store::PopupAdmission::Show, @@ -353,7 +375,7 @@ fn visible_popup_generations_are_not_seeded_after_renderer_reconnect() { let mut store = make_store_with_limits(10, 10); let notification = store .insert(make_notification("already visible"), 0) - .notification; + .active_notification(); assert_eq!(store.list_popup_candidates().len(), 1); assert_eq!( @@ -373,7 +395,9 @@ fn visible_popup_generations_are_not_seeded_after_renderer_reconnect() { #[test] fn materialized_but_not_visible_popup_remains_eligible_for_reconnect_seed() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("overflow"), 0).notification; + let notification = store + .insert(make_notification("overflow"), 0) + .active_notification(); assert_eq!( store.record_popup_delivery_stage( @@ -385,25 +409,170 @@ fn materialized_but_not_visible_popup_remains_eligible_for_reconnect_seed() { assert_eq!(store.list_popup_candidates().len(), 1); } +#[test] +fn popup_renderer_outage_does_not_restart_an_expired_admission_deadline() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("renderer unavailable"), 0) + .active_notification(); + let unavailable = unixnotis_core::UiHealth::default(); + let admitted_at = Instant::now() + .checked_sub(Duration::from_millis(51)) + .expect("test admission instant should be representable"); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unavailable, + 50, + admitted_at, + ); + + assert!( + store.list_popup_candidates().is_empty(), + "an expired popup must not be seeded after renderer recovery" + ); + assert!( + store.popup_candidate(notification.id).is_none(), + "an expired popup must not receive a fresh timeout" + ); + assert_eq!( + store.list_active().len(), + 1, + "popup expiration must not destroy the active notification" + ); +} + +#[test] +fn popup_materialization_returns_only_the_remaining_admission_time() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("remaining deadline"), 0) + .active_notification(); + let admitted_at = Instant::now() + .checked_sub(Duration::from_millis(25)) + .expect("test admission instant should be representable"); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + 100, + admitted_at, + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("unexpired popup candidate"); + assert!( + (1..=75).contains(&candidate.notification.popup_hide_after_ms), + "materialization must return the remaining timeout" + ); +} + +#[test] +fn popup_deadline_is_expired_at_the_exact_admission_boundary() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("exact deadline"), 0) + .active_notification(); + let deadline = Instant::now(); + store.popup_timings.insert( + notification.key(), + crate::store::model::PopupTiming { + deadline: Some(deadline), + }, + ); + + assert!(!store.popup_deadline_is_current(notification.key(), deadline)); +} + +#[test] +fn popup_materialization_keeps_zero_as_the_no_automatic_hide_value() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("no automatic hide"), 0) + .active_notification(); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + 0, + Instant::now(), + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("indefinite popup should remain eligible"); + assert_eq!(candidate.notification.popup_hide_after_ms, 0); +} + #[test] fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("retained"), 0).notification; + let notification = store + .insert(make_notification("retained"), 0) + .active_notification(); assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); store.close(notification.id, CloseReason::Expired); assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); store.clear_history(); assert!(store.popup_decisions.is_empty()); + assert!(store.popup_timings.is_empty()); } #[test] fn action_dismissal_prunes_the_removed_generation_popup_decision() { let mut store = make_store_with_limits(10, 10); - let notification = store.insert(make_notification("actioned"), 0).notification; + let notification = store + .insert(make_notification("actioned"), 0) + .active_notification(); assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); assert!(store.dismiss_active_if_current(notification.id, ¬ification)); assert!(store.popup_decisions.is_empty()); + assert!(store.popup_timings.is_empty()); +} + +#[test] +fn popup_pruning_removes_a_stale_timing_for_a_same_id_replacement() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + store.popup_timings.insert( + original.key(), + crate::store::model::PopupTiming { deadline: None }, + ); + + store.prune_popup_decisions(); + + assert!(!store.popup_timings.contains_key(&original.key())); + assert!(store.popup_timings.contains_key(&replacement.key())); +} + +#[test] +fn popup_replacement_discards_the_prior_generation_timing_at_commit() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + + let retained_for_id = store + .popup_timings + .keys() + .filter(|key| key.id == original.id) + .copied() + .collect::>(); + assert_eq!(retained_for_id, [replacement.key()]); + assert!(!store.popup_timings.contains_key(&original.key())); } diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/tests/support.rs new file mode 100644 index 000000000..07f1365be --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/support.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use std::time::Instant; + +use unixnotis_core::{Notification, NotificationKey, UiHealth}; + +use crate::store::{CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission}; + +impl NotificationStore { + pub(crate) fn insert(&mut self, notification: Notification, replaces_id: u32) -> InsertOutcome { + // Store tests use a neutral renderer snapshot unless a case provides one explicitly + self.insert_with_ui_health(notification, replaces_id, &UiHealth::default()) + } + + pub(crate) fn record_popup_commit_environment( + &mut self, + key: NotificationKey, + admission: PopupAdmission, + ui_health: &UiHealth, + popup_hide_after_ms: u64, + ) { + self.record_popup_commit_environment_at( + key, + admission, + ui_health, + popup_hide_after_ms, + Instant::now(), + ); + } +} + +impl InsertOutcome { + pub(crate) fn active_notification(&self) -> Arc { + match &self.disposition { + CommitDisposition::Active(notification) => Arc::clone(notification), + CommitDisposition::SuppressedDropAll(_) => { + panic!("active insertion outcome must retain its notification") + } + } + } +} diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 1edb861d6..c4a9012f0 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -197,8 +197,8 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { let key = { let mut store = state.store.lock().await; let outcome = store.insert(make_notification("expires"), 0); - let key = outcome.notification.key(); - store.set_expiration(&outcome.notification, Some(deadline)); + let key = outcome.active_notification().key(); + store.set_expiration(&outcome.active_notification(), Some(deadline)); key }; @@ -236,14 +236,16 @@ async fn old_timer_never_closes_or_signals_for_same_id_replacement() { // Holding the store lock forces the expired worker to wait at its commit point let mut store = state.store.lock().await; - let original = store.insert(make_notification("original"), 0).notification; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); store.set_expiration(&original, Some(old_deadline)); scheduler.schedule(original.id, original.generation, Some(old_deadline)); tokio::time::sleep(Duration::from_millis(80)).await; let replacement = store .insert(make_notification("replacement"), original.id) - .notification; + .active_notification(); let replacement_deadline = Instant::now() + Duration::from_millis(250); store.set_expiration(&replacement, Some(replacement_deadline)); scheduler.schedule( From 18fbfadc6ed782000190c111387f3876425ba26e Mon Sep 17 00:00:00 2001 From: locainin Date: Sat, 8 Aug 2026 12:44:43 -0500 Subject: [PATCH 255/275] fix(installer): recover runtime masks and report incompatible config Treat session-only systemd masks as recoverable during explicit installs while preserving persistent masks and checking for package-owned artifacts. Clear runtime masks immediately before service activation, surface the config v5 clean-break requirement without exposing parser details, and derive test home layouts without hardcoded account paths. --- .../src/actions/config/provision.rs | 14 +- .../src/actions/config/tests/provision.rs | 33 ++ .../src/actions/install/service/lifecycle.rs | 12 + .../src/actions/install/tests/service/flow.rs | 1 + .../service/flow_failures/systemd_dinit.rs | 33 ++ .../src/actions/installation_channel.rs | 119 ++++++- .../src/actions/tests/installation_channel.rs | 318 +++++++++++++++--- .../src/service_manager/backends/systemd.rs | 9 + .../service_manager/backends/tests/systemd.rs | 8 + .../orchestration/lifecycle.rs | 8 + .../orchestration/tests/lifecycle.rs | 20 ++ .../src/trial/tests/launch.rs | 21 +- 12 files changed, 524 insertions(+), 72 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index 9c8134d87..c4f5f734c 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -8,7 +8,8 @@ use unixnotis_core::{ filesystem::open_regular_file, filesystem::{create_directory_all, write_file_atomic, write_file_if_missing, ContainedPath}, render_default_config_toml, reset_config_to_defaults, Config, ResetConfigOptions, - DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, + CURRENT_CONFIG_VERSION, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, + DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, }; use crate::paths::format_with_home; @@ -31,9 +32,14 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { ); // Existing theme paths are part of the configuration contract - Config::load_from_path(&config_path) - .map_err(|error| anyhow!(error.to_string())) - .context("load existing configuration before provisioning theme files")? + Config::load_from_path(&config_path).map_err(|error| { + // Parser details may contain private config text, so only a stable summary is shown + anyhow!( + "existing configuration cannot be loaded ({}); schema v{} is required; use Reset config to back it up and create current defaults", + error.shareable_summary(), + CURRENT_CONFIG_VERSION + ) + })? } else { let config = Config::default(); // Write a default config so there is always a working base to edit diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index 99a46fc37..c4f7ca467 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -148,6 +148,39 @@ fn ensure_config_provisions_the_existing_configured_theme_paths() { let _ = fs::remove_dir_all(root); } +#[test] +fn ensure_config_rejects_v4_with_reset_guidance_and_preserves_the_file() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-v4-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("create legacy config directory"); + let legacy = current_config_text("").replacen("config_version = 5", "config_version = 4", 1); + fs::write(&config_path, &legacy).expect("write legacy config fixture"); + + let error = ensure_config(&mut context).expect_err("v4 config must remain a clean break"); + + assert_eq!( + error.to_string(), + "existing configuration cannot be loaded (Configuration TOML or schema is invalid); schema v5 is required; use Reset config to back it up and create current defaults" + ); + assert_eq!( + fs::read_to_string(&config_path).expect("read preserved legacy config"), + legacy, + "failed installation must not modify the legacy config" + ); + fs::remove_dir_all(root).expect("remove legacy config fixture"); +} + #[cfg(unix)] #[test] fn ensure_config_preserves_external_theme_files_without_creating_missing_or_unsafe_targets() { diff --git a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs index 06e95bc24..088b13c66 100644 --- a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs @@ -35,6 +35,18 @@ pub(in crate::actions::install) fn service_start_mode_from_enabled( } pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> Result<()> { + if let Some(spec) = ctx.paths.service.prepare_start_command() { + // A runtime mask is temporary session state and should not defeat explicit installation + log_line( + ctx, + format!( + "Clearing temporary service mask for {}", + ctx.paths.service.service_name() + ), + ); + run_command_spec(ctx, &spec).context("clear temporary service mask")?; + } + match service_start_mode(ctx) { ServiceStartMode::EnableAndStart => { // First install still needs the symlink creation done by `enable` diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs index 71be78d90..566ffd100 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs @@ -41,6 +41,7 @@ fn systemd_install_flow_runs_reload_env_import_and_enable() { "program=systemctl argv=[--user][unset-environment][DBUS_SESSION_BUS_ADDRESS]", "program=dbus-update-activation-environment argv=[WAYLAND_DISPLAY]", "program=systemctl argv=[--user][--no-pager][import-environment][WAYLAND_DISPLAY]", + "program=systemctl argv=[--user][--runtime][unmask][unixnotis-daemon.service]", "program=systemctl argv=[--user][enable][--now][unixnotis-daemon.service]", ], ); diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs index 27a85cfbd..03a0e7d17 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs @@ -79,3 +79,36 @@ fn dinit_install_fails_before_start_when_setenv_fails() { ); let _ = fs::remove_dir_all(&root); } + +#[test] +fn systemd_install_fails_before_enable_when_runtime_unmask_fails() { + let _lock = lock_env(); + let root = service_flow_root("install-fail-systemd-unmask"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let _failure = fake_failure_env("systemctl", "unmask"); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + + let error = run_install_and_enable(&paths).expect_err("runtime unmask should fail"); + + assert!(error.to_string().contains("clear temporary service mask")); + let calls = read_calls(&log_path); + assert!(calls + .iter() + .any(|call| call.contains("[--user][--runtime][unmask][unixnotis-daemon.service]"))); + assert!( + !calls.iter().any(|call| call.contains("[enable][--now]")), + "service enable must not run after a failed runtime unmask" + ); + fs::remove_dir_all(root).expect("remove systemd unmask failure fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/installation_channel.rs b/crates/unixnotis-installer/src/actions/installation_channel.rs index cef82b195..d20fc4efe 100644 --- a/crates/unixnotis-installer/src/actions/installation_channel.rs +++ b/crates/unixnotis-installer/src/actions/installation_channel.rs @@ -1,5 +1,7 @@ //! Active systemd unit channel classification for source-install safety +use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; @@ -18,35 +20,75 @@ enum InstallationChannel { Unknown, } +#[derive(Debug, Eq, PartialEq)] +enum ActiveUnitMetadata { + // No loaded unit leaves the home-local channel available + Absent, + // Session-only masks are safe to clear during an explicit installation + RuntimeMasked, + // Persistent masks reflect a user decision and must remain untouched + PersistentMasked, + // Loaded units retain both paths so mixed channels cannot pass unnoticed + Paths { + fragment: PathBuf, + executable: PathBuf, + }, +} + pub(super) fn reject_conflicting_installation_channel(ctx: &mut ActionContext) -> Result<()> { if !ctx.paths.service.is_systemd() { return Ok(()); } - let Some((fragment, executable)) = active_unit_paths()? else { - return Ok(()); + let metadata = active_unit_metadata()?; + let paths = match metadata { + ActiveUnitMetadata::Absent => return Ok(()), + ActiveUnitMetadata::RuntimeMasked => { + // A temporary mask can hide a package unit, so inspect its fixed artifacts first + let Some(paths) = installed_system_package_paths_at( + Path::new(SYSTEM_UNIT_ROOT), + Path::new(SYSTEM_BINARY_ROOT), + )? + else { + return Ok(()); + }; + paths + } + ActiveUnitMetadata::PersistentMasked => { + bail!( + "UnixNotis systemd unit is persistently masked; run `systemctl --user unmask unixnotis-daemon.service` before installing" + ) + } + ActiveUnitMetadata::Paths { + fragment, + executable, + } => (fragment, executable), }; + reject_channel(ctx, &paths.0, &paths.1) +} + +fn reject_channel(ctx: &mut ActionContext, fragment: &Path, executable: &Path) -> Result<()> { let channel = classify_installation_channel( - &fragment, - &executable, + fragment, + executable, ctx.paths.service.artifact_root(), &ctx.paths.bin_dir, ); match channel { InstallationChannel::HomeLocal => Ok(()), InstallationChannel::SystemPackage => { - log_channel_conflict(ctx, "system package", &fragment, &executable); + log_channel_conflict(ctx, "system package", fragment, executable); bail!( "the system-package UnixNotis installation must be removed with its package manager before a home-local install" ) } InstallationChannel::Mixed => { - log_channel_conflict(ctx, "mixed", &fragment, &executable); + log_channel_conflict(ctx, "mixed", fragment, executable); bail!( "mixed UnixNotis installation channels detected; repair the unit and executable paths before installing" ) } InstallationChannel::Unknown => { - log_channel_conflict(ctx, "unrecognized", &fragment, &executable); + log_channel_conflict(ctx, "unrecognized", fragment, executable); bail!( "the active UnixNotis unit uses an unrecognized installation channel; automatic replacement is unsafe" ) @@ -54,12 +96,14 @@ pub(super) fn reject_conflicting_installation_channel(ctx: &mut ActionContext) - } } -fn active_unit_paths() -> Result> { +fn active_unit_metadata() -> Result { let mut command = crate::system_tools::command("systemctl")?; command.args([ "--user", "show", "unixnotis-daemon.service", + "--property=LoadState", + "--property=UnitFileState", "--property=FragmentPath", "--property=ExecStart", "--no-pager", @@ -68,25 +112,70 @@ fn active_unit_paths() -> Result> { .output() .context("inspect active UnixNotis systemd unit")?; if !output.status.success() { - return Ok(None); + return Ok(ActiveUnitMetadata::Absent); } if output.stdout.len() > MAX_SYSTEMCTL_OUTPUT_BYTES { bail!("systemctl unit metadata exceeded the safe output limit"); } let text = String::from_utf8(output.stdout).context("systemctl unit metadata was not UTF-8")?; - let fragment = property_value(&text, "FragmentPath").map(PathBuf::from); - let executable = property_value(&text, "ExecStart") + parse_active_unit_metadata(&text) +} + +fn parse_active_unit_metadata(text: &str) -> Result { + match property_value(text, "LoadState") { + Some("not-found") => return Ok(ActiveUnitMetadata::Absent), + Some("masked") => { + return if property_value(text, "UnitFileState") == Some("masked-runtime") { + Ok(ActiveUnitMetadata::RuntimeMasked) + } else { + Ok(ActiveUnitMetadata::PersistentMasked) + }; + } + Some("loaded") => {} + Some(state) => bail!("systemctl reported unusable UnixNotis unit load state {state}"), + None => bail!("systemctl omitted UnixNotis unit load state metadata"), + } + + let fragment = property_value(text, "FragmentPath").map(PathBuf::from); + let executable = property_value(text, "ExecStart") .and_then(parse_exec_start_path) .map(PathBuf::from); match (fragment, executable) { - (Some(fragment), Some(executable)) if !fragment.as_os_str().is_empty() => { - Ok(Some((fragment, executable))) - } - (None, None) => Ok(None), + (Some(fragment), Some(executable)) => Ok(ActiveUnitMetadata::Paths { + fragment, + executable, + }), _ => bail!("systemctl returned incomplete UnixNotis unit path metadata"), } } +fn installed_system_package_paths_at( + unit_root: &Path, + binary_root: &Path, +) -> Result> { + // Fixed package locations remain visible even when systemd reports only a runtime mask + let fragment = unit_root.join("unixnotis-daemon.service"); + let executable = binary_root.join("unixnotis-daemon"); + let fragment_exists = path_entry_exists(&fragment)?; + let executable_exists = path_entry_exists(&executable)?; + match (fragment_exists, executable_exists) { + (false, false) => Ok(None), + (true, true) => Ok(Some((fragment, executable))), + _ => bail!( + "incomplete system-package UnixNotis artifacts detected; repair or remove the package before installing" + ), + } +} + +fn path_entry_exists(path: &Path) -> Result { + // Metadata on the directory entry detects dangling links without following them + match fs::symlink_metadata(path) { + Ok(_metadata) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + fn property_value<'a>(text: &'a str, name: &str) -> Option<&'a str> { text.lines() .find_map(|line| line.strip_prefix(name)?.strip_prefix('=')) diff --git a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs index 88e94da48..c89c3f358 100644 --- a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs +++ b/crates/unixnotis-installer/src/actions/tests/installation_channel.rs @@ -1,29 +1,84 @@ -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; use super::{ - classify_installation_channel, parse_exec_start_path, property_value, InstallationChannel, + active_unit_metadata, classify_installation_channel, installed_system_package_paths_at, + parse_active_unit_metadata, parse_exec_start_path, path_entry_exists, property_value, + reject_channel, reject_conflicting_installation_channel, ActiveUnitMetadata, + InstallationChannel, SYSTEM_BINARY_ROOT, SYSTEM_UNIT_ROOT, }; +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; -const HOME_UNITS: &str = "/home/user/.config/systemd/user"; -const HOME_BIN: &str = "/home/user/.local/bin"; +struct TestHomeLayout { + unit_root: PathBuf, + binary_root: PathBuf, +} + +fn test_home_layout(label: &str) -> TestHomeLayout { + // Each test gets an isolated home layout instead of assuming an account path + let home = crate::test_support::fs::unique_temp_path(label).join("home"); + TestHomeLayout { + unit_root: home.join(".config").join("systemd").join("user"), + binary_root: home.join(".local").join("bin"), + } +} + +fn test_context(root: &Path) -> (Detection, InstallPaths) { + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + (detection, paths) +} + +fn action_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(32); + ActionContext { + detection, + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} #[test] fn matching_home_and_system_paths_select_one_installation_channel() { + let home = test_home_layout("installation-channel-matching"); assert_eq!( classify_installation_channel( - Path::new("/home/user/.config/systemd/user/unixnotis-daemon.service"), - Path::new("/home/user/.local/bin/unixnotis-daemon"), - Path::new(HOME_UNITS), - Path::new(HOME_BIN), + &home.unit_root.join("unixnotis-daemon.service"), + &home.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, ), InstallationChannel::HomeLocal ); assert_eq!( classify_installation_channel( - Path::new("/usr/lib/systemd/user/unixnotis-daemon.service"), - Path::new("/usr/bin/unixnotis-daemon"), - Path::new(HOME_UNITS), - Path::new(HOME_BIN), + &Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"), + &Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, ), InstallationChannel::SystemPackage ); @@ -31,23 +86,14 @@ fn matching_home_and_system_paths_select_one_installation_channel() { #[test] fn crossed_unit_and_binary_paths_are_always_mixed() { - for (unit, binary) in [ - ( - "/home/user/.config/systemd/user/unixnotis-daemon.service", - "/usr/bin/unixnotis-daemon", - ), - ( - "/usr/lib/systemd/user/unixnotis-daemon.service", - "/home/user/.local/bin/unixnotis-daemon", - ), - ] { + let home = test_home_layout("installation-channel-crossed"); + let home_unit = home.unit_root.join("unixnotis-daemon.service"); + let home_binary = home.binary_root.join("unixnotis-daemon"); + let system_unit = Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"); + let system_binary = Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"); + for (unit, binary) in [(&home_unit, &system_binary), (&system_unit, &home_binary)] { assert_eq!( - classify_installation_channel( - Path::new(unit), - Path::new(binary), - Path::new(HOME_UNITS), - Path::new(HOME_BIN), - ), + classify_installation_channel(unit, binary, &home.unit_root, &home.binary_root,), InstallationChannel::Mixed ); } @@ -55,12 +101,14 @@ fn crossed_unit_and_binary_paths_are_always_mixed() { #[test] fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { + let root = crate::test_support::fs::unique_temp_path("installation-channel-custom"); + let home = test_home_layout("installation-channel-custom-home"); assert_eq!( classify_installation_channel( - Path::new("/opt/systemd/user/unixnotis-daemon.service"), - Path::new("/opt/unixnotis/bin/unixnotis-daemon"), - Path::new(HOME_UNITS), - Path::new(HOME_BIN), + &root.join("custom-units").join("unixnotis-daemon.service"), + &root.join("custom-bin").join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, ), InstallationChannel::Unknown ); @@ -68,31 +116,213 @@ fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { #[test] fn systemd_exec_start_parser_reads_only_the_structured_path_field() { - assert_eq!( - parse_exec_start_path( - "{ path=/home/user/.local/bin/unixnotis-daemon ; argv[]=/home/user/.local/bin/unixnotis-daemon ; ignore_errors=no ; }" - ), - Some("/home/user/.local/bin/unixnotis-daemon") - ); + let home = test_home_layout("exec-start-parser"); + let executable = home.binary_root.join("unixnotis-daemon"); + let executable = executable.to_string_lossy(); + let metadata = format!("{{ path={executable} ; argv[]={executable} ; ignore_errors=no ; }}"); + assert_eq!(parse_exec_start_path(&metadata), Some(executable.as_ref())); assert_eq!(parse_exec_start_path("argv[]=/tmp/fake"), None); } #[test] fn systemd_property_parser_requires_an_exact_nonempty_key() { - let output = "FragmentPath=/home/user/unit\nExecStart={ path=/home/user/bin ; }\n"; + let home = test_home_layout("property-parser"); + let fragment = home.unit_root.join("unixnotis-daemon.service"); + let executable = home.binary_root.join("unixnotis-daemon"); + let output = format!( + "FragmentPath={}\nExecStart={{ path={} ; }}\n", + fragment.display(), + executable.display() + ); assert_eq!( - property_value(output, "FragmentPath"), - Some("/home/user/unit") + property_value(&output, "FragmentPath"), + Some(fragment.to_string_lossy().as_ref()) ); assert_eq!( - property_value(output, "ExecStart"), - Some("{ path=/home/user/bin ; }") + property_value(&output, "ExecStart"), + Some(format!("{{ path={} ; }}", executable.display()).as_str()) ); - assert_eq!(property_value(output, "Path"), None); + assert_eq!(property_value(&output, "Path"), None); assert_eq!(property_value("FragmentPath=\n", "FragmentPath"), None); assert_eq!( property_value("FragmentPathx=/tmp/wrong\n", "FragmentPath"), None ); } + +#[test] +fn systemd_unit_metadata_accepts_loaded_units_and_absent_units() { + let home = test_home_layout("loaded-unit-metadata"); + let fragment = home.unit_root.join("unixnotis-daemon.service"); + let executable = home.binary_root.join("unixnotis-daemon"); + let loaded = format!( + "LoadState=loaded\nUnitFileState=enabled\nFragmentPath={}\nExecStart={{ path={} ; }}\n", + fragment.display(), + executable.display() + ); + assert_eq!( + parse_active_unit_metadata(&loaded).expect("loaded metadata should parse"), + ActiveUnitMetadata::Paths { + fragment, + executable, + } + ); + + let absent = "LoadState=not-found\nUnitFileState=\nFragmentPath=\nExecStart=\n"; + assert_eq!( + parse_active_unit_metadata(absent).expect("an absent unit should not be active"), + ActiveUnitMetadata::Absent + ); +} + +#[test] +fn runtime_mask_is_recoverable_during_explicit_installation() { + let masked = "LoadState=masked\nUnitFileState=masked-runtime\nFragmentPath=\nExecStart=\n"; + + assert_eq!( + parse_active_unit_metadata(masked).expect("runtime mask metadata should parse"), + ActiveUnitMetadata::RuntimeMasked + ); +} + +#[test] +fn persistent_mask_remains_distinct_from_temporary_state() { + let masked = "LoadState=masked\nUnitFileState=masked\nFragmentPath=\nExecStart=\n"; + + assert_eq!( + parse_active_unit_metadata(masked).expect("persistent mask metadata should parse"), + ActiveUnitMetadata::PersistentMasked + ); +} + +#[test] +fn loaded_unit_still_requires_complete_channel_metadata() { + let home = test_home_layout("incomplete-unit-metadata"); + let incomplete = format!( + "LoadState=loaded\nUnitFileState=disabled\nFragmentPath={}\nExecStart=\n", + home.unit_root.join("unixnotis-daemon.service").display() + ); + let error = parse_active_unit_metadata(&incomplete) + .expect_err("loaded units require an executable path"); + + assert_eq!( + error.to_string(), + "systemctl returned incomplete UnixNotis unit path metadata" + ); +} + +#[test] +fn package_artifact_probe_distinguishes_complete_absent_and_partial_installs() { + let root = crate::test_support::fs::unique_temp_path("package-artifact-probe"); + let unit_root = root.join("units"); + let binary_root = root.join("bin"); + fs::create_dir_all(&unit_root).expect("create test unit root"); + fs::create_dir_all(&binary_root).expect("create test binary root"); + + assert_eq!( + installed_system_package_paths_at(&unit_root, &binary_root) + .expect("missing package artifacts should be accepted"), + None + ); + + fs::write(unit_root.join("unixnotis-daemon.service"), "[Service]\n") + .expect("create package unit fixture"); + let partial = installed_system_package_paths_at(&unit_root, &binary_root) + .expect_err("partial package artifacts should fail closed"); + assert_eq!( + partial.to_string(), + "incomplete system-package UnixNotis artifacts detected; repair or remove the package before installing" + ); + + fs::write(binary_root.join("unixnotis-daemon"), []).expect("create package binary fixture"); + assert_eq!( + installed_system_package_paths_at(&unit_root, &binary_root) + .expect("complete package artifacts should be detected"), + Some(( + unit_root.join("unixnotis-daemon.service"), + binary_root.join("unixnotis-daemon") + )) + ); + fs::remove_dir_all(root).expect("remove package artifact fixture"); +} + +#[test] +fn path_entry_probe_propagates_errors_other_than_missing_paths() { + let root = crate::test_support::fs::unique_temp_path("package-artifact-probe-error"); + fs::write(&root, []).expect("create regular file fixture"); + + let error = path_entry_exists(&root.join("child")) + .expect_err("a child below a regular file must report its metadata error"); + + assert_ne!( + error + .downcast_ref::() + .map(std::io::Error::kind), + Some(std::io::ErrorKind::NotFound), + "non-missing metadata errors must remain distinguishable" + ); + fs::remove_file(root).expect("remove regular file fixture"); +} + +#[test] +fn systemctl_probe_returns_runtime_mask_metadata_without_dynamic_unit_paths() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("systemctl-runtime-mask"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'LoadState=masked' 'UnitFileState=masked-runtime' 'FragmentPath=' 'ExecStart='\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert_eq!( + active_unit_metadata().expect("systemctl metadata should be inspected"), + ActiveUnitMetadata::RuntimeMasked + ); + fs::remove_dir_all(root).expect("remove fake systemctl fixture"); +} + +#[test] +fn installation_channel_guard_rejects_a_persistent_mask_through_the_real_action_boundary() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("channel-guard-persistent-mask"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'LoadState=masked' 'UnitFileState=masked' 'FragmentPath=' 'ExecStart='\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let (detection, paths) = test_context(&root); + let mut context = action_context(&detection, &paths); + + let error = reject_conflicting_installation_channel(&mut context) + .expect_err("persistent mask must stop the real install check"); + + assert_eq!( + error.to_string(), + "UnixNotis systemd unit is persistently masked; run `systemctl --user unmask unixnotis-daemon.service` before installing" + ); + fs::remove_dir_all(root).expect("remove persistent mask fixture"); +} + +#[test] +fn conflict_dispatcher_rejects_system_package_paths() { + let root = crate::test_support::fs::unique_temp_path("channel-dispatch-package"); + let (detection, paths) = test_context(&root); + let mut context = action_context(&detection, &paths); + + let error = reject_channel( + &mut context, + &Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"), + &Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"), + ) + .expect_err("system package channel must stop home-local installation"); + + assert_eq!( + error.to_string(), + "the system-package UnixNotis installation must be removed with its package manager before a home-local install" + ); +} diff --git a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs index 91018fc54..9c7ffd6f8 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs @@ -68,6 +68,15 @@ pub fn reload_after_artifact_change() -> CommandSpec { ) } +pub fn clear_runtime_mask_command() -> CommandSpec { + // Explicit installation may clear only temporary state from the current login session + CommandSpec::new( + format!("systemctl --user --runtime unmask {SERVICE_NAME}"), + "systemctl", + ["--user", "--runtime", "unmask", SERVICE_NAME], + ) +} + pub fn enable_now_command() -> CommandSpec { CommandSpec::new( format!("systemctl --user enable --now {SERVICE_NAME}"), diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs index 0096539ae..53230a74b 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs @@ -106,6 +106,14 @@ fn systemd_backend_commands_match_existing_behavior() { &["--user", "enable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); + let prepare = manager + .prepare_start_command() + .expect("systemd should clear temporary masks before starting"); + assert_eq!( + prepare.args(), + &["--user", "--runtime", "unmask", UNIXNOTIS_DAEMON_SERVICE] + ); + let start = manager.start_command(); assert_eq!(start.args(), &["--user", "start", UNIXNOTIS_DAEMON_SERVICE]); diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs index 9f1817d40..add95c6cb 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs @@ -5,6 +5,14 @@ use super::super::contract::{CommandSpec, ServiceProbe}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { + pub fn prepare_start_command(&self) -> Option { + // Other managers have no temporary mask state to clear before an explicit start + match self.kind { + ServiceManagerKind::Systemd => Some(systemd::clear_runtime_mask_command()), + ServiceManagerKind::Dinit | ServiceManagerKind::Runit | ServiceManagerKind::S6 => None, + } + } + pub fn availability_command(&self) -> Option { // Availability checks must stay read-only and must not start a service match self.kind { diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs index 40e130671..8dfb54cc6 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs @@ -30,3 +30,23 @@ fn non_systemd_enablement_uses_owned_artifacts() { assert_eq!(systemd.enabled_by_artifacts(), None); assert_eq!(dinit.enabled_by_artifacts(), Some(false)); } + +#[test] +fn only_systemd_needs_temporary_start_state_cleanup() { + let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); + assert!( + systemd.prepare_start_command().is_some(), + "systemd should clear a runtime mask before an explicit start" + ); + + for manager in [ + ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")), + ServiceManager::runit_user(PathBuf::from("/tmp/runit")), + ServiceManager::s6_user(PathBuf::from("/tmp/s6"), PathBuf::from("/tmp/live")), + ] { + assert!( + manager.prepare_start_command().is_none(), + "non-systemd managers must not receive systemd mask cleanup" + ); + } +} diff --git a/crates/unixnotis-installer/src/trial/tests/launch.rs b/crates/unixnotis-installer/src/trial/tests/launch.rs index ffd98ad30..c781ed0f3 100644 --- a/crates/unixnotis-installer/src/trial/tests/launch.rs +++ b/crates/unixnotis-installer/src/trial/tests/launch.rs @@ -6,17 +6,20 @@ use crate::test_support::fs::write_executable; #[test] fn trial_launch_script_guards_cleanup_with_expected_symlink_target() { - let script = trial_launch_script( - "'/tmp/unixnotis-daemon'", - "'/home/user/.local/bin/noticenterctl'", - "'/tmp/target/debug/noticenterctl'", - ); + let root = crate::test_support::fs::unique_temp_path("trial-launch-script"); + let daemon_path = root.join("unixnotis-daemon"); + let shim_path = root.join("home").join(".local/bin/noticenterctl"); + let target_path = root.join("target/debug/noticenterctl"); + let daemon = shell_quote(&daemon_path.to_string_lossy()); + let shim = shell_quote(&shim_path.to_string_lossy()); + let target = shell_quote(&target_path.to_string_lossy()); + let script = trial_launch_script(&daemon, &shim, &target); // Signal-time cleanup must not be a blind rm of whatever is at the shim path - assert!(script.contains("[ -L '/home/user/.local/bin/noticenterctl' ]")); - assert!(script.contains("readlink -- '/home/user/.local/bin/noticenterctl'")); - assert!(script.contains("= '/tmp/target/debug/noticenterctl'")); - assert!(script.contains("rm -f -- '/home/user/.local/bin/noticenterctl'")); + assert!(script.contains(&format!("[ -L {shim} ]"))); + assert!(script.contains(&format!("readlink -- {shim}"))); + assert!(script.contains(&format!("= {target}"))); + assert!(script.contains(&format!("rm -f -- {shim}"))); } #[test] From 36001371c8d0d751560bd71400a46f794ed31bd6 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:18:37 -0500 Subject: [PATCH 256/275] fix(daemon): serialize notification interactions and admission Harden notification mutation and interaction boundaries against same-ID replacement races and unfair quota consumption. - serialize action, reply, close, and replacement commits by notification ID - revalidate exact notification generations before protocol signals and cleanup - make notify admission atomic across global and per-principal buckets - separate unauthorized close attempts from authorized close-commit budget - bound fresh-principal churn - enforce per-principal active notification limits - prevent one sender from monopolizing the global active set - add regression coverage for replacement races and quota accounting --- .../src/daemon/control/action.rs | 5 +- .../src/daemon/control/reply.rs | 14 + .../src/daemon/control/tests/action.rs | 56 ++- .../src/daemon/control/tests/reply.rs | 33 +- .../src/daemon/notifications/ingress/quota.rs | 207 +++++++++-- .../notifications/ingress/tests/quota.rs | 342 ++++++++++++++---- .../src/daemon/notifications/server/close.rs | 44 ++- .../src/daemon/notifications/server/flow.rs | 55 +-- .../daemon/notifications/server/interface.rs | 37 +- .../daemon/notifications/server/tests/flow.rs | 3 +- .../src/daemon/state/interaction_gates.rs | 34 ++ .../unixnotis-daemon/src/daemon/state/mod.rs | 3 + .../src/daemon/state/model.rs | 4 + .../src/daemon/state/notification_commit.rs | 36 ++ .../src/daemon/state/status.rs | 11 - .../src/daemon/state/tests/status.rs | 6 +- .../src/store/notifications/insertion.rs | 124 +++++-- .../src/store/notifications/ownership.rs | 14 +- .../store/notifications/tests/insertion.rs | 147 ++++++++ .../store/notifications/tests/ownership.rs | 37 +- 20 files changed, 978 insertions(+), 234 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/notification_commit.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs index 85b50d203..87d462508 100644 --- a/crates/unixnotis-daemon/src/daemon/control/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -37,6 +37,9 @@ impl ControlServer { F: FnOnce() -> Fut, Fut: Future, { + // The guard spans validation, destination lookup, signal delivery, and exact cleanup + // A same-ID replacement cannot commit while an ID-only protocol signal is in flight + let _interaction = self.state.interaction_gates.lock(notification.id).await; let target = { // Capture one concrete generation while validating the stored action identity let store = self.state.store.lock().await; @@ -58,7 +61,7 @@ impl ControlServer { .await .ok_or_else(application_unavailable_error)?; - // The test seam models replacement after the external liveness query + // The test seam models concurrent replacement pressure after external liveness work pre_emit().await; let is_current = self .state diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs index 5901090a4..13529f29d 100644 --- a/crates/unixnotis-daemon/src/daemon/control/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -40,6 +40,8 @@ impl ControlServer { { // Text validation happens before any notification lookup or signal work let reply_text = validate_reply_text(reply_text)?; + // Reply signals carry only a numeric ID, so replacement commits share this exact gate + let _interaction = self.state.interaction_gates.lock(id).await; let target = { // Keep the Arc so later cleanup can distinguish a same-ID replacement let store = self.state.store.lock().await; @@ -54,6 +56,18 @@ impl ControlServer { }; let destination = self.reply_destination(&target).await?; + let is_current = self + .state + .store + .lock() + .await + .is_active_notification_generation(id, &target); + if !is_current { + return Err(zbus::fdo::Error::InvalidArgs( + "notification changed before its reply could be submitted".to_string(), + )); + } + // A destination header keeps sensitive reply text visible only to its owning connection let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) .map_err(to_fdo_error)? diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs index 564cfa545..46d1fdf62 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; use chrono::Utc; @@ -171,12 +173,12 @@ async fn unconfirmed_action_does_not_emit_or_dismiss() { async fn validated_action_rejects_missing_and_stale_action_generations() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); - let (id, notification) = { + let notification = { let mut store = state.store.lock().await; let notification = store .insert(action_notification(&sender, "open"), 0) .active_notification(); - (notification.id, notification.key()) + notification.key() }; let server = ControlServer::new(state.clone()); @@ -184,21 +186,63 @@ async fn validated_action_rejects_missing_and_stale_action_generations() { .invoke_validated_action_generation(notification, "missing", false) .await .expect_err("unadvertised action must fail"); +} + +#[tokio::test] +async fn replacement_commit_waits_until_action_signal_is_emitted() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = action_signal_stream(&sender).await; + let mut original = action_notification(&sender, "open"); + original.is_resident = true; + let notification = state + .store + .lock() + .await + .insert(original, 0) + .active_notification() + .key(); + let id = notification.id; + let scheduler = crate::expire::ExpirationScheduler::start(state.clone()); let replacement_state = state.clone(); let replacement_sender = sender.clone(); - server + let (replacement_done_tx, replacement_done_rx) = tokio::sync::oneshot::channel(); + let replacement_committed = Arc::new(AtomicBool::new(false)); + let observed_replacement_commit = Arc::clone(&replacement_committed); + + ControlServer::new(state.clone()) .invoke_validated_action_generation_with_pre_emit( notification, "open", false, move || async move { let replacement = action_notification(&replacement_sender, "different"); - let outcome = replacement_state.store.lock().await.insert(replacement, id); - assert!(outcome.replaced); + let replacement_committed = Arc::clone(&replacement_committed); + tokio::spawn(async move { + let outcome = replacement_state + .commit_notification_generation(replacement, id, &scheduler) + .await; + let replaced = outcome.replaced; + let committed_id = outcome.active_notification().id; + replacement_committed.store(true, Ordering::Release); + let _sent = replacement_done_tx.send((committed_id, replaced)); + }); + tokio::task::yield_now().await; + assert!( + !observed_replacement_commit.load(Ordering::Acquire), + "replacement commit must remain blocked while the action signal is in flight" + ); }, ) .await - .expect_err("stale action generation must fail"); + .expect("action must finish before replacement commit"); + + assert_eq!(next_action_signal(&mut stream).await.0, id); + let (committed_id, replaced) = replacement_done_rx + .await + .expect("replacement commit task must finish"); + assert_eq!(committed_id, id); + assert!(replaced); } #[tokio::test] diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs index 190ebac79..496606b63 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; use chrono::Utc; @@ -41,6 +43,7 @@ fn validate_reply_text_preserves_unicode_and_bidirectional_content_exactly() { #[test] fn validate_reply_text_accepts_exact_byte_limit() { + assert_eq!(MAX_REPLY_TEXT_BYTES, 4_096); let reply = "🙂".repeat(MAX_REPLY_TEXT_BYTES / "🙂".len()); assert_eq!(reply.len(), MAX_REPLY_TEXT_BYTES); @@ -168,19 +171,23 @@ async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { } #[tokio::test] -async fn reply_listener_replacement_survives_generation_safe_dismissal() { +async fn replacement_commit_waits_until_reply_signal_is_emitted() { let state = daemon_state_for_test(false).await; let sender = Connection::session().await.expect("sender session bus"); let mut stream = reply_signal_stream(&state, &sender).await; let (id, generation) = { let mut store = state.store.lock().await; let notification = store - .insert(reply_notification(false, &sender), 0) + .insert(reply_notification(true, &sender), 0) .active_notification(); (notification.id, notification.generation) }; let replacement_state = state.clone(); let replacement_sender = sender.clone(); + let scheduler = crate::expire::ExpirationScheduler::start(state.clone()); + let (replacement_done_tx, replacement_done_rx) = tokio::sync::oneshot::channel(); + let replacement_committed = Arc::new(AtomicBool::new(false)); + let observed_replacement_commit = Arc::clone(&replacement_committed); ControlServer::new(state.clone()) .submit_inline_reply_with_post_emit(id, generation, "yes", move || async move { @@ -189,12 +196,30 @@ async fn reply_listener_replacement_survives_generation_safe_dismissal() { assert_eq!((signal_id, text.as_str()), (id, "yes")); let mut replacement = reply_notification(false, &replacement_sender); replacement.summary = "Reply received".to_string(); - let outcome = replacement_state.store.lock().await.insert(replacement, id); - assert!(outcome.replaced); + let replacement_committed = Arc::clone(&replacement_committed); + tokio::spawn(async move { + let outcome = replacement_state + .commit_notification_generation(replacement, id, &scheduler) + .await; + replacement_committed.store(true, Ordering::Release); + let _sent = + replacement_done_tx.send((outcome.active_notification().id, outcome.replaced)); + }); + tokio::task::yield_now().await; + assert!( + !observed_replacement_commit.load(Ordering::Acquire), + "replacement commit must remain blocked while reply delivery is in flight" + ); }) .await .expect("reply with replacement"); + let (committed_id, replaced) = replacement_done_rx + .await + .expect("replacement commit task must finish"); + assert_eq!(committed_id, id); + assert!(replaced); + let active = state .store .lock() diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs index 23b63746e..bc8ec9c6f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs @@ -10,6 +10,8 @@ const SENDER_BURST: f64 = 40.0; const SENDER_REFILL_PER_SECOND: f64 = 20.0; const CLOSE_GLOBAL_BURST: f64 = 480.0; const CLOSE_GLOBAL_REFILL_PER_SECOND: f64 = 240.0; +const CLOSE_ATTEMPT_GLOBAL_BURST: f64 = 960.0; +const CLOSE_ATTEMPT_GLOBAL_REFILL_PER_SECOND: f64 = 480.0; const CLOSE_SENDER_BURST: f64 = 160.0; const CLOSE_SENDER_REFILL_PER_SECOND: f64 = 80.0; const OVERFLOW_BURST: f64 = 10.0; @@ -50,14 +52,32 @@ struct QuotaPolicy { sender_refill_per_second: f64, overflow_burst: f64, overflow_refill_per_second: f64, + attempt_global_burst: Option, + attempt_global_refill_per_second: Option, } struct QuotaState { + // Mutation work and rejected-request work use separate process-wide ceilings global: TokenBucket, + attempt_global: Option, principals: HashMap, overflow: TokenBucket, } +/// One result describes the complete hierarchical admission decision +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::daemon::notifications) enum Admission { + Allowed, + GlobalLimited, + PrincipalLimited, +} + +impl Admission { + pub(in crate::daemon::notifications) const fn is_allowed(self) -> bool { + matches!(self, Self::Allowed) + } +} + struct PrincipalBucket { bucket: TokenBucket, last_seen: Instant, @@ -89,6 +109,8 @@ impl NotificationQuota { sender_refill_per_second: SENDER_REFILL_PER_SECOND, overflow_burst: OVERFLOW_BURST, overflow_refill_per_second: OVERFLOW_REFILL_PER_SECOND, + attempt_global_burst: None, + attempt_global_refill_per_second: None, }, ) } @@ -103,6 +125,8 @@ impl NotificationQuota { sender_refill_per_second: CLOSE_SENDER_REFILL_PER_SECOND, overflow_burst: CLOSE_OVERFLOW_BURST, overflow_refill_per_second: CLOSE_OVERFLOW_REFILL_PER_SECOND, + attempt_global_burst: Some(CLOSE_ATTEMPT_GLOBAL_BURST), + attempt_global_refill_per_second: Some(CLOSE_ATTEMPT_GLOBAL_REFILL_PER_SECOND), }, ) } @@ -111,6 +135,10 @@ impl NotificationQuota { Self { state: Mutex::new(QuotaState { global: TokenBucket::new(policy.global_burst, policy.global_refill_per_second, now), + attempt_global: policy + .attempt_global_burst + .zip(policy.attempt_global_refill_per_second) + .map(|(burst, refill)| TokenBucket::new(burst, refill, now)), principals: HashMap::new(), overflow: TokenBucket::new( policy.overflow_burst, @@ -122,64 +150,175 @@ impl NotificationQuota { } } - pub(in crate::daemon::notifications) fn admit_global(&self, now: Instant) -> bool { + pub(in crate::daemon::notifications) fn try_admit_close_attempt( + &self, + principal: Option, + now: Instant, + ) -> Admission { let Ok(mut state) = self.state.lock() else { - // A poisoned limiter fails closed instead of disabling ingress control - return false; + return Admission::GlobalLimited; }; - state.global.refill(now); - state.global.take_token() + state.prune_principal_buckets(now); + // Process churn cannot mint work after this shared attempt budget is empty + if !state.attempt_global_has_token(now) { + return Admission::GlobalLimited; + } + if !state.principal_has_token(principal, now, self.policy) { + return Admission::PrincipalLimited; + } + let principal_taken = state.take_principal_token(principal, now, self.policy); + let attempt_global_taken = state.take_attempt_global_token(now); + debug_assert!( + principal_taken, + "checked close principal token must remain available" + ); + debug_assert!( + attempt_global_taken, + "checked close attempt token must remain available" + ); + Admission::Allowed } - pub(in crate::daemon::notifications) fn admit_principal( + pub(in crate::daemon::notifications) fn try_admit_notify( &self, principal: Option, now: Instant, - ) -> bool { + ) -> Admission { + self.try_admit_hierarchical(principal, now) + } + + pub(in crate::daemon::notifications) fn try_admit_close_commit( + &self, + now: Instant, + ) -> Admission { let Ok(mut state) = self.state.lock() else { - return false; + return Admission::GlobalLimited; + }; + // Only an authorized close consumes the protected mutation budget + state.global.refill(now); + if !state.global.has_token() { + return Admission::GlobalLimited; + } + let global_taken = state.global.take_token(); + debug_assert!( + global_taken, + "checked close commit token must remain available" + ); + Admission::Allowed + } + + fn try_admit_hierarchical(&self, principal: Option, now: Instant) -> Admission { + let Ok(mut state) = self.state.lock() else { + return Admission::GlobalLimited; }; state.prune_principal_buckets(now); + state.global.refill(now); + + // Check both budgets before decrementing either one + // A shared rejection also avoids mutating principal LRU admission state + if !state.global.has_token() { + return Admission::GlobalLimited; + } + if !state.principal_has_token(principal, now, self.policy) { + return Admission::PrincipalLimited; + } + + let principal_taken = state.take_principal_token(principal, now, self.policy); + let global_taken = state.global.take_token(); + debug_assert!( + principal_taken, + "checked principal token must remain available" + ); + debug_assert!(global_taken, "checked global token must remain available"); + Admission::Allowed + } +} + +impl QuotaState { + fn attempt_global_has_token(&mut self, now: Instant) -> bool { + self.attempt_global.as_mut().is_some_and(|bucket| { + bucket.refill(now); + bucket.has_token() + }) + } + + fn take_attempt_global_token(&mut self, now: Instant) -> bool { + self.attempt_global.as_mut().is_some_and(|bucket| { + bucket.refill(now); + bucket.take_token() + }) + } + + fn prune_principal_buckets(&mut self, now: Instant) { + self.principals.retain(|_principal, bucket| { + bucket.bucket.refill(now); + let idle = now.saturating_duration_since(bucket.last_seen).as_secs() + >= PRINCIPAL_IDLE_TTL_SECONDS; + // Only a fully restored idle principal may release its bounded map slot + !(idle && bucket.bucket.is_full()) + }); + } + + fn principal_has_token( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> bool { + self.principal_bucket_mut(principal, now, policy) + .is_some_and(|bucket| bucket.has_token()) + } + + fn take_principal_token( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> bool { + self.principal_bucket_mut(principal, now, policy) + .is_some_and(TokenBucket::take_token) + } + + fn principal_bucket_mut( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> Option<&mut TokenBucket> { let Some(principal) = principal else { - state.overflow.refill(now); - return state.overflow.take_token(); + // Callers without stable process evidence share one deliberately small allowance + self.overflow.refill(now); + return Some(&mut self.overflow); }; - if !state.principals.contains_key(&principal) - && state.principals.len() >= MAX_TRACKED_PRINCIPALS + + if !self.principals.contains_key(&principal) + && self.principals.len() >= MAX_TRACKED_PRINCIPALS { - // D-Bus unique names are ephemeral transport addresses, not stable principals - // Unknown or overflow identities share a restricted bucket rather than receiving - // a fresh burst - state.overflow.refill(now); - return state.overflow.take_token(); + // Stable newcomers displace the least-recent entry instead of falling off a quota cliff + if let Some(oldest) = self + .principals + .iter() + .min_by_key(|(_key, bucket)| bucket.last_seen) + .map(|(key, _bucket)| *key) + { + self.principals.remove(&oldest); + } } + let principal_bucket = - state - .principals + self.principals .entry(principal) .or_insert_with(|| PrincipalBucket { bucket: TokenBucket::new( - self.policy.sender_burst, - self.policy.sender_refill_per_second, + policy.sender_burst, + policy.sender_refill_per_second, now, ), last_seen: now, }); principal_bucket.last_seen = now; principal_bucket.bucket.refill(now); - principal_bucket.bucket.take_token() - } -} - -impl QuotaState { - fn prune_principal_buckets(&mut self, now: Instant) { - self.principals.retain(|_principal, bucket| { - bucket.bucket.refill(now); - let idle = now.saturating_duration_since(bucket.last_seen).as_secs() - >= PRINCIPAL_IDLE_TTL_SECONDS; - // Only a fully restored idle principal may release its bounded map slot - !(idle && bucket.bucket.is_full()) - }); + Some(&mut principal_bucket.bucket) } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs index 0ea20021c..925ed06cc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use super::{ - NotificationQuota, PrincipalBucket, QuotaPrincipal, QuotaState, TokenBucket, - CLOSE_SENDER_BURST, GLOBAL_BURST, MAX_TRACKED_PRINCIPALS, OVERFLOW_BURST, + Admission, NotificationQuota, PrincipalBucket, QuotaPrincipal, QuotaState, TokenBucket, + CLOSE_GLOBAL_BURST, CLOSE_SENDER_BURST, GLOBAL_BURST, MAX_TRACKED_PRINCIPALS, PRINCIPAL_IDLE_TTL_SECONDS, SENDER_BURST, }; @@ -21,79 +21,311 @@ fn principal_bucket(now: Instant) -> PrincipalBucket { fn quota_state(now: Instant) -> QuotaState { QuotaState { global: TokenBucket::new(GLOBAL_BURST, 1.0, now), + attempt_global: None, principals: HashMap::new(), - overflow: TokenBucket::new(OVERFLOW_BURST, 1.0, now), + overflow: TokenBucket::new(10.0, 1.0, now), } } #[test] -fn principal_bucket_rejects_a_burst_and_refills_over_time() { +fn hierarchical_admission_charges_neither_bucket_when_principal_is_limited() { let now = Instant::now(); let quota = NotificationQuota::new_at(now); let caller = principal(10); - for _ in 0..SENDER_BURST as usize { - assert!(quota.admit_principal(Some(caller), now)); + for _request in 0..SENDER_BURST as usize { + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::Allowed + ); } - assert!(!quota.admit_principal(Some(caller), now)); - assert!(quota.admit_principal(Some(caller), now + Duration::from_millis(50))); + let global_before = quota.state.lock().expect("quota state").global.tokens; + + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::PrincipalLimited + ); + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .global + .tokens + .to_bits(), + global_before.to_bits(), + "principal rejection must not spend a shared token" + ); } #[test] -fn global_bucket_limits_requests_before_identity_resolution() { +fn hierarchical_admission_charges_neither_bucket_when_global_is_limited() { let now = Instant::now(); let quota = NotificationQuota::new_at(now); + let caller = principal(10); + assert!(quota.try_admit_notify(Some(caller), now).is_allowed()); + { + let mut state = quota.state.lock().expect("quota state"); + state.global.tokens = 0.0; + } + let principal_before = quota + .state + .lock() + .expect("quota state") + .principals + .get(&caller) + .expect("principal bucket") + .bucket + .tokens; + + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::GlobalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!( + state + .principals + .get(&caller) + .expect("principal bucket") + .bucket + .tokens + .to_bits(), + principal_before.to_bits(), + "global rejection must not spend a caller token" + ); +} + +#[test] +fn global_rejection_does_not_evict_an_established_principal() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + { + let mut state = quota.state.lock().expect("quota state"); + state.global.tokens = 0.0; + for index in 0..MAX_TRACKED_PRINCIPALS { + state.principals.insert( + principal(index as u32), + PrincipalBucket { + bucket: TokenBucket::new(SENDER_BURST, 1.0, now), + last_seen: now + Duration::from_nanos(index as u64), + }, + ); + } + } + let newcomer = principal(u32::MAX); + + assert_eq!( + quota.try_admit_notify(Some(newcomer), now), + Admission::GlobalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert!(state.principals.contains_key(&principal(0))); + assert!(!state.principals.contains_key(&newcomer)); +} + +#[test] +fn close_commit_charges_only_global_capacity_and_refills_over_time() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); - for _ in 0..GLOBAL_BURST as usize { - assert!(quota.admit_global(now)); + for _request in 0..CLOSE_GLOBAL_BURST as usize { + assert!(quota.try_admit_close_commit(now).is_allowed()); } - assert!(!quota.admit_global(now)); - assert!(quota.admit_global(now + Duration::from_millis(17))); + assert_eq!(quota.try_admit_close_commit(now), Admission::GlobalLimited); + assert!(quota + .try_admit_close_commit(now + Duration::from_millis(5)) + .is_allowed()); + assert!(quota + .state + .lock() + .expect("quota state") + .principals + .is_empty()); } #[test] -fn close_requests_use_a_separate_higher_principal_budget() { +fn successful_close_sequence_charges_one_principal_token_per_operation() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + let caller = principal(10); + + for _request in 0..CLOSE_SENDER_BURST as usize { + assert!( + quota + .try_admit_close_attempt(Some(caller), now) + .is_allowed(), + "every documented caller burst token must admit one successful close" + ); + assert!(quota.try_admit_close_commit(now).is_allowed()); + } + + assert_eq!( + quota.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!( + state.global.tokens.to_bits(), + (CLOSE_GLOBAL_BURST - CLOSE_SENDER_BURST).to_bits(), + "each successful close must consume one shared commit token" + ); +} + +#[test] +fn close_attempts_use_a_separate_higher_principal_budget() { let now = Instant::now(); let notify = NotificationQuota::new_at(now); let close = NotificationQuota::new_close_at(now); let caller = principal(10); - for _ in 0..SENDER_BURST as usize { - assert!(notify.admit_principal(Some(caller), now)); - assert!(close.admit_principal(Some(caller), now)); + for _request in 0..SENDER_BURST as usize { + assert!(notify.try_admit_notify(Some(caller), now).is_allowed()); + assert!(close + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); } - assert!(!notify.admit_principal(Some(caller), now)); - for _ in SENDER_BURST as usize..CLOSE_SENDER_BURST as usize { - assert!(close.admit_principal(Some(caller), now)); + assert_eq!( + notify.try_admit_notify(Some(caller), now), + Admission::PrincipalLimited + ); + for _request in SENDER_BURST as usize..CLOSE_SENDER_BURST as usize { + assert!(close + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); } - assert!(!close.admit_principal(Some(caller), now)); + assert_eq!( + close.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); } #[test] -fn unknown_and_overflow_principals_share_one_restricted_bucket() { +fn close_attempt_admission_never_charges_shared_mutation_capacity() { let now = Instant::now(); - let quota = NotificationQuota::new_at(now); + let quota = NotificationQuota::new_close_at(now); + let global_before = quota.state.lock().expect("quota state").global.tokens; + + assert!(quota + .try_admit_close_attempt(Some(principal(10)), now) + .is_allowed()); + + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .global + .tokens + .to_bits(), + global_before.to_bits(), + "an ownership-rejected close must leave the shared mutation budget untouched" + ); +} +#[test] +fn principal_rejection_does_not_charge_shared_close_attempt_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + let caller = principal(10); + + for _request in 0..CLOSE_SENDER_BURST as usize { + assert!(quota + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); + } + let before = quota + .state + .lock() + .expect("quota state") + .attempt_global + .as_ref() + .expect("close attempt bucket") + .tokens; + + assert_eq!( + quota.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .attempt_global + .as_ref() + .expect("close attempt bucket") + .tokens + .to_bits(), + before.to_bits(), + "caller rejection must not spend shared close-attempt capacity" + ); +} + +#[test] +fn stable_principal_churn_cannot_mint_unbounded_close_attempt_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + + for index in 0..960_u32 { + assert_eq!( + quota.try_admit_close_attempt(Some(principal(index)), now), + Admission::Allowed, + "every documented global attempt token should admit one cold principal" + ); + } + + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert_eq!(state.global.tokens.to_bits(), CLOSE_GLOBAL_BURST.to_bits()); + drop(state); + assert_eq!( + quota.try_admit_close_attempt(Some(principal(u32::MAX)), now), + Admission::GlobalLimited, + "a new process identity must not create capacity after the attempt budget is empty" + ); +} + +#[test] +fn stable_newcomer_displaces_the_least_recent_principal_at_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); for index in 0..MAX_TRACKED_PRINCIPALS { - assert!(quota.admit_principal(Some(principal(index as u32)), now)); + let observed = now + Duration::from_nanos(index as u64); + assert!(quota + .try_admit_close_attempt(Some(principal(index as u32)), observed) + .is_allowed()); } - for index in 0..OVERFLOW_BURST as usize { - let admitted = if index % 2 == 0 { - quota.admit_principal(None, now) - } else { - quota.admit_principal( - Some(principal((MAX_TRACKED_PRINCIPALS + index) as u32)), - now, - ) - }; - assert!(admitted); + let newcomer = principal(u32::MAX); + + assert!(quota + .try_admit_close_attempt(Some(newcomer), now + Duration::from_secs(1)) + .is_allowed()); + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert!(!state.principals.contains_key(&principal(0))); + assert!(state.principals.contains_key(&newcomer)); +} + +#[test] +fn unknown_principals_remain_in_one_restricted_bucket() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + + for _request in 0..10 { + assert!(quota.try_admit_notify(None, now).is_allowed()); } - assert!(!quota.admit_principal(None, now)); - assert!(!quota.admit_principal(Some(principal(u32::MAX)), now)); assert_eq!( - quota.state.lock().expect("quota state").principals.len(), - MAX_TRACKED_PRINCIPALS + quota.try_admit_notify(None, now), + Admission::PrincipalLimited ); + assert!(quota + .state + .lock() + .expect("quota state") + .principals + .is_empty()); } #[test] @@ -119,33 +351,6 @@ fn principal_pruning_removes_only_fully_refilled_idle_entries() { assert!(state.principals.contains_key(&recent)); } -#[test] -fn capacity_never_evicts_a_live_throttled_principal_for_a_new_burst() { - let now = Instant::now(); - let quota = NotificationQuota::new_at(now); - let protected = principal(0); - - for _ in 0..SENDER_BURST as usize { - assert!(quota.admit_principal(Some(protected), now)); - } - for index in 1..MAX_TRACKED_PRINCIPALS { - assert!(quota.admit_principal(Some(principal(index as u32)), now)); - } - assert!(!quota.admit_principal(Some(protected), now)); - - for index in MAX_TRACKED_PRINCIPALS..MAX_TRACKED_PRINCIPALS + 20 { - let _admitted = quota.admit_principal(Some(principal(index as u32)), now); - } - - assert!(!quota.admit_principal(Some(protected), now)); - assert!(quota - .state - .lock() - .expect("quota state") - .principals - .contains_key(&protected)); -} - #[test] fn reconnect_address_churn_does_not_reset_a_process_principal_bucket() { let now = Instant::now(); @@ -154,10 +359,13 @@ fn reconnect_address_churn_does_not_reset_a_process_principal_bucket() { let mut admitted = 0usize; for _transport_connection in 0..MAX_TRACKED_PRINCIPALS + 64 { - admitted += usize::from(quota.admit_principal(Some(same_process), now)); + admitted += usize::from(quota.try_admit_notify(Some(same_process), now).is_allowed()); } assert_eq!(admitted, SENDER_BURST as usize); - assert!(!quota.admit_principal(Some(same_process), now)); + assert_eq!( + quota.try_admit_notify(Some(same_process), now), + Admission::PrincipalLimited + ); assert_eq!(quota.state.lock().expect("quota state").principals.len(), 1); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs index 02aa4c024..05e1c0203 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -15,16 +15,18 @@ impl NotificationServer { ) -> zbus::fdo::Result<()> { debug!(id, "close notification requested"); - // Close requests are ownership checked and become no-op when unauthorized + // Unauthorized close targets collapse into one generic protocol failure let sender = resolve_sender_metadata( &self.state.sender_metadata_cache, self.state.connection(), header, ) .await; + let principal = super::quota_principal(&sender); if !self .close_quota - .admit_principal(super::quota_principal(&sender), Instant::now()) + .try_admit_close_attempt(principal, Instant::now()) + .is_allowed() { let rejected = self .ingress_metrics @@ -34,14 +36,46 @@ impl NotificationServer { "notification close quota exceeded".to_string(), )); } + // Replacement commits share this gate so one close request targets one generation + let _interaction = self.state.interaction_gates.lock(id).await; let removed = { let mut store = self.state.store.lock().await; - // Ownership and removal share one lock so a same-ID replacement cannot race the close - store.close_owned_active( + let authorization = store.close_authorization( id, sender.sender_name.as_deref(), sender.sender_pid, sender.sender_start_time, + ); + let crate::store::CloseAuthorization::OwnedActive(expected) = authorization else { + debug!( + id, + sender = sender.sender_name.as_deref().unwrap_or("unknown"), + sender_pid = sender.sender_pid, + "notification close target is not closable" + ); + // Invalid attempts charge only their caller and never consume shared mutation capacity + return Err(generic_close_error()); + }; + if !self + .close_quota + .try_admit_close_commit(Instant::now()) + .is_allowed() + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::CloseQuota); + debug!(rejected, "owned close rejected by global commit quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification close quota exceeded".to_string(), + )); + } + + // Admission and removal share one store lock so only a real mutation spends global quota + store.close_owned_active_generation( + expected, + sender.sender_name.as_deref(), + sender.sender_pid, + sender.sender_start_time, CloseReason::ClosedByCall, ) }; @@ -52,7 +86,7 @@ impl NotificationServer { sender_pid = sender.sender_pid, "notification close target is not closable" ); - // Missing, foreign, historical, and otherwise non-closable IDs are indistinguishable + // A concurrent replacement or close stays indistinguishable from every invalid target return Err(generic_close_error()); }; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 9af43e561..be7ff5e6b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; use tracing::{debug, warn}; use unixnotis_core::{ImageData, Notification, NotificationKey}; use zbus::message::Header; @@ -12,7 +11,6 @@ use crate::daemon::notifications::identity::{ use crate::daemon::notifications::identity::{ resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, }; -use crate::daemon::notifications::ingress::metrics::RejectedRequest; use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, @@ -56,7 +54,7 @@ impl NotificationServer { body: String, actions: Vec, hints: WireHints, - header: &Header<'_>, + sender: SenderMetadata, expire_timeout: i32, ) -> zbus::fdo::Result { let _ = Self::log_received_notification( @@ -66,19 +64,6 @@ impl NotificationServer { replaces_id, expire_timeout, ); - let sender = self.resolve_sender(header).await; - if !self - .notify_quota - .admit_principal(super::quota_principal(&sender), Instant::now()) - { - let rejected = self - .ingress_metrics - .record_rejection(RejectedRequest::NotifyQuota); - debug!(rejected, "notification request rejected by principal quota"); - return Err(zbus::fdo::Error::LimitsExceeded( - "notification ingress quota exceeded".to_string(), - )); - } let (hints, wire_image_data, image_path) = hints.into_parts(); let notification = self .notification_from_wire( @@ -128,7 +113,7 @@ impl NotificationServer { true } - async fn resolve_sender(&self, header: &Header<'_>) -> SenderMetadata { + pub(super) async fn resolve_sender(&self, header: &Header<'_>) -> SenderMetadata { // Sender metadata helps with ownership checks and diagnostics if let Ok(sender) = tokio::time::timeout( SENDER_CREDENTIAL_TIMEOUT, @@ -177,8 +162,12 @@ impl NotificationServer { &input.actions, &input.app_icon, ); - let sender_visual = - materialize_sender_visual_for_role(sender_visual_role, input.app_icon.clone()).await; + let sender_visual = materialize_sender_visual_for_role( + sender_visual_role, + &resolution.attribution, + input.app_icon.clone(), + ) + .await; let materialized_content = materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; let (image_data, wire_sender_visual) = normalize_wire_image_for_role( @@ -236,26 +225,11 @@ impl NotificationServer { notification: Notification, replaces_id: u32, ) -> StoredNotification { - // Store mutation and scheduler delivery share one serialized lock scope - let outcome = { - let mut store = self.state.store.lock().await; - // Sample renderer health immediately before the serialized commit - let ui_health = self.state.ui_health(); - let outcome = store.insert_with_ui_health(notification, replaces_id, &ui_health); - if let CommitDisposition::Active(notification) = &outcome.disposition { - // The store resolved both clocks after applying rules and committing the generation - let expiration = outcome.expiration; - store.set_expiration(notification, expiration); - // Unbounded send is synchronous, so commit order is preserved without an await - self.scheduler - .schedule(notification.id, notification.generation, expiration); - } - // Eviction cancellation is committed in the same order as the insertion - for key in &outcome.evicted { - self.scheduler.schedule(key.id, key.generation, None); - } - outcome - }; + // Shared state owns generation serialization for every current and future caller + let outcome = self + .state + .commit_notification_generation(notification, replaces_id, &self.scheduler) + .await; StoredNotification { outcome } } @@ -412,9 +386,10 @@ fn normalize_wire_image_for_role( async fn materialize_sender_visual_for_role( role: SenderVisualRole, + attribution: &unixnotis_core::NotificationAttribution, app_icon: String, ) -> Option { - if matches!(role, SenderVisualRole::None) { + if !super::super::ingress::payload::sender_visual_path_allowed(role, attribution) { return None; } run_avatar_worker( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index 5e1410d3b..46b7f6710 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -75,15 +75,6 @@ impl NotificationServer { #[zbus(header)] header: Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { - if !self.notify_quota.admit_global(Instant::now()) { - let rejected = self - .ingress_metrics - .record_rejection(RejectedRequest::NotifyQuota); - debug!(rejected, "notification request rejected by ingress quota"); - return Err(zbus::fdo::Error::LimitsExceeded( - "notification ingress quota exceeded".to_string(), - )); - } let _slot = self.notify_slots.try_acquire().map_err(|_error| { let rejected = self .ingress_metrics @@ -97,6 +88,23 @@ impl NotificationServer { ) })?; let _activity = self.ingress_metrics.enter_handler(); + let sender = self.resolve_sender(&header).await; + if !self + .notify_quota + .try_admit_notify(super::quota_principal(&sender), Instant::now()) + .is_allowed() + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyQuota); + debug!( + rejected, + "notification request rejected by hierarchical quota" + ); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification ingress quota exceeded".to_string(), + )); + } // The interface adapter forwards the authenticated header with the exact wire payload let completion = self .ingest_notify_deferred( @@ -107,7 +115,7 @@ impl NotificationServer { body, actions, hints, - &header, + sender, expire_timeout, ) .await?; @@ -133,15 +141,6 @@ impl NotificationServer { id: u32, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - if !self.close_quota.admit_global(Instant::now()) { - let rejected = self - .ingress_metrics - .record_rejection(RejectedRequest::CloseQuota); - debug!(rejected, "close request rejected by ingress quota"); - return Err(zbus::fdo::Error::LimitsExceeded( - "notification close quota exceeded".to_string(), - )); - } let _activity = self.ingress_metrics.enter_handler(); // Ownership checks remain in the shared close path used by all D-Bus callers self.close_notification_if_owned(id, &header).await diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 867f3034d..74280053f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -45,6 +45,7 @@ impl NotificationServer { header: &Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { + let sender = self.resolve_sender(header).await; let completion = self .ingest_notify_deferred( app_name, @@ -54,7 +55,7 @@ impl NotificationServer { body, actions, hints, - header, + sender, expire_timeout, ) .await?; diff --git a/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs b/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs new file mode 100644 index 000000000..b3566c626 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs @@ -0,0 +1,34 @@ +//! Bounded serialization for same-ID notification interactions + +use tokio::sync::{Mutex, MutexGuard}; + +const INTERACTION_GATE_SHARDS: usize = 128; + +/// Fixed interaction locks prevent an attacker-controlled notification ID space from growing state +pub(in crate::daemon) struct InteractionGates { + shards: Box<[Mutex<()>]>, +} + +impl InteractionGates { + pub(in crate::daemon) fn new() -> Self { + let shards = (0..INTERACTION_GATE_SHARDS) + .map(|_index| Mutex::new(())) + .collect::>() + .into_boxed_slice(); + Self { shards } + } + + pub(in crate::daemon) async fn lock(&self, id: u32) -> MutexGuard<'_, ()> { + // IDs sharing a shard serialize conservatively while memory remains strictly bounded + let index = interaction_gate_index(id); + self.shards[index].lock().await + } +} + +fn interaction_gate_index(id: u32) -> usize { + usize::try_from(id).unwrap_or(usize::MAX) % INTERACTION_GATE_SHARDS +} + +#[cfg(test)] +#[path = "tests/interaction_gates.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/state/mod.rs b/crates/unixnotis-daemon/src/daemon/state/mod.rs index c7a8cf469..5a413d914 100644 --- a/crates/unixnotis-daemon/src/daemon/state/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/mod.rs @@ -1,10 +1,13 @@ //! Shared daemon state and signal fanout coordination +mod interaction_gates; mod model; +mod notification_commit; mod notification_lifecycle; mod schedulers; mod status; +pub(in crate::daemon) use interaction_gates::InteractionGates; pub use model::DaemonState; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index b29262fdd..01c47368d 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -19,6 +19,7 @@ use crate::daemon::events::DaemonEventPublisher; use crate::daemon::notifications::identity::{DesktopIdentityIndex, DesktopIndexRefreshHandle}; use crate::daemon::notifications::NotificationBurstState; use crate::daemon::notifications::SenderMetadataCache; +use crate::daemon::state::InteractionGates; #[derive(Clone, Default)] #[expect( @@ -38,6 +39,8 @@ pub(in crate::daemon::state) struct UiHealthState { /// Shared daemon state guarded behind an async mutex pub struct DaemonState { pub store: Mutex, + // Action, reply, and replacement commits for one numeric ID share this bounded gate + pub(in crate::daemon) interaction_gates: InteractionGates, // This map is built before the control object is exported and never rebuilt from callers pub(in crate::daemon) trusted_executables: Arc>, /// Immutable sound settings resolved at startup @@ -108,6 +111,7 @@ impl DaemonState { Arc::new(build_trusted_control_snapshots_for_current_executable()); Arc::new(Self { store: Mutex::new(store), + interaction_gates: InteractionGates::new(), trusted_executables, sound, connection: connection.clone(), diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs b/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs new file mode 100644 index 000000000..479c2bf22 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs @@ -0,0 +1,36 @@ +//! Serialized notification generation commits + +use unixnotis_core::Notification; + +use crate::expire::ExpirationScheduler; +use crate::store::{CommitDisposition, InsertOutcome}; + +use super::DaemonState; + +impl DaemonState { + pub(in crate::daemon) async fn commit_notification_generation( + &self, + notification: Notification, + replaces_id: u32, + scheduler: &ExpirationScheduler, + ) -> InsertOutcome { + // Every nonzero replacement request shares the ID gate with actions and inline replies + let _interaction = if replaces_id == 0 { + None + } else { + Some(self.interaction_gates.lock(replaces_id).await) + }; + let mut store = self.store.lock().await; + let outcome = store.insert_with_ui_health(notification, replaces_id, &self.ui_health()); + if let CommitDisposition::Active(notification) = &outcome.disposition { + // The committed generation and its expiration ticket become visible together + store.set_expiration(notification, outcome.expiration); + scheduler.schedule(notification.id, notification.generation, outcome.expiration); + } + for key in &outcome.evicted { + // Eviction cancels the exact generation removed by the same commit + scheduler.schedule(key.id, key.generation, None); + } + outcome + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs index 5d056b48e..d2834621a 100644 --- a/crates/unixnotis-daemon/src/daemon/state/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -81,17 +81,6 @@ impl DaemonState { .popups_ready } - #[cfg_attr( - not(test), - expect(dead_code, reason = "getter is used by child-process tests") - )] - pub(crate) fn popups_process_running(&self) -> bool { - self.ui_health - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .popups_process_running - } - pub(crate) fn should_warn_popups_unready(&self) -> bool { !self.popups_ready() && self diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs index 9d2edbe85..f6fa43791 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -6,7 +6,7 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { assert!(state.trial_mode()); assert!(!state.panel_ready()); - assert!(!state.popups_process_running()); + assert!(!state.ui_health().popups_process_running); // These health flags gate user-visible command handling, so getters must reflect writes exactly state.set_center_process_running(true); @@ -14,7 +14,7 @@ async fn daemon_state_boolean_flags_reflect_runtime_updates() { state.set_popups_process_running(true); assert!(state.panel_ready()); - assert!(state.popups_process_running()); + assert!(state.ui_health().popups_process_running); state.set_popups_ready(":1.10", true); let health = state.ui_health(); @@ -36,7 +36,7 @@ async fn daemon_state_boolean_flags_can_return_to_false() { state.set_popups_process_running(false); assert!(!state.panel_ready()); - assert!(!state.popups_process_running()); + assert!(!state.ui_health().popups_process_running); } #[tokio::test] diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs index c31ea99e9..a36f05f23 100644 --- a/crates/unixnotis-daemon/src/store/notifications/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -12,8 +12,17 @@ use crate::store::{ use super::timeout::resolve_timeout_policy; -// Hard ceiling for concurrently active notifications to protect panel/popups stability -const ACTIVE_HARD_CAP: usize = 12; +// Each resolved sender principal receives an isolated active-state budget +const ACTIVE_PER_PRINCIPAL_HARD_CAP: usize = 12; +// The emergency ceiling remains large enough that one normal sender cannot displace another +const ABSOLUTE_ACTIVE_HARD_CAP: usize = 128; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum ActivePrincipal { + Stable(StableProcessIdentity), + BusName(zbus::names::OwnedUniqueName), + Unknown, +} impl NotificationStore { pub fn insert_with_ui_health( @@ -89,12 +98,13 @@ impl NotificationStore { let admitted_at = std::time::Instant::now(); let expiration = timeout_policy .active_close_after - .map(|duration| admitted_at.checked_add(duration).unwrap_or(admitted_at)); + // Overflow disables automatic expiration instead of reversing it into immediate close + .and_then(|duration| admitted_at.checked_add(duration)); let notification = Arc::new(notification); - // Active map keeps insertion order so oldest eviction is deterministic + // Active map keeps insertion order so principal-local eviction is deterministic self.active.insert(assigned_id, notification.clone()); - // Enforce active cap immediately so UI never sees oversized active sets - let evicted = self.enforce_active_limit(); + // Replacement already removed its previous generation and therefore consumes one slot + let evicted = self.enforce_active_limits(active_principal(¬ification)); let popup_admission = self.popup_admission(¬ification); self.record_popup_commit_environment_at( @@ -114,41 +124,75 @@ impl NotificationStore { } } - fn enforce_active_limit(&mut self) -> Vec { - // Config limit still applies, but active list never exceeds the global safety cap - let max_active = self.config.history.max_active.min(ACTIVE_HARD_CAP); - if max_active == 0 { - // max_active=0 means archive everything immediately - let mut evicted = Vec::new(); - while let Some((id, notification)) = self.active.shift_remove_index(0) { - let key = notification.key(); - // Evicted notifications should not retain pending expiration entries - self.expirations.remove(&id); - // Active-cap eviction behaves like a daemon-side close for history policy - self.push_history(notification, CloseReason::Undefined); - evicted.push(key); - } - return evicted; + fn enforce_active_limits(&mut self, admitted: ActivePrincipal) -> Vec { + let per_principal_limit = self + .config + .history + .max_active + .min(ACTIVE_PER_PRINCIPAL_HARD_CAP); + let mut evicted = Vec::new(); + while self.active_count_for(&admitted) > per_principal_limit { + // A sender over its budget can remove only that sender's oldest active generation + let Some(key) = self.evict_oldest_for_principal(&admitted) else { + break; + }; + evicted.push(key); } - let mut evicted = Vec::new(); - while self.active.len() > max_active { - // remove_index(0) always pops the oldest notification first - if let Some((id, notification)) = self.active.shift_remove_index(0) { - let key = notification.key(); - // Eviction path mirrors close path so state stays consistent - self.expirations.remove(&id); - // Evicted rows still need the same archive rule as any other close - self.push_history(notification, CloseReason::Undefined); - evicted.push(key); - } else { - // Defensive break for impossible map/index mismatch cases + while self.active.len() > ABSOLUTE_ACTIVE_HARD_CAP { + // At the emergency boundary, the largest consumer yields first + // Equal shares prefer the newly admitted principal so established clients stay intact + let victim = self + .largest_active_principal(&admitted) + .unwrap_or_else(|| admitted.clone()); + let Some(key) = self.evict_oldest_for_principal(&victim) else { break; - } + }; + evicted.push(key); } evicted } + fn active_count_for(&self, principal: &ActivePrincipal) -> usize { + self.active + .values() + .filter(|notification| &active_principal(notification) == principal) + .count() + } + + fn largest_active_principal(&self, admitted: &ActivePrincipal) -> Option { + let mut counts = std::collections::HashMap::new(); + for notification in self.active.values() { + let count = counts + .entry(active_principal(notification)) + .or_insert(0usize); + *count = count.saturating_add(1); + } + let admitted_count = counts.get(admitted).copied().unwrap_or(0); + let largest = counts.values().copied().max()?; + if admitted_count == largest { + return Some(admitted.clone()); + } + counts + .into_iter() + .find_map(|(principal, count)| (count == largest).then_some(principal)) + } + + fn evict_oldest_for_principal( + &mut self, + principal: &ActivePrincipal, + ) -> Option { + let index = self + .active + .values() + .position(|notification| &active_principal(notification) == principal)?; + let (id, notification) = self.active.shift_remove_index(index)?; + let key = notification.key(); + self.expirations.remove(&id); + self.push_history(notification, CloseReason::Undefined); + Some(key) + } + pub(super) fn push_history(&mut self, notification: Arc, reason: CloseReason) { if self.config.history.max_entries == 0 { // Clear keeps memory bounded when history feature is disabled @@ -214,3 +258,15 @@ impl NotificationStore { true } } + +fn active_principal(notification: &Notification) -> ActivePrincipal { + if let Some((pid, start_time)) = notification.sender_pid.zip(notification.sender_start_time) { + return ActivePrincipal::Stable(StableProcessIdentity { pid, start_time }); + } + // A unique bus address is weaker than process identity but still isolates live connections + notification + .sender_name + .as_deref() + .and_then(|sender| zbus::names::OwnedUniqueName::try_from(sender).ok()) + .map_or(ActivePrincipal::Unknown, ActivePrincipal::BusName) +} diff --git a/crates/unixnotis-daemon/src/store/notifications/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/ownership.rs index 746ad5f1a..aa1639a0e 100644 --- a/crates/unixnotis-daemon/src/store/notifications/ownership.rs +++ b/crates/unixnotis-daemon/src/store/notifications/ownership.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use tracing::warn; -use unixnotis_core::{CloseReason, Notification}; +use unixnotis_core::{CloseReason, Notification, NotificationKey}; use crate::store::{CloseAuthorization, NotificationStore}; @@ -23,9 +23,9 @@ impl NotificationStore { } } - pub fn close_owned_active( + pub fn close_owned_active_generation( &mut self, - id: u32, + expected: NotificationKey, sender: Option<&str>, sender_pid: Option, sender_start_time: Option, @@ -33,9 +33,11 @@ impl NotificationStore { ) -> Option> { // SECURITY: missing and foreign-owned IDs collapse before leaving the store // CloseNotification therefore cannot become a notification-existence oracle - match self.close_authorization(id, sender, sender_pid, sender_start_time) { - CloseAuthorization::OwnedActive(_key) => self.close(id, reason), - CloseAuthorization::NotClosable => None, + match self.close_authorization(expected.id, sender, sender_pid, sender_start_time) { + CloseAuthorization::OwnedActive(current) if current == expected => { + self.close(expected.id, reason) + } + CloseAuthorization::OwnedActive(_) | CloseAuthorization::NotClosable => None, } } diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs index 1c28fee44..fe81c5d06 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs @@ -44,6 +44,153 @@ fn max_active_hard_cap_limits_even_when_config_is_higher() { assert_eq!(active[11].summary, "entry-6"); } +#[test] +fn noisy_principal_cannot_evict_another_principals_active_notifications() { + let mut store = make_store_with_limits(5, 128); + let protected = (0..5) + .map(|index| { + store + .insert( + make_notification_with_sender( + &format!("protected-{index}"), + ":1.protected", + 10, + 100, + ), + 0, + ) + .active_notification() + .key() + }) + .collect::>(); + + for index in 0..50 { + store.insert( + make_notification_with_sender(&format!("noisy-{index}"), ":1.noisy", 20, 200), + 0, + ); + } + + let active = store.list_active(); + for key in protected { + assert!( + active.iter().any(|notification| notification.key() == key), + "a different principal must not evict protected active state" + ); + } + assert_eq!(active.len(), 10); +} + +#[test] +fn distinct_bus_senders_remain_isolated_without_process_metadata() { + let mut store = make_store_with_limits(5, 128); + let protected = (0..5) + .map(|index| { + let mut notification = make_notification(&format!("protected-bus-{index}")); + notification.sender_name = Some(":1.100".to_string()); + notification.sender_pid = None; + notification.sender_start_time = None; + store.insert(notification, 0).active_notification().key() + }) + .collect::>(); + + for index in 0..20 { + let mut notification = make_notification(&format!("noisy-bus-{index}")); + notification.sender_name = Some(":1.200".to_string()); + notification.sender_pid = None; + notification.sender_start_time = None; + store.insert(notification, 0); + } + + let active = store.list_active(); + assert!( + protected + .iter() + .all(|key| active.iter().any(|notification| notification.key() == *key)), + "a degraded sender must not evict a different unique bus connection" + ); + assert_eq!(active.len(), 10); +} + +#[test] +fn absolute_active_cap_keeps_exact_capacity_and_evicts_the_admitted_tie() { + let mut store = make_store_with_limits(12, 256); + let mut admitted_oldest = None; + for principal in 0..12_u32 { + let count = if principal < 8 { 11 } else { 10 }; + for index in 0..count { + let outcome = store.insert( + make_notification_with_sender( + &format!("principal-{principal}-{index}"), + &format!(":1.{principal}"), + principal.saturating_add(1), + u64::from(principal).saturating_add(100), + ), + 0, + ); + if principal == 0 && index == 0 { + admitted_oldest = Some(outcome.active_notification().key()); + } + assert!( + outcome.evicted.is_empty(), + "the exact global capacity must not evict active state" + ); + } + } + assert_eq!(store.list_active().len(), 128); + + let outcome = store.insert( + make_notification_with_sender("principal-0-tie", ":1.0", 1, 100), + 0, + ); + + assert_eq!(store.list_active().len(), 128); + assert_eq!(outcome.evicted, vec![admitted_oldest.expect("oldest key")]); +} + +#[test] +fn absolute_active_cap_evicts_a_largest_existing_share_not_the_newcomer() { + let mut store = make_store_with_limits(12, 256); + for principal in 0..10_u32 { + for index in 0..12 { + store.insert( + make_notification_with_sender( + &format!("incumbent-{principal}-{index}"), + &format!(":1.{principal}"), + principal.saturating_add(1), + u64::from(principal).saturating_add(100), + ), + 0, + ); + } + } + let mut newcomer_keys = Vec::new(); + let mut final_outcome = None; + for index in 0..9 { + let outcome = store.insert( + make_notification_with_sender(&format!("newcomer-{index}"), ":1.newcomer", 999, 9_999), + 0, + ); + newcomer_keys.push(outcome.active_notification().key()); + final_outcome = Some(outcome); + } + let outcome = final_outcome.expect("newcomer outcome"); + + assert_eq!(store.list_active().len(), 128); + assert_eq!(outcome.evicted.len(), 1); + assert!( + !newcomer_keys.contains(&outcome.evicted[0]), + "a smaller newcomer share must not be selected as the emergency victim" + ); + let active = store.list_active(); + assert!( + newcomer_keys + .iter() + .all(|key| active.iter().any(|notification| notification.key() == *key)), + "every newcomer generation must survive when a larger share exists" + ); +} + #[test] fn zero_history_limit_keeps_active_notifications_and_drops_evictions() { let mut active_store = make_store_with_limits(2, 0); diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs index 3dcd6d2de..92c4df309 100644 --- a/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs +++ b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs @@ -230,7 +230,7 @@ fn close_authorization_collapses_missing_foreign_and_history_only_ids() { } #[test] -fn close_owned_active_removes_only_the_authorized_live_object() { +fn close_owned_active_generation_removes_only_the_authorized_live_object() { let mut store = make_store_with_limits(10, 10); let active = store .insert( @@ -240,8 +240,8 @@ fn close_owned_active_removes_only_the_authorized_live_object() { .active_notification(); let removed = store - .close_owned_active( - active.id, + .close_owned_active_generation( + active.key(), Some(":1.owner-b"), Some(1234), Some(55), @@ -253,6 +253,37 @@ fn close_owned_active_removes_only_the_authorized_live_object() { assert!(store.list_active().is_empty()); } +#[test] +fn close_owned_active_generation_rejects_a_same_id_replacement() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert( + make_notification_with_sender("original", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + let replacement = store + .insert( + make_notification_with_sender("replacement", ":1.owner", 1234, 55), + original.id, + ) + .active_notification(); + + let removed = store.close_owned_active_generation( + original.key(), + Some(":1.owner"), + Some(1234), + Some(55), + CloseReason::ClosedByCall, + ); + + assert!(removed.is_none()); + assert_eq!( + store.active.get(&replacement.id).map(|item| item.key()), + Some(replacement.key()) + ); +} + #[test] fn replacement_allows_same_process_after_bus_reconnect() { let mut store = make_store_with_limits(2, 10); From 8256c1ddacf35fb72c7506086c1e733a0521f948 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:19:52 -0500 Subject: [PATCH 257/275] fix(notifications): preserve visual identity and timeout boundaries Keep application identity, conversation identity, and message content as separate presentation concepts. - retain bounded conversation-avatar pixels without treating them as app trust - require positive attribution before materializing local application artwork - keep application-provided visuals out of the message-thumbnail lane - restore conversation avatars to the popup identity column - preserve avatars across collapsed and expanded notification-center rows - retain authenticated app-icon fallback and trust presentation - clamp configured popup timeouts and handle deadline overflow defensively - preserve fail-closed trial-mode owner inspection - harden SVG renderer test coverage for timeout and output bounds --- .../src/ui/icons/decode/tests/pipeline.rs | 3 +- .../src/ui/icons/decode/tests/support.rs | 57 +++++++ .../src/ui/icons/decode/tests/svg.rs | 46 +---- .../notification/update/tests/thumbnail.rs | 66 ++++++++ .../src/ui/notifications/row/tests/group.rs | 65 ++++++++ .../tests/fixtures/svg-renderers/bad-renderer | 6 + .../fixtures/svg-renderers/chatty-renderer | 4 + .../svg-renderers/noisy-failing-renderer | 4 + .../fixtures/svg-renderers/slow-renderer | 5 + .../unixnotis-core/src/config/layout/mod.rs | 2 +- .../unixnotis-core/src/config/layout/popup.rs | 3 + .../src/config/runtime/sanitize/pipeline.rs | 14 +- .../config/runtime/sanitize/tests/pipeline.rs | 30 ++++ crates/unixnotis-core/src/config/types.rs | 4 +- .../src/child_process/tests/command.rs | 2 +- .../identity/resolver/pipeline.rs | 8 - .../identity/resolver/sender_context.rs | 11 -- .../identity/resolver/tests/mod.rs | 8 +- .../resolver/tests/pipeline/provenance.rs | 6 - .../identity/resolver/tests/sender_context.rs | 12 +- .../daemon/notifications/identity/sender.rs | 17 -- .../notifications/identity/tests/sender.rs | 13 ++ .../notifications/ingress/payload/build.rs | 36 ++-- .../notifications/ingress/payload/mod.rs | 5 +- .../ingress/payload/tests/build.rs | 50 ++++++ .../ingress/payload/tests/mod.rs | 2 +- .../ingress/payload/tests/visuals.rs | 48 ++++-- .../notifications/ingress/payload/visuals.rs | 18 +- crates/unixnotis-daemon/src/store/runtime.rs | 13 +- .../src/store/tests/runtime/popup.rs | 30 ++++ .../unixnotis-daemon/src/trial_mode/owner.rs | 48 ++++-- .../unixnotis-daemon/src/trial_mode/state.rs | 41 +++-- .../src/trial_mode/tests/control.rs | 5 + .../src/trial_mode/tests/owner.rs | 157 +++++++----------- .../src/trial_mode/tests/state.rs | 27 ++- .../src/ui/entry/builders/mod.rs | 29 ++-- .../src/ui/entry/builders/tests/layout.rs | 101 ++++++++++- .../src/ui/entry/builders/tests/thumbnail.rs | 121 ++++++++++++++ crates/unixnotis-popups/src/ui/icons/state.rs | 17 -- .../src/ui/state/tests/mutation.rs | 4 +- 40 files changed, 833 insertions(+), 305 deletions(-) create mode 100755 crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer create mode 100755 crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer create mode 100755 crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer create mode 100755 crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs index f6e56215a..1f1e6a382 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs @@ -1,7 +1,7 @@ use std::path::Path; use super::super::pipeline::{decode_icon_bytes, decode_target, path_suggests_svg}; -use super::support::png_bytes; +use super::support::{png_bytes, svg_renderer_binary}; #[test] fn content_routing_decodes_raster_bytes_with_an_svg_suffix() { @@ -27,6 +27,7 @@ fn content_routing_rejects_incomplete_png_data_with_an_svg_suffix() { #[test] fn content_routing_decodes_extensionless_svg_with_resvg() { let svg = br#""#; + let _renderer = svg_renderer_binary(); let decoded = decode_icon_bytes(Path::new("icon"), svg, 16).expect("bounded SVG fallback"); diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs index abec10d6b..e6365a564 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs @@ -1,5 +1,7 @@ use std::fs; use std::path::PathBuf; +use std::process::Command; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use image::codecs::png::PngEncoder; @@ -26,3 +28,58 @@ pub(super) fn png_bytes(width: u32, height: u32) -> Vec { .expect("encode PNG"); bytes } + +pub(super) fn svg_renderer_binary() -> &'static PathBuf { + static BINARY: OnceLock = OnceLock::new(); + + BINARY.get_or_init(|| { + if let Some(path) = option_env!("CARGO_BIN_EXE_unixnotis-svg-renderer") { + return path.into(); + } + let current_exe = std::env::current_exe().expect("current center test binary"); + let profile_dir = current_exe + .parent() + .and_then(|path| path.parent()) + .expect("Cargo profile directory"); + let target_root = profile_dir.parent().expect("Cargo target root"); + let candidate = profile_dir.join(format!( + "unixnotis-svg-renderer{}", + std::env::consts::EXE_SUFFIX + )); + if fs::metadata(&candidate).is_err() { + build_svg_renderer(target_root); + } + assert!( + fs::metadata(&candidate).is_ok(), + "SVG renderer binary is missing at {candidate:?}" + ); + candidate + }) +} + +pub(super) fn renderer_fixture(name: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/svg-renderers") + .join(name); + assert!( + fs::metadata(&fixture).is_ok_and(|metadata| metadata.is_file()), + "SVG renderer fixture is missing at {fixture:?}" + ); + fixture +} + +fn build_svg_renderer(target_root: &std::path::Path) { + // Unit-test targets do not guarantee that the non-test helper binary was built + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let output = Command::new(cargo) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["build", "--bin", "unixnotis-svg-renderer", "--target-dir"]) + .arg(target_root) + .output() + .expect("build SVG renderer for center tests"); + assert!( + output.status.success(), + "failed to build SVG renderer\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index a7a9671eb..1320bd4f4 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -1,5 +1,4 @@ use std::io::Write; -use std::os::unix::fs::PermissionsExt; use flate2::write::GzEncoder; use flate2::Compression; @@ -8,12 +7,11 @@ use super::super::model::RasterImage; use super::super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; use super::super::svg::{ checked_rgba_len, decode_svg_bytes_with_renderer, decompress_svgz_with_limit, is_gzip_payload, - resolve_svg_renderer, }; +use super::support::{renderer_fixture, svg_renderer_binary}; fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { - let renderer = resolve_svg_renderer()?; - decode_svg_bytes_with_renderer(bytes, target, &renderer) + decode_svg_bytes_with_renderer(bytes, target, svg_renderer_binary()) } #[test] @@ -171,15 +169,7 @@ fn renderer_output_dimensions_are_checked_before_allocation() { #[test] fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { - let directory = tempfile::tempdir().expect("create renderer fixture directory"); - let renderer = directory.path().join("bad-renderer"); - std::fs::write( - &renderer, - "#!/bin/sh\n# Consume the complete request before returning malformed dimensions\ndd bs=1 count=8 iflag=fullblock of=/dev/null 2>/dev/null || exit 1\ncat >/dev/null\nprintf '\\377\\377\\377\\377\\377\\377\\377\\377'\n", - ) - .expect("write renderer fixture"); - std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) - .expect("make renderer executable"); + let renderer = renderer_fixture("bad-renderer"); let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect_err("oversized child dimensions must fail"); @@ -191,15 +181,7 @@ fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { #[test] fn renderer_deadline_terminates_a_slow_child() { - let directory = tempfile::tempdir().expect("create renderer fixture directory"); - let renderer = directory.path().join("slow-renderer"); - std::fs::write( - &renderer, - "#!/bin/sh\n# Consume the request so the parent can finish writing before the timeout\ncat >/dev/null\nsleep 2\n", - ) - .expect("write renderer fixture"); - std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) - .expect("make renderer executable"); + let renderer = renderer_fixture("slow-renderer"); let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect_err("slow renderer must be stopped"); @@ -208,15 +190,7 @@ fn renderer_deadline_terminates_a_slow_child() { #[test] fn renderer_stderr_is_drained_while_stdout_is_decoded() { - let directory = tempfile::tempdir().expect("create renderer fixture directory"); - let renderer = directory.path().join("chatty-renderer"); - std::fs::write( - &renderer, - "#!/bin/sh\nhead -c 1048576 /dev/zero >&2\nprintf '\\001\\000\\000\\000\\001\\000\\000\\000\\000\\000\\000\\377'\n", - ) - .expect("write renderer fixture"); - std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) - .expect("make renderer executable"); + let renderer = renderer_fixture("chatty-renderer"); let decoded = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect("chatty renderer should not deadlock"); @@ -225,15 +199,7 @@ fn renderer_stderr_is_drained_while_stdout_is_decoded() { #[test] fn renderer_stderr_is_bounded_before_error_reporting() { - let directory = tempfile::tempdir().expect("create renderer fixture directory"); - let renderer = directory.path().join("noisy-failing-renderer"); - std::fs::write( - &renderer, - "#!/bin/sh\nyes X | head -c 1048576 >&2\nexit 1\n", - ) - .expect("write renderer fixture"); - std::fs::set_permissions(&renderer, std::fs::Permissions::from_mode(0o755)) - .expect("make renderer executable"); + let renderer = renderer_fixture("noisy-failing-renderer"); let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) .expect_err("failing renderer should return an error"); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index ef159dc23..06ab68147 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -197,6 +197,72 @@ fn conversation_avatar_uses_the_master_panel_lead_slot_by_default() { assert!(!row.thumbnail.has_css_class("unixnotis-panel-sender-visual")); } +#[gtk::test] +fn disabled_notification_avatars_suppress_conversation_lead_visual() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_avatar: false, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn collapsed_and_expanded_group_rows_keep_conversation_avatar() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let notification = Rc::new(notification); + let collapsed = row_data( + Rc::clone(¬ification), + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + let expanded = row_data(notification, RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &collapsed, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + + update_notification_row(&row, &expanded, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); +} + #[gtk::test] fn historical_empty_avatar_role_does_not_create_a_blank_lead_slot() { let (_root, row) = notification_row(); diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 571a1fe8b..d4218d3f6 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -185,6 +185,71 @@ fn update_group_row_keeps_conflict_warning_out_of_the_title() { assert!(root.has_css_class("unixnotis-attribution-warning")); } +#[gtk::test] +fn recognized_group_keeps_application_icon_separate_from_trust_chip() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut recognized = notification("Example Chat").as_ref().clone(); + recognized.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "application-x-executable-symbolic", + unixnotis_core::IdentityAssurance::UserAssociated, + unixnotis_core::InteractionPolicies::CONFIRM_ACTIONS, + unixnotis_core::AttributionReason::ExactUserExecutable, + "associated user application", + "associated:user-app:org.example.Chat".to_string(), + ); + let data = RowData::group_header( + Rc::from("associated:user-app:org.example.Chat"), + 2, + false, + Rc::new(recognized), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert!(widgets.icon.paintable().is_some()); + assert_ne!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); + assert_eq!(widgets.trust_chip.text().as_str(), "Local app"); + assert!(widgets.trust_chip.get_visible()); +} + +#[gtk::test] +fn unresolved_group_uses_neutral_icon_despite_claimed_application_branding() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut unresolved = notification("Example Chat").as_ref().clone(); + unresolved.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ); + unresolved.image.claimed_theme_icon = "example-chat".to_string(); + let data = RowData::group_header( + Rc::from("claim:example-chat"), + 2, + false, + Rc::new(unresolved), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); + assert_eq!(widgets.trust_chip.text().as_str(), "Unverified"); + assert!(widgets.trust_chip.get_visible()); +} + #[gtk::test] fn relay_group_header_keeps_claim_below_command_line_identity() { support::init_gtk(); diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer new file mode 100755 index 000000000..327cdf70d --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer @@ -0,0 +1,6 @@ +#!/bin/sh + +# Consume the complete request before returning malformed dimensions +dd bs=1 count=8 iflag=fullblock of=/dev/null 2>/dev/null || exit 1 +cat >/dev/null +printf '\377\377\377\377\377\377\377\377' diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer new file mode 100755 index 000000000..20bf78ac6 --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer @@ -0,0 +1,4 @@ +#!/bin/sh + +head -c 1048576 /dev/zero >&2 +printf '\001\000\000\000\001\000\000\000\000\000\000\377' diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer new file mode 100755 index 000000000..1fa687e4e --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer @@ -0,0 +1,4 @@ +#!/bin/sh + +yes X | head -c 1048576 >&2 +exit 1 diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer new file mode 100755 index 000000000..00e486f6d --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer @@ -0,0 +1,5 @@ +#!/bin/sh + +# Consume the request so the parent can finish writing before the timeout +cat >/dev/null +sleep 2 diff --git a/crates/unixnotis-core/src/config/layout/mod.rs b/crates/unixnotis-core/src/config/layout/mod.rs index db20ed3c2..dd02a302a 100644 --- a/crates/unixnotis-core/src/config/layout/mod.rs +++ b/crates/unixnotis-core/src/config/layout/mod.rs @@ -10,4 +10,4 @@ pub use self::common::{ Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT, PANEL_RUNTIME_WIDTH_MIN, }; -pub use self::popup::PopupConfig; +pub use self::popup::{PopupConfig, MAX_POPUP_TIMEOUT_MS}; diff --git a/crates/unixnotis-core/src/config/layout/popup.rs b/crates/unixnotis-core/src/config/layout/popup.rs index c70e5dbdb..115a7c7e4 100644 --- a/crates/unixnotis-core/src/config/layout/popup.rs +++ b/crates/unixnotis-core/src/config/layout/popup.rs @@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize}; use super::{Anchor, Margins}; +/// Longest automatic popup timer accepted by the freedesktop millisecond domain +pub const MAX_POPUP_TIMEOUT_MS: u64 = 2_147_483_647; + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct PopupConfig { diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs index 2fd4e7fc4..1d4dc56a8 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs @@ -1,4 +1,6 @@ -use super::super::super::{Config, PanelConfig, PopupConfig, PANEL_HEIGHT_PERCENT_DEFAULT}; +use super::super::super::{ + Config, PanelConfig, PopupConfig, MAX_POPUP_TIMEOUT_MS, PANEL_HEIGHT_PERCENT_DEFAULT, +}; use super::{media, panel, plugins, refresh, shell, theme}; pub(in super::super) const MIN_REFRESH_MS: u64 = 100; @@ -28,6 +30,7 @@ pub(in super::super::super) fn sanitize_config(config: &mut Config) { sanitize_refresh_intervals(config); sanitize_panel_geometry(config); sanitize_popup_geometry(config); + sanitize_popup_timeouts(config); // Media, plugin, and theme rules live in their own files because each has // enough edge cases to test directly @@ -109,6 +112,15 @@ fn sanitize_popup_geometry(config: &mut Config) { config.popups.margin.left = config.popups.margin.left.clamp(0, MAX_MARGIN); } +fn sanitize_popup_timeouts(config: &mut Config) { + // Zero remains the no-timeout sentinel while positive values share one finite domain + config.popups.default_timeout_ms = config.popups.default_timeout_ms.min(MAX_POPUP_TIMEOUT_MS); + config.popups.critical_timeout_ms = config + .popups + .critical_timeout_ms + .map(|timeout| timeout.min(MAX_POPUP_TIMEOUT_MS)); +} + fn sanitize_history(config: &mut Config) { // Active notifications are bounded tighter than history to protect panel layout and memory config.history.max_active = config.history.max_active.min(MAX_HISTORY_ACTIVE); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 9f6ddc517..133ea4209 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -468,6 +468,36 @@ fn sanitize_keeps_active_limit_independent_from_history_retention() { assert_eq!(config.history.max_active, 12); } +#[test] +fn sanitize_clamps_popup_timeouts_to_the_supported_timer_domain() { + let mut config = Config::default(); + config.popups.default_timeout_ms = u64::MAX; + config.popups.critical_timeout_ms = Some(u64::MAX); + + sanitize_config(&mut config); + + assert_eq!( + config.popups.default_timeout_ms, + crate::MAX_POPUP_TIMEOUT_MS + ); + assert_eq!( + config.popups.critical_timeout_ms, + Some(crate::MAX_POPUP_TIMEOUT_MS) + ); +} + +#[test] +fn sanitize_preserves_zero_popup_timeout_as_indefinite() { + let mut config = Config::default(); + config.popups.default_timeout_ms = 0; + config.popups.critical_timeout_ms = Some(0); + + sanitize_config(&mut config); + + assert_eq!(config.popups.default_timeout_ms, 0); + assert_eq!(config.popups.critical_timeout_ms, Some(0)); +} + #[test] fn sanitize_clamps_margins_and_card_heights() { // Margin and min-height clamping should cover both stats and cards diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index 84032b20f..57d46887e 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -87,7 +87,7 @@ pub enum InhibitMode { pub struct HistoryConfig { // Saved items pub max_entries: usize, - // Live items + // Live items retained per stable sender process pub max_active: usize, // Save transient items too pub transient_to_history: bool, @@ -97,7 +97,7 @@ impl Default for HistoryConfig { fn default() -> Self { Self { max_entries: 200, - // Match the daemon cap + // Match the daemon's per-principal cap max_active: 12, transient_to_history: false, } diff --git a/crates/unixnotis-daemon/src/child_process/tests/command.rs b/crates/unixnotis-daemon/src/child_process/tests/command.rs index ecfc71723..1c27515f7 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/command.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/command.rs @@ -19,7 +19,7 @@ async fn mark_running_updates_popup_health_and_resets_center_readiness() { let state = daemon_state_for_test(false).await; UiProcessKind::Popups.mark_running(&state, true); - assert!(state.popups_process_running()); + assert!(state.ui_health().popups_process_running); // Center process spawn is not readiness; readiness only flips after live subscriptions state.set_panel_ready(":1.20", true); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs index ac371fd37..b13784c69 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -167,14 +167,6 @@ where resolution } -#[cfg_attr( - not(test), - expect(dead_code, reason = "helper remains as an explicit pipeline test seam") -)] -pub(super) const fn should_return_initial_resolution(needs_provenance: bool) -> bool { - !needs_provenance -} - pub(super) fn needs_sender_provenance( status: AttributionStatus, interactions: InteractionPolicies, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs index d96c4fd8b..0ad05e140 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs @@ -31,14 +31,3 @@ pub(super) fn enrich_sender_install_provenance_blocking( } sender.install_provenance = index.install_provenance_for_path(current.canonical_path); } - -#[cfg_attr( - not(test), - expect(dead_code, reason = "async wrapper remains for resolver tests") -)] -pub(super) async fn enrich_sender_install_provenance( - sender: &mut SenderMetadata, - index: &DesktopIdentityIndex, -) { - enrich_sender_install_provenance_blocking(sender, index); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs index fa8fb2358..4e6f9e31c 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -14,9 +14,9 @@ use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRe use super::pipeline::{ claim_has_index_candidate, needs_sender_provenance, resolve_attribution_owned_with, resolve_attribution_owned_with_pool, resolve_attribution_with_deadline, resolve_with_evidence, - should_return_initial_resolution, ATTRIBUTION_TIMEOUT, + ATTRIBUTION_TIMEOUT, }; -use super::sender_context::enrich_sender_install_provenance; +use super::sender_context::enrich_sender_install_provenance_blocking; use super::AppClaim; use crate::daemon::notifications::identity::desktop_index::model::{ ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, @@ -50,10 +50,10 @@ pub(super) async fn resolve_attribution( initial.attribution.interactions, claim_has_index_candidate(claim, index), ); - if should_return_initial_resolution(needs_provenance) { + if !needs_provenance { return initial; } - enrich_sender_install_provenance(&mut sender, index).await; + enrich_sender_install_provenance_blocking(&mut sender, index); resolve_with_evidence(claim, &sender, index) } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs index 3f33a89dd..750b5fdc5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -6,12 +6,6 @@ use std::time::Duration; use super::super::*; -#[test] -fn initial_resolution_skips_provenance_when_no_lookup_is_needed() { - assert!(should_return_initial_resolution(false)); - assert!(!should_return_initial_resolution(true)); -} - #[test] fn provenance_enrichment_is_limited_to_denied_association_candidates() { for (status, policies, has_candidate, expected) in [ diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs index 79d8b4fd2..2c1a4c9e5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs @@ -1,6 +1,6 @@ //! Live sender-context enrichment tests -use super::super::sender_context::enrich_sender_install_provenance; +use super::super::sender_context::enrich_sender_install_provenance_blocking; use super::*; #[tokio::test] @@ -11,7 +11,7 @@ async fn provenance_enrichment_preserves_known_ownership_without_lookup() { ..SenderMetadata::default() }; - enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); assert_eq!(metadata.install_provenance, expected); } @@ -20,7 +20,7 @@ async fn provenance_enrichment_preserves_known_ownership_without_lookup() { async fn provenance_enrichment_keeps_unknown_when_process_identity_is_missing() { let mut metadata = SenderMetadata::default(); - enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); } @@ -30,7 +30,7 @@ async fn provenance_enrichment_resolves_a_reopened_system_executable() { let (path, executable_identity) = installed_system_executable(); let mut metadata = sender(&path, executable_identity); - enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); assert!(metadata.install_provenance.is_known()); } @@ -51,7 +51,7 @@ async fn provenance_enrichment_rejects_untrusted_or_nonexecutable_sender_metadat for invalid_identity in invalid_identities { let mut metadata = sender(&path, invalid_identity); - enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); } } @@ -65,7 +65,7 @@ async fn provenance_enrichment_rejects_a_stale_executable_identity() { }; let mut metadata = sender(&path, stale_identity); - enrich_sender_install_provenance(&mut metadata, &DesktopIdentityIndex::default()).await; + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index df889f4cd..79869921f 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -395,23 +395,6 @@ fn classify_command_line( } } -#[cfg_attr( - not(test), - expect(dead_code, reason = "kept as a focused process-lifetime test seam") -)] -fn stable_process_evidence( - start_before: Option, - evidence: Option, - start_after: Option, -) -> (Option, Option) { - // Both lifetime reads must name the same process before executable evidence is trusted - if start_before.is_some() && start_before == start_after { - (start_before, evidence) - } else { - (None, None) - } -} - #[cfg(all(target_os = "linux", test))] fn parse_process_start_time(stat: &str) -> Option { parse_process_stat(stat).map(|stat| stat.start_time) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 3262c1c9e..867034314 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -1,5 +1,18 @@ use super::*; +fn stable_process_evidence( + start_before: Option, + evidence: Option, + start_after: Option, +) -> (Option, Option) { + // Both lifetime reads must name the same process before executable evidence is trusted + if start_before.is_some() && start_before == start_after { + (start_before, evidence) + } else { + (None, None) + } +} + #[tokio::test] async fn credential_reads_run_concurrently_within_the_supported_deadline() { let started = std::time::Instant::now(); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs index 8817c7eb2..c8beca1be 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -178,20 +178,28 @@ fn build_image( if let Some(image_data) = content_image.and_then(NotificationImage::normalize_image_data) { image.content_image = image_data; } - if may_materialize_application_icon(attribution) { - if let Some(visual) = wire_sender_visual - .or(sender_visual) - .and_then(normalize_avatar_visual) - { - image.sender_visual_role = match sender_visual_role { - SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, - SenderVisualRole::ApplicationProvidedIcon => { - NotificationVisualRole::ApplicationProvidedIcon - } - SenderVisualRole::None => NotificationVisualRole::None, - }; - image.sender_visual = visual; - } + let sender_visual = match sender_visual_role { + // Bounded wire pixels are conversation presentation, not application identity evidence + SenderVisualRole::ConversationAvatar => wire_sender_visual.or_else(|| { + may_materialize_application_icon(attribution) + .then_some(sender_visual) + .flatten() + }), + // Decorative application art still requires a positive local association + SenderVisualRole::ApplicationProvidedIcon => may_materialize_application_icon(attribution) + .then_some(wire_sender_visual.or(sender_visual)) + .flatten(), + SenderVisualRole::None => None, + }; + if let Some(visual) = sender_visual.and_then(normalize_avatar_visual) { + image.sender_visual_role = match sender_visual_role { + SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, + SenderVisualRole::ApplicationProvidedIcon => { + NotificationVisualRole::ApplicationProvidedIcon + } + SenderVisualRole::None => NotificationVisualRole::None, + }; + image.sender_visual = visual; } image } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs index 9379a707b..b058092a5 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -7,8 +7,9 @@ mod visuals; pub(in crate::daemon::notifications) use build::{build_notification, NotificationInput}; pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) use visuals::{ - materialize_sender_visual, may_materialize_content_image, sender_visual_role, SenderVisualRole, - CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, + materialize_sender_visual, may_materialize_content_image, sender_visual_path_allowed, + sender_visual_role, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, + MAX_STORED_CONTENT_DIMENSION, }; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs index 9886a64d1..9423047b7 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -475,3 +475,53 @@ fn unassociated_communication_image_data_stays_untrusted_content() { assert!(!notification.image.content_image.data.is_empty()); assert!(notification.image.sender_visual.data.is_empty()); } + +#[test] +fn unresolved_communication_keeps_bounded_wire_pixels_as_conversation_avatar() { + let avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7, 8, 9, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example Chat".to_string(), + app_icon: "/tmp/untrusted-avatar.png".to_string(), + summary: "Conversation".to_string(), + body: "Message".to_string(), + actions: Vec::new(), + hints: HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category"), + )]), + image_data: None, + sender_visual_data: Some(avatar), + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert_eq!(notification.image.sender_visual.data, vec![7, 8, 9, 255]); + assert!(notification.image.content_image.data.is_empty()); + assert!(notification.app_icon.is_empty()); + assert_eq!( + notification.attribution.assurance, + unixnotis_core::IdentityAssurance::Unresolved + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs index 4b559f7c2..d3cfa00fe 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -12,7 +12,7 @@ pub(super) use super::sanitize::{ pub(super) use super::visuals::{ avatar_buffer_size_allowed, avatar_file_size_allowed, bounded_decode_dimension, materialize_sender_visual, may_materialize_application_icon, sender_visual_file_allowed, - MAX_SENDER_VISUAL_BYTES, + sender_visual_path_allowed, MAX_SENDER_VISUAL_BYTES, }; pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs index 45c8b1e97..52bc3b6c3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -87,10 +87,15 @@ fn associated_noncommunication_path_is_a_small_application_visual() { ); assert_eq!(role, SenderVisualRole::ApplicationProvidedIcon); + assert!(sender_visual_path_allowed(role, &attribution)); + assert!(!sender_visual_path_allowed( + SenderVisualRole::None, + &attribution + )); } #[test] -fn portal_association_cannot_start_host_avatar_materialization() { +fn portal_communication_keeps_wire_avatar_role_without_allowing_host_path_access() { let attribution = unixnotis_core::NotificationAttribution::associated( "Portal app", "Portal app", @@ -103,16 +108,39 @@ fn portal_association_cannot_start_host_avatar_materialization() { "recognized:portal:org.example.PortalApp".to_string(), ); assert!(!may_materialize_application_icon(&attribution)); - assert_eq!( - sender_visual_role( - &attribution, - &super::super::super::super::identity::DesktopIdentityIndex::default(), - &HashMap::new(), - &["inline-reply".to_string(), "Reply".to_string()], - "", - ), - SenderVisualRole::None + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + "/tmp/untrusted-avatar.png", + ); + + assert_eq!(role, SenderVisualRole::ConversationAvatar); + assert!(!sender_visual_path_allowed(role, &attribution)); +} + +#[test] +fn unresolved_communication_role_never_authorizes_sender_filesystem_paths() { + let attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), ); + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category value"), + )]), + &[], + "/tmp/untrusted-avatar.png", + ); + + assert_eq!(role, SenderVisualRole::ConversationAvatar); + assert!(!sender_visual_path_allowed(role, &attribution)); } #[test] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs index 52a3b7eb3..1becaa727 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -55,12 +55,7 @@ pub(in crate::daemon::notifications) fn sender_visual_role( actions: &[String], app_icon: &str, ) -> SenderVisualRole { - // Sender paths are never opened until attribution grants positive local evidence - if !may_materialize_application_icon(attribution) { - return SenderVisualRole::None; - } - - // An advertised inline reply is an explicit communication signal + // Communication metadata selects a presentation slot without authenticating the application if actions .chunks_exact(2) .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) @@ -83,13 +78,22 @@ pub(in crate::daemon::notifications) fn sender_visual_role( let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); if explicit_metadata || desktop_metadata { SenderVisualRole::ConversationAvatar - } else if local_avatar_path(app_icon).is_some() { + } else if may_materialize_application_icon(attribution) && local_avatar_path(app_icon).is_some() + { SenderVisualRole::ApplicationProvidedIcon } else { SenderVisualRole::None } } +pub(in crate::daemon::notifications) const fn sender_visual_path_allowed( + role: SenderVisualRole, + attribution: &NotificationAttribution, +) -> bool { + // Local paths remain identity-gated even when wire pixels may be shown as presentation data + !matches!(role, SenderVisualRole::None) && may_materialize_application_icon(attribution) +} + pub(in crate::daemon::notifications) fn materialize_sender_visual( app_icon: &str, max_dimension: u32, diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs index eb6103697..6185a669d 100644 --- a/crates/unixnotis-daemon/src/store/runtime.rs +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -237,15 +237,10 @@ impl NotificationStore { popup_hide_after_ms, }, ); - let deadline = if popup_hide_after_ms == 0 { - None - } else { - Some( - admitted_at - .checked_add(Duration::from_millis(popup_hide_after_ms)) - .unwrap_or(admitted_at), - ) - }; + let deadline = (popup_hide_after_ms != 0) + .then(|| Duration::from_millis(popup_hide_after_ms)) + .and_then(|duration| admitted_at.checked_add(duration)); + // None means indefinite both for the explicit zero sentinel and defensive clock overflow self.popup_timings.insert(key, PopupTiming { deadline }); } diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs index bc3ef11b6..e8f712a74 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -505,6 +505,36 @@ fn popup_materialization_keeps_zero_as_the_no_automatic_hide_value() { assert_eq!(candidate.notification.popup_hide_after_ms, 0); } +#[test] +fn popup_deadline_overflow_becomes_indefinite_instead_of_immediate() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("overflow-safe deadline"), 0) + .active_notification(); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + u64::MAX, + Instant::now(), + ); + + assert!( + store.popup_deadline_is_current(notification.key(), Instant::now()), + "timer overflow must not expire a popup at admission" + ); + let timing = store + .popup_timings + .get(¬ification.key()) + .expect("popup timing"); + assert!( + timing + .deadline + .is_none_or(|deadline| deadline > Instant::now()), + "a representable large timer must remain in the future" + ); +} + #[test] fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { let mut store = make_store_with_limits(10, 10); diff --git a/crates/unixnotis-daemon/src/trial_mode/owner.rs b/crates/unixnotis-daemon/src/trial_mode/owner.rs index d6543c5f5..d9460ed21 100644 --- a/crates/unixnotis-daemon/src/trial_mode/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/owner.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use tokio::fs; use tokio::time::timeout; use tracing::warn; @@ -7,32 +7,27 @@ use zbus::fdo::DBusProxy; use crate::system_tools; +use super::state::NotificationOwnerState; use super::{DetectedDaemon, OwnerInfo, KNOWN_DAEMONS, TRIAL_COMMAND_TIMEOUT}; pub(super) async fn detect_owner( dbus_proxy: &DBusProxy<'_>, notifications_name: zbus::names::BusName<'_>, -) -> Result> { +) -> Result { // Quick owner check avoids extra calls when Notifications is unclaimed - let has_owner = match dbus_proxy.name_has_owner(notifications_name.clone()).await { - Ok(value) => value, - Err(err) => { - warn!(?err, "failed to query D-Bus owner state"); - false - } - }; + let has_owner = dbus_proxy + .name_has_owner(notifications_name.clone()) + .await + .context("query Notifications ownership")?; if !has_owner { - return Ok(None); + return Ok(NotificationOwnerState::Unowned); } let owner = dbus_proxy .get_name_owner(notifications_name) .await - .ok() - .map(|name| name.to_string()); - let Some(unique_name) = owner else { - return Ok(None); - }; + .context("resolve Notifications owner")?; + let unique_name = owner.to_string(); // Resolve PID from unique bus name when possible let pid = if let Ok(bus_name) = zbus::names::BusName::try_from(unique_name.as_str()) { @@ -56,7 +51,28 @@ pub(super) async fn detect_owner( None => None, }); - Ok(Some(OwnerInfo { pid, comm, args })) + Ok(NotificationOwnerState::Owned(OwnerInfo { + unique_name, + pid, + comm, + args, + })) +} + +pub(super) async fn ensure_owner_is_current( + dbus_proxy: &DBusProxy<'_>, + notifications_name: zbus::names::BusName<'_>, + inspected: &OwnerInfo, +) -> Result<()> { + let current = dbus_proxy + .get_name_owner(notifications_name) + .await + .context("revalidate Notifications owner before trial stop")?; + anyhow::ensure!( + current.as_str() == inspected.unique_name, + "Notifications owner changed during trial preparation; refusing to stop either process" + ); + Ok(()) } pub(super) async fn detect_known_daemons(owner: &Option) -> Vec { diff --git a/crates/unixnotis-daemon/src/trial_mode/state.rs b/crates/unixnotis-daemon/src/trial_mode/state.rs index 38c377d99..cd6d51f9c 100644 --- a/crates/unixnotis-daemon/src/trial_mode/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/state.rs @@ -35,6 +35,8 @@ pub enum RestoreAction { } pub struct OwnerInfo { + // Exact broker address is revalidated immediately before any stop operation + pub(super) unique_name: String, // D-Bus owner PID when available pub(super) pid: Option, // Process name from /proc or ps @@ -43,6 +45,11 @@ pub struct OwnerInfo { pub(super) args: Option>, } +pub(in crate::trial_mode) enum NotificationOwnerState { + Unowned, + Owned(OwnerInfo), +} + pub struct DetectedDaemon { pub(super) name: String, pub(super) systemd_active: bool, @@ -62,23 +69,24 @@ pub async fn prepare_trial( ) -> Result { debug!("trial mode detection started"); // Step 1: resolve the current D-Bus owner for Notifications - let owner = owner::detect_owner(dbus_proxy, notifications_name.clone()).await?; - if owner.is_none() { - debug!("trial mode: no current notification owner"); - return Ok(TrialState::default()); - } + let owner = match owner::detect_owner(dbus_proxy, notifications_name.clone()).await? { + NotificationOwnerState::Unowned => { + debug!("trial mode: no current notification owner"); + return Ok(TrialState::default()); + } + NotificationOwnerState::Owned(owner) => owner, + }; - if let Some(info) = owner.as_ref() { - debug!( - pid = info.pid, - comm = info.comm.as_deref().unwrap_or("unknown"), - "trial mode: current owner detected" - ); - } + debug!( + pid = owner.pid, + comm = owner.comm.as_deref().unwrap_or("unknown"), + "trial mode: current owner detected" + ); // Step 2: collect known daemon status so prompt output is actionable - let daemons = owner::detect_known_daemons(&owner).await; - owner::print_detected_daemons(&daemons, &owner); + let owner_view = Some(owner); + let daemons = owner::detect_known_daemons(&owner_view).await; + owner::print_detected_daemons(&daemons, &owner_view); if !args.yes { // Prompt runs on a blocking worker to keep async runtime responsive @@ -90,9 +98,10 @@ pub async fn prepare_trial( } } - let Some(owner) = owner else { - return Err(anyhow!("no current owner detected for trial mode")); + let Some(owner) = owner_view else { + return Err(anyhow!("trial owner state disappeared before revalidation")); }; + owner::ensure_owner_is_current(dbus_proxy, notifications_name.clone(), &owner).await?; // Step 3: stop current owner and capture restore plan when applicable let restore_action = control::stop_active_owner(args, &owner).await?; diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/control.rs b/crates/unixnotis-daemon/src/trial_mode/tests/control.rs index 7ec387aa8..bc0044d9a 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/control.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/control.rs @@ -46,6 +46,7 @@ impl Drop for TempDirGuard { #[test] fn restart_command_preserves_captured_argv_without_trusted_lookup() { let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: Some(vec![ @@ -67,6 +68,7 @@ fn restart_command_resolves_missing_argv_fallback_from_trusted_tools() { root.write_executable("mako", "#!/bin/sh\nexit 0\n"); let _tools = use_fake_tool_bin(&root.path); let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -86,6 +88,7 @@ fn restart_command_rejects_missing_argv_fallback_when_not_trusted() { let empty_trusted = TempDirGuard::new("empty-trusted"); let _tools = use_fake_tool_bin(&empty_trusted.path); let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -147,6 +150,7 @@ async fn process_restore_is_fully_constructed_before_owner_is_stopped() { run_seconds: None, }; let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -174,6 +178,7 @@ async fn stop_active_owner_returns_systemd_restore_action_in_auto_mode() { run_seconds: None, }; let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: Some(vec!["/usr/bin/mako".to_string()]), diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs index c3d0cb1fc..10881c3c8 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs @@ -1,104 +1,69 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::time::{SystemTime, UNIX_EPOCH}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::Connection; -use crate::system_tools::routing::use_fake_tool_bin; - -use super::{command_program_name, is_unit_active, pgrep_exact, read_args, read_comm}; - -struct TempDirGuard { - path: std::path::PathBuf, -} - -impl TempDirGuard { - fn new(label: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "unixnotis-trial-owner-{label}-{}-{stamp}", - std::process::id() - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write_executable(&self, name: &str, contents: &str) { - let path = self.path.join(name); - fs::write(&path, contents).expect("write fake tool"); - let mut permissions = fs::metadata(&path) - .expect("fake tool metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("chmod fake tool"); - } -} - -impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} +use super::{detect_owner, ensure_owner_is_current}; +use crate::trial_mode::state::NotificationOwnerState; #[tokio::test] -async fn is_unit_active_uses_trusted_systemctl_exit_status() { - let root = TempDirGuard::new("systemctl-active"); - root.write_executable( - "systemctl", - "#!/bin/sh\ncase \"$*\" in *mako.service*) exit 0;; *) exit 3;; esac\n", +async fn broker_failure_does_not_become_an_unowned_notification_name() { + let connection = Connection::session().await.expect("session bus connection"); + let proxy = DBusProxy::new(&connection).await.expect("D-Bus proxy"); + connection.close().await.expect("close test bus connection"); + let notifications = + BusName::try_from(unixnotis_core::NOTIFICATIONS_BUS_NAME).expect("Notifications bus name"); + + assert!( + detect_owner(&proxy, notifications).await.is_err(), + "broker failure must remain an error" ); - let _tools = use_fake_tool_bin(&root.path); - - assert!(is_unit_active("mako.service").await); - assert!(!is_unit_active("dunst.service").await); -} - -#[tokio::test] -async fn pgrep_exact_parses_only_numeric_pids() { - let root = TempDirGuard::new("pgrep"); - root.write_executable("pgrep", "#!/bin/sh\nprintf '12\\nnot-a-pid\\n34\\n'\n"); - let _tools = use_fake_tool_bin(&root.path); - - let pids = pgrep_exact("mako").await; - - assert_eq!(pids, [12, 34]); } #[tokio::test] -async fn read_comm_uses_trusted_ps_fallback_when_procfs_is_missing() { - let root = TempDirGuard::new("comm"); - root.write_executable("ps", "#!/bin/sh\nprintf 'mako\\n'\n"); - let _tools = use_fake_tool_bin(&root.path); - - let comm = read_comm(u32::MAX).await; - - assert_eq!(comm.as_deref(), Some("mako")); -} - -#[tokio::test] -async fn read_args_uses_trusted_ps_fallback_when_procfs_is_missing() { - let root = TempDirGuard::new("args"); - root.write_executable( - "ps", - "#!/bin/sh\nprintf '/usr/bin/mako --config mako.conf\\n'\n", - ); - let _tools = use_fake_tool_bin(&root.path); - - let args = read_args(u32::MAX).await.expect("fallback args"); - - assert_eq!(args, ["/usr/bin/mako", "--config", "mako.conf"]); -} - -#[test] -fn command_program_name_preserves_long_notification_daemon_names() { - let args = vec![ - "/usr/bin/mate-notification-daemon".to_string(), - "--replace".to_string(), - ]; - - assert_eq!( - command_program_name(&args).as_deref(), - Some("mate-notification-daemon") - ); +async fn owner_handoff_after_inspection_blocks_the_stop_precondition() { + let owner_a = Connection::session().await.expect("first owner connection"); + let owner_b = Connection::session() + .await + .expect("second owner connection"); + let observer = Connection::session().await.expect("observer connection"); + let name = format!("com.unixnotis.TrialOwner.p{}", std::process::id()); + owner_a + .request_name(name.as_str()) + .await + .expect("first owner acquires test name"); + let proxy = DBusProxy::new(&observer) + .await + .expect("observer D-Bus proxy"); + let inspected = match detect_owner( + &proxy, + BusName::try_from(name.as_str()).expect("test bus name"), + ) + .await + .expect("inspect first owner") + { + NotificationOwnerState::Owned(owner) => owner, + NotificationOwnerState::Unowned => panic!("test name must be owned"), + }; + owner_a + .release_name(name.as_str()) + .await + .expect("first owner releases test name"); + owner_b + .request_name(name.as_str()) + .await + .expect("second owner acquires test name"); + + let error = ensure_owner_is_current( + &proxy, + BusName::try_from(name.as_str()).expect("test bus name"), + &inspected, + ) + .await + .expect_err("owner handoff must block process stopping"); + + assert!(error.to_string().contains("owner changed")); + owner_b + .release_name(name.as_str()) + .await + .expect("release second test owner"); } diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/state.rs b/crates/unixnotis-daemon/src/trial_mode/tests/state.rs index 4c467e996..e6d9ba105 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/state.rs @@ -1,6 +1,7 @@ use anyhow::anyhow; -use super::{restore_after_prepare_failure, RestoreAction, TrialState}; +use super::{prepare_trial, restore_after_prepare_failure, RestoreAction, TrialState}; +use crate::cli::{Args, RestoreStrategy}; impl TrialState { pub(crate) const fn with_restore_action_for_test(action: RestoreAction) -> Self { @@ -25,3 +26,27 @@ fn preparation_failure_restores_once_and_preserves_both_errors() { assert!(message.contains("trial restoration also failed")); assert!(trial.take_restore_action().is_none()); } + +#[tokio::test] +async fn trial_preparation_propagates_broker_failure_instead_of_assuming_unowned() { + let connection = zbus::Connection::session() + .await + .expect("session bus connection"); + let proxy = zbus::fdo::DBusProxy::new(&connection) + .await + .expect("D-Bus proxy"); + connection.close().await.expect("close test bus connection"); + let notifications = zbus::names::BusName::try_from(unixnotis_core::NOTIFICATIONS_BUS_NAME) + .expect("Notifications bus name"); + let args = Args { + config: None, + trial: true, + restore: RestoreStrategy::Auto, + yes: true, + restore_wait_ms: 1, + check: false, + run_seconds: None, + }; + + assert!(prepare_trial(&args, &proxy, notifications).await.is_err()); +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index e4b5b3db2..7f23e42b3 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -11,7 +11,6 @@ use unixnotis_core::NotificationView; use super::presentation::{PopupEntryViewModel, PopupKind}; use crate::ui::UiState; -use unixnotis_ui::presentation::SenderVisualPresentation; pub(super) use common::{build_action_row, build_close_button}; pub(in crate::ui::entry) use reply::build_inline_reply; @@ -44,17 +43,10 @@ pub(super) fn append_thumbnail( view: &PopupEntryViewModel, content: >k::Box, ) -> bool { - let sender_visual = view.visuals.sender; - let is_application_visual = sender_visual == SenderVisualPresentation::ApplicationProvidedIcon; - if view.thumbnail != super::presentation::ThumbnailKind::Content && !is_application_visual { + if !should_append_thumbnail(view) { return false; } - let image = - if is_application_visual && view.thumbnail != super::presentation::ThumbnailKind::Content { - UiState::build_sender_visual_widget(notification) - } else { - UiState::build_content_image_widget(notification) - }; + let image = UiState::build_content_image_widget(notification); let Some(image) = image else { return false; }; @@ -62,13 +54,18 @@ pub(super) fn append_thumbnail( return false; } - // Keep decorative sender art compact while content media gets the larger image treatment + // Only genuine message media belongs below the body in the content lane image.set_halign(gtk::Align::Start); - if is_application_visual { - image.add_css_class("unixnotis-popup-sender-visual"); - } else { - image.add_css_class("unixnotis-popup-content-image"); - } + image.add_css_class("unixnotis-popup-content-image"); content.append(&image); true } + +const fn should_append_thumbnail(view: &PopupEntryViewModel) -> bool { + // Sender and application visuals are identity-lane data, never message attachments + matches!(view.thumbnail, super::presentation::ThumbnailKind::Content) +} + +#[cfg(test)] +#[path = "tests/thumbnail.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs index f6115d08c..884471104 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -1,5 +1,9 @@ -use super::popup_accessible_label; +use super::{build_popup_grid, popup_accessible_label, PopupLayout}; use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{Config, NotificationImage, NotificationView, ThemePaths}; +use unixnotis_ui::css::CssManager; use unixnotis_ui::presentation::{ BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, VisualPresentation, @@ -38,6 +42,52 @@ fn conflict_accessible_name_includes_trust_claim_and_body() { ); } +#[gtk::test] +fn conversation_avatar_occupies_left_grid_column_across_message_rows() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupAvatarGrid") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register avatar grid application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-avatar-grid"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let notification = conversation_notification(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: true, + }, + ); + + let avatar = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("left grid cell should contain the identity avatar"); + let avatar_second_row = rendered + .widget + .child_at(0, 1) + .expect("avatar should span the message row"); + let icon = avatar + .first_child() + .and_downcast::() + .expect("avatar slot should contain one image"); + + assert_eq!(avatar_second_row, avatar.upcast::()); + assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(rendered.widget.child_at(1, 1).is_some()); +} + fn view_model() -> PopupEntryViewModel { PopupEntryViewModel { kind: PopupKind::Communication, @@ -64,3 +114,52 @@ fn view_model() -> PopupEntryViewModel { critical: false, } } + +fn conversation_notification() -> NotificationView { + let mut notification = NotificationView { + id: 7, + generation: 1, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ), + summary: "PV2 Rivera in Tel Aviv 2026".to_string(), + body: "10 eps I heard ts tuff asf".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + notification +} + +fn theme_paths(root: &std::path::Path) -> ThemePaths { + ThemePaths { + base_dir: root.to_path_buf(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs new file mode 100644 index 000000000..237eed3cd --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs @@ -0,0 +1,121 @@ +use super::{append_thumbnail, should_append_thumbnail}; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use gtk::prelude::*; +use unixnotis_core::{NotificationImage, NotificationView}; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; + +#[test] +fn application_provided_visual_cannot_enter_message_thumbnail_lane() { + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ApplicationProvidedIcon; + + assert!(!should_append_thumbnail(&view)); +} + +#[test] +fn genuine_content_image_enters_message_thumbnail_lane() { + let mut view = view_model(); + view.thumbnail = ThumbnailKind::Content; + view.visuals.content_image = true; + + assert!(should_append_thumbnail(&view)); +} + +#[gtk::test] +fn append_thumbnail_rejects_application_visual_without_adding_widget() { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = pixel(); + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ApplicationProvidedIcon; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(!append_thumbnail(¬ification, &view, &content)); + assert!(content.first_child().is_none()); +} + +#[gtk::test] +fn append_thumbnail_adds_only_genuine_content_image() { + let mut notification = notification(); + notification.image.content_image = pixel(); + let mut view = view_model(); + view.thumbnail = ThumbnailKind::Content; + view.visuals.content_image = true; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(append_thumbnail(¬ification, &view, &content)); + let image = content + .first_child() + .and_downcast::() + .expect("content lane should contain one image"); + assert!(image.has_css_class("unixnotis-popup-content-image")); +} + +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel { + kind: PopupKind::Communication, + app_label: "Example Chat".to_string(), + secondary_claim: None, + badge: BadgePresentation::UnknownApplication, + timestamp_label: "now".to_string(), + title: "Conversation".to_string(), + body: Some("Message".to_string()), + thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, + default_action_key: None, + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Unresolved, + short_label: Some("Unverified".to_string()), + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + } +} + +fn notification() -> NotificationView { + NotificationView { + id: 1, + generation: 1, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ), + summary: "Conversation".to_string(), + body: "Message".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn pixel() -> unixnotis_core::ImageData { + unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + } +} diff --git a/crates/unixnotis-popups/src/ui/icons/state.rs b/crates/unixnotis-popups/src/ui/icons/state.rs index b84c36d8f..f9a79d18e 100644 --- a/crates/unixnotis-popups/src/ui/icons/state.rs +++ b/crates/unixnotis-popups/src/ui/icons/state.rs @@ -24,8 +24,6 @@ const ICON_CACHE_MAX_ENTRIES: usize = 256; const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1_048_576; // Content stays visibly separate from the daemon-associated application badge const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 64; -// Decorative sender art is a small context cue, not the notification identity -const POPUP_APPLICATION_VISUAL_SIZE: i32 = 38; // Missing icons are retried soon so package and theme installs heal without a process restart const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); @@ -49,21 +47,6 @@ impl UiState { Some(widget) } - pub(in crate::ui) fn build_sender_visual_widget( - notification: &NotificationView, - ) -> Option { - if notification.image.sender_visual_role - != unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon - { - return None; - } - let texture = image_data_texture_for_data(¬ification.image.sender_visual)?; - let widget = gtk::Image::from_paintable(Some(&texture)); - set_popup_icon_size(&widget, POPUP_APPLICATION_VISUAL_SIZE); - widget.add_css_class("unixnotis-popup-application-visual"); - Some(widget) - } - pub(in crate::ui) fn build_content_image_widget( notification: &NotificationView, ) -> Option { diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 14858ac49..6f81212b2 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -215,7 +215,8 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { data: vec![255, 32, 32, 255], }; let decorative_root = state.build_popup_root(&decorative); - assert!(descendant_has_class( + // Application identity art never enters the message-content lane below the body + assert!(!descendant_has_class( decorative_root.upcast_ref(), "unixnotis-popup-sender-visual" )); @@ -223,6 +224,7 @@ fn popup_image_builders_distinguish_content_badges_and_missing_sources() { decorative_root.upcast_ref(), "unixnotis-popup-content-image" )); + assert!(!decorative_root.has_css_class(hooks::popup_card::HAS_IMAGE)); } #[gtk::test] From d6b79a8bb9318f17fb347ff1a72379a209910dbc Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:23:32 -0500 Subject: [PATCH 258/275] fix(installer): make config restore transactional Make backup restoration durable, validated, and recoverable. - validate restore input before publishing live configuration - stage restore contents before mutation - journal previous file state before replacing live files - reject unsafe paths, duplicate entries, invalid schemas, and oversized files - restore original files after interrupted or failed transactions - commit successful restores by removing transaction state - add recovery, validation, and exact-size boundary tests --- .../src/actions/config/backup/mod.rs | 1 + .../src/actions/config/backup/restore.rs | 380 +++++++++------- .../config/backup/restore_transaction.rs | 413 ++++++++++++++++++ .../actions/config/backup/tests/restore.rs | 146 ++++++- .../backup/tests/restore_transaction.rs | 319 ++++++++++++++ .../config/backup/tests/restore_validation.rs | 108 +++++ .../actions/config/backup/tests/support.rs | 3 +- .../src/actions/config/tests/provision.rs | 3 +- .../src/actions/config/tests/state_cleanup.rs | 6 - 9 files changed, 1186 insertions(+), 193 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs create mode 100644 crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs diff --git a/crates/unixnotis-installer/src/actions/config/backup/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/mod.rs index 5b380d0ee..0b19589c5 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/mod.rs @@ -2,6 +2,7 @@ mod listing; mod restore; +mod restore_transaction; mod settings; mod snapshot; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 9e78bdd49..e3044f0f2 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -1,223 +1,274 @@ -//! Backup restore helpers and path guards +//! Transactional backup restore planning and commit +use std::collections::HashSet; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::{create_directory_all, open_regular_file, write_file_atomic}; -use unixnotis_core::{Config, DEFAULT_SCRIPTS}; +use unixnotis_core::filesystem::open_regular_file; +use unixnotis_core::{Config, DEFAULT_SCRIPTS, MAX_CONFIG_BYTES}; use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; use super::listing::BACKUP_PREFIX; +pub(super) const MAX_RESTORE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +struct RestorePlan { + config_path: PathBuf, + files: Vec, + warnings: Vec, +} + +struct RestoreFile { + label: String, + target: PathBuf, + mode: u32, + contents: Vec, +} + pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { let Some(backup_dir) = ctx.restore_backup.clone() else { return Err(anyhow!("no backup directory selected")); }; - - // Derive the config root from the selected backup so tests do not depend on env state let config_dir = backup_dir .parent() .ok_or_else(|| anyhow!("backup directory missing parent"))? .to_path_buf(); - let config_path = config_dir.join("config.toml"); + validate_backup_directory_name(&backup_dir)?; - let backup_name = backup_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - if !backup_name.starts_with(BACKUP_PREFIX) { - return Err(anyhow!("backup directory name is not recognized")); - } + // A durable journal makes an interrupted earlier restore safe before another plan is built + super::restore_transaction::recover_pending_restore(&config_dir)?; log_line( ctx, format!("Restoring config from {}", format_with_home(&backup_dir)), ); - - // Restore config.toml first so restored theme paths drive the rest of the write targets - restore_config_file(ctx, &backup_dir, &config_path)?; - let config = load_restored_config(ctx, &config_path); - let theme_paths = config - .resolve_theme_paths_from(&config_dir) - .map_err(|err| anyhow!(err.to_string()))?; - - restore_theme_files(ctx, &backup_dir, &config_dir, &theme_paths)?; - restore_bundled_scripts(ctx, &backup_dir, &config_dir)?; - - Ok(()) -} - -fn restore_config_file( - ctx: &mut ActionContext, - backup_dir: &Path, - config_path: &Path, -) -> Result<()> { - let source = backup_dir.join("config.toml"); - if !source.exists() { + // Planning reads, parses, resolves, and bounds every source before any live file changes + let plan = build_restore_plan(&backup_dir, &config_dir)?; + for warning in &plan.warnings { + log_line(ctx, format!("Warning: {warning}")); + } + apply_restore_plan(&plan)?; + for file in &plan.files { log_line( ctx, - "Warning: backup missing config.toml; leaving current file unchanged".to_string(), + format!( + "Restored {} -> {}", + file.label, + format_with_home(&file.target) + ), ); - return Ok(()); } - - let contents = - fs::read_to_string(&source).with_context(|| "failed to read backup config.toml")?; - write_file_atomic(config_path, contents.as_bytes(), 0o644) - .with_context(|| "failed to restore config.toml")?; - log_line( - ctx, - format!("Restored config.toml -> {}", format_with_home(config_path)), - ); Ok(()) } -fn load_restored_config(ctx: &mut ActionContext, config_path: &Path) -> Config { - if !config_path.exists() { - return Config::default(); - } - match Config::load_from_path(config_path) { - Ok(config) => config, - Err(err) => { - log_line( - ctx, - format!("Warning: failed to parse restored config.toml ({err:?}); using defaults"), - ); - Config::default() - } +fn validate_backup_directory_name(backup_dir: &Path) -> Result<()> { + let backup_name = backup_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if !backup_name.starts_with(BACKUP_PREFIX) { + return Err(anyhow!("backup directory name is not recognized")); } + Ok(()) } -fn restore_theme_files( - ctx: &mut ActionContext, - backup_dir: &Path, - config_dir: &Path, - theme_paths: &unixnotis_core::ThemePaths, -) -> Result<()> { +fn build_restore_plan(backup_dir: &Path, config_dir: &Path) -> Result { + let config_path = config_dir.join("config.toml"); + let backup_config = backup_dir.join("config.toml"); + let mut files = Vec::new(); + let mut warnings = Vec::new(); + + let (config, config_restore) = if backup_entry_exists(&backup_config)? { + let contents = read_backup_file_bounded(&backup_config, MAX_CONFIG_BYTES) + .context("failed to read backup config.toml")?; + let text = std::str::from_utf8(&contents) + .map_err(|_error| anyhow!("backup config.toml is not valid UTF-8"))?; + // Parser details may contain private configuration values, so the public error stays stable + let config = Config::parse(text) + .map_err(|_error| anyhow!("backup config.toml is not valid schema v5"))?; + ( + config, + Some(RestoreFile { + label: "config.toml".to_string(), + target: config_path.clone(), + mode: 0o644, + contents, + }), + ) + } else if backup_entry_exists(&config_path)? { + warnings.push("backup missing config.toml; leaving current file unchanged".to_string()); + ( + Config::load_from_path(&config_path) + .map_err(|_error| anyhow!("live config.toml is not valid schema v5"))?, + None, + ) + } else { + warnings.push("backup missing config.toml; leaving current file unchanged".to_string()); + (Config::default(), None) + }; + + let theme_paths = config + .resolve_theme_paths_from(config_dir) + .map_err(|error| anyhow!(error.to_string()))?; let theme_targets = [ - ("base.css", &theme_paths.base_css), - ("panel.css", &theme_paths.panel_css), - ("popup.css", &theme_paths.popup_css), - ("widgets.css", &theme_paths.widgets_css), - ("media.css", &theme_paths.media_css), + ("base.css", theme_paths.base_css), + ("panel.css", theme_paths.panel_css), + ("popup.css", theme_paths.popup_css), + ("widgets.css", theme_paths.widgets_css), + ("media.css", theme_paths.media_css), ]; for (name, target) in theme_targets { - restore_theme_file(ctx, backup_dir, config_dir, name, target)?; + plan_optional_file( + &mut files, + &mut warnings, + backup_dir, + config_dir, + name, + target, + 0o644, + )?; } - Ok(()) + + for script in DEFAULT_SCRIPTS { + let name = Path::new(script.relative_path) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("script path has no UTF-8 file name"))?; + plan_optional_file( + &mut files, + &mut warnings, + backup_dir, + config_dir, + script.relative_path, + config_dir.join(script.relative_path), + 0o755, + ) + .with_context(|| format!("plan restore for {name}"))?; + } + + if let Some(config_restore) = config_restore { + // Config is the final visibility switch after every referenced payload is durable + files.push(config_restore); + } + + reject_duplicate_targets(&files)?; + Ok(RestorePlan { + config_path, + files, + warnings, + }) } -fn restore_theme_file( - ctx: &mut ActionContext, +fn plan_optional_file( + files: &mut Vec, + warnings: &mut Vec, backup_dir: &Path, config_dir: &Path, - name: &str, - target: &Path, + label: &str, + target: PathBuf, + mode: u32, ) -> Result<()> { - let source = backup_dir.join(name); - if !source.exists() { - log_line( - ctx, - format!("Warning: backup missing {name}; leaving current file unchanged"), - ); + let source_name = Path::new(label) + .file_name() + .ok_or_else(|| anyhow!("restore label has no file name"))?; + let source = backup_dir.join(source_name); + if !backup_entry_exists(&source)? { + warnings.push(format!( + "backup missing {label}; leaving current file unchanged" + )); return Ok(()); } - if !is_restore_target_allowed(config_dir, target) { - log_line( - ctx, - format!( - "Warning: skipped restoring {name} because target escapes config dir ({})", - format_with_home(target) - ), - ); + if !is_restore_target_allowed(config_dir, &target) { + warnings.push(format!( + "skipped restoring {label} because target escapes config dir ({})", + format_with_home(&target) + )); return Ok(()); } - let contents = - fs::read_to_string(&source).with_context(|| format!("failed to read backup {name}"))?; - write_file_atomic(target, contents.as_bytes(), 0o644) - .with_context(|| format!("failed to restore {name}"))?; - log_line( - ctx, - format!("Restored {name} -> {}", format_with_home(target)), - ); + let contents = read_backup_file_bounded(&source, MAX_RESTORE_FILE_BYTES) + .with_context(|| format!("failed to read backup {label}"))?; + files.push(RestoreFile { + label: label.to_string(), + target, + mode, + contents, + }); Ok(()) } -fn restore_bundled_scripts( - ctx: &mut ActionContext, - backup_dir: &Path, - config_dir: &Path, -) -> Result<()> { - // Script backups use their basename because reset stores them directly in the backup root - for script in DEFAULT_SCRIPTS { - restore_bundled_script(ctx, backup_dir, config_dir, script)?; +fn reject_duplicate_targets(files: &[RestoreFile]) -> Result<()> { + let mut targets = HashSet::new(); + for file in files { + let normalized = normalize_path_for_compare(&file.target); + if !targets.insert(normalized) { + return Err(anyhow!( + "backup maps multiple files to the same live restore target" + )); + } } Ok(()) } -fn restore_bundled_script( - ctx: &mut ActionContext, - backup_dir: &Path, - config_dir: &Path, - script: &unixnotis_core::DefaultScript, -) -> Result<()> { - let script_name = Path::new(script.relative_path) - .file_name() - .ok_or_else(|| anyhow!("script path has no file name"))?; - let source = backup_dir.join(script_name); - if !source.exists() { - log_line( - ctx, - format!( - "Warning: backup missing {}; leaving current file unchanged", - script.relative_path - ), - ); - return Ok(()); - } +fn apply_restore_plan(plan: &RestorePlan) -> Result<()> { + let config_dir = plan + .config_path + .parent() + .ok_or_else(|| anyhow!("live config path has no parent directory"))?; + let writes = plan + .files + .iter() + .map(|file| super::restore_transaction::RestoreWrite { + label: &file.label, + target: &file.target, + mode: file.mode, + contents: &file.contents, + }) + .collect::>(); + super::restore_transaction::apply_restore_transaction(config_dir, &writes, || { + // Reloading the published config catches an unexpected filesystem race before commit + if plan.config_path.exists() { + Config::load_from_path(&plan.config_path) + .map_err(|_error| anyhow!("restored config.toml failed post-commit validation"))?; + } + Ok(()) + }) +} - let target = config_dir.join(script.relative_path); - if !is_restore_target_allowed(config_dir, &target) { - log_line( - ctx, - format!( - "Warning: skipped restoring {} because target escapes config dir ({})", - script.relative_path, - format_with_home(&target) - ), - ); - return Ok(()); +fn backup_entry_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_metadata) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), } - if let Some(parent) = target.parent() { - create_directory_all(parent, 0o700) - .with_context(|| format!("create script restore directory {}", parent.display()))?; - } - let contents = read_backup_file(&source) - .with_context(|| format!("failed to read backup {}", script.relative_path))?; - write_file_atomic(&target, &contents, 0o755) - .with_context(|| format!("failed to restore {}", script.relative_path))?; - log_line( - ctx, - format!( - "Restored {} -> {}", - script.relative_path, - format_with_home(&target) - ), - ); - Ok(()) } -fn read_backup_file(path: &Path) -> Result> { - // Pin the backup object and reject links or special files before reading it - let mut file = - open_regular_file(path).with_context(|| format!("open backup file {}", path.display()))?; - let mut contents = Vec::new(); - file.read_to_end(&mut contents) - .with_context(|| format!("read backup file {}", path.display()))?; +fn read_backup_file_bounded(path: &Path, max_bytes: u64) -> Result> { + // Pin the object and reject links or special files before reading any payload bytes + let file = open_regular_file(path).with_context(|| format!("open {}", path.display()))?; + let size = file + .metadata() + .with_context(|| format!("inspect {}", path.display()))? + .len(); + if size > max_bytes { + return Err(anyhow!( + "restore file exceeds {max_bytes} bytes: {}", + path.display() + )); + } + let mut contents = Vec::with_capacity(usize::try_from(size).unwrap_or(usize::MAX)); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents) + .with_context(|| format!("read {}", path.display()))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(anyhow!( + "restore file grew beyond {max_bytes} bytes: {}", + path.display() + )); + } Ok(contents) } @@ -231,7 +282,7 @@ pub(in crate::actions::config::backup) fn is_restore_target_allowed( } fn normalize_path_for_compare(path: &Path) -> PathBuf { - // Canonicalize when possible, then fall back to lexical cleanup for missing paths + // Existing objects are resolved first so an in-tree symlink cannot redirect a restore if let Ok(canonical) = fs::canonicalize(path) { return canonical; } @@ -243,9 +294,6 @@ fn normalize_path_for_compare(path: &Path) -> PathBuf { |current_dir| current_dir.join(path), ) }; - if let Ok(canonical) = fs::canonicalize(&absolute) { - return canonical; - } if let Some(parent) = absolute.parent() { if let Ok(parent_canonical) = fs::canonicalize(parent) { if let Some(name) = absolute.file_name() { @@ -267,3 +315,7 @@ fn normalize_path_for_compare(path: &Path) -> PathBuf { } normalized } + +#[cfg(test)] +#[path = "tests/restore_validation.rs"] +mod validation_tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs b/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs new file mode 100644 index 000000000..aea1977aa --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs @@ -0,0 +1,413 @@ +//! Durable multi-file restore publication and recovery + +use std::collections::HashSet; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use unixnotis_core::filesystem::{ + create_directory_all, open_regular_file, read_regular_file_bounded, remove_directory_tree, + remove_regular_file, write_file_atomic, write_file_if_missing, CreateDirectoryOutcome, +}; + +use super::restore::MAX_RESTORE_FILE_BYTES; + +const RESTORE_JOURNAL_FILE: &str = ".unixnotis-restore-pending.json"; +const RESTORE_TRANSACTION_PREFIX: &str = ".unixnotis-restore-"; +const RESTORE_JOURNAL_SCHEMA: u32 = 1; +const MAX_RESTORE_JOURNAL_BYTES: u64 = 256 * 1024; +const TRANSACTION_DIRECTORY_ATTEMPTS: u8 = 16; +static RESTORE_TRANSACTION_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(super) struct RestoreWrite<'a> { + pub(super) label: &'a str, + pub(super) target: &'a Path, + pub(super) mode: u32, + pub(super) contents: &'a [u8], +} + +#[derive(Debug, Deserialize, Serialize)] +struct RestoreJournal { + schema_version: u32, + transaction_dir: String, + entries: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct RestoreJournalEntry { + target: PathBuf, + staged: PathBuf, + staged_size: u64, + previous: PreviousFile, +} + +#[derive(Debug, Deserialize, Serialize)] +enum PreviousFile { + Missing, + Existing { + rollback: PathBuf, + size: u64, + mode: u32, + }, +} + +pub(super) fn apply_restore_transaction( + config_dir: &Path, + writes: &[RestoreWrite<'_>], + post_validate: impl FnOnce() -> Result<()>, +) -> Result<()> { + apply_restore_transaction_with_writer(config_dir, writes, post_validate, write_file_atomic) +} + +fn apply_restore_transaction_with_writer( + config_dir: &Path, + writes: &[RestoreWrite<'_>], + post_validate: impl FnOnce() -> Result<()>, + mut publish: impl FnMut(&Path, &[u8], u32) -> std::io::Result<()>, +) -> Result<()> { + // One journal owns the config tree so separate restores cannot overlap + if pending_journal(config_dir)?.is_some() { + return Err(anyhow!( + "an incomplete restore transaction must be recovered before another restore" + )); + } + let journal = prepare_restore_transaction(config_dir, writes)?; + let transaction_dir = config_dir.join(&journal.transaction_dir); + + // Every payload comes from the bounded staged copy recorded in the journal + let operation = (|| { + for (write, entry) in writes.iter().zip(&journal.entries) { + let staged = read_exact_transaction_file( + &transaction_dir.join(&entry.staged), + entry.staged_size, + )?; + publish(write.target, &staged, write.mode) + .with_context(|| format!("failed to restore {}", write.label))?; + } + post_validate() + })(); + if let Err(error) = operation { + // Failed publication keeps recovery authority until rollback is complete + return Err(rollback_or_retain(config_dir, &journal, error)); + } + + // Journal removal is the transaction commit point + finish_transaction(config_dir, &journal)?; + Ok(()) +} + +pub(super) fn recover_pending_restore(config_dir: &Path) -> Result { + let Some(journal) = pending_journal(config_dir)? else { + return Ok(false); + }; + // Recovery trusts only a fully validated local journal + validate_journal(&journal)?; + rollback_transaction(config_dir, &journal) + .context("recover interrupted config restore transaction")?; + finish_transaction(config_dir, &journal)?; + Ok(true) +} + +fn prepare_restore_transaction( + config_dir: &Path, + writes: &[RestoreWrite<'_>], +) -> Result { + create_directory_all(config_dir, 0o700).context("create config directory for restore")?; + let transaction_dir = reserve_transaction_directory(config_dir)?; + let prepared = (|| { + // Staged and rollback data stay private to this transaction + create_directory_all(&transaction_dir.join("staged"), 0o700) + .context("create restore staging directory")?; + create_directory_all(&transaction_dir.join("rollback"), 0o700) + .context("create restore rollback directory")?; + + let mut entries = Vec::with_capacity(writes.len()); + let mut targets = HashSet::new(); + for (index, write) in writes.iter().enumerate() { + // Targets are stored relative to the pinned config root + let target = relative_target(config_dir, write.target)?; + if !targets.insert(target.clone()) { + return Err(anyhow!( + "restore transaction contains duplicate live targets" + )); + } + let staged = PathBuf::from("staged").join(index.to_string()); + // Payload staging happens before any live target can change + write_file_atomic(&transaction_dir.join(&staged), write.contents, write.mode) + .with_context(|| format!("stage restore payload for {}", write.label))?; + let previous = + snapshot_previous_file(write.target, &transaction_dir, index, write.label)?; + entries.push(RestoreJournalEntry { + target, + staged, + staged_size: u64::try_from(write.contents.len()).unwrap_or(u64::MAX), + previous, + }); + } + let journal = RestoreJournal { + schema_version: RESTORE_JOURNAL_SCHEMA, + transaction_dir: transaction_dir + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("restore transaction directory name is not UTF-8"))? + .to_string(), + entries, + }; + validate_journal(&journal)?; + let bytes = serde_json::to_vec_pretty(&journal).context("serialize restore journal")?; + if !journal_size_is_allowed(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) { + return Err(anyhow!("restore journal exceeds its safe byte limit")); + } + if !write_file_if_missing(&config_dir.join(RESTORE_JOURNAL_FILE), &bytes, 0o600) + .context("publish restore transaction journal")? + { + return Err(anyhow!("restore transaction journal already exists")); + } + Ok(journal) + })(); + if prepared.is_err() { + let _cleanup = remove_directory_tree(&transaction_dir); + } + prepared +} + +fn snapshot_previous_file( + target: &Path, + transaction_dir: &Path, + index: usize, + label: &str, +) -> Result { + let file = match open_regular_file(target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PreviousFile::Missing) + } + Err(error) => { + return match fs::symlink_metadata(target) { + Ok(_metadata) => Err(anyhow!("restore target is not a regular file: {label}")), + Err(metadata_error) => Err(error).context(format!( + "inspect restore target for {label}: {metadata_error}" + )), + } + } + }; + // One retained descriptor keeps rollback mode, length, and bytes on the same object + let metadata = file + .metadata() + .with_context(|| format!("inspect live restore target for {label}"))?; + if metadata.len() > MAX_RESTORE_FILE_BYTES { + return Err(anyhow!( + "live restore target exceeds its safe byte limit: {label}" + )); + } + let mut contents = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(usize::MAX)); + file.take(MAX_RESTORE_FILE_BYTES.saturating_add(1)) + .read_to_end(&mut contents) + .with_context(|| format!("snapshot live restore target for {label}"))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_RESTORE_FILE_BYTES { + return Err(anyhow!( + "live restore target grew beyond its safe byte limit: {label}" + )); + } + let rollback = PathBuf::from("rollback").join(index.to_string()); + let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777; + write_file_atomic(&transaction_dir.join(&rollback), &contents, mode) + .with_context(|| format!("stage restore rollback for {label}"))?; + Ok(PreviousFile::Existing { + rollback, + size: u64::try_from(contents.len()).unwrap_or(u64::MAX), + mode, + }) +} + +fn rollback_transaction(config_dir: &Path, journal: &RestoreJournal) -> Result<()> { + let transaction_dir = config_dir.join(&journal.transaction_dir); + let mut errors = Vec::new(); + // Reverse order mirrors publication and limits partial dependency exposure + for entry in journal.entries.iter().rev() { + let target = config_dir.join(&entry.target); + let result = match &entry.previous { + PreviousFile::Missing => remove_regular_file(&target) + .map(|_removed| ()) + .map_err(anyhow::Error::from), + PreviousFile::Existing { + rollback, + size, + mode, + } => read_exact_transaction_file(&transaction_dir.join(rollback), *size).and_then( + |contents| { + write_file_atomic(&target, &contents, *mode).map_err(anyhow::Error::from) + }, + ), + }; + if let Err(error) = result { + // Every remaining target is attempted before reporting incomplete recovery + errors.push(format!("{}: {error}", target.display())); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "restore rollback was incomplete: {}", + errors.join("; ") + )) + } +} + +fn rollback_or_retain( + config_dir: &Path, + journal: &RestoreJournal, + operation_error: anyhow::Error, +) -> anyhow::Error { + match rollback_transaction(config_dir, journal) { + Ok(()) => match finish_transaction(config_dir, journal) { + Ok(()) => operation_error, + Err(cleanup_error) => operation_error.context(format!( + "restore rollback completed but journal cleanup failed: {cleanup_error:#}" + )), + }, + Err(rollback_error) => operation_error.context(format!( + "restore rollback was retained for recovery: {rollback_error:#}" + )), + } +} + +fn finish_transaction(config_dir: &Path, journal: &RestoreJournal) -> Result<()> { + remove_regular_file(&config_dir.join(RESTORE_JOURNAL_FILE)) + .context("remove committed restore journal")?; + // The journal is the authority, so scratch cleanup becomes harmless after its removal + let _cleanup = remove_directory_tree(&config_dir.join(&journal.transaction_dir)); + Ok(()) +} + +fn pending_journal(config_dir: &Path) -> Result> { + let path = config_dir.join(RESTORE_JOURNAL_FILE); + // Raw journal bytes are bounded before JSON allocation + let bytes = match read_regular_file_bounded(&path, MAX_RESTORE_JOURNAL_BYTES) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read pending restore journal"), + }; + let journal = serde_json::from_slice(&bytes).context("parse pending restore journal")?; + validate_journal(&journal)?; + Ok(Some(journal)) +} + +fn validate_journal(journal: &RestoreJournal) -> Result<()> { + // Unknown schemas never gain filesystem authority + if journal.schema_version != RESTORE_JOURNAL_SCHEMA { + return Err(anyhow!( + "unsupported restore journal schema {}", + journal.schema_version + )); + } + validate_transaction_directory_name(&journal.transaction_dir)?; + let mut targets = HashSet::new(); + for entry in &journal.entries { + // Journal paths allow normal relative components only + validate_relative_path(&entry.target)?; + validate_relative_path(&entry.staged)?; + if !matches!(entry.staged.components().next(), Some(Component::Normal(root)) if root == "staged") + { + return Err(anyhow!("restore journal contains an invalid staged path")); + } + if !targets.insert(entry.target.clone()) { + return Err(anyhow!("restore journal contains duplicate live targets")); + } + if let PreviousFile::Existing { rollback, .. } = &entry.previous { + validate_relative_path(rollback)?; + if !matches!(rollback.components().next(), Some(Component::Normal(root)) if root == "rollback") + { + return Err(anyhow!("restore journal contains an invalid rollback path")); + } + } + } + Ok(()) +} + +fn relative_target(config_dir: &Path, target: &Path) -> Result { + let relative = target.strip_prefix(config_dir).map_err(|_error| { + anyhow!( + "restore target escapes the live config directory: {}", + target.display() + ) + })?; + validate_relative_path(relative)?; + Ok(relative.to_path_buf()) +} + +fn validate_relative_path(path: &Path) -> Result<()> { + if path.as_os_str().is_empty() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(anyhow!("restore journal contains an unsafe relative path")); + } + Ok(()) +} + +fn validate_transaction_directory_name(name: &str) -> Result<()> { + let path = Path::new(name); + if !name.starts_with(RESTORE_TRANSACTION_PREFIX) + || path.components().count() != 1 + || !matches!(path.components().next(), Some(Component::Normal(_))) + { + return Err(anyhow!( + "restore journal contains an unsafe transaction directory" + )); + } + Ok(()) +} + +const fn journal_size_is_allowed(size: u64) -> bool { + size <= MAX_RESTORE_JOURNAL_BYTES +} + +const fn transaction_file_size_is_allowed(size: u64) -> bool { + size <= MAX_RESTORE_FILE_BYTES +} + +fn reserve_transaction_directory(config_dir: &Path) -> Result { + // Process, time, counter, and bounded retry values avoid attacker-selected names + for attempt in 0..TRANSACTION_DIRECTORY_ATTEMPTS { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let counter = RESTORE_TRANSACTION_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = config_dir.join(format!( + "{RESTORE_TRANSACTION_PREFIX}{}-{nanos}-{counter}-{attempt}", + std::process::id() + )); + if create_directory_all(&path, 0o700)? == CreateDirectoryOutcome::TargetCreated { + return Ok(path); + } + } + Err(anyhow!( + "unable to reserve a unique restore transaction directory" + )) +} + +fn read_exact_transaction_file(path: &Path, expected_size: u64) -> Result> { + // Journal lengths remain bounded before reading staged or rollback content + if !transaction_file_size_is_allowed(expected_size) { + return Err(anyhow!( + "restore transaction file exceeds its safe byte limit" + )); + } + let contents = read_regular_file_bounded(path, expected_size) + .with_context(|| format!("read restore transaction file {}", path.display()))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) != expected_size { + return Err(anyhow!("restore transaction file size changed")); + } + Ok(contents) +} + +#[cfg(test)] +#[path = "tests/restore_transaction.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs index 4f88cba21..894b34f1b 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs @@ -1,6 +1,5 @@ use super::super::restore::{is_restore_target_allowed, restore_config}; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::test_support::current_config_text; @@ -44,14 +43,9 @@ media_css = "themes/custom/media.css" fs::write(backup_dir.join("media.css"), "media").expect("write media"); // Restore path selection is driven through ActionContext just like runtime - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -82,9 +76,9 @@ media_css = "themes/custom/media.css" #[test] fn restore_target_guard_blocks_paths_outside_config_dir() { // Guard should allow in-tree writes and reject out-of-tree targets - let config_dir = PathBuf::from("/tmp/unixnotis-restore-guard"); + let config_dir = std::env::temp_dir().join("unixnotis-restore-guard"); let inside = config_dir.join("themes/base.css"); - let outside = PathBuf::from("/tmp/unixnotis-escape.css"); + let outside = std::env::temp_dir().join("unixnotis-escape.css"); assert!(is_restore_target_allowed(&config_dir, &inside)); assert!(!is_restore_target_allowed(&config_dir, &outside)); } @@ -122,14 +116,9 @@ fn restore_config_skips_absolute_theme_targets() { fs::write(backup_dir.join("widgets.css"), "widgets").expect("write widgets"); fs::write(backup_dir.join("media.css"), "media").expect("write media"); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -163,7 +152,7 @@ fn restore_config_restores_all_bundled_scripts_and_executable_modes() { )); let config_dir = root.join("unixnotis"); fs::create_dir_all(config_dir.join("scripts")).expect("create script directory"); - fs::write(config_dir.join("config.toml"), "custom = true\n").expect("write config"); + fs::write(config_dir.join("config.toml"), current_config_text("")).expect("write config"); // Seed every bundled script with distinct user content and non-default permissions for (index, script) in DEFAULT_SCRIPTS.iter().enumerate() { @@ -180,14 +169,9 @@ fn restore_config_restores_all_bundled_scripts_and_executable_modes() { .expect("reset should create a restorable script backup"); let backup_dir = report.backup_dir.expect("reset backup directory"); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(16); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -216,3 +200,127 @@ fn restore_config_restores_all_bundled_scripts_and_executable_modes() { let _ = fs::remove_dir_all(&root); } + +#[test] +fn malformed_backup_config_fails_before_any_live_file_changes() { + let _lock = crate::test_support::env::test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-invalid-restore-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-invalid"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + fs::write( + config_dir.join("config.toml"), + current_config_text("[theme]\nbase_css = \"live.css\"\n"), + ) + .expect("write live config"); + fs::write(config_dir.join("live.css"), "live theme\n").expect("write live theme"); + fs::write(backup_dir.join("config.toml"), "config_version = 5\n[") + .expect("write malformed backup config"); + fs::write(backup_dir.join("base.css"), "backup theme\n").expect("write backup theme"); + let before = snapshot_tree(&config_dir); + + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut ctx = crate::actions::ActionContext { + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = restore_config(&mut ctx).expect_err("malformed backup must fail closed"); + + assert!(error.to_string().contains("not valid schema v5")); + assert_eq!( + snapshot_tree(&config_dir), + before, + "validation failure must leave the complete live tree unchanged" + ); + fs::remove_dir_all(root).expect("remove restore test root"); +} + +#[test] +fn restore_target_snapshot_failure_happens_before_any_file_is_published() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-rollback"); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-snapshot-rollback"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + let original_config = current_config_text("[theme]\npanel_css = \"live-panel.css\"\n"); + fs::write(config_dir.join("config.toml"), &original_config).expect("write live config"); + fs::write(config_dir.join("live-panel.css"), "live panel\n").expect("write live panel"); + fs::create_dir(config_dir.join("blocked-panel.css")).expect("create invalid target directory"); + fs::write( + backup_dir.join("config.toml"), + current_config_text("[theme]\npanel_css = \"blocked-panel.css\"\n"), + ) + .expect("write backup config"); + fs::write(backup_dir.join("panel.css"), "restored panel\n").expect("write backup panel"); + let before = snapshot_tree(&config_dir); + + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut ctx = crate::actions::ActionContext { + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = restore_config(&mut ctx) + .expect_err("an invalid later target must roll back an earlier config replacement"); + + assert!(error + .to_string() + .contains("restore target is not a regular file")); + assert_eq!( + snapshot_tree(&config_dir), + before, + "snapshot failure must happen before any live file is published" + ); + fs::remove_dir_all(root).expect("remove restore rollback fixture"); +} + +fn snapshot_tree(root: &std::path::Path) -> Vec<(PathBuf, Vec, u32)> { + fn visit( + root: &std::path::Path, + directory: &std::path::Path, + snapshot: &mut Vec<(PathBuf, Vec, u32)>, + ) { + let mut entries = fs::read_dir(directory) + .expect("read snapshot directory") + .collect::, _>>() + .expect("collect snapshot entries"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).expect("snapshot metadata"); + if metadata.is_dir() { + visit(root, &path, snapshot); + } else { + snapshot.push(( + path.strip_prefix(root) + .expect("snapshot relative path") + .to_path_buf(), + fs::read(&path).expect("snapshot file"), + metadata.permissions().mode() & 0o777, + )); + } + } + } + + let mut snapshot = Vec::new(); + visit(root, root, &mut snapshot); + snapshot +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs new file mode 100644 index 000000000..f6cc26b91 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs @@ -0,0 +1,319 @@ +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use super::super::restore::MAX_RESTORE_FILE_BYTES; +use super::{ + apply_restore_transaction, apply_restore_transaction_with_writer, journal_size_is_allowed, + pending_journal, prepare_restore_transaction, read_exact_transaction_file, + recover_pending_restore, snapshot_previous_file, transaction_file_size_is_allowed, + validate_journal, validate_relative_path, validate_transaction_directory_name, PreviousFile, + RestoreJournal, RestoreJournalEntry, RestoreWrite, MAX_RESTORE_JOURNAL_BYTES, +}; + +#[test] +fn restore_transaction_rolls_back_every_published_file_after_a_late_failure() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-rollback"); + fs::create_dir_all(&root).expect("create restore transaction fixture"); + let first = root.join("first.css"); + let second = root.join("second.css"); + fs::write(&first, "old first").expect("write first live file"); + fs::write(&second, "old second").expect("write second live file"); + fs::set_permissions(&first, fs::Permissions::from_mode(0o640)).expect("set first live mode"); + let writes = [ + RestoreWrite { + label: "first.css", + target: &first, + mode: 0o644, + contents: b"new first", + }, + RestoreWrite { + label: "second.css", + target: &second, + mode: 0o644, + contents: b"new second", + }, + ]; + let mut calls = 0usize; + + let error = apply_restore_transaction_with_writer( + &root, + &writes, + || Ok(()), + |target, contents, mode| { + calls = calls.saturating_add(1); + if calls == 2 { + return Err(io::Error::other("injected second publish failure")); + } + unixnotis_core::filesystem::write_file_atomic(target, contents, mode) + }, + ) + .expect_err("a late publish failure must fail the complete restore"); + + assert!(error.to_string().contains("failed to restore second.css")); + assert_eq!( + fs::read_to_string(&first).expect("read restored first"), + "old first" + ); + assert_eq!( + fs::read_to_string(&second).expect("read restored second"), + "old second" + ); + assert_eq!( + fs::metadata(&first) + .expect("inspect restored first") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert!(pending_journal(&root) + .expect("inspect pending journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore transaction fixture"); +} + +#[test] +fn failed_restore_removes_a_new_file_published_before_the_failure() { + let root = crate::test_support::fs::unique_temp_path("restore-created-rollback"); + fs::create_dir_all(&root).expect("create new-file rollback fixture"); + let created = root.join("created.css"); + let blocker = root.join("blocker.css"); + fs::write(&blocker, "old blocker").expect("write blocker file"); + let writes = [ + RestoreWrite { + label: "created.css", + target: &created, + mode: 0o644, + contents: b"new created", + }, + RestoreWrite { + label: "blocker.css", + target: &blocker, + mode: 0o644, + contents: b"new blocker", + }, + ]; + let mut calls = 0usize; + + apply_restore_transaction_with_writer( + &root, + &writes, + || Ok(()), + |target, contents, mode| { + calls = calls.saturating_add(1); + if calls == 2 { + return Err(io::Error::other("injected blocker failure")); + } + unixnotis_core::filesystem::write_file_atomic(target, contents, mode) + }, + ) + .expect_err("failed restore must remove a newly published target"); + + assert!(!created.exists()); + assert_eq!( + fs::read_to_string(blocker).expect("read blocker"), + "old blocker" + ); + fs::remove_dir_all(root).expect("remove new-file rollback fixture"); +} + +#[test] +fn restore_snapshot_rejects_special_targets_and_nonmissing_lookup_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-errors"); + fs::create_dir_all(&root).expect("create restore snapshot error fixture"); + let directory = root.join("directory.css"); + fs::create_dir(&directory).expect("create directory target"); + let directory_write = [RestoreWrite { + label: "directory.css", + target: &directory, + mode: 0o644, + contents: b"new", + }]; + let directory_error = apply_restore_transaction(&root, &directory_write, || Ok(())) + .expect_err("directory target must fail before publication"); + assert!(directory_error + .to_string() + .contains("restore target is not a regular file")); + + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("write invalid parent"); + let invalid_target = regular_parent.join("child"); + let invalid_write = [RestoreWrite { + label: "child", + target: &invalid_target, + mode: 0o644, + contents: b"new", + }]; + assert!(apply_restore_transaction(&root, &invalid_write, || Ok(())).is_err()); + assert!(pending_journal(&root) + .expect("inspect failed journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore snapshot error fixture"); +} + +#[test] +fn pending_restore_probe_propagates_nonmissing_journal_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-journal-probe-error"); + fs::create_dir_all(&root).expect("create restore journal error fixture"); + fs::create_dir(root.join(".unixnotis-restore-pending.json")) + .expect("create invalid journal directory"); + + assert!(recover_pending_restore(&root).is_err()); + fs::remove_dir_all(root).expect("remove restore journal error fixture"); +} + +#[test] +fn restore_transaction_byte_domains_accept_the_exact_limit_only() { + assert!(journal_size_is_allowed(MAX_RESTORE_JOURNAL_BYTES)); + assert!(!journal_size_is_allowed( + MAX_RESTORE_JOURNAL_BYTES.saturating_add(1) + )); + assert!(transaction_file_size_is_allowed(MAX_RESTORE_FILE_BYTES)); + assert!(!transaction_file_size_is_allowed( + MAX_RESTORE_FILE_BYTES.saturating_add(1) + )); +} + +#[test] +fn restore_snapshot_accepts_a_live_file_at_the_exact_byte_limit() { + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-exact-limit"); + let transaction = root.join("transaction"); + fs::create_dir_all(transaction.join("rollback")).expect("create rollback directory"); + let target = root.join("config.toml"); + let file = fs::File::create(&target).expect("create exact-limit live file"); + file.set_len(MAX_RESTORE_FILE_BYTES) + .expect("size exact-limit live file"); + + let previous = snapshot_previous_file(&target, &transaction, 0, "config.toml") + .expect("snapshot exact-limit live file"); + + assert!(matches!( + previous, + PreviousFile::Existing { + size: MAX_RESTORE_FILE_BYTES, + .. + } + )); + fs::remove_dir_all(root).expect("remove exact-limit snapshot fixture"); +} + +#[test] +fn restore_journal_validation_rejects_unsafe_paths_names_schemas_and_duplicates() { + assert!(validate_relative_path(Path::new("theme/panel.css")).is_ok()); + assert!(validate_relative_path(Path::new("")).is_err()); + assert!(validate_relative_path(Path::new("../outside")).is_err()); + assert!(validate_transaction_directory_name(".unixnotis-restore-safe").is_ok()); + assert!(validate_transaction_directory_name("wrong-prefix").is_err()); + assert!(validate_transaction_directory_name(".unixnotis-restore-bad/child").is_err()); + + let entry = RestoreJournalEntry { + target: PathBuf::from("config.toml"), + staged: PathBuf::from("staged/0"), + staged_size: 0, + previous: PreviousFile::Missing, + }; + let mut journal = RestoreJournal { + schema_version: 1, + transaction_dir: ".unixnotis-restore-safe".to_string(), + entries: vec![entry], + }; + assert!(validate_journal(&journal).is_ok()); + journal.schema_version = 2; + assert!(validate_journal(&journal).is_err()); + journal.schema_version = 1; + journal.entries[0].staged = PathBuf::from("rollback/0"); + assert!(validate_journal(&journal).is_err()); + journal.entries[0].staged = PathBuf::from("staged/0"); + journal.entries.push(RestoreJournalEntry { + target: PathBuf::from("config.toml"), + staged: PathBuf::from("staged/1"), + staged_size: 0, + previous: PreviousFile::Existing { + rollback: PathBuf::from("wrong/1"), + size: 0, + mode: 0o644, + }, + }); + assert!(validate_journal(&journal).is_err()); + journal.entries[1].target = PathBuf::from("other.css"); + assert!(validate_journal(&journal).is_err()); +} + +#[test] +fn interrupted_restore_journal_restores_original_files_on_recovery() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-recovery"); + fs::create_dir_all(&root).expect("create restore recovery fixture"); + let target = root.join("config.toml"); + fs::write(&target, "old config").expect("write old config"); + let writes = [RestoreWrite { + label: "config.toml", + target: &target, + mode: 0o644, + contents: b"new config", + }]; + + let journal = prepare_restore_transaction(&root, &writes).expect("prepare restore journal"); + let staged = read_exact_transaction_file( + &root + .join(&journal.transaction_dir) + .join(&journal.entries[0].staged), + journal.entries[0].staged_size, + ) + .expect("read staged config"); + unixnotis_core::filesystem::write_file_atomic(&target, &staged, 0o644) + .expect("simulate published config before process exit"); + assert_eq!( + fs::read_to_string(&target).expect("read interrupted config"), + "new config" + ); + + assert!(recover_pending_restore(&root).expect("recover interrupted restore")); + assert_eq!( + fs::read_to_string(&target).expect("read recovered config"), + "old config" + ); + assert!(pending_journal(&root) + .expect("inspect recovered journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore recovery fixture"); +} + +#[test] +fn successful_restore_commits_all_files_and_removes_its_journal() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-success"); + fs::create_dir_all(&root).expect("create successful restore fixture"); + let existing = root.join("existing.css"); + let created = root.join("created.css"); + fs::write(&existing, "old").expect("write existing file"); + let writes = [ + RestoreWrite { + label: "existing.css", + target: &existing, + mode: 0o644, + contents: b"new existing", + }, + RestoreWrite { + label: "created.css", + target: &created, + mode: 0o600, + contents: b"new created", + }, + ]; + + apply_restore_transaction(&root, &writes, || Ok(())).expect("commit restore transaction"); + + assert_eq!( + fs::read_to_string(existing).expect("read existing file"), + "new existing" + ); + assert_eq!( + fs::read_to_string(created).expect("read created file"), + "new created" + ); + assert!(pending_journal(&root) + .expect("inspect committed journal") + .is_none()); + fs::remove_dir_all(root).expect("remove successful restore fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs new file mode 100644 index 000000000..30947b227 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs @@ -0,0 +1,108 @@ +use std::fs; +use std::path::PathBuf; + +use super::{ + backup_entry_exists, build_restore_plan, read_backup_file_bounded, reject_duplicate_targets, + validate_backup_directory_name, RestoreFile, MAX_RESTORE_FILE_BYTES, +}; + +#[test] +fn restore_file_budget_keeps_its_declared_byte_domain() { + assert_eq!(MAX_RESTORE_FILE_BYTES, 16_777_216); +} + +#[test] +fn restore_source_reader_accepts_exact_limit_and_rejects_one_extra_byte() { + let root = crate::test_support::fs::unique_temp_path("restore-reader-boundary"); + fs::create_dir_all(&root).expect("create restore reader fixture"); + let source = root.join("source"); + fs::write(&source, vec![b'x'; 4_096]).expect("write exact-limit source"); + + assert_eq!( + read_backup_file_bounded(&source, 4_096) + .expect("exact restore source limit") + .len(), + 4_096 + ); + fs::write(&source, vec![b'x'; 4_097]).expect("write oversized source"); + assert!(read_backup_file_bounded(&source, 4_096).is_err()); + fs::remove_dir_all(root).expect("remove restore reader fixture"); +} + +#[test] +fn backup_directory_validation_rejects_unrecognized_names() { + assert!(validate_backup_directory_name(&PathBuf::from("Backup-valid")).is_ok()); + assert!(validate_backup_directory_name(&PathBuf::from("unrecognized")).is_err()); +} + +#[test] +fn duplicate_restore_targets_are_rejected_before_commit() { + let target = std::env::temp_dir().join("unixnotis-duplicate-restore-target"); + let files = [ + RestoreFile { + label: "config.toml".to_string(), + target: target.clone(), + mode: 0o644, + contents: Vec::new(), + }, + RestoreFile { + label: "base.css".to_string(), + target, + mode: 0o644, + contents: Vec::new(), + }, + ]; + + assert!(reject_duplicate_targets(&files).is_err()); +} + +#[test] +fn backup_entry_probe_propagates_lookup_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-entry-probe-error"); + fs::create_dir_all(&root).expect("create restore probe fixture"); + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("write invalid parent"); + + assert!( + backup_entry_exists(®ular_parent.join("target")).is_err(), + "lookup errors must not become absent backup entries" + ); + fs::remove_dir_all(root).expect("remove restore probe fixture"); +} + +#[test] +fn restore_plan_publishes_supporting_payloads_before_config() { + let root = crate::test_support::fs::unique_temp_path("restore-config-last"); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-config-last"); + fs::create_dir_all(&backup_dir).expect("create restore plan fixture"); + fs::write( + backup_dir.join("config.toml"), + crate::test_support::current_config_text(""), + ) + .expect("write backup config"); + fs::write(backup_dir.join("base.css"), "restored base\n") + .expect("write supporting theme payload"); + + let plan = build_restore_plan(&backup_dir, &config_dir).expect("build restore plan"); + let labels = plan + .files + .iter() + .map(|file| file.label.as_str()) + .collect::>(); + let base_index = labels + .iter() + .position(|label| *label == "base.css") + .expect("supporting base theme in plan"); + let config_index = labels + .iter() + .position(|label| *label == "config.toml") + .expect("config in plan"); + + assert_eq!(labels.last(), Some(&"config.toml")); + assert!( + base_index < config_index, + "supporting theme payload must publish before its config reference" + ); + fs::remove_dir_all(root).expect("remove restore plan fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs index 20a83fe29..680754a0f 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs @@ -17,12 +17,11 @@ pub(super) fn test_paths(root: &std::path::Path) -> InstallPaths { } pub(super) fn test_context<'a>( - detection: &'a Detection, + _detection: &'a Detection, paths: &'a InstallPaths, ) -> ActionContext<'a> { let (log_tx, _log_rx) = mpsc::sync_channel::(8); ActionContext { - detection, paths, install_state: None, log_tx, diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs index c4f7ca467..75e07c5e1 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -29,10 +29,9 @@ fn test_paths(root: &std::path::Path) -> InstallPaths { } } -fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { +fn test_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { let (log_tx, _log_rx) = mpsc::sync_channel::(64); ActionContext { - detection, paths, install_state: None, log_tx, diff --git a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs index a4cc79bc4..24178640a 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs @@ -10,7 +10,6 @@ use std::sync::{mpsc, Arc}; use crate::actions::ActionContext; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -166,13 +165,8 @@ fn remove_state_uses_xdg_state_home_and_deletes_persisted_state() { bin_dir: state_home.join("bin"), service: ServiceManager::systemd_user(state_home.join("service")), }; - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let (log_tx, log_rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx, From a46d1b08b53a2983415f7699c14f0ec7bf8bcbea Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:24:41 -0500 Subject: [PATCH 259/275] fix(installer): normalize bounded service-manager probing Replace raw exit-status interpretation with bounded semantic service-manager probes. - add explicit Available, Unavailable, and Indeterminate manager states - bound subprocess runtime and captured output - interpret systemd and dinit manager state using backend-specific semantics - use native service probes for runit and s6 where no manager-wide query exists - fail closed on ambiguous active-state and manager-availability results - classify partial, unsafe, active, and indeterminate alternate-manager installs - make compatibility checks consume the same semantic availability API - add backend regressions for reachable, absent, inactive, and ambiguous states --- Cargo.lock | 1 + crates/unixnotis-installer/Cargo.toml | 1 + .../src/actions/conflicts.rs | 212 ++++++++++++--- .../unixnotis-installer/src/actions/state.rs | 53 +++- .../unixnotis-installer/src/checks/system.rs | 57 ++-- .../src/checks/tests/system.rs | 154 ++++++++++- .../src/service_manager/backends/dinit.rs | 72 ++++- .../src/service_manager/backends/runit.rs | 40 ++- .../src/service_manager/backends/s6.rs | 25 +- .../src/service_manager/backends/systemd.rs | 98 +++++-- .../service_manager/backends/tests/dinit.rs | 58 +++- .../service_manager/backends/tests/runit.rs | 38 ++- .../src/service_manager/backends/tests/s6.rs | 37 ++- .../service_manager/backends/tests/systemd.rs | 38 +-- .../src/service_manager/contract/artifact.rs | 110 ++++---- .../service_manager/contract/availability.rs | 99 +++++++ .../src/service_manager/contract/command.rs | 20 +- .../src/service_manager/contract/mod.rs | 8 +- .../src/service_manager/contract/probe.rs | 123 ++++++--- .../contract/tests/artifact.rs | 62 ++++- .../contract/tests/availability.rs | 249 ++++++++++++++++++ .../contract/tests/command_routing.rs | 38 ++- .../src/service_manager/contract/tests/mod.rs | 1 + .../service_manager/contract/tests/probe.rs | 172 ++++++++++-- .../src/service_manager/mod.rs | 2 +- .../orchestration/lifecycle.rs | 26 +- .../orchestration/tests/lifecycle.rs | 20 -- .../src/system_tools/mod.rs | 2 + .../src/system_tools/process.rs | 163 ++++++++++++ .../src/system_tools/tests/mod.rs | 1 + .../src/system_tools/tests/process.rs | 66 +++++ 31 files changed, 1724 insertions(+), 322 deletions(-) create mode 100644 crates/unixnotis-installer/src/service_manager/contract/availability.rs create mode 100644 crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs create mode 100644 crates/unixnotis-installer/src/system_tools/process.rs create mode 100644 crates/unixnotis-installer/src/system_tools/tests/process.rs diff --git a/Cargo.lock b/Cargo.lock index cf50847f6..930503b0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3713,6 +3713,7 @@ dependencies = [ "toml 0.8.23", "unicode-width", "unixnotis-core", + "wait-timeout", "zbus", ] diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index d47d5aa92..802119d2e 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -17,4 +17,5 @@ rustix.workspace = true tokio.workspace = true unixnotis-core = { path = "../unixnotis-core" } unicode-width.workspace = true +wait-timeout.workspace = true zbus.workspace = true diff --git a/crates/unixnotis-installer/src/actions/conflicts.rs b/crates/unixnotis-installer/src/actions/conflicts.rs index e95514818..ca6b2f3f6 100644 --- a/crates/unixnotis-installer/src/actions/conflicts.rs +++ b/crates/unixnotis-installer/src/actions/conflicts.rs @@ -1,70 +1,202 @@ -//! Cross-backend service-manager conflict detection +//! Fail-closed cross-backend service-manager conflict detection -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::paths::InstallPaths; +use crate::service_manager::contract::{ServiceManagerAvailability, ServiceProbeState}; +use crate::service_manager::{ServiceArtifactState, ServiceManager}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(in crate::actions) enum ServiceManagerConflictKind { + Active, + Installed, + PartialInstall, + UnsafeArtifact, + Indeterminate, +} #[derive(Clone)] pub(in crate::actions) struct ServiceManagerConflict { - // User-facing manager name for the backend that appears to own UnixNotis already pub(in crate::actions) manager_label: &'static str, - // Artifact wording stays backend-specific so errors are clear for s6/runit directories pub(in crate::actions) artifact_label: &'static str, - // Primary artifact path gives the user one concrete place to inspect pub(in crate::actions) artifact_path: PathBuf, - // Installed means every steady artifact for the other backend matches the safe shape - pub(in crate::actions) installed: bool, - // Active means the other backend's native runtime probe says its daemon is running - pub(in crate::actions) active: bool, + pub(in crate::actions) kinds: Vec, + pub(in crate::actions) artifact_paths: Vec, + pub(in crate::actions) detail: Option, +} + +struct ArtifactInspection { + expected: Vec, + missing: usize, + unsafe_paths: Vec, + error: Option, +} + +enum RuntimeInspection { + Unavailable, + State(ServiceProbeState), + Indeterminate(String), } pub(in crate::actions) fn detect_service_manager_conflict_state( paths: &InstallPaths, ) -> (Vec, Vec) { let mut conflicts = Vec::new(); - let mut warnings = Vec::new(); - // Selected-backend reinstall is valid, but sibling backends must not keep owning the daemon for manager in paths.alternate_service_managers() { let manager = match manager { Ok(manager) => manager, - Err(err) => { - // A broken non-selected backend path should be visible but should not block install - warnings.push(err.to_string()); + Err(error) => { + // An invalid alternate root is unknown ownership state, never proof of absence + conflicts.push(ServiceManagerConflict { + manager_label: "alternate service manager", + artifact_label: "service artifacts", + artifact_path: PathBuf::new(), + kinds: vec![ServiceManagerConflictKind::Indeterminate], + artifact_paths: Vec::new(), + detail: Some(error.to_string()), + }); continue; } }; - // Artifact ownership uses the same safe shape checks as selected-backend state - let artifacts = manager.artifacts(&paths.bin_dir); - let installed = !artifacts.is_empty() - && artifacts - .iter() - .all(crate::service_manager::ServiceArtifact::is_present_safely); - // Active probes are best-effort because missing tools should not become false conflicts - let active = match manager.active_probe().evaluate() { - Ok(active) => active, - Err(err) => { - // Probe failures do not block install, but they should not disappear either - warnings.push(format!( - "could not check whether {} is active: {err}", - manager.label() + let inspection = inspect_artifacts(&manager, &paths.bin_dir); + let mut kinds = Vec::new(); + add_artifact_conflict_kind(&inspection, &mut kinds); + let mut inspection_error = inspection.error; + let runtime = inspect_runtime(&manager); + if matches!(runtime, RuntimeInspection::Unavailable) && kinds.is_empty() { + // No transport and no artifacts means this alternate backend owns nothing here + continue; + } + add_runtime_conflict_kind(runtime, manager.label(), &mut kinds, &mut inspection_error); + // An unavailable or absent manager with no artifacts owns no live UnixNotis service + // Existing artifacts still retain their installed, partial, or unsafe conflict kind + if kinds.is_empty() { + continue; + } + + let mut artifact_paths = inspection.expected; + artifact_paths.extend(inspection.unsafe_paths); + artifact_paths.sort(); + artifact_paths.dedup(); + conflicts.push(ServiceManagerConflict { + manager_label: manager.label(), + artifact_label: manager.artifact_label(), + artifact_path: manager.primary_artifact_path(), + kinds, + artifact_paths, + detail: inspection_error, + }); + } + + // Indeterminate states are conflicts now, so no fail-open warning channel remains + (conflicts, Vec::new()) +} + +fn inspect_artifacts(manager: &ServiceManager, bin_dir: &Path) -> ArtifactInspection { + let mut inspection = ArtifactInspection { + expected: Vec::new(), + missing: 0, + unsafe_paths: Vec::new(), + error: None, + }; + for artifact in manager.artifacts(bin_dir) { + match artifact.inspect() { + Ok(ServiceArtifactState::Expected) => inspection.expected.push(artifact.path), + Ok(ServiceArtifactState::Missing) => { + inspection.missing = inspection.missing.saturating_add(1); + } + Ok(ServiceArtifactState::UnexpectedObject) => { + inspection.unsafe_paths.push(artifact.path); + } + Err(error) => { + inspection.error = Some(format!( + "could not inspect {} at {}: {error}", + manager.artifact_label(), + artifact.path.display() )); - false + break; } - }; + } + } + inspection +} + +fn add_artifact_conflict_kind( + inspection: &ArtifactInspection, + kinds: &mut Vec, +) { + let kind = if inspection.error.is_some() { + Some(ServiceManagerConflictKind::Indeterminate) + } else if !inspection.unsafe_paths.is_empty() { + Some(ServiceManagerConflictKind::UnsafeArtifact) + } else if !inspection.expected.is_empty() && inspection.missing == 0 { + Some(ServiceManagerConflictKind::Installed) + } else if inspection.expected.is_empty() { + None + } else { + Some(ServiceManagerConflictKind::PartialInstall) + }; + kinds.extend(kind); +} - // Only real evidence should block install; probe errors are treated as not active - if installed || active { - conflicts.push(ServiceManagerConflict { - manager_label: manager.label(), - artifact_label: manager.artifact_label(), - artifact_path: manager.primary_artifact_path(), - installed, - active, - }); +fn inspect_runtime(manager: &ServiceManager) -> RuntimeInspection { + match manager.availability_state() { + Ok(Some(ServiceManagerAvailability::Unavailable)) => RuntimeInspection::Unavailable, + Ok(Some(ServiceManagerAvailability::Available) | None) => { + match manager.active_probe().evaluate_state() { + Ok(state) => RuntimeInspection::State(state), + Err(error) => RuntimeInspection::Indeterminate(format!( + "could not establish whether {} is active: {error}", + manager.label() + )), + } + } + Ok(Some(ServiceManagerAvailability::Indeterminate)) => { + RuntimeInspection::Indeterminate(format!( + "{} returned an indeterminate manager availability state", + manager.label() + )) } + Err(error) => RuntimeInspection::Indeterminate(format!( + "could not establish whether {} is reachable: {error}", + manager.label() + )), } +} - (conflicts, warnings) +fn add_runtime_conflict_kind( + runtime: RuntimeInspection, + manager_label: &str, + kinds: &mut Vec, + detail: &mut Option, +) { + let runtime_detail = match runtime { + RuntimeInspection::State(ServiceProbeState::Active) => { + kinds.push(ServiceManagerConflictKind::Active); + None + } + RuntimeInspection::State(ServiceProbeState::Indeterminate) => { + kinds.push(ServiceManagerConflictKind::Indeterminate); + Some(format!( + "{manager_label} returned an indeterminate service state" + )) + } + RuntimeInspection::Indeterminate(message) => { + kinds.push(ServiceManagerConflictKind::Indeterminate); + Some(message) + } + RuntimeInspection::Unavailable + | RuntimeInspection::State( + ServiceProbeState::Unavailable + | ServiceProbeState::Absent + | ServiceProbeState::Inactive, + ) => None, + }; + if detail.is_none() { + *detail = runtime_detail; + } + kinds.sort_unstable(); + kinds.dedup(); } diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index fe1496596..62348bdd6 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -56,6 +56,10 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { ); if let Some(err) = state.service_active_error.as_ref() { log_line(ctx, format!("- service status check failed: {err}")); + return Err(anyhow!( + "cannot establish whether {} is active; refusing to install while service ownership is indeterminate", + ctx.paths.service.label() + )); } if let Some(err) = state.service_enabled_error.as_ref() { log_line(ctx, format!("- service enable check failed: {err}")); @@ -143,7 +147,7 @@ fn reject_service_manager_conflicts(ctx: &mut ActionContext, state: &InstallStat // Block before build/copy/write steps so two managers never race to restart the daemon for conflict in &state.service_conflicts { - if conflict.active { + if conflict.kinds.contains(&ServiceManagerConflictKind::Active) { log_line( ctx, format!( @@ -153,7 +157,10 @@ fn reject_service_manager_conflicts(ctx: &mut ActionContext, state: &InstallStat ), ); } - if conflict.installed { + if conflict + .kinds + .contains(&ServiceManagerConflictKind::Installed) + { log_line( ctx, format!( @@ -164,6 +171,48 @@ fn reject_service_manager_conflicts(ctx: &mut ActionContext, state: &InstallStat ), ); } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::PartialInstall) + { + log_line( + ctx, + format!( + "Error: incomplete {} remains under {}", + conflict.artifact_label, conflict.manager_label + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::UnsafeArtifact) + { + log_line( + ctx, + format!( + "Error: unsafe {} objects remain under {}", + conflict.artifact_label, conflict.manager_label + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate) + { + log_line( + ctx, + format!( + "Error: cannot establish whether {} owns or runs UnixNotis", + conflict.manager_label + ), + ); + } + for path in &conflict.artifact_paths { + log_line(ctx, format!("- leftover: {}", format_with_home(path))); + } + if let Some(detail) = conflict.detail.as_ref() { + log_line(ctx, format!("- inspection failure: {detail}")); + } } Err(anyhow!( "UnixNotis already appears managed by another service manager; uninstall or migrate it before installing with {}", diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index 4a1892375..5e91d85e5 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -4,7 +4,8 @@ use std::env; use std::path::Path; use crate::paths::{InstallPaths, ServiceManagerChoice}; -use crate::service_manager::{CommandSpec, ReadinessIssue, ServiceManager}; +use crate::service_manager::contract::{ServiceManagerAvailability, ServiceProbeState}; +use crate::service_manager::{ReadinessIssue, ServiceManager}; use crate::system_tools; use crate::toolchain::resolve_cargo; use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; @@ -46,32 +47,52 @@ pub(super) fn service_manager_check_from(manager: &ServiceManager) -> CheckItem // Hard readiness errors are shown before running optional availability probes return CheckItem::fail("Service manager", &detail); } - if let Some(spec) = manager.availability_command() { - // Backends with a native availability command still report softer setup warnings - return availability_check_item(manager, &spec, &issues); - } - if let Some(detail) = readiness_warning_detail(manager, &issues) { - // Some experimental backends have no global probe, so warnings become the check result - return CheckItem::warn("Service manager", &detail); + // One semantic interpreter is shared with conflict detection and activation checks + match manager.availability_state() { + Ok(Some(ServiceManagerAvailability::Available)) => { + available_manager_check_item(manager, &issues) + } + Ok(Some(ServiceManagerAvailability::Unavailable)) => CheckItem::fail( + "Service manager", + &format!("{} unavailable", manager.label()), + ), + Ok(Some(ServiceManagerAvailability::Indeterminate)) => CheckItem::fail( + "Service manager", + &format!("{} availability is indeterminate", manager.label()), + ), + Ok(None) => native_service_probe_check_item(manager, &issues), + Err(err) => CheckItem::fail("Service manager", &format!("check failed: {err}")), } - // Some managers have no cheap global probe, so backend readiness is the availability check - CheckItem::ok("Service manager", &format!("{} ready", manager.label())) } -fn availability_check_item( +fn available_manager_check_item(manager: &ServiceManager, issues: &[ReadinessIssue]) -> CheckItem { + readiness_warning_detail(manager, issues).map_or_else( + || CheckItem::ok("Service manager", &format!("{} available", manager.label())), + |detail| CheckItem::warn("Service manager", &detail), + ) +} + +fn native_service_probe_check_item( manager: &ServiceManager, - spec: &CommandSpec, issues: &[ReadinessIssue], ) -> CheckItem { - match spec.to_command().and_then(|mut command| command.status()) { - Ok(status) if status.success() => readiness_warning_detail(manager, issues).map_or_else( - || CheckItem::ok("Service manager", &format!("{} available", manager.label())), - |detail| CheckItem::warn("Service manager", &detail), - ), - Ok(_) => CheckItem::fail( + // Runit and s6 have no separate manager transport query, so their bounded service probe + // decides whether the selected backend can be inspected without inventing another contract + match manager.active_probe().evaluate_state() { + Ok(ServiceProbeState::Absent | ServiceProbeState::Inactive | ServiceProbeState::Active) => { + readiness_warning_detail(manager, issues).map_or_else( + || CheckItem::ok("Service manager", &format!("{} ready", manager.label())), + |detail| CheckItem::warn("Service manager", &detail), + ) + } + Ok(ServiceProbeState::Unavailable) => CheckItem::fail( "Service manager", &format!("{} unavailable", manager.label()), ), + Ok(ServiceProbeState::Indeterminate) => CheckItem::fail( + "Service manager", + &format!("{} state is indeterminate", manager.label()), + ), Err(err) => CheckItem::fail("Service manager", &format!("check failed: {err}")), } } diff --git a/crates/unixnotis-installer/src/checks/tests/system.rs b/crates/unixnotis-installer/src/checks/tests/system.rs index 9ec2b7b31..ae9f20529 100644 --- a/crates/unixnotis-installer/src/checks/tests/system.rs +++ b/crates/unixnotis-installer/src/checks/tests/system.rs @@ -5,13 +5,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::checks::CheckState; use crate::paths::InstallPaths; +use crate::service_manager::contract::ServiceManagerAvailability; use crate::service_manager::{ReadinessIssue, ServiceManager}; use crate::test_support::fs::write_executable; use super::{ command_success, dbus_update_env_check, install_paths_check, path_is_writable, readiness_error_detail, readiness_messages, readiness_warning_detail, - service_manager_check_from, + service_manager_check_from, wayland_check, }; fn env_lock() -> std::sync::MutexGuard<'static, ()> { @@ -34,6 +35,20 @@ fn readiness_error_detail_collects_only_blocking_issues() { assert!(!detail.contains("boot setup incomplete")); } +#[test] +fn wayland_check_accepts_exact_wayland_session_without_display_fallback() { + let _lock = env_lock(); + let root = test_root("exact-wayland-session-check"); + let _session = crate::test_support::env::EnvGuard::set("XDG_SESSION_TYPE", "wayland"); + let _display = crate::test_support::env::EnvGuard::set("WAYLAND_DISPLAY", ""); + let _runtime = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", &root); + + let item = wayland_check(); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "session detected"); +} + #[test] fn readiness_warning_detail_keeps_backend_label() { let manager = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit.d")); @@ -62,6 +77,113 @@ fn readiness_messages_split_warnings_and_errors() { assert_eq!(readiness_messages(&issues, true), ["error one".to_string()]); } +#[test] +fn service_manager_check_uses_canonical_reachable_nonzero_systemd_state() { + let _lock = env_lock(); + let root = test_root("reachable-nonzero-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' degraded\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let availability = manager + .availability_state() + .expect("systemd availability query") + .expect("systemd manager-level probe"); + let item = service_manager_check_from(&manager); + + assert_eq!(availability, ServiceManagerAvailability::Available); + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "systemd --user available"); + fs::remove_dir_all(root).expect("remove systemd availability fixture"); +} + +#[test] +fn service_manager_check_rejects_indeterminate_systemd_availability() { + let _lock = env_lock(); + let root = test_root("indeterminate-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' unexpected-manager-state\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "systemd --user availability is indeterminate"); + fs::remove_dir_all(root).expect("remove indeterminate systemd fixture"); +} + +#[test] +fn service_manager_check_rejects_unavailable_systemd_transport() { + let _lock = env_lock(); + let root = test_root("unavailable-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'Failed to connect to bus: No medium found' >&2\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "systemd --user unavailable"); + fs::remove_dir_all(root).expect("remove unavailable systemd fixture"); +} + +#[test] +fn service_manager_check_accepts_absent_runit_service_as_ready_backend() { + let _lock = env_lock(); + let root = test_root("absent-runit-service-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool(&fake_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_fake_tool( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf '%s\\n' 'fail: unixnotis-daemon: runsv not running'\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::runit_user(root.join("service")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "runit user services ready"); + fs::remove_dir_all(root).expect("remove absent runit fixture"); +} + +#[test] +fn service_manager_check_rejects_ambiguous_runit_service_state() { + let _lock = env_lock(); + let root = test_root("ambiguous-runit-service-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool(&fake_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_fake_tool( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf '%s\\n' ambiguous\nexit 0\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::runit_user(root.join("service")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "runit user services state is indeterminate"); + fs::remove_dir_all(root).expect("remove ambiguous runit fixture"); +} + #[test] fn service_manager_check_fails_for_s6_missing_live_directory() { let _lock = env_lock(); @@ -204,6 +326,26 @@ fn install_paths_check_fails_when_service_root_is_not_directory() { let _ = fs::remove_dir_all(root); } +#[test] +fn install_paths_check_accepts_writable_binary_and_service_directories() { + let root = test_root("writable-install-paths-check"); + let bin_dir = root.join("bin"); + let service_root = root.join("service-root"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&service_root).expect("service root"); + let paths = InstallPaths { + repo_root: root.clone(), + bin_dir, + service: ServiceManager::systemd_user(service_root), + }; + + let item = install_paths_check(&paths); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "writable"); + fs::remove_dir_all(root).expect("remove writable install paths fixture"); +} + #[test] fn path_is_writable_accepts_a_real_directory_and_removes_its_probe() { let root = test_root("writable-path-check"); @@ -261,6 +403,10 @@ fn write_fake_s6_tools(fake_bin: &std::path::Path) { let path = fake_bin.join(tool); write_executable(&path, "#!/bin/sh\nexit 0\n"); } + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\n# Exit 1 means the service is not supervised\nexit 1\n", + ); } fn write_fake_tool(path: &std::path::Path, contents: &str) { @@ -283,6 +429,12 @@ fn write_fake_s6_tools_except(fake_bin: &std::path::Path, missing_tool: &str) { let path = fake_bin.join(tool); write_executable(&path, "#!/bin/sh\nexit 0\n"); } + if missing_tool != "s6-svstat" { + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\n# Exit 1 means the service is not supervised\nexit 1\n", + ); + } } fn test_root(name: &str) -> PathBuf { diff --git a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs index 7be2ef2b5..a0702c3a7 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs @@ -1,7 +1,11 @@ use std::fs; use std::path::{Path, PathBuf}; -use super::super::contract::{CommandSpec, ReadinessIssue, ServiceArtifact, ServiceArtifactKind}; +use super::super::contract::{ + CommandSpec, ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceManagerAvailability, + ServiceManagerAvailabilityOutput, ServiceManagerAvailabilityProbe, ServiceProbe, + ServiceProbeOutput, ServiceProbeState, +}; // Dinit service names are file names without the .service suffix used by systemd pub const SERVICE_NAME: &str = "unixnotis-daemon"; @@ -43,13 +47,33 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub fn availability_command() -> CommandSpec { - CommandSpec::new( +pub fn availability_probe() -> ServiceManagerAvailabilityProbe { + let command = CommandSpec::new( "dinitctl --user --quiet list", "dinitctl", ["--user", "--quiet", "list"], ) - .quiet() + // Transport diagnostics are matched only in the stable C locale + .env("LC_ALL", "C"); + ServiceManagerAvailabilityProbe::new(command, interpret_availability) +} + +fn interpret_availability( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() { + return ServiceManagerAvailability::Available; + } + // This prefix comes from dinit's client-side control-socket connection failure + if output.did_exit() + && output + .stderr() + .trim() + .starts_with("dinit-client: connecting to socket") + { + return ServiceManagerAvailability::Unavailable; + } + ServiceManagerAvailability::Indeterminate } pub const fn is_enabled_command() -> Option { @@ -57,12 +81,42 @@ pub const fn is_enabled_command() -> Option { None } -pub fn is_active_command() -> CommandSpec { - CommandSpec::new( - format!("dinitctl --user --quiet is-started {SERVICE_NAME}"), +pub fn active_probe() -> ServiceProbe { + // `status` identifies an unloaded service separately from control-socket failures + let command = CommandSpec::new( + format!("dinitctl --user status {SERVICE_NAME}"), "dinitctl", - ["--user", "--quiet", "is-started", SERVICE_NAME], - ) + ["--user", "status", SERVICE_NAME], + ); + ServiceProbe::new(command, interpret_active_state) +} + +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + if output.status_success() { + return match output + .stdout() + .lines() + .find_map(|line| line.trim().strip_prefix("State: ")) + { + Some("STARTED" | "STARTING" | "STOPPING") => ServiceProbeState::Active, + Some("STOPPED") => ServiceProbeState::Inactive, + Some(state) if state.starts_with("STOPPED (") && state.ends_with(')') => { + ServiceProbeState::Inactive + } + Some(_) | None => ServiceProbeState::Indeterminate, + }; + } + + // dinitctl status uses this exact result when the service has no live record + // Other exit failures may be socket or protocol faults and remain indeterminate + if output.status_code() == Some(1) + && output.stdout().trim().is_empty() + && output.stderr().trim() == "dinitctl: service not loaded." + { + return ServiceProbeState::Absent; + } + + ServiceProbeState::Indeterminate } pub const fn reload_after_artifact_change() -> Option { diff --git a/crates/unixnotis-installer/src/service_manager/backends/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/runit.rs index 2a6f1399e..0e58c229a 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/runit.rs @@ -5,7 +5,8 @@ use crate::system_tools; use super::super::contract::{ envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, - ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceProbe, MANAGED_DIRECTORY_MARKER, + ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceProbe, ServiceProbeOutput, + ServiceProbeState, MANAGED_DIRECTORY_MARKER, }; // Runit service directories use the service name directly under the supervision root @@ -67,11 +68,6 @@ pub fn install_artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec CommandSpec { - // `sv -V` checks the control binary without requiring the service to exist yet - CommandSpec::new("sv -V", "sv", ["-V"]).quiet() -} - pub const fn is_enabled_command() -> Option { // Enablement is the presence of the service directory under the watched root None @@ -94,8 +90,10 @@ pub fn active_probe(artifact_root: &Path) -> ServiceProbe { format!("sv status {service}"), "sv", ["status".to_string(), service], - ); - ServiceProbe::stdout(command, status_output_is_running) + ) + // Runit diagnostics are stable English strings only under the C locale + .env("LC_ALL", "C"); + ServiceProbe::new(command, interpret_active_state) } pub const fn reload_after_artifact_change() -> Option { @@ -235,6 +233,28 @@ fn path_is_missing(path: &Path) -> bool { .map_or_else(|err| err.kind() == std::io::ErrorKind::NotFound, |_| false) } -fn status_output_is_running(stdout: &str) -> bool { - stdout.trim_start().starts_with("run:") +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + let stdout = output.stdout().trim(); + if output.status_success() { + return if stdout.starts_with("run:") { + ServiceProbeState::Active + } else if stdout.starts_with("down:") { + ServiceProbeState::Inactive + } else { + ServiceProbeState::Indeterminate + }; + } + + // One-service probes return one only for this service-level failure class + // Match only runit's documented absence diagnostics so timeouts stay blocking + let service_is_absent = output.status_code() == Some(1) + && output.stderr().trim().is_empty() + && (stdout.ends_with(": runsv not running") + || stdout + .ends_with(": unable to change to service directory: No such file or directory")); + if service_is_absent { + ServiceProbeState::Absent + } else { + ServiceProbeState::Indeterminate + } } diff --git a/crates/unixnotis-installer/src/service_manager/backends/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/s6.rs index 33d0775a9..51e51ec4f 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/s6.rs @@ -6,7 +6,8 @@ use crate::system_tools; use super::super::contract::{ envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, - ServiceArtifactRefresh, ServiceProbe, MANAGED_DIRECTORY_MARKER, + ServiceArtifactRefresh, ServiceProbe, ServiceProbeOutput, ServiceProbeState, + MANAGED_DIRECTORY_MARKER, }; pub const SERVICE_NAME: &str = "unixnotis-daemon"; @@ -71,11 +72,6 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub const fn availability_command() -> Option { - // s6 readiness needs several tools and paths, so readiness_issues owns validation - None -} - pub const fn is_enabled_command() -> Option { // Enablement is source-backed through the default bundle membership file None @@ -99,7 +95,7 @@ pub fn active_probe(live_dir: &Path) -> ServiceProbe { "s6-svstat", ["-o".to_string(), "up".to_string(), service], ); - ServiceProbe::stdout(command, status_output_is_running) + ServiceProbe::new(command, interpret_active_state) } pub fn refresh_after_artifact_change( @@ -304,6 +300,17 @@ fn is_directory_or_symlink_to_directory(path: &Path) -> bool { .is_ok_and(|metadata| metadata.is_dir()) } -fn status_output_is_running(stdout: &str) -> bool { - stdout.trim() == "true" +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + // s6 assigns exit one specifically to an absent s6-supervise process + if output.status_code() == Some(1) { + return ServiceProbeState::Absent; + } + if !output.status_success() { + return ServiceProbeState::Indeterminate; + } + match output.stdout().trim() { + "true" => ServiceProbeState::Active, + "false" => ServiceProbeState::Inactive, + _ => ServiceProbeState::Indeterminate, + } } diff --git a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs index 9c7ffd6f8..d3cc51e23 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs @@ -1,6 +1,9 @@ use std::path::{Path, PathBuf}; -use super::super::contract::{CommandSpec, ServiceArtifact}; +use super::super::contract::{ + CommandSpec, ServiceArtifact, ServiceManagerAvailability, ServiceManagerAvailabilityOutput, + ServiceManagerAvailabilityProbe, ServiceProbe, ServiceProbeOutput, ServiceProbeState, +}; // Keep the systemd unit name stable for existing installs and migration cleanup pub const SERVICE_NAME: &str = "unixnotis-daemon.service"; @@ -29,19 +32,37 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub fn availability_command() -> CommandSpec { - CommandSpec::new( - "systemctl --user --no-pager --plain list-units --type=service", +pub fn availability_probe() -> ServiceManagerAvailabilityProbe { + // is-system-running gives one bounded manager state instead of an unbounded unit listing + let command = CommandSpec::new( + "systemctl --user is-system-running", "systemctl", - [ - "--user", - "--no-pager", - "--plain", - "list-units", - "--type=service", - ], + ["--user", "is-system-running"], ) - .quiet() + // Transport diagnostics are matched only in the stable C locale + .env("LC_ALL", "C"); + ServiceManagerAvailabilityProbe::new(command, interpret_availability) +} + +fn interpret_availability( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + match output.stdout().trim() { + "initializing" | "starting" | "running" | "degraded" | "maintenance" | "stopping" => { + ServiceManagerAvailability::Available + } + "offline" => ServiceManagerAvailability::Unavailable, + _ if !output.status_success() + && output.did_exit() + && output + .stderr() + .trim() + .starts_with("Failed to connect to bus") => + { + ServiceManagerAvailability::Unavailable + } + _ => ServiceManagerAvailability::Indeterminate, + } } pub fn is_enabled_command() -> CommandSpec { @@ -52,12 +73,55 @@ pub fn is_enabled_command() -> CommandSpec { ) } -pub fn is_active_command() -> CommandSpec { - CommandSpec::new( - format!("systemctl --user is-active --quiet {SERVICE_NAME}"), +pub fn active_probe() -> ServiceProbe { + // `show` is systemd's machine-readable state interface + // A generic nonzero `is-active` result cannot prove the manager was reachable + let command = CommandSpec::new( + format!("systemctl --user show LoadState and ActiveState for {SERVICE_NAME}"), "systemctl", - ["--user", "is-active", "--quiet", SERVICE_NAME], - ) + [ + "--user", + "show", + "--property=LoadState", + "--property=ActiveState", + SERVICE_NAME, + ], + ); + ServiceProbe::new(command, interpret_active_state) +} + +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + if !output.status_success() { + return ServiceProbeState::Indeterminate; + } + let mut load_state = None; + let mut active_state = None; + for line in output.stdout().lines() { + match line.split_once('=') { + Some(("LoadState", value)) if load_state.replace(value).is_none() => {} + Some(("ActiveState", value)) if active_state.replace(value).is_none() => {} + Some(("LoadState" | "ActiveState", _)) | None => { + return ServiceProbeState::Indeterminate; + } + Some((_other, _value)) => {} + } + } + let load_is_known = matches!( + load_state, + Some("loaded" | "not-found" | "masked" | "error" | "bad-setting") + ); + if !load_is_known { + return ServiceProbeState::Indeterminate; + } + match (load_state, active_state) { + // A missing unit is different from a stopped unit already known to systemd + (Some("not-found"), Some("inactive")) => ServiceProbeState::Absent, + (_, Some("active" | "activating" | "deactivating" | "reloading" | "refreshing")) => { + ServiceProbeState::Active + } + (_, Some("inactive" | "failed")) => ServiceProbeState::Inactive, + (_, Some(_) | None) => ServiceProbeState::Indeterminate, + } } pub fn reload_after_artifact_change() -> CommandSpec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs index b53f75c10..4d6d3a3c1 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs @@ -53,24 +53,58 @@ fn dinit_backend_renders_boot_dependency_artifacts() { fn dinit_backend_commands_match_expected_behavior() { let manager = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit.d")); - let availability = manager - .availability_command() - .expect("dinit has an availability command"); - assert_eq!(availability.program(), "dinitctl"); - assert_eq!(availability.args(), &["--user", "--quiet", "list"]); - // Enablement is artifact-backed, so no manager command should be required for install state assert!(manager.is_enabled_command().is_none()); let active = manager.active_probe(); assert_eq!( active.command().args(), - &[ - "--user", - "--quiet", - "is-started", - UNIXNOTIS_DAEMON_DINIT_SERVICE - ] + &["--user", "status", UNIXNOTIS_DAEMON_DINIT_SERVICE] + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: STARTED\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + for transition in ["STARTING", "STOPPING"] { + assert_eq!( + active.parser_state( + true, + &format!("Service: unixnotis-daemon\n State: {transition}\n") + ), + crate::service_manager::contract::ServiceProbeState::Active + ); + } + assert_eq!( + active.parser_state( + true, + "Service: unixnotis-daemon\n State: STOPPED (terminated)\n" + ), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: STOPPED\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: UNKNOWN\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + for malformed_state in ["STOPPED_BUT_UNKNOWN", "STOPPED (unterminated", "UNKNOWN)"] { + assert_eq!( + active.parser_state( + true, + &format!("Service: unixnotis-daemon\n State: {malformed_state}\n") + ), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + } + assert_eq!( + active.parser_state_with_result(Some(1), "", "dinitctl: service not loaded.\n"), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state_with_result(Some(1), "", "dinit-client: connecting to socket failed\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); // First install should not reload a service that dinit has not loaded yet diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs index 880e1a24e..2fc1da56e 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs @@ -69,12 +69,6 @@ fn runit_backend_commands_match_expected_behavior() { let manager = ServiceManager::runit_user(PathBuf::from("/tmp/service")); let service_path = "/tmp/service/unixnotis-daemon"; - let availability = manager - .availability_command() - .expect("runit checks sv availability"); - assert_eq!(availability.program(), "sv"); - assert_eq!(availability.args(), &["-V"]); - // A watched service directory is the enablement source, not an sv query assert!(manager.is_enabled_command().is_none()); assert!(manager.refresh_after_artifact_change().is_none()); @@ -83,12 +77,36 @@ fn runit_backend_commands_match_expected_behavior() { let active = manager.active_probe(); assert_eq!(active.command().args(), &["status", service_path]); assert_eq!( - active.parser_matches("run: /tmp/service/unixnotis-daemon: (pid 123) 2s"), - Some(true) + active.parser_state(true, "run: /tmp/service/unixnotis-daemon: (pid 123) 2s"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "down: /tmp/service/unixnotis-daemon: 1s"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state_with_result( + Some(1), + "fail: /tmp/service/unixnotis-daemon: runsv not running\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Absent ); assert_eq!( - active.parser_matches("down: /tmp/service/unixnotis-daemon: 1s"), - Some(false) + active.parser_state_with_result( + Some(1), + "fail: /tmp/service/unixnotis-daemon: unable to change to service directory: No such file or directory\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state_with_result( + Some(1), + "timeout: down: /tmp/service/unixnotis-daemon: 30s\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); let enable = manager.enable_now_command(); diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs index c9e4b40b2..afacca979 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs @@ -71,8 +71,6 @@ fn s6_backend_commands_match_expected_behavior() { PathBuf::from("/run/user/s6-rc"), ); - // Readiness checks own tool validation because availability needs several s6 programs - assert!(manager.availability_command().is_none()); assert!(manager.is_enabled_command().is_none()); // Database refresh compiles the user source tree before s6-rc can change the live service let Some(ServiceArtifactRefresh::S6Database(refresh)) = manager.refresh_after_artifact_change() @@ -124,8 +122,24 @@ fn s6_backend_active_probe_parses_s6_svstat_output() { let active = manager.active_probe(); // s6-svstat -o up prints a boolean, so parsing stays exact and cheap - assert_eq!(active.parser_matches("true\n"), Some(true)); - assert_eq!(active.parser_matches("false\n"), Some(false)); + assert_eq!( + active.parser_state(true, "true\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "false\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state_with_result(Some(1), "", ""), + crate::service_manager::contract::ServiceProbeState::Absent + ); + for failure_code in [100, 111] { + assert_eq!( + active.parser_state_with_result(Some(failure_code), "", "system error\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + } } #[test] @@ -390,9 +404,18 @@ fn s6_active_probe_rejects_truthy_but_non_exact_output() { let active = manager.active_probe(); // s6-svstat -o up emits exact true/false, so loose text must not count as active - assert_eq!(active.parser_matches(" true\n"), Some(true)); - assert_eq!(active.parser_matches("true enough\n"), Some(false)); - assert_eq!(active.parser_matches("1\n"), Some(false)); + assert_eq!( + active.parser_state(true, " true\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "true enough\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + assert_eq!( + active.parser_state(true, "1\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); } fn test_root(name: &str) -> PathBuf { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs index 53230a74b..6fd900dc7 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs @@ -62,23 +62,7 @@ fn systemd_backend_renders_exact_unit_artifact() { fn systemd_backend_commands_match_existing_behavior() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - // Availability should remain a read-only user-manager query - let availability = manager - .availability_command() - .expect("systemd has an availability command"); - assert_eq!(availability.program(), "systemctl"); - assert_eq!( - availability.args(), - &[ - "--user", - "--no-pager", - "--plain", - "list-units", - "--type=service" - ] - ); - - // Enabled and active probes intentionally use quiet status checks for fast install-state reads + // Enabled state uses the native status check while active state uses explicit properties let enabled = manager .is_enabled_command() .expect("systemd has an enabled-state command"); @@ -90,7 +74,25 @@ fn systemd_backend_commands_match_existing_behavior() { let active = manager.active_probe(); assert_eq!( active.command().args(), - &["--user", "is-active", "--quiet", UNIXNOTIS_DAEMON_SERVICE] + &[ + "--user", + "show", + "--property=LoadState", + "--property=ActiveState", + UNIXNOTIS_DAEMON_SERVICE + ] + ); + assert_eq!( + active.parser_state(true, "LoadState=loaded\nActiveState=inactive\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "LoadState=not-found\nActiveState=inactive\n"), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state(false, "Failed to connect to bus\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); // Unit file changes still require daemon-reload before enable/start diff --git a/crates/unixnotis-installer/src/service_manager/contract/artifact.rs b/crates/unixnotis-installer/src/service_manager/contract/artifact.rs index 2d18ba6b0..9495dddee 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/artifact.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/artifact.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io; use std::path::{Path, PathBuf}; pub const MANAGED_DIRECTORY_MARKER: &str = ".unixnotis-managed"; @@ -32,6 +33,13 @@ pub struct ServiceArtifact { pub mode: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceArtifactState { + Missing, + Expected, + UnexpectedObject, +} + impl ServiceArtifact { pub(in crate::service_manager) const fn file(path: PathBuf, contents: String) -> Self { // File artifacts are the simplest manager-owned shape, used by systemd and dinit @@ -44,69 +52,69 @@ impl ServiceArtifact { } pub fn is_present_safely(&self) -> bool { - // State checks must match writer/remover ownership rules, not raw path existence - match &self.kind { + // Compatibility callers need a boolean while conflict scans retain inspection errors + self.inspect() + .is_ok_and(|state| state == ServiceArtifactState::Expected) + } + + pub fn exists_at_path_but_not_safely(&self) -> bool { + self.inspect() + .is_ok_and(|state| state == ServiceArtifactState::UnexpectedObject) + } + + pub fn inspect(&self) -> io::Result { + let metadata = match fs::symlink_metadata(&self.path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceArtifactState::Missing) + } + Err(error) => return Err(error), + }; + + let expected = match &self.kind { ServiceArtifactKind::File | ServiceArtifactKind::ExecutableFile => { - // A symlink at a file path is never counted as installed - path_is_regular_file(&self.path) + metadata.file_type().is_file() } ServiceArtifactKind::SharedFile { .. } => { - // Shared files are safe only when the existing bytes match the backend contract - path_is_regular_file(&self.path) - && self - .contents - .as_ref() - .is_some_and(|expected| file_contents_match(&self.path, expected)) + if metadata.file_type().is_file() { + let Some(expected) = self.contents.as_ref() else { + return Ok(ServiceArtifactState::UnexpectedObject); + }; + fs::read_to_string(&self.path)? == *expected + } else { + false + } } - ServiceArtifactKind::Directory => path_is_directory(&self.path), + ServiceArtifactKind::Directory => metadata.file_type().is_dir(), ServiceArtifactKind::ManagedDirectory => { - // Directory backends need the marker before state can call them installer-owned - path_is_directory(&self.path) - && managed_directory_marker_is_valid(&managed_directory_marker(&self.path)) + metadata.file_type().is_dir() && inspect_managed_marker(&self.path)? } - ServiceArtifactKind::Symlink { target } => fs::read_link(&self.path) - // Symlink state is exact because enablement can depend on the stored target - .is_ok_and(|actual| actual == *target), - } - } - - pub fn exists_at_path_but_not_safely(&self) -> bool { - // Unsafe paths are real filesystem entries that do not match the expected artifact shape - // Reporting them separately avoids making symlinks or foreign directories look absent - path_exists_without_following(&self.path) && !self.is_present_safely() + ServiceArtifactKind::Symlink { target } => { + metadata.file_type().is_symlink() && fs::read_link(&self.path)? == *target + } + }; + Ok(if expected { + ServiceArtifactState::Expected + } else { + ServiceArtifactState::UnexpectedObject + }) } } -fn path_is_regular_file(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) -} - -fn path_is_directory(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) -} - -fn path_exists_without_following(path: &Path) -> bool { - // symlink_metadata checks the artifact path itself, which is what safety diagnostics need - fs::symlink_metadata(path).is_ok() -} - -fn file_contents_match(path: &Path, expected: &str) -> bool { - // Shared setup files use exact tiny contents, such as s6 bundle type declarations - fs::read_to_string(path).is_ok_and(|contents| contents == expected) +fn inspect_managed_marker(directory: &Path) -> io::Result { + let marker = managed_directory_marker(directory); + let metadata = match fs::symlink_metadata(&marker) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if !metadata.file_type().is_file() { + return Ok(false); + } + Ok(fs::read_to_string(marker)? == MANAGED_DIRECTORY_MARKER_CONTENTS) } pub fn managed_directory_marker(path: &Path) -> PathBuf { // Keep marker placement centralized so writer, remover, and state checks agree path.join(MANAGED_DIRECTORY_MARKER) } - -pub fn managed_directory_marker_is_valid(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - // A marker symlink is not ownership proof because it can point outside the service dir - if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { - return false; - } - fs::read_to_string(path).is_ok_and(|contents| contents == MANAGED_DIRECTORY_MARKER_CONTENTS) -} diff --git a/crates/unixnotis-installer/src/service_manager/contract/availability.rs b/crates/unixnotis-installer/src/service_manager/contract/availability.rs new file mode 100644 index 000000000..bdea5ebaa --- /dev/null +++ b/crates/unixnotis-installer/src/service_manager/contract/availability.rs @@ -0,0 +1,99 @@ +//! Bounded manager-transport availability probes + +use std::io; +use std::time::Duration; + +use super::CommandSpec; + +const DEFAULT_AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_AVAILABILITY_STREAM_BYTES: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceManagerAvailability { + // The manager transport accepted a read-only query + Available, + // The command is missing or its manager transport is not reachable + Unavailable, + // The manager query ran but did not prove whether its transport is reachable + Indeterminate, +} + +pub struct ServiceManagerAvailabilityProbe { + command: CommandSpec, + interpret: fn(ServiceManagerAvailabilityOutput<'_>) -> ServiceManagerAvailability, +} + +impl ServiceManagerAvailabilityProbe { + pub(in crate::service_manager) const fn new( + command: CommandSpec, + interpret: fn(ServiceManagerAvailabilityOutput<'_>) -> ServiceManagerAvailability, + ) -> Self { + Self { command, interpret } + } + + pub fn evaluate(&self) -> io::Result { + self.evaluate_with_timeout(DEFAULT_AVAILABILITY_TIMEOUT) + } + + pub(crate) fn evaluate_with_timeout( + &self, + timeout: Duration, + ) -> io::Result { + let mut command = match self.command.to_command() { + Ok(command) => command, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceManagerAvailability::Unavailable); + } + Err(error) => return Err(error), + }; + let output = crate::system_tools::output_bounded( + &mut command, + timeout, + MAX_AVAILABILITY_STREAM_BYTES, + )?; + if output.stdout_truncated || output.stderr_truncated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service-manager availability output exceeded its safe byte limit", + )); + } + let Ok(stdout) = std::str::from_utf8(&output.stdout) else { + return Ok(ServiceManagerAvailability::Indeterminate); + }; + let Ok(stderr) = std::str::from_utf8(&output.stderr) else { + return Ok(ServiceManagerAvailability::Indeterminate); + }; + Ok((self.interpret)(ServiceManagerAvailabilityOutput { + status_success: output.status.success(), + did_exit: output.status.code().is_some(), + stdout, + stderr, + })) + } +} + +#[derive(Clone, Copy)] +pub(in crate::service_manager) struct ServiceManagerAvailabilityOutput<'a> { + status_success: bool, + did_exit: bool, + stdout: &'a str, + stderr: &'a str, +} + +impl<'a> ServiceManagerAvailabilityOutput<'a> { + pub(in crate::service_manager) const fn status_success(self) -> bool { + self.status_success + } + + pub(in crate::service_manager) const fn did_exit(self) -> bool { + self.did_exit + } + + pub(in crate::service_manager) const fn stdout(self) -> &'a str { + self.stdout + } + + pub(in crate::service_manager) const fn stderr(self) -> &'a str { + self.stderr + } +} diff --git a/crates/unixnotis-installer/src/service_manager/contract/command.rs b/crates/unixnotis-installer/src/service_manager/contract/command.rs index 417ed2bb1..1797c9ea3 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/command.rs @@ -1,4 +1,4 @@ -use std::process::{Command, Stdio}; +use std::process::Command; use unixnotis_core::CommandSpec as ProcessCommandSpec; #[derive(Clone, Debug, Eq, PartialEq)] @@ -7,9 +7,6 @@ pub struct CommandSpec { label: String, // Shared process spec keeps executable, arguments, and environment structurally separate command: ProcessCommandSpec, - // Some probes are intentionally quiet to avoid corrupting the TUI - suppress_stdout: bool, - suppress_stderr: bool, } impl CommandSpec { @@ -28,8 +25,6 @@ impl CommandSpec { program.into(), args.into_iter().map(|arg| arg.to_string()), ), - suppress_stdout: false, - suppress_stderr: false, } } @@ -43,13 +38,6 @@ impl CommandSpec { self } - pub(in crate::service_manager) const fn quiet(mut self) -> Self { - // Availability probes should not leak command output into the parent process - self.suppress_stdout = true; - self.suppress_stderr = true; - self - } - pub fn label(&self) -> &str { &self.label } @@ -79,12 +67,6 @@ impl CommandSpec { // CommandSpec never goes through a shell, which keeps service-manager commands predictable command.args(self.args()); command.envs(self.envs()); - if self.suppress_stdout { - command.stdout(Stdio::null()); - } - if self.suppress_stderr { - command.stderr(Stdio::null()); - } Ok(command) } } diff --git a/crates/unixnotis-installer/src/service_manager/contract/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/mod.rs index 8f29d76e0..55e53b775 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/mod.rs @@ -1,6 +1,7 @@ //! Shared service-manager artifacts, commands, probes, and refresh plans mod artifact; +mod availability; mod command; // Fake service-manager routing lives under /tests and never enters production binaries #[expect( @@ -18,11 +19,14 @@ mod refresh; mod shell; pub use artifact::{ - ServiceArtifact, ServiceArtifactKind, MANAGED_DIRECTORY_MARKER, + ServiceArtifact, ServiceArtifactKind, ServiceArtifactState, MANAGED_DIRECTORY_MARKER, MANAGED_DIRECTORY_MARKER_CONTENTS, }; +pub(super) use availability::ServiceManagerAvailabilityOutput; +pub use availability::{ServiceManagerAvailability, ServiceManagerAvailabilityProbe}; pub use command::CommandSpec; -pub use probe::ServiceProbe; +pub(super) use probe::ServiceProbeOutput; +pub use probe::{ServiceProbe, ServiceProbeState}; pub use readiness::ReadinessIssue; pub use refresh::{S6DatabaseRefresh, ServiceArtifactRefresh}; pub(super) use shell::{envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path}; diff --git a/crates/unixnotis-installer/src/service_manager/contract/probe.rs b/crates/unixnotis-installer/src/service_manager/contract/probe.rs index 182c3fdeb..899221d73 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/probe.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/probe.rs @@ -1,47 +1,110 @@ +//! Bounded service-manager state probes + use std::io; +use std::time::Duration; use super::command::CommandSpec; +const DEFAULT_SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_SERVICE_PROBE_STREAM_BYTES: usize = 16 * 1024; + #[derive(Clone, Debug)] -pub enum ServiceProbe { - // Exit-only probes fit managers with exact status commands - ExitStatus(CommandSpec), - // Some managers need stdout because exit status means "command worked", not "service runs" - Stdout { - command: CommandSpec, - parser: fn(&str) -> bool, - }, +pub struct ServiceProbe { + pub(in crate::service_manager::contract) command: CommandSpec, + pub(in crate::service_manager::contract) interpret: + fn(ServiceProbeOutput<'_>) -> ServiceProbeState, } -impl ServiceProbe { - pub(in crate::service_manager) const fn exit_status(command: CommandSpec) -> Self { - Self::ExitStatus(command) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceProbeState { + // The manager tool or its manager-level transport is unavailable + Unavailable, + // The manager exists but has no live UnixNotis service record + Absent, + // UnixNotis is known to the manager and stopped + Inactive, + // UnixNotis is running or moving through a live transition + Active, + // The probe could not establish a trustworthy state + Indeterminate, +} + +#[derive(Clone, Copy)] +pub(in crate::service_manager) struct ServiceProbeOutput<'a> { + pub(in crate::service_manager::contract) status_success: bool, + pub(in crate::service_manager::contract) status_code: Option, + pub(in crate::service_manager::contract) stdout: &'a str, + pub(in crate::service_manager::contract) stderr: &'a str, +} + +impl<'a> ServiceProbeOutput<'a> { + pub(in crate::service_manager) const fn status_success(self) -> bool { + self.status_success } - pub(in crate::service_manager) fn stdout( + pub(in crate::service_manager) const fn status_code(self) -> Option { + self.status_code + } + + pub(in crate::service_manager) const fn stdout(self) -> &'a str { + self.stdout + } + + pub(in crate::service_manager) const fn stderr(self) -> &'a str { + self.stderr + } +} + +impl ServiceProbe { + pub(in crate::service_manager) const fn new( command: CommandSpec, - parser: fn(&str) -> bool, + interpret: fn(ServiceProbeOutput<'_>) -> ServiceProbeState, ) -> Self { - Self::Stdout { command, parser } + Self { command, interpret } } - pub fn evaluate(&self) -> io::Result { - match self { - Self::ExitStatus(command) => { - // systemd and dinit status commands already encode active state in exit status - command - .to_command()? - .status() - .map(|status| status.success()) - } - Self::Stdout { command, parser } => { - // runit status needs stdout parsing because `sv check` can pass for down state - let output = command.to_command()?.output()?; - if !output.status.success() { - return Ok(false); - } - Ok(parser(&String::from_utf8_lossy(&output.stdout))) + pub fn evaluate_state(&self) -> io::Result { + self.evaluate_state_with_timeout(DEFAULT_SERVICE_PROBE_TIMEOUT) + } + + pub(crate) fn evaluate_state_with_timeout( + &self, + timeout: Duration, + ) -> io::Result { + let mut command = match self.command.to_command() { + Ok(command) => command, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceProbeState::Unavailable); } + Err(error) => return Err(error), + }; + let output = crate::system_tools::output_bounded( + &mut command, + timeout, + MAX_SERVICE_PROBE_STREAM_BYTES, + )?; + if output.stdout_truncated || output.stderr_truncated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service-manager probe output exceeded its safe byte limit", + )); } + let Ok(stdout) = std::str::from_utf8(&output.stdout) else { + return Ok(ServiceProbeState::Indeterminate); + }; + let Ok(stderr) = std::str::from_utf8(&output.stderr) else { + return Ok(ServiceProbeState::Indeterminate); + }; + Ok((self.interpret)(ServiceProbeOutput { + status_success: output.status.success(), + // A signal-terminated manager cannot prove a stable service state + status_code: output.status.code(), + stdout, + stderr, + })) + } + + pub(crate) const fn default_timeout() -> Duration { + DEFAULT_SERVICE_PROBE_TIMEOUT } } diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs index aba6052e7..4424d6012 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs @@ -1,27 +1,30 @@ use std::fs; -use std::os::unix::fs::symlink; +use std::os::unix::fs::{symlink, PermissionsExt}; use std::path::PathBuf; use crate::service_manager::backends::systemd::SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE; use crate::service_manager::contract::MANAGED_DIRECTORY_MARKER; -use crate::service_manager::{ServiceArtifact, ServiceArtifactKind, ServiceManager}; +use crate::service_manager::{ + ServiceArtifact, ServiceArtifactKind, ServiceArtifactState, ServiceManager, +}; #[test] fn systemd_backend_reports_primary_artifact_path() { - let root = PathBuf::from("/tmp/systemd/user"); + let root = std::env::temp_dir().join("systemd").join("user"); let manager = ServiceManager::systemd_user(root.clone()); assert_eq!(manager.artifact_root(), root); assert_eq!( manager.primary_artifact_path(), - PathBuf::from("/tmp/systemd/user").join(UNIXNOTIS_DAEMON_SERVICE) + root.join(UNIXNOTIS_DAEMON_SERVICE) ); } #[test] fn systemd_backend_uses_file_artifact_not_external_renderer() { - let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - let artifacts = manager.artifacts(std::path::Path::new("/tmp/bin")); + let manager = ServiceManager::systemd_user(std::env::temp_dir().join("systemd").join("user")); + let binary_root = std::env::temp_dir().join("bin"); + let artifacts = manager.artifacts(&binary_root); assert_eq!(artifacts[0].kind, ServiceArtifactKind::File); assert!(artifacts[0].contents.is_some()); @@ -41,6 +44,10 @@ fn managed_directory_presence_requires_marker_file() { }; assert!(!artifact.is_present_safely()); + assert_eq!( + artifact.inspect().expect("missing marker state"), + ServiceArtifactState::UnexpectedObject + ); fs::write(service_dir.join(MANAGED_DIRECTORY_MARKER), "unixnotis\n").expect("marker"); @@ -195,6 +202,49 @@ fn managed_directory_marker_rejects_wrong_contents() { let _ = fs::remove_dir_all(root); } +#[test] +fn artifact_inspection_propagates_non_missing_path_errors() { + let root = test_root("artifact-inspection-error"); + fs::create_dir_all(&root).expect("create artifact inspection root"); + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("create invalid parent"); + let artifact = ServiceArtifact { + path: regular_parent.join("service"), + kind: ServiceArtifactKind::File, + contents: Some("owned".to_string()), + mode: None, + }; + + assert!( + artifact.inspect().is_err(), + "path lookup failures must not become missing artifacts" + ); + fs::remove_dir_all(root).expect("remove artifact inspection fixture"); +} + +#[test] +fn managed_marker_inspection_propagates_permission_errors() { + let root = test_root("managed-marker-inspection-error"); + let service_dir = root.join("service"); + fs::create_dir_all(&service_dir).expect("create managed service directory"); + fs::set_permissions(&service_dir, fs::Permissions::from_mode(0o000)) + .expect("remove service directory search permission"); + let artifact = ServiceArtifact { + path: service_dir.clone(), + kind: ServiceArtifactKind::ManagedDirectory, + contents: None, + mode: None, + }; + + assert!( + artifact.inspect().is_err(), + "marker lookup failures must not become an absent marker" + ); + fs::set_permissions(&service_dir, fs::Permissions::from_mode(0o700)) + .expect("restore service directory permission"); + fs::remove_dir_all(root).expect("remove marker inspection fixture"); +} + #[test] fn plain_directory_presence_rejects_regular_file() { let root = test_root("artifact-directory-presence"); diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs new file mode 100644 index 000000000..09fbe4782 --- /dev/null +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::super::{ + command_routing::use_fake_command_bin, CommandSpec, ServiceManagerAvailability, + ServiceManagerAvailabilityOutput, ServiceManagerAvailabilityProbe, +}; + +fn success_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +fn successful_output_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() && output.did_exit() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +fn normal_exit_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.did_exit() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +struct TempDirGuard { + path: std::path::PathBuf, +} + +impl TempDirGuard { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-manager-availability-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create availability test directory"); + Self { path } + } + + fn link_shell(&self, name: &str) { + symlink("/bin/sh", self.path.join(name)).expect("link fake availability tool"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn successful_transport_query_reports_manager_available() { + let root = TempDirGuard::new("available"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("manager availability", "managerctl", ["-c", "exit 0"]), + success_is_available, + ); + + assert_eq!( + probe.evaluate().expect("availability probe should run"), + ServiceManagerAvailability::Available + ); +} + +#[test] +fn generic_nonzero_result_remains_indeterminate_until_a_backend_recognizes_it() { + let root = TempDirGuard::new("indeterminate-failure"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("manager availability", "managerctl", ["-c", "exit 1"]), + success_is_available, + ); + + assert_eq!( + probe + .evaluate() + .expect("backend interpretation should return a stable state"), + ServiceManagerAvailability::Indeterminate + ); +} + +#[test] +fn signal_terminated_query_remains_indeterminate() { + let root = TempDirGuard::new("signal-terminated"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "signal-terminated manager availability", + "managerctl", + ["-c", "kill -TERM $$"], + ), + normal_exit_is_available, + ); + + assert_eq!( + probe + .evaluate() + .expect("signal termination should remain a stable probe result"), + ServiceManagerAvailability::Indeterminate + ); +} + +#[test] +fn missing_manager_tool_reports_manager_unavailable() { + let root = TempDirGuard::new("tool-unavailable"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "missing manager availability", + "missing-managerctl", + ["status"], + ), + success_is_available, + ); + + assert_eq!( + probe.evaluate().expect("missing manager is a stable state"), + ServiceManagerAvailability::Unavailable + ); +} + +#[test] +fn unsafe_manager_tool_object_remains_an_error() { + let root = TempDirGuard::new("unsafe-tool"); + fs::create_dir(root.path.join("managerctl")).expect("create unsafe manager tool object"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("unsafe manager availability", "managerctl", ["status"]), + success_is_available, + ); + + probe + .evaluate() + .expect_err("an unsafe manager tool must not look unavailable"); +} + +#[test] +fn availability_timeout_remains_an_error() { + let root = TempDirGuard::new("timeout"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "timed manager availability", + "managerctl", + ["-c", "sleep 30"], + ), + success_is_available, + ); + + let error = probe + .evaluate_with_timeout(Duration::from_millis(25)) + .expect_err("a hung manager must not look unavailable"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); +} + +#[test] +fn moderate_availability_output_remains_within_the_capture_budget() { + let root = TempDirGuard::new("moderate-output"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with moderate output", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 2048 ]; do printf x; i=$((i + 1)); done", + ], + ), + successful_output_is_available, + ); + + assert_eq!( + probe.evaluate().expect("moderate output must stay bounded"), + ServiceManagerAvailability::Available + ); +} + +#[test] +fn oversized_stdout_is_rejected_without_requiring_oversized_stderr() { + let root = TempDirGuard::new("oversized-stdout"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with oversized stdout", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 17000 ]; do printf x; i=$((i + 1)); done", + ], + ), + success_is_available, + ); + + let error = probe + .evaluate() + .expect_err("oversized stdout must not reach the backend parser"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn oversized_stderr_is_rejected_without_requiring_oversized_stdout() { + let root = TempDirGuard::new("oversized-stderr"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with oversized stderr", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 17000 ]; do printf x >&2; i=$((i + 1)); done", + ], + ), + success_is_available, + ); + + let error = probe + .evaluate() + .expect_err("oversized stderr must not reach the backend parser"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs index 382188a9e..579b9175b 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs @@ -8,20 +8,38 @@ thread_local! { } pub(super) fn command_program(program: &str) -> std::io::Result { - if let Some(fake_program) = fake_command_program(program) { - return Ok(fake_program.into_os_string()); + if let Some(fake_bin) = configured_fake_command_bin() { + if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "invalid isolated test tool name", + )); + } + let candidate = fake_bin.join(program); + // Fake executable links resolve through the stable test dispatcher + if candidate.is_file() { + return Ok(candidate.into_os_string()); + } + match std::fs::symlink_metadata(&candidate) { + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{program} is not a regular test tool"), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{program} is unavailable in the isolated test tool directory"), + )); } crate::system_tools::program_path(program).map(PathBuf::into_os_string) } -fn fake_command_program(program: &str) -> Option { - if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { - return None; - } - FAKE_COMMAND_BIN.with(|fake_bin| { - let candidate = fake_bin.borrow().as_ref()?.join(program); - candidate.is_file().then_some(candidate) - }) +fn configured_fake_command_bin() -> Option { + FAKE_COMMAND_BIN.with(|fake_bin| fake_bin.borrow().clone()) } pub struct FakeCommandBinGuard { diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs index c9b7e92dc..ffe091c6b 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs @@ -1,4 +1,5 @@ mod artifact; +mod availability; mod command; mod probe; mod readiness; diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs index f2a971749..af21d6f19 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs @@ -3,21 +3,30 @@ use std::os::unix::fs::symlink; use std::time::{SystemTime, UNIX_EPOCH}; use super::super::command_routing::use_fake_command_bin; -use crate::service_manager::contract::ServiceProbe; +use crate::service_manager::contract::{ServiceProbe, ServiceProbeState}; use crate::service_manager::CommandSpec; impl ServiceProbe { pub(crate) const fn command(&self) -> &CommandSpec { - match self { - Self::ExitStatus(command) | Self::Stdout { command, .. } => command, - } + &self.command } - pub(crate) fn parser_matches(&self, stdout: &str) -> Option { - match self { - Self::ExitStatus(_) => None, - Self::Stdout { parser, .. } => Some(parser(stdout)), - } + pub(crate) fn parser_state(&self, status_success: bool, stdout: &str) -> ServiceProbeState { + self.parser_state_with_result(if status_success { Some(0) } else { Some(1) }, stdout, "") + } + + pub(crate) fn parser_state_with_result( + &self, + status_code: Option, + stdout: &str, + stderr: &str, + ) -> ServiceProbeState { + (self.interpret)(super::super::ServiceProbeOutput { + status_success: status_code == Some(0), + status_code, + stdout, + stderr, + }) } } @@ -52,33 +61,154 @@ impl Drop for TempDirGuard { } #[test] -fn stdout_probe_uses_parser_only_after_successful_command() { +fn explicit_probe_state_is_returned_after_successful_command() { let root = TempDirGuard::new("success"); root.link_shell("probe-tool"); let _tools = use_fake_command_bin(&root.path); - let probe = ServiceProbe::stdout( + let probe = ServiceProbe::new( CommandSpec::new("probe", "probe-tool", ["-c", "printf 'true\\n'; exit 0"]), - |stdout| stdout.trim() == "true", + |output| { + if output.status_success() && output.stdout().trim() == "true" { + ServiceProbeState::Active + } else { + ServiceProbeState::Indeterminate + } + }, ); - let active = probe.evaluate().expect("probe should run"); + let state = probe.evaluate_state().expect("probe should run"); - // Successful stdout probes are allowed to derive active state from command output - assert!(active); + assert_eq!(state, ServiceProbeState::Active); } #[test] -fn stdout_probe_treats_failed_command_as_inactive_even_with_matching_output() { +fn failed_command_is_indeterminate_even_with_active_looking_output() { let root = TempDirGuard::new("failure"); root.link_shell("probe-tool"); let _tools = use_fake_command_bin(&root.path); - let probe = ServiceProbe::stdout( + let probe = ServiceProbe::new( CommandSpec::new("probe", "probe-tool", ["-c", "printf 'true\\n'; exit 1"]), - |stdout| stdout.trim() == "true", + |output| { + if output.status_success() && output.stdout().trim() == "true" { + ServiceProbeState::Active + } else { + ServiceProbeState::Indeterminate + } + }, + ); + + let state = probe.evaluate_state().expect("probe should run"); + + assert_eq!(state, ServiceProbeState::Indeterminate); +} + +#[test] +fn missing_probe_program_is_classified_as_unavailable() { + let root = TempDirGuard::new("unavailable"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("missing manager probe", "missing-managerctl", ["status"]), + |_output| ServiceProbeState::Indeterminate, + ); + + let state = probe + .evaluate_state() + .expect("missing alternate manager should have a stable state"); + + assert_eq!(state, ServiceProbeState::Unavailable); +} + +#[test] +fn unsafe_probe_program_is_an_error_instead_of_unavailable() { + let root = TempDirGuard::new("unsafe-program"); + fs::create_dir(root.path.join("probe-tool")).expect("create unsafe probe tool object"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("unsafe manager probe", "probe-tool", ["status"]), + |_output| ServiceProbeState::Inactive, + ); + + probe + .evaluate_state() + .expect_err("an unsafe program must not be classified as unavailable"); +} + +#[test] +fn oversized_stdout_is_rejected_even_when_stderr_is_empty() { + let root = TempDirGuard::new("oversized-stdout"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "oversized stdout probe", + "probe-tool", + ["-c", "head -c 32768 /dev/zero"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let error = probe + .evaluate_state() + .expect_err("oversized stdout must fail independently of stderr"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn oversized_stderr_is_rejected_even_when_stdout_is_empty() { + let root = TempDirGuard::new("oversized-stderr"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "oversized stderr probe", + "probe-tool", + ["-c", "head -c 32768 /dev/zero >&2"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let error = probe + .evaluate_state() + .expect_err("oversized stderr must fail independently of stdout"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn malformed_stderr_is_indeterminate_before_backend_interpretation() { + let root = TempDirGuard::new("malformed-stderr"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "malformed stderr probe", + "probe-tool", + ["-c", "printf '\\377' >&2"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let state = probe + .evaluate_state() + .expect("malformed manager output has a stable fail-closed state"); + + assert_eq!(state, ServiceProbeState::Indeterminate); +} + +#[test] +fn probe_timeout_is_never_reported_as_inactive() { + let root = TempDirGuard::new("timeout"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("timed probe", "probe-tool", ["-c", "sleep 30"]), + |_output| ServiceProbeState::Inactive, ); - let active = probe.evaluate().expect("probe should run"); + let error = probe + .evaluate_state_with_timeout(std::time::Duration::from_millis(25)) + .expect_err("a timed-out probe must remain indeterminate"); - // Command failure means the manager did not provide trustworthy status output - assert!(!active); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); } diff --git a/crates/unixnotis-installer/src/service_manager/mod.rs b/crates/unixnotis-installer/src/service_manager/mod.rs index 7142d7da4..059a3afd7 100644 --- a/crates/unixnotis-installer/src/service_manager/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/mod.rs @@ -12,6 +12,6 @@ mod orchestration; pub use contract::MANAGED_DIRECTORY_MARKER_CONTENTS; pub use contract::{ CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, - ServiceArtifactRefresh, + ServiceArtifactRefresh, ServiceArtifactState, }; pub use orchestration::ServiceManager; diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs index add95c6cb..17571587f 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs @@ -1,7 +1,9 @@ //! Availability, state probes, and lifecycle command dispatch use super::super::backends::{dinit, runit, s6, systemd}; -use super::super::contract::{CommandSpec, ServiceProbe}; +use super::super::contract::{ + CommandSpec, ServiceManagerAvailability, ServiceManagerAvailabilityProbe, ServiceProbe, +}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { @@ -13,13 +15,19 @@ impl ServiceManager { } } - pub fn availability_command(&self) -> Option { - // Availability checks must stay read-only and must not start a service + pub(crate) fn availability_state(&self) -> std::io::Result> { + // None keeps backends without a single manager-level query on their native service probe + self.availability_probe() + .map(|probe| probe.evaluate()) + .transpose() + } + + fn availability_probe(&self) -> Option { match self.kind { - ServiceManagerKind::Systemd => Some(systemd::availability_command()), - ServiceManagerKind::Dinit => Some(dinit::availability_command()), - ServiceManagerKind::Runit => Some(runit::availability_command()), - ServiceManagerKind::S6 => s6::availability_command(), + ServiceManagerKind::Systemd => Some(systemd::availability_probe()), + ServiceManagerKind::Dinit => Some(dinit::availability_probe()), + // sv has no separate manager transport query; status is the authoritative probe + ServiceManagerKind::Runit | ServiceManagerKind::S6 => None, } } @@ -46,8 +54,8 @@ impl ServiceManager { pub fn active_probe(&self) -> ServiceProbe { // Probe parsing stays inside each backend because status formats differ match self.kind { - ServiceManagerKind::Systemd => ServiceProbe::exit_status(systemd::is_active_command()), - ServiceManagerKind::Dinit => ServiceProbe::exit_status(dinit::is_active_command()), + ServiceManagerKind::Systemd => systemd::active_probe(), + ServiceManagerKind::Dinit => dinit::active_probe(), ServiceManagerKind::Runit => runit::active_probe(&self.artifact_root), ServiceManagerKind::S6 => s6::active_probe(self.live_root()), } diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs index 8dfb54cc6..0e38cd869 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs @@ -2,26 +2,6 @@ use std::path::PathBuf; use crate::service_manager::ServiceManager; -#[test] -fn direct_command_backends_expose_native_availability_probes() { - for manager in [ - ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")), - ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")), - ServiceManager::runit_user(PathBuf::from("/tmp/runit")), - ] { - assert!( - manager.availability_command().is_some(), - "supported manager must expose an availability command" - ); - } - - let s6 = ServiceManager::s6_user(PathBuf::from("/tmp/s6"), PathBuf::from("/tmp/live")); - assert!( - s6.availability_command().is_none(), - "s6 validates its command set through readiness checks" - ); -} - #[test] fn non_systemd_enablement_uses_owned_artifacts() { let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); diff --git a/crates/unixnotis-installer/src/system_tools/mod.rs b/crates/unixnotis-installer/src/system_tools/mod.rs index 5ee9e6e12..08b789e34 100644 --- a/crates/unixnotis-installer/src/system_tools/mod.rs +++ b/crates/unixnotis-installer/src/system_tools/mod.rs @@ -2,6 +2,7 @@ mod command; mod lookup; +mod process; // Fake executable routing lives under /tests and never enters production binaries #[expect( @@ -15,6 +16,7 @@ mod routing; pub mod routing; pub use command::{command, program_exists, program_path}; +pub use process::{output_bounded, BoundedOutput}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/system_tools/process.rs b/crates/unixnotis-installer/src/system_tools/process.rs new file mode 100644 index 000000000..717582333 --- /dev/null +++ b/crates/unixnotis-installer/src/system_tools/process.rs @@ -0,0 +1,163 @@ +//! Bounded execution for trusted installer probes + +use std::io::{self, Read, Write}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use rustix::process::{kill_process_group, Pid, Signal}; +use wait_timeout::ChildExt; + +#[derive(Debug)] +pub struct BoundedOutput { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +struct CapturedStream { + bytes: Vec, + truncated: bool, +} + +struct BoundedCapture { + bytes: Vec, + max_bytes: usize, + truncated: bool, +} + +impl Write for BoundedCapture { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let retained = self + .max_bytes + .saturating_sub(self.bytes.len()) + .min(buffer.len()); + self.bytes.extend_from_slice(&buffer[..retained]); + self.truncated |= retained != buffer.len(); + // Report the full write so excess data is drained without being retained + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub fn output_bounded( + command: &mut Command, + timeout: Duration, + max_stream_bytes: usize, +) -> io::Result { + let deadline = probe_deadline(timeout)?; + // A private process group lets timeout cleanup include helper grandchildren + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + let mut child = command.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("probe stdout pipe was not captured"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("probe stderr pipe was not captured"))?; + let stdout_reader = spawn_bounded_reader(stdout, max_stream_bytes); + let stderr_reader = spawn_bounded_reader(stderr, max_stream_bytes); + + let status = wait_for_probe(&mut child, deadline)?; + + let stdout = receive_reader(&stdout_reader, deadline, "stdout")?; + let stderr = receive_reader(&stderr_reader, deadline, "stderr")?; + Ok(BoundedOutput { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + stdout_truncated: stdout.truncated, + stderr_truncated: stderr.truncated, + }) +} + +fn probe_deadline(timeout: Duration) -> io::Result { + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe deadline elapsed before process start", + )); + } + Instant::now() + .checked_add(timeout) + .ok_or_else(|| io::Error::other("probe deadline exceeded the monotonic clock")) +} + +fn wait_for_probe(child: &mut Child, deadline: Instant) -> io::Result { + match child.wait_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(status)) => { + // A probe may exit after leaving a helper that still owns the output pipes + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + Ok(status) + } + Ok(None) => { + // Group kill prevents a helper child from retaining captured pipes after timeout + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + let _direct_kill = child.kill(); + let _reap = child.wait(); + Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe process exceeded its deadline", + )) + } + Err(error) => { + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + let _direct_kill = child.kill(); + let _reap = child.wait(); + Err(error) + } + } +} + +fn spawn_bounded_reader( + mut stream: impl Read + Send + 'static, + max_stream_bytes: usize, +) -> Receiver> { + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut capture = BoundedCapture { + bytes: Vec::with_capacity(max_stream_bytes.min(8 * 1024)), + max_bytes: max_stream_bytes, + truncated: false, + }; + let result = io::copy(&mut stream, &mut capture).map(|_copied| CapturedStream { + bytes: capture.bytes, + truncated: capture.truncated, + }); + let _sent = sender.send(result); + }); + receiver +} + +fn receive_reader( + reader: &Receiver>, + deadline: Instant, + stream_name: &str, +) -> io::Result { + reader + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => io::Error::new( + io::ErrorKind::TimedOut, + format!("probe {stream_name} reader exceeded its deadline"), + ), + mpsc::RecvTimeoutError::Disconnected => { + io::Error::other(format!("probe {stream_name} reader stopped unexpectedly")) + } + })? +} diff --git a/crates/unixnotis-installer/src/system_tools/tests/mod.rs b/crates/unixnotis-installer/src/system_tools/tests/mod.rs index 264b05ea0..429d47b1c 100644 --- a/crates/unixnotis-installer/src/system_tools/tests/mod.rs +++ b/crates/unixnotis-installer/src/system_tools/tests/mod.rs @@ -1 +1,2 @@ mod command; +mod process; diff --git a/crates/unixnotis-installer/src/system_tools/tests/process.rs b/crates/unixnotis-installer/src/system_tools/tests/process.rs new file mode 100644 index 000000000..f62fad77f --- /dev/null +++ b/crates/unixnotis-installer/src/system_tools/tests/process.rs @@ -0,0 +1,66 @@ +use std::process::Command; +use std::time::{Duration, Instant}; + +use super::super::output_bounded; + +#[test] +fn bounded_probe_returns_complete_small_output() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf stdout; printf stderr >&2"]); + + let output = output_bounded(&mut command, Duration::from_secs(1), 64) + .expect("bounded probe should finish"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"stdout"); + assert_eq!(output.stderr, b"stderr"); + assert!(!output.stdout_truncated); + assert!(!output.stderr_truncated); +} + +#[test] +fn bounded_probe_drains_but_does_not_retain_oversized_streams() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "head -c 65536 /dev/zero; head -c 65536 /dev/zero >&2"]); + + let output = output_bounded(&mut command, Duration::from_secs(1), 1_024) + .expect("large bounded probe should finish without a pipe deadlock"); + + assert!(output.status.success()); + assert_eq!(output.stdout.len(), 1_024); + assert_eq!(output.stderr.len(), 1_024); + assert!(output.stdout_truncated); + assert!(output.stderr_truncated); +} + +#[test] +fn bounded_probe_kills_a_hung_process_group_at_the_deadline() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30"]); + let started = Instant::now(); + + let error = output_bounded(&mut command, Duration::from_millis(25), 64) + .expect_err("hung probe must time out"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert!( + started.elapsed() < Duration::from_secs(1), + "probe timeout must kill and reap the process group promptly" + ); +} + +#[test] +fn bounded_probe_reaps_helpers_that_outlive_a_successful_parent() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30 & exit 0"]); + let started = Instant::now(); + + let output = output_bounded(&mut command, Duration::from_secs(1), 64) + .expect("completed probe should clean up its inherited pipe owners"); + + assert!(output.status.success()); + assert!( + started.elapsed() < Duration::from_secs(1), + "background helpers must not extend the probe deadline" + ); +} From 8e347408607717486b88f538b7bf31b04297ba52 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:25:14 -0500 Subject: [PATCH 260/275] feat(installer): add verified release generations Represent installed binaries as immutable verified release generations. - generate manifests for complete binary sets - record package version, file size, mode, and SHA-256 digests - stage complete generations before publication - verify staged and existing generations before trusting them - inspect installed entrypoints against the current verified generation - reject corrupt, mixed, malformed, or unmanaged binary layouts - retain generation health metadata for install-state reporting - add manifest, health, corruption, and generation-consistency tests --- Cargo.lock | 1 + crates/unixnotis-installer/Cargo.toml | 1 + .../src/actions/releases/manifest.rs | 376 ++++++++++++++++++ .../src/actions/releases/tests/health.rs | 260 ++++++++++++ .../src/actions/releases/tests/manifest.rs | 134 +++++++ .../src/paths/discovery.rs | 24 ++ .../src/paths/tests/general.rs | 34 ++ 7 files changed, 830 insertions(+) create mode 100644 crates/unixnotis-installer/src/actions/releases/manifest.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/health.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/manifest.rs diff --git a/Cargo.lock b/Cargo.lock index 930503b0f..77c984859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3709,6 +3709,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "tokio", "toml 0.8.23", "unicode-width", diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index 802119d2e..b8840d4bc 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -14,6 +14,7 @@ serde_json.workspace = true semver.workspace = true serde.workspace = true rustix.workspace = true +sha2.workspace = true tokio.workspace = true unixnotis-core = { path = "../unixnotis-core" } unicode-width.workspace = true diff --git a/crates/unixnotis-installer/src/actions/releases/manifest.rs b/crates/unixnotis-installer/src/actions/releases/manifest.rs new file mode 100644 index 000000000..1a5b39d19 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/manifest.rs @@ -0,0 +1,376 @@ +//! Release manifest construction and installed generation verification + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use unixnotis_core::filesystem::{open_regular_file, read_regular_file_bounded}; + +use crate::managed_binaries::is_managed_binary_name; +use crate::paths::InstallPaths; + +pub(super) const INSTALLED_MANIFEST_FILE: &str = "manifest.json"; +const INSTALLED_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub(in crate::actions::releases) const MAX_INSTALLED_MANIFEST_BYTES: u64 = 256 * 1024; +pub(in crate::actions::releases) const HASH_BUFFER_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct InstalledReleaseManifest { + pub(super) schema_version: u32, + pub(super) package_version: String, + pub(super) build_id: String, + pub(super) binaries: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct BinaryManifest { + pub(super) size: u64, + pub(super) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::actions) enum BinaryHealth { + Missing, + Healthy { + generation: String, + package_version: String, + digest: String, + }, + WrongType, + NotExecutable, + BrokenLink, + WrongGeneration, + HashMismatch, + Unsafe(String), +} + +impl BinaryHealth { + pub(in crate::actions) const fn label(&self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Healthy { .. } => "healthy", + Self::WrongType => "wrong type", + Self::NotExecutable => "not executable", + Self::BrokenLink => "broken link", + Self::WrongGeneration => "wrong generation", + Self::HashMismatch => "hash mismatch", + Self::Unsafe(_) => "unsafe", + } + } +} + +pub(super) fn build_manifest(sources: &[(String, PathBuf)]) -> Result { + let mut binaries = BTreeMap::new(); + for (name, source) in sources { + // One no-follow descriptor ties size, mode, and digest to the same source object + let mut file = + open_regular_file(source).with_context(|| format!("open build artifact {name}"))?; + let metadata = file + .metadata() + .with_context(|| format!("inspect build artifact {name}"))?; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(anyhow!("build artifact is not executable: {name}")); + } + binaries.insert( + name.clone(), + BinaryManifest { + size: metadata.len(), + sha256: sha256_open_file(&mut file, source) + .with_context(|| format!("hash build artifact {name}"))?, + }, + ); + } + let package_version = env!("CARGO_PKG_VERSION").to_string(); + let build_id = release_build_id(&package_version, &binaries); + Ok(InstalledReleaseManifest { + schema_version: INSTALLED_MANIFEST_SCHEMA_VERSION, + package_version, + build_id, + binaries, + }) +} + +pub(super) fn verify_release_directory( + release_dir: &Path, + expected: &InstalledReleaseManifest, +) -> Result<()> { + let stored = read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE))?; + if &stored != expected { + return Err(anyhow!( + "installed release manifest does not match staged generation" + )); + } + for (name, binary) in &stored.binaries { + let path = release_dir.join("bin").join(name); + let mut file = open_regular_file(&path) + .with_context(|| format!("open installed release binary {name}"))?; + let metadata = file + .metadata() + .with_context(|| format!("inspect installed release binary {name}"))?; + if metadata.len() != binary.size { + return Err(anyhow!( + "installed release binary shape or size mismatch: {name}" + )); + } + if metadata.permissions().mode() & 0o111 == 0 { + return Err(anyhow!( + "installed release binary is not executable: {name}" + )); + } + if sha256_open_file(&mut file, &path)? != binary.sha256 { + return Err(anyhow!("installed release binary digest mismatch: {name}")); + } + } + Ok(()) +} + +pub(in crate::actions) fn inspect_installed_generation( + paths: &InstallPaths, + binaries: &[String], +) -> Vec<(String, BinaryHealth)> { + let current = match paths.installed_current_link() { + Ok(current) => current, + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + let current_target = match std::fs::read_link(¤t) { + Ok(target) => target, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return binaries + .iter() + .cloned() + .map(|name| { + let entry = paths.bin_dir.join(&name); + let health = classify_missing_generation_entry(&entry); + (name, health) + }) + .collect() + } + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + if !is_release_target(¤t_target) { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + } + let release_dir = paths + .installed_release_root() + .map(|root| root.join(¤t_target)); + let Ok(release_dir) = release_dir else { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + }; + let manifest = match read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE)) { + Ok(manifest) => manifest, + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + let expected_generation = format!("{}-{}", manifest.package_version, manifest.build_id); + // The current link names the same generation proven by the content manifest digest + if current_target.file_name().and_then(|name| name.to_str()) + != Some(expected_generation.as_str()) + { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + } + let generation = manifest.build_id.clone(); + let entry_target = entrypoint_target(); + + binaries + .iter() + .map(|name| { + let entry = paths.bin_dir.join(name); + let health = inspect_binary_entry( + &entry, + &entry_target.join(name), + &release_dir, + &manifest, + name, + &generation, + ); + (name.clone(), health) + }) + .collect() +} + +fn is_release_target(target: &Path) -> bool { + let mut components = target.components(); + matches!( + (components.next(), components.next(), components.next()), + ( + Some(std::path::Component::Normal(root)), + Some(std::path::Component::Normal(_generation)), + None + ) if root == "releases" + ) +} + +fn classify_missing_generation_entry(entry: &Path) -> BinaryHealth { + match std::fs::symlink_metadata(entry) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BinaryHealth::Missing, + Ok(metadata) if metadata.file_type().is_symlink() => BinaryHealth::BrokenLink, + Ok(_metadata) => BinaryHealth::WrongGeneration, + Err(error) => BinaryHealth::Unsafe(error.to_string()), + } +} + +fn inspect_binary_entry( + entry: &Path, + expected_link: &Path, + release_dir: &Path, + manifest: &InstalledReleaseManifest, + name: &str, + generation: &str, +) -> BinaryHealth { + let metadata = match std::fs::symlink_metadata(entry) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return BinaryHealth::Missing, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + if !metadata.file_type().is_symlink() { + return BinaryHealth::WrongType; + } + match std::fs::read_link(entry) { + Ok(target) if target == expected_link => {} + Ok(_target) => return BinaryHealth::WrongGeneration, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + } + let Some(expected) = manifest.binaries.get(name) else { + return BinaryHealth::WrongGeneration; + }; + let binary = release_dir.join("bin").join(name); + // The retained descriptor keeps health metadata and hashing on one exact object + let mut file = match open_regular_file(&binary) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return BinaryHealth::BrokenLink + } + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + let metadata = match file.metadata() { + Ok(metadata) => metadata, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + if metadata.len() != expected.size { + return BinaryHealth::WrongType; + } + if metadata.permissions().mode() & 0o111 == 0 { + return BinaryHealth::NotExecutable; + } + match sha256_open_file(&mut file, &binary) { + Ok(digest) if digest == expected.sha256 => BinaryHealth::Healthy { + generation: generation.to_string(), + package_version: manifest.package_version.clone(), + digest, + }, + Ok(_digest) => BinaryHealth::HashMismatch, + Err(error) => BinaryHealth::Unsafe(error.to_string()), + } +} + +pub(super) fn read_manifest(path: &Path) -> Result { + let bytes = read_regular_file_bounded(path, MAX_INSTALLED_MANIFEST_BYTES) + .with_context(|| format!("read installed release manifest {}", path.display()))?; + let manifest: InstalledReleaseManifest = + serde_json::from_slice(&bytes).with_context(|| "parse installed release manifest")?; + if manifest.schema_version != INSTALLED_MANIFEST_SCHEMA_VERSION { + return Err(anyhow!( + "unsupported installed release manifest schema {}", + manifest.schema_version + )); + } + if manifest.binaries.is_empty() + || manifest + .binaries + .keys() + .any(|name| !is_managed_binary_name(name)) + { + return Err(anyhow!( + "installed release manifest contains unmanaged binary names" + )); + } + let expected_build_id = release_build_id(&manifest.package_version, &manifest.binaries); + if manifest.build_id != expected_build_id { + return Err(anyhow!( + "installed release manifest build identity is inconsistent" + )); + } + Ok(manifest) +} + +pub(super) fn manifest_bytes(manifest: &InstalledReleaseManifest) -> Result> { + serde_json::to_vec_pretty(manifest).with_context(|| "serialize installed release manifest") +} + +pub(in crate::actions) fn entrypoint_target() -> PathBuf { + PathBuf::from("..") + .join("lib") + .join("unixnotis") + .join("current") + .join("bin") +} + +fn release_build_id(package_version: &str, binaries: &BTreeMap) -> String { + let mut digest = Sha256::new(); + digest.update(package_version.as_bytes()); + for (name, binary) in binaries { + digest.update(name.as_bytes()); + digest.update(binary.size.to_le_bytes()); + digest.update(binary.sha256.as_bytes()); + } + format_digest(&digest.finalize()) +} + +fn sha256_open_file(file: &mut File, path: &Path) -> Result { + let mut digest = Sha256::new(); + let mut buffer = vec![0u8; HASH_BUFFER_BYTES].into_boxed_slice(); + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("read {}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format_digest(&digest.finalize())) +} + +fn format_digest(bytes: &[u8]) -> String { + use std::fmt::Write; + + let mut output = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); + } + output +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/health.rs b/crates/unixnotis-installer/src/actions/releases/tests/health.rs new file mode 100644 index 000000000..bc8041d69 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/health.rs @@ -0,0 +1,260 @@ +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use super::super::manifest::{entrypoint_target, inspect_installed_generation, BinaryHealth}; +use super::super::transaction::commit_pending_release; +use super::install_release_generation; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn install_one_binary(label: &str) -> (PathBuf, InstallPaths, String, PathBuf) { + let root = crate::test_support::fs::unique_temp_path(label); + let source = root.join("source"); + fs::create_dir_all(&source).expect("create release source"); + let name = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&name), "healthy payload"); + let paths = paths(&root); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&name), + || Ok(()), + || Ok(()), + ) + .expect("install health fixture"); + commit_pending_release(&paths).expect("commit health fixture"); + let binary = paths + .installed_releases_dir() + .expect("releases directory") + .join(&generation) + .join("bin") + .join(&name); + (root, paths, name, binary) +} + +fn health_for(paths: &InstallPaths, name: &str) -> BinaryHealth { + inspect_installed_generation(paths, &[name.to_string()]) + .into_iter() + .next() + .expect("one binary health result") + .1 +} + +#[test] +fn installed_generation_health_distinguishes_every_binary_failure_class() { + let (root, paths, name, binary) = install_one_binary("release-health-classes"); + let entry = paths.bin_dir.join(&name); + let expected_entry = entrypoint_target().join(&name); + let original = fs::read(&binary).expect("read original release binary"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Healthy { .. } + )); + + fs::remove_file(&entry).expect("remove managed entrypoint"); + assert_eq!(health_for(&paths, &name), BinaryHealth::Missing); + symlink(&expected_entry, &entry).expect("restore managed entrypoint"); + + fs::remove_file(&entry).expect("remove managed entrypoint"); + fs::write(&entry, "legacy file").expect("write wrong entrypoint type"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongType); + fs::remove_file(&entry).expect("remove wrong entrypoint type"); + symlink(Path::new("unmanaged-target"), &entry).expect("write wrong entrypoint link"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongGeneration); + fs::remove_file(&entry).expect("remove wrong entrypoint link"); + symlink(&expected_entry, &entry).expect("restore managed entrypoint"); + + fs::set_permissions(&binary, fs::Permissions::from_mode(0o644)) + .expect("remove executable bits"); + assert_eq!(health_for(&paths, &name), BinaryHealth::NotExecutable); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)) + .expect("restore executable bits"); + + fs::write(&binary, vec![b'x'; original.len()]).expect("write same-size changed binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::HashMismatch); + fs::write(&binary, &original[..original.len() - 1]).expect("write truncated binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongType); + + fs::remove_file(&binary).expect("remove release binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::BrokenLink); + fs::create_dir(&binary).expect("create unsafe release binary object"); + assert!(matches!(health_for(&paths, &name), BinaryHealth::Unsafe(_))); + + fs::remove_dir_all(root).expect("remove release health fixture"); +} + +#[test] +fn missing_generation_classifies_missing_broken_and_legacy_entrypoints() { + let root = crate::test_support::fs::unique_temp_path("release-health-no-current"); + let paths = paths(&root); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + symlink("missing-target", paths.bin_dir.join("broken")).expect("create broken entrypoint"); + fs::write(paths.bin_dir.join("legacy"), "legacy binary").expect("create legacy entrypoint"); + + let health = inspect_installed_generation( + &paths, + &[ + "missing".to_string(), + "broken".to_string(), + "legacy".to_string(), + ], + ); + + assert_eq!(health[0].1, BinaryHealth::Missing); + assert_eq!(health[1].1, BinaryHealth::BrokenLink); + assert_eq!(health[2].1, BinaryHealth::WrongGeneration); + fs::remove_dir_all(root).expect("remove missing generation fixture"); +} + +#[test] +fn invalid_current_release_objects_never_count_as_an_installed_generation() { + let regular_root = crate::test_support::fs::unique_temp_path("release-health-current-file"); + let regular_paths = paths(®ular_root); + let current = regular_paths + .installed_current_link() + .expect("current link path"); + fs::create_dir_all(current.parent().expect("current parent")).expect("create install root"); + fs::write(¤t, "not a link").expect("create wrong current object"); + assert!(matches!( + health_for(®ular_paths, "unixnotis-daemon"), + BinaryHealth::Unsafe(_) + )); + + let foreign_root = crate::test_support::fs::unique_temp_path("release-health-foreign-link"); + let foreign_paths = paths(&foreign_root); + let current = foreign_paths + .installed_current_link() + .expect("current link path"); + fs::create_dir_all(current.parent().expect("current parent")).expect("create install root"); + symlink(Path::new("foreign").join("generation"), ¤t) + .expect("create foreign current link"); + assert_eq!( + health_for(&foreign_paths, "unixnotis-daemon"), + BinaryHealth::WrongGeneration + ); + + fs::remove_dir_all(regular_root).expect("remove current file fixture"); + fs::remove_dir_all(foreign_root).expect("remove foreign current fixture"); +} + +#[test] +fn entrypoint_lookup_errors_remain_unsafe_with_and_without_a_current_generation() { + let missing_root = crate::test_support::fs::unique_temp_path("release-health-entry-error"); + let missing_paths = paths(&missing_root); + fs::create_dir_all(missing_paths.bin_dir.parent().expect("entrypoint parent")) + .expect("create entrypoint parent"); + fs::write(&missing_paths.bin_dir, "not a directory").expect("create invalid entrypoint root"); + assert!(matches!( + health_for(&missing_paths, "unixnotis-daemon"), + BinaryHealth::Unsafe(_) + )); + + let (installed_root, installed_paths, name, _binary) = + install_one_binary("release-health-installed-entry-error"); + fs::remove_file(installed_paths.bin_dir.join(&name)).expect("remove managed entrypoint"); + fs::remove_dir(&installed_paths.bin_dir).expect("remove entrypoint directory"); + fs::write(&installed_paths.bin_dir, "not a directory") + .expect("create invalid installed entrypoint root"); + assert!(matches!( + health_for(&installed_paths, &name), + BinaryHealth::Unsafe(_) + )); + + fs::remove_dir_all(missing_root).expect("remove missing entrypoint error fixture"); + fs::remove_dir_all(installed_root).expect("remove installed entrypoint error fixture"); +} + +#[test] +fn installed_generation_recomputes_manifest_build_identity() { + let (root, paths, name, binary) = install_one_binary("release-health-build-identity"); + let manifest_path = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory") + .join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read installed manifest")) + .expect("parse installed manifest"); + manifest["build_id"] = serde_json::Value::String("forged-build-id".to_string()); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize changed manifest"), + ) + .expect("write changed manifest"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Unsafe(detail) if detail.contains("build identity is inconsistent") + )); + fs::remove_dir_all(root).expect("remove build identity fixture"); +} + +#[test] +fn installed_generation_requires_the_current_directory_to_match_its_manifest_identity() { + let (root, paths, name, binary) = install_one_binary("release-health-directory-identity"); + let generation_dir = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory"); + let renamed = generation_dir + .parent() + .expect("release generations parent") + .join("renamed-generation"); + fs::rename(generation_dir, &renamed).expect("rename generation away from manifest identity"); + let current = paths + .installed_current_link() + .expect("current generation link"); + fs::remove_file(¤t).expect("remove prior current link"); + symlink(Path::new("releases").join("renamed-generation"), ¤t) + .expect("point current at renamed generation"); + + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongGeneration); + fs::remove_dir_all(root).expect("remove directory identity fixture"); +} + +#[test] +fn installed_generation_rejects_unmanaged_manifest_binary_names() { + let (root, paths, name, binary) = install_one_binary("release-health-managed-names"); + let manifest_path = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory") + .join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read installed manifest")) + .expect("parse installed manifest"); + let existing = manifest["binaries"][&name].clone(); + manifest["binaries"]["../outside"] = existing; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize changed manifest"), + ) + .expect("write changed manifest"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Unsafe(detail) if detail.contains("unmanaged binary names") + )); + fs::remove_dir_all(root).expect("remove managed-name fixture"); +} + +fn write_test_binary(path: &Path, contents: &str) { + fs::write(path, contents).expect("write release test binary"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs b/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs new file mode 100644 index 000000000..7c92ad77e --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs @@ -0,0 +1,134 @@ +use super::super::manifest::{ + build_manifest, verify_release_directory, BinaryHealth, HASH_BUFFER_BYTES, + INSTALLED_MANIFEST_FILE, MAX_INSTALLED_MANIFEST_BYTES, +}; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn manifest_records_a_digest_for_every_declared_binary() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-digests"); + std::fs::create_dir_all(root.join("bin")).expect("create release test root"); + let source = root.join("source"); + write_test_binary(&source, "binary payload"); + + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + + let binary = manifest + .binaries + .get("unixnotis-daemon") + .expect("daemon manifest entry"); + assert_eq!(binary.size, 14); + assert_eq!(binary.sha256.len(), 64); + std::fs::remove_dir_all(root).expect("remove release manifest fixture"); +} + +#[test] +fn release_verification_rejects_a_changed_binary_digest() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-mismatch"); + let source = root.join("source"); + let release = root.join("release"); + std::fs::create_dir_all(release.join("bin")).expect("create release fixture"); + write_test_binary(&source, "original"); + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + write_test_binary(&release.join("bin").join("unixnotis-daemon"), "changed!"); + std::fs::write( + release.join(INSTALLED_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let error = verify_release_directory(&release, &manifest) + .expect_err("changed binary must fail verification"); + + assert!(error.to_string().contains("digest mismatch")); + std::fs::remove_dir_all(root).expect("remove release mismatch fixture"); +} + +#[test] +fn release_security_limits_keep_their_declared_byte_domains() { + assert_eq!(MAX_INSTALLED_MANIFEST_BYTES, 262_144); + assert_eq!(HASH_BUFFER_BYTES, 65_536); +} + +#[test] +fn binary_health_labels_cover_every_installed_state() { + let states = [ + (BinaryHealth::Missing, "missing"), + ( + BinaryHealth::Healthy { + generation: "generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "digest".to_string(), + }, + "healthy", + ), + (BinaryHealth::WrongType, "wrong type"), + (BinaryHealth::NotExecutable, "not executable"), + (BinaryHealth::BrokenLink, "broken link"), + (BinaryHealth::WrongGeneration, "wrong generation"), + (BinaryHealth::HashMismatch, "hash mismatch"), + (BinaryHealth::Unsafe("detail".to_string()), "unsafe"), + ]; + + for (state, expected) in states { + assert_eq!(state.label(), expected); + } + assert!(!matches!( + BinaryHealth::Missing, + BinaryHealth::Healthy { .. } + )); + assert!(!matches!( + BinaryHealth::WrongType, + BinaryHealth::Healthy { .. } + )); +} + +#[test] +fn manifest_construction_rejects_a_non_executable_source() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-source-mode"); + let source = root.join("unixnotis-daemon"); + std::fs::create_dir_all(&root).expect("create source mode fixture"); + std::fs::write(&source, "binary payload").expect("write non-executable source"); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o644)) + .expect("set non-executable mode"); + + let error = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect_err("non-executable sources must not enter a release manifest"); + + assert!(error.to_string().contains("not executable")); + std::fs::remove_dir_all(root).expect("remove source mode fixture"); +} + +#[test] +fn release_verification_rejects_a_non_executable_installed_binary() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-installed-mode"); + let source = root.join("source"); + let release = root.join("release"); + std::fs::create_dir_all(release.join("bin")).expect("create release fixture"); + write_test_binary(&source, "binary payload"); + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + let installed = release.join("bin").join("unixnotis-daemon"); + std::fs::write(&installed, "binary payload").expect("write installed binary"); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)) + .expect("remove installed executable bits"); + std::fs::write( + release.join(INSTALLED_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let error = verify_release_directory(&release, &manifest) + .expect_err("non-executable installed binaries must fail verification"); + + assert!(error.to_string().contains("not executable")); + std::fs::remove_dir_all(root).expect("remove installed mode fixture"); +} + +fn write_test_binary(path: &std::path::Path, contents: &str) { + std::fs::write(path, contents).expect("write release test binary"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index f0d200a9f..1cf2f5222 100644 --- a/crates/unixnotis-installer/src/paths/discovery.rs +++ b/crates/unixnotis-installer/src/paths/discovery.rs @@ -75,6 +75,30 @@ impl InstallPaths { pub fn release_binary_dir(&self) -> PathBuf { self.repo_root.join(RELEASE_BIN_DIR) } + + pub fn installed_release_root(&self) -> Result { + let local_root = self + .bin_dir + .parent() + .ok_or_else(|| anyhow!("binary directory has no local installation root"))?; + Ok(local_root.join("lib").join("unixnotis")) + } + + pub fn installed_releases_dir(&self) -> Result { + Ok(self.installed_release_root()?.join("releases")) + } + + pub fn installed_current_link(&self) -> Result { + Ok(self.installed_release_root()?.join("current")) + } + + pub fn installed_pending_manifest(&self) -> Result { + Ok(self.installed_release_root()?.join("pending-install.json")) + } + + pub fn installed_rollback_root(&self) -> Result { + Ok(self.installed_release_root()?.join("rollback")) + } } fn service_manager_from_selection( diff --git a/crates/unixnotis-installer/src/paths/tests/general.rs b/crates/unixnotis-installer/src/paths/tests/general.rs index 12ed06923..4d32ad6b4 100644 --- a/crates/unixnotis-installer/src/paths/tests/general.rs +++ b/crates/unixnotis-installer/src/paths/tests/general.rs @@ -14,6 +14,40 @@ fn format_with_home_rewrites_prefix() { assert!(rendered.starts_with("$HOME")); } +#[test] +fn installed_release_paths_share_the_binary_directories_local_root() { + let root = crate::test_support::fs::unique_temp_path("installed-release-paths"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("prefix").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user(root.join("units")), + }; + let install_root = root.join("prefix").join("lib").join("unixnotis"); + + assert_eq!( + paths.installed_release_root().expect("release root"), + install_root + ); + assert_eq!( + paths.installed_releases_dir().expect("releases directory"), + install_root.join("releases") + ); + assert_eq!( + paths.installed_current_link().expect("current link"), + install_root.join("current") + ); + assert_eq!( + paths + .installed_pending_manifest() + .expect("pending manifest"), + install_root.join("pending-install.json") + ); + assert_eq!( + paths.installed_rollback_root().expect("rollback root"), + install_root.join("rollback") + ); +} + #[test] fn is_unixnotis_repo_detects_markers() { // Validates that known workspace markers are detected in a Cargo.toml file From fe0db5b33043fb5856312d40bccc6d5c864af627 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:25:32 -0500 Subject: [PATCH 261/275] fix(installer): journal release activation and rollback Make binary publication atomic and recoverable across interrupted installs. - create a durable pending-release journal before live entrypoint mutation - move legacy entrypoints into generation-scoped rollback storage - publish managed entrypoints without following unsafe links - atomically switch the current generation - recover interrupted release transactions before new mutation - revalidate the previous generation before rollback - retain rollback authority until service readiness commits the release - prune superseded generations only after successful activation - add journal, entrypoint, rollback, recovery, and transaction regressions --- .../src/actions/install/binaries.rs | 134 +++-- .../src/actions/install/tests/binaries.rs | 173 +++++- .../src/actions/releases/entrypoints.rs | 259 +++++++++ .../src/actions/releases/mod.rs | 17 + .../src/actions/releases/tests/entrypoints.rs | 98 ++++ .../src/actions/releases/tests/journal.rs | 167 ++++++ .../src/actions/releases/tests/mod.rs | 57 ++ .../src/actions/releases/tests/recovery.rs | 249 +++++++++ .../src/actions/releases/tests/transaction.rs | 504 ++++++++++++++++++ .../src/actions/releases/transaction.rs | 448 ++++++++++++++++ .../unixnotis-installer/src/tests/release.rs | 33 +- 11 files changed, 2095 insertions(+), 44 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/releases/entrypoints.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/mod.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/journal.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/mod.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/recovery.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/tests/transaction.rs create mode 100644 crates/unixnotis-installer/src/actions/releases/transaction.rs diff --git a/crates/unixnotis-installer/src/actions/install/binaries.rs b/crates/unixnotis-installer/src/actions/install/binaries.rs index 5db702261..83a5fb076 100644 --- a/crates/unixnotis-installer/src/actions/install/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/binaries.rs @@ -1,9 +1,12 @@ //! Binary install and uninstall helpers -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::filesystem::{copy_file_atomic, remove_regular_file}; +use unixnotis_core::filesystem::{ + read_symlink, remove_directory_tree, remove_regular_file, remove_symlink_if_target, + RemoveSymlinkOutcome, +}; use crate::managed_binaries::validate_managed_binary_names; use crate::paths::format_with_home; @@ -14,14 +17,35 @@ use super::super::{ }, log_line, ActionContext, }; +use crate::actions::daemon::DaemonActivationReservation; +use crate::actions::releases::install_release_generation_transaction; -pub fn install_binaries(ctx: &mut ActionContext) -> Result<()> { +pub fn install_binaries( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + let (binaries, release_dir) = resolve_install_inputs(ctx)?; + let generation = install_release_generation_transaction( + ctx.paths, + &release_dir, + &binaries, + || Ok(()), + || Ok(()), + || crate::actions::daemon::ensure_selected_service_inactive(ctx.paths), + )?; + log_installed_generation(ctx, &binaries, &generation); + Ok(()) +} + +pub(in crate::actions::install) fn resolve_install_inputs( + ctx: &mut ActionContext, +) -> Result<(Vec, PathBuf)> { // Read the managed binary list from installer metadata so install and uninstall stay aligned let binaries = resolve_install_binaries(ctx.paths)?; // Cargo metadata is the only reliable way to find the active release target directory let release_dir = resolve_release_dir(ctx)?; - // Check every source first so install never leaves a half-updated bin directory behind + // Check every source before the versioned release transaction allocates staging state let mut missing = Vec::new(); for binary in &binaries { let source = release_dir.join(binary); @@ -39,14 +63,27 @@ pub fn install_binaries(ctx: &mut ActionContext) -> Result<()> { // Validate again at the copy boundary so future discovery changes cannot widen file access let binaries = validate_managed_binary_names(binaries) .with_context(|| "refusing to install an unmanaged binary path")?; + Ok((binaries, release_dir)) +} + +pub(in crate::actions::install) fn log_installed_generation( + ctx: &mut ActionContext, + binaries: &[String], + generation: &str, +) { + log_line( + ctx, + format!("Activated complete UnixNotis release generation {generation}"), + ); for binary in binaries { - let source = release_dir.join(&binary); - let destination = ctx.paths.bin_dir.join(&binary); - // One helper handles both source builds and downloaded archives after source resolution - copy_binary(ctx, &source, &destination)?; + log_line( + ctx, + format!( + "Installed {binary} -> {}", + format_with_home(&ctx.paths.bin_dir.join(binary)) + ), + ); } - - Ok(()) } pub fn remove_binaries(ctx: &mut ActionContext) -> Result<()> { @@ -69,9 +106,39 @@ pub(in crate::actions::install) fn remove_resolved_binaries( // Uninstall is destructive, so validate again immediately before building removal paths let binaries = validate_managed_binary_names(binaries) .with_context(|| "refusing to remove an unmanaged binary path")?; + let expected_root = crate::actions::releases::entrypoint_target(); for binary in binaries { let path = ctx.paths.bin_dir.join(binary); - if remove_regular_file(&path).with_context(|| "failed to remove binary")? { + let removed = match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + match remove_symlink_if_target( + &path, + &expected_root.join(path.file_name().unwrap_or_default()), + )? { + RemoveSymlinkOutcome::Removed => true, + RemoveSymlinkOutcome::Missing => false, + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "refusing to remove unmanaged binary link {} -> {}", + path.display(), + actual.display() + )) + } + } + } + Ok(metadata) if metadata.file_type().is_file() => { + remove_regular_file(&path).with_context(|| "failed to remove legacy binary")? + } + Ok(_metadata) => { + return Err(anyhow!( + "refusing to remove non-file binary entrypoint {}", + path.display() + )) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())), + }; + if removed { log_line(ctx, format!("Removed binary {}", format_with_home(&path))); } else { log_line( @@ -81,6 +148,26 @@ pub(in crate::actions::install) fn remove_resolved_binaries( } } + let install_root = ctx.paths.installed_release_root()?; + if let Some(current_target) = read_symlink(&ctx.paths.installed_current_link()?)? { + match remove_symlink_if_target(&ctx.paths.installed_current_link()?, ¤t_target)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => {} + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "current release link changed during uninstall to {}", + actual.display() + )) + } + } + } + let pending = ctx.paths.installed_pending_manifest()?; + if pending.exists() { + remove_regular_file(&pending).context("remove pending release state")?; + } + if install_root.exists() { + remove_directory_tree(&install_root).context("remove installed release generations")?; + } + Ok(()) } @@ -100,28 +187,3 @@ fn resolve_release_dir(ctx: &mut ActionContext) -> Result { })?; Ok(target_dir.join("release")) } - -fn copy_binary(ctx: &mut ActionContext, source: &Path, destination: &Path) -> Result<()> { - if !source.exists() { - return Err(anyhow!( - "missing build artifact: {}", - format_with_home(source) - )); - } - - let source_display = format_with_home(source); - let destination_display = format_with_home(destination); - // Core stages beside the destination and validates both paths through stable descriptors - copy_file_atomic(source, destination).map_err(|err| { - anyhow!("failed to install {source_display} -> {destination_display}: {err}") - })?; - log_line( - ctx, - format!( - "Installed {} -> {}", - source.file_name().unwrap_or_default().to_string_lossy(), - format_with_home(destination) - ), - ); - Ok(()) -} diff --git a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs index 097fc5c1d..deea7a4fc 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs @@ -4,9 +4,32 @@ use crate::detect::Detection; use crate::model::ActionMode; use super::super::binaries::remove_resolved_binaries; -use super::super::{install_binaries, remove_binaries}; +use super::super::binaries::{log_installed_generation, resolve_install_inputs}; +use super::super::remove_binaries; use super::support::{test_context, test_paths, test_root, write_fake_workspace}; +fn install_binaries_with_guards( + ctx: &mut crate::actions::ActionContext, + mut precommit: F, + mut reserve_activation: R, +) -> anyhow::Result<()> +where + F: FnMut(&crate::paths::InstallPaths) -> anyhow::Result<()>, + R: FnMut(&crate::paths::InstallPaths) -> anyhow::Result, +{ + let (binaries, release_dir) = resolve_install_inputs(ctx)?; + let generation = crate::actions::releases::install_release_generation_transaction( + ctx.paths, + &release_dir, + &binaries, + || precommit(ctx.paths), + || reserve_activation(ctx.paths), + || Ok(()), + )?; + log_installed_generation(ctx, &binaries, &generation); + Ok(()) +} + #[cfg(unix)] use std::os::unix::fs::{symlink, PermissionsExt}; @@ -47,7 +70,8 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("install should copy binaries"); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("install should copy binaries"); for binary in [ "unixnotis-daemon", @@ -87,7 +111,8 @@ fn install_binaries_copies_from_release_archive_bin_dir() { }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("release archive install should copy binaries"); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("release archive install should copy binaries"); for binary in [ "unixnotis-daemon", @@ -107,6 +132,60 @@ fn install_binaries_copies_from_release_archive_bin_dir() { let _ = fs::remove_dir_all(&root); } +#[test] +fn binary_install_runs_the_live_precommit_gate_and_activates_the_release() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("install-binaries-public-boundary"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + let source = paths + .repo_root + .join("target") + .join("release") + .join("unixnotis-daemon"); + fs::create_dir_all(source.parent().expect("release source parent")) + .expect("create release source directory"); + fs::write(&source, "public boundary binary").expect("write release source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set release source mode"); + let fake_bin = root.join("fake-tools"); + fs::create_dir_all(&fake_bin).expect("create fake tools directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\nprintf 'b false\\n'\n", + ); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=not-found\\nActiveState=inactive\\n'\n", + ); + let _system_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let _manager_tools = + crate::service_manager::contract::command_routing::use_fake_command_bin(&fake_bin); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + + install_binaries_with_guards( + &mut ctx, + |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }, + |_paths| Ok(()), + ) + .expect("binary install should activate one generation after the live gate"); + + assert_eq!( + fs::read_to_string(paths.bin_dir.join("unixnotis-daemon")).expect("read installed binary"), + "public boundary binary" + ); + fs::remove_dir_all(root).expect("remove public install fixture"); +} + #[cfg(unix)] #[test] fn install_binaries_rejects_destination_symlink_without_touching_its_target() { @@ -133,6 +212,8 @@ fn install_binaries_rejects_destination_symlink_without_touching_its_target() { let source = paths.repo_root.join("target").join("release").join(binary); fs::create_dir_all(source.parent().expect("release dir")).expect("make release dir"); fs::write(&source, format!("binary:{binary}")).expect("write fake binary"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set fake binary mode"); } fs::create_dir_all(&paths.bin_dir).expect("bin dir"); let destination = paths.bin_dir.join("unixnotis-daemon"); @@ -145,9 +226,13 @@ fn install_binaries_rejects_destination_symlink_without_touching_its_target() { }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - let error = install_binaries(&mut ctx).expect_err("destination symlink should fail"); + let error = install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect_err("destination symlink should fail"); - assert!(error.to_string().contains("failed to install")); + assert!( + error.to_string().contains("unmanaged target"), + "unexpected destination error: {error:#}" + ); assert_eq!( fs::read_to_string(&protected).expect("protected remains"), "protected" @@ -211,6 +296,79 @@ fn remove_binaries_removes_all_managed_binaries_and_runtime_helpers() { let _ = fs::remove_dir_all(&root); } +#[test] +fn remove_binaries_accepts_only_the_managed_generation_entrypoints() { + let root = test_root("remove-managed-generation-binaries"); + write_fake_workspace(&root, &["unixnotis-daemon", "unixnotis-center"]); + let paths = test_paths(&root); + for binary in ["unixnotis-daemon", "unixnotis-center"] { + let source = paths.repo_root.join("target").join("release").join(binary); + fs::create_dir_all(source.parent().expect("release source parent")) + .expect("create release source directory"); + fs::write(&source, format!("managed:{binary}")).expect("write release source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set release source mode"); + } + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("install managed generation"); + + remove_binaries(&mut ctx).expect("remove managed generation"); + + assert!(fs::symlink_metadata(paths.bin_dir.join("unixnotis-daemon")).is_err()); + assert!(fs::symlink_metadata(paths.bin_dir.join("unixnotis-center")).is_err()); + assert!(!paths + .installed_release_root() + .expect("installed release root") + .exists()); + fs::remove_dir_all(root).expect("remove managed uninstall fixture"); +} + +#[test] +fn remove_binaries_rejects_a_directory_entrypoint_with_a_stable_error() { + let root = test_root("remove-binaries-directory-entrypoint"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + fs::create_dir_all(paths.bin_dir.join("unixnotis-daemon")) + .expect("create directory entrypoint"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + let error = remove_binaries(&mut ctx).expect_err("directory entrypoint must fail closed"); + + assert!(error.to_string().contains("non-file binary entrypoint")); + assert!(paths.bin_dir.join("unixnotis-daemon").is_dir()); + fs::remove_dir_all(root).expect("remove directory entrypoint fixture"); +} + +#[test] +fn resolved_binary_removal_propagates_entrypoint_inspection_errors() { + let root = test_root("remove-binaries-inspection-error"); + let paths = test_paths(&root); + fs::create_dir_all(paths.bin_dir.parent().expect("binary parent")) + .expect("create binary parent"); + fs::write(&paths.bin_dir, "not a directory").expect("create invalid binary root"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + let error = remove_resolved_binaries(&mut ctx, vec!["unixnotis-daemon".to_string()]) + .expect_err("entrypoint metadata errors must not become missing files"); + + assert!(error.to_string().contains("inspect")); + fs::remove_file(&paths.bin_dir).expect("remove invalid binary root"); + fs::remove_dir_all(root).expect("remove inspection error fixture"); +} + #[cfg(unix)] #[test] fn remove_binaries_rejects_symlink_without_touching_its_target() { @@ -316,6 +474,9 @@ fn write_fake_release_archive(root: &std::path::Path) { "unixnotis-css-validate", "noticenterctl", ] { - fs::write(bin_dir.join(binary), format!("release:{binary}")).expect("release binary"); + let path = bin_dir.join(binary); + fs::write(&path, format!("release:{binary}")).expect("release binary"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("set release binary mode"); } } diff --git a/crates/unixnotis-installer/src/actions/releases/entrypoints.rs b/crates/unixnotis-installer/src/actions/releases/entrypoints.rs new file mode 100644 index 000000000..d70b1a7b9 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/entrypoints.rs @@ -0,0 +1,259 @@ +//! Binary entrypoint planning, publication, and crash recovery + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, create_symlink_if_missing, read_symlink, remove_directory_tree, + remove_symlink_if_target, rename_regular_file_no_replace, CreateSymlinkOutcome, + RemoveSymlinkOutcome, RenameRegularFileOutcome, +}; + +use crate::paths::InstallPaths; + +use super::manifest::entrypoint_target; +use super::transaction::{verify_release_target, PendingRelease, PENDING_RELEASE_SCHEMA_VERSION}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntrypointState { + Missing, + Regular, + ManagedSymlink, +} + +pub(in crate::actions::releases) fn plan_entrypoint_changes( + paths: &InstallPaths, + binaries: &[String], + generation: &str, +) -> Result { + // Planning inspects live entrypoints but never changes them + let link_root = entrypoint_target(); + let previous_current = read_symlink(&paths.installed_current_link()?)?; + if let Some(previous) = previous_current.as_ref() { + verify_release_target(paths, previous) + .context("verify current generation before retaining it for rollback")?; + } + let (legacy_entrypoints, created_entrypoints) = + classify_entrypoint_changes(paths, binaries, &link_root)?; + + Ok(PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: generation.to_string(), + new_current: PathBuf::from("releases").join(generation), + previous_current, + legacy_entrypoints, + created_entrypoints, + }) +} + +pub(in crate::actions::releases) fn apply_entrypoint_changes( + paths: &InstallPaths, + pending: &PendingRelease, +) -> Result<()> { + // The durable journal must exist before this function is called + create_directory_all(&paths.bin_dir, 0o755).context("create binary entrypoint directory")?; + let rollback_dir = rollback_bin_dir(paths, pending)?; + create_directory_all(&rollback_dir, 0o700).context("create binary rollback directory")?; + + // Legacy files move to the generation-scoped rollback area before links appear + for name in &pending.legacy_entrypoints { + let entry = paths.bin_dir.join(name); + let backup = rollback_dir.join(name); + match rename_regular_file_no_replace(&entry, &backup)? { + RenameRegularFileOutcome::Renamed => {} + RenameRegularFileOutcome::SourceMissing => { + return Err(anyhow!( + "binary entrypoint disappeared before migration: {name}" + )); + } + RenameRegularFileOutcome::DestinationExists => { + return Err(anyhow!( + "binary rollback entry already exists: {}", + backup.display() + )); + } + } + } + + let expected_root = entrypoint_target(); + // Every public binary name resolves through the one current-generation switch + for name in pending + .legacy_entrypoints + .iter() + .chain(&pending.created_entrypoints) + { + let entry = paths.bin_dir.join(name); + let expected = expected_root.join(name); + match create_symlink_if_missing(&entry, &expected)? { + CreateSymlinkOutcome::Created | CreateSymlinkOutcome::Unchanged => {} + CreateSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "binary entrypoint changed to unmanaged target {}", + actual.display() + )); + } + } + } + Ok(()) +} + +pub(in crate::actions::releases) fn rollback_entrypoint_changes( + paths: &InstallPaths, + pending: &PendingRelease, +) -> Result<()> { + let expected_root = entrypoint_target(); + let rollback_dir = rollback_bin_dir(paths, pending)?; + + // Journal state permits recovery before, during, or after each legacy move + for name in &pending.legacy_entrypoints { + rollback_legacy_entrypoint( + &paths.bin_dir.join(name), + &rollback_dir.join(name), + &expected_root.join(name), + name, + )?; + } + // Newly created links contain no legacy bytes and can be removed directly + for name in &pending.created_entrypoints { + rollback_created_entrypoint(&paths.bin_dir.join(name), &expected_root.join(name), name)?; + } + + let rollback_generation = paths.installed_rollback_root()?.join(&pending.generation); + if rollback_generation.exists() { + remove_directory_tree(&rollback_generation).context("remove completed rollback data")?; + } + Ok(()) +} + +fn classify_entrypoint_changes( + paths: &InstallPaths, + binaries: &[String], + expected_root: &Path, +) -> Result<(Vec, Vec)> { + let mut legacy = Vec::new(); + let mut created = Vec::new(); + // Managed links need no per-entrypoint rollback record + for name in binaries { + match inspect_entrypoint(&paths.bin_dir.join(name), &expected_root.join(name))? { + EntrypointState::Missing => created.push(name.clone()), + EntrypointState::Regular => legacy.push(name.clone()), + EntrypointState::ManagedSymlink => {} + } + } + Ok((legacy, created)) +} + +fn rollback_legacy_entrypoint( + entry: &Path, + backup: &Path, + expected: &Path, + name: &str, +) -> Result<()> { + let entry_state = inspect_entrypoint(entry, expected)?; + let backup_state = inspect_backup(backup)?; + match (entry_state, backup_state) { + // The move never started or a prior recovery already restored it + (EntrypointState::Regular, EntrypointState::Missing) => Ok(()), + // The move completed but link creation did not + (EntrypointState::Missing, EntrypointState::Regular) => restore_backup(backup, entry, name), + // Both the move and managed-link publication completed + (EntrypointState::ManagedSymlink, EntrypointState::Regular) => { + remove_expected_entrypoint(entry, expected)?; + restore_backup(backup, entry, name) + } + (EntrypointState::Regular, EntrypointState::Regular) => Err(anyhow!( + "binary rollback has both live and backup files: {name}" + )), + (EntrypointState::Missing, EntrypointState::Missing) => { + Err(anyhow!("binary rollback lost both copies: {name}")) + } + (EntrypointState::ManagedSymlink, EntrypointState::Missing) => Err(anyhow!( + "binary rollback source is missing behind managed entrypoint: {name}" + )), + (_, EntrypointState::ManagedSymlink) => Err(anyhow!( + "binary rollback copy is an unexpected symbolic link: {name}" + )), + } +} + +fn rollback_created_entrypoint(entry: &Path, expected: &Path, name: &str) -> Result<()> { + match inspect_entrypoint(entry, expected)? { + // Link creation never started or a prior recovery already removed it + EntrypointState::Missing => Ok(()), + EntrypointState::ManagedSymlink => remove_expected_entrypoint(entry, expected), + EntrypointState::Regular => Err(anyhow!( + "new binary entrypoint changed to a regular file during rollback: {name}" + )), + } +} + +fn inspect_entrypoint(path: &Path, expected: &Path) -> Result { + // Link metadata keeps classification on the entrypoint itself + match fs::symlink_metadata(path) { + Err(error) if error.kind() == ErrorKind::NotFound => Ok(EntrypointState::Missing), + Ok(metadata) if metadata.file_type().is_file() => Ok(EntrypointState::Regular), + Ok(metadata) if metadata.file_type().is_symlink() => { + let actual = fs::read_link(path) + .with_context(|| format!("inspect binary entrypoint {}", path.display()))?; + if actual == expected { + Ok(EntrypointState::ManagedSymlink) + } else { + Err(anyhow!( + "binary entrypoint {} points to an unmanaged target {}", + path.display(), + actual.display() + )) + } + } + Ok(_metadata) => Err(anyhow!( + "binary entrypoint is not a regular file or managed link: {}", + path.display() + )), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn inspect_backup(path: &Path) -> Result { + // Backups must remain regular files and never redirect recovery + match fs::symlink_metadata(path) { + Err(error) if error.kind() == ErrorKind::NotFound => Ok(EntrypointState::Missing), + Ok(metadata) if metadata.file_type().is_file() => Ok(EntrypointState::Regular), + Ok(metadata) if metadata.file_type().is_symlink() => Ok(EntrypointState::ManagedSymlink), + Ok(_metadata) => Err(anyhow!( + "binary rollback copy is not a regular file: {}", + path.display() + )), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn restore_backup(backup: &Path, entry: &Path, name: &str) -> Result<()> { + match rename_regular_file_no_replace(backup, entry)? { + RenameRegularFileOutcome::Renamed => Ok(()), + RenameRegularFileOutcome::SourceMissing => { + Err(anyhow!("binary rollback source disappeared: {name}")) + } + RenameRegularFileOutcome::DestinationExists => { + Err(anyhow!("binary rollback destination changed: {name}")) + } + } +} + +fn remove_expected_entrypoint(entry: &Path, expected: &Path) -> Result<()> { + match remove_symlink_if_target(entry, expected)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => Ok(()), + RemoveSymlinkOutcome::TargetMismatch(actual) => Err(anyhow!( + "binary entrypoint changed during rollback to {}", + actual.display() + )), + } +} + +fn rollback_bin_dir(paths: &InstallPaths, pending: &PendingRelease) -> Result { + Ok(paths + .installed_rollback_root()? + .join(&pending.generation) + .join("bin")) +} diff --git a/crates/unixnotis-installer/src/actions/releases/mod.rs b/crates/unixnotis-installer/src/actions/releases/mod.rs new file mode 100644 index 000000000..bb7c48626 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/mod.rs @@ -0,0 +1,17 @@ +//! Versioned release generation installation and recovery + +mod entrypoints; +mod manifest; +mod transaction; + +pub(in crate::actions) use manifest::{ + entrypoint_target, inspect_installed_generation, BinaryHealth, +}; +pub use transaction::rollback_pending_release; +pub use transaction::{ + commit_pending_release, install_release_generation_transaction, pending_release_exists, + pending_release_has_runtime_rollback, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs b/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs new file mode 100644 index 000000000..6d631b0f1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs @@ -0,0 +1,98 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::rollback_entrypoint_changes; +use super::super::transaction::{PendingRelease, PENDING_RELEASE_SCHEMA_VERSION}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +const BINARY: &str = "unixnotis-daemon"; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn pending() -> PendingRelease { + PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: "test-generation".to_string(), + new_current: PathBuf::from("releases/test-generation"), + previous_current: None, + legacy_entrypoints: vec![BINARY.to_string()], + created_entrypoints: Vec::new(), + } +} + +#[test] +fn rollback_rejects_a_symbolic_link_instead_of_a_regular_backup() { + let root = crate::test_support::fs::unique_temp_path("release-backup-link"); + let paths = paths(&root); + let pending = pending(); + let backup = rollback_backup(&paths, &pending); + fs::create_dir_all(backup.parent().expect("backup parent")).expect("create backup parent"); + symlink("unexpected", &backup).expect("create invalid backup link"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("a symbolic-link backup must fail closed"); + + assert!(error.to_string().contains("unexpected symbolic link")); + fs::remove_dir_all(root).expect("remove backup link fixture"); +} + +#[test] +fn rollback_rejects_a_directory_instead_of_a_regular_backup() { + let root = crate::test_support::fs::unique_temp_path("release-backup-directory"); + let paths = paths(&root); + let pending = pending(); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::write(paths.bin_dir.join(BINARY), "legacy").expect("write live legacy binary"); + let backup = rollback_backup(&paths, &pending); + fs::create_dir_all(&backup).expect("create invalid backup directory"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("a directory backup must fail closed"); + + assert!(error.to_string().contains("not a regular file")); + fs::remove_dir_all(root).expect("remove backup directory fixture"); +} + +#[test] +fn rollback_propagates_backup_lookup_errors_instead_of_treating_them_as_missing() { + let root = crate::test_support::fs::unique_temp_path("release-backup-lookup-error"); + let paths = paths(&root); + let pending = pending(); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::write(paths.bin_dir.join(BINARY), "legacy").expect("write live legacy binary"); + let rollback_generation = paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation); + fs::create_dir_all(rollback_generation.parent().expect("rollback parent")) + .expect("create rollback parent"); + fs::write(&rollback_generation, "not a directory").expect("write invalid rollback object"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("backup lookup errors must remain errors"); + + assert!(error.to_string().contains("inspect")); + fs::remove_dir_all(root).expect("remove backup lookup fixture"); +} + +fn rollback_backup(paths: &InstallPaths, pending: &PendingRelease) -> PathBuf { + paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation) + .join("bin") + .join(BINARY) +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/journal.rs b/crates/unixnotis-installer/src/actions/releases/tests/journal.rs new file mode 100644 index 000000000..df9425ef0 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/journal.rs @@ -0,0 +1,167 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::plan_entrypoint_changes; +use super::super::transaction::{ + is_managed_current_target, pending_release_has_runtime_rollback, read_pending, + validate_pending_targets, write_pending, PendingRelease, MAX_PENDING_MANIFEST_BYTES, + PENDING_RELEASE_SCHEMA_VERSION, +}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn pending(new_current: &str, previous_current: Option<&str>) -> PendingRelease { + PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: Path::new(new_current) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("missing") + .to_string(), + new_current: PathBuf::from(new_current), + previous_current: previous_current.map(PathBuf::from), + legacy_entrypoints: Vec::new(), + created_entrypoints: Vec::new(), + } +} + +#[test] +fn pending_release_journal_keeps_its_declared_byte_limit() { + assert_eq!(MAX_PENDING_MANIFEST_BYTES, 262_144); +} + +#[test] +fn pending_journal_rejects_obsolete_recovery_semantics_before_publication() { + let root = crate::test_support::fs::unique_temp_path("release-journal-schema"); + let journal = root.join("pending-install.json"); + fs::create_dir_all(&root).expect("create journal schema fixture"); + let mut obsolete = pending("releases/new", None); + obsolete.schema_version = PENDING_RELEASE_SCHEMA_VERSION.saturating_sub(1); + + let error = write_pending(&journal, &obsolete) + .expect_err("obsolete entrypoint recovery semantics must fail closed"); + + assert!(error + .to_string() + .contains("unsupported pending release schema")); + assert!(fs::symlink_metadata(&journal).is_err()); + fs::remove_dir_all(root).expect("remove journal schema fixture"); +} + +#[test] +fn managed_current_targets_require_exactly_one_release_generation_component() { + assert!(is_managed_current_target(Path::new("releases/generation"))); + for target in [ + "generation", + "foreign/generation", + "releases", + "releases/generation/extra", + "/releases/generation", + ] { + assert!( + !is_managed_current_target(Path::new(target)), + "unmanaged target was accepted: {target}" + ); + } +} + +#[test] +fn pending_journal_rejects_an_unmanaged_new_or_previous_target() { + assert!(validate_pending_targets(&pending("foreign/new", None)).is_err()); + assert!(validate_pending_targets(&pending("releases/new", Some("foreign/previous"))).is_err()); + assert!(validate_pending_targets(&pending("releases/new", Some("releases/previous"))).is_ok()); + + let mut inconsistent = pending("releases/new", None); + inconsistent.generation = "different-generation".to_string(); + assert!(validate_pending_targets(&inconsistent).is_err()); + + let mut unmanaged_binary = pending("releases/new", None); + unmanaged_binary + .legacy_entrypoints + .push("../outside".to_string()); + assert!(validate_pending_targets(&unmanaged_binary).is_err()); + + let mut duplicate_binary = pending("releases/new", None); + duplicate_binary + .legacy_entrypoints + .push("unixnotis-daemon".to_string()); + duplicate_binary + .created_entrypoints + .push("unixnotis-daemon".to_string()); + assert!(validate_pending_targets(&duplicate_binary).is_err()); +} + +#[test] +fn pending_runtime_rollback_state_distinguishes_fresh_and_prior_installs() { + let root = crate::test_support::fs::unique_temp_path("release-journal-runtime-rollback"); + let paths = paths(&root); + let journal = paths + .installed_pending_manifest() + .expect("pending journal path"); + fs::create_dir_all(journal.parent().expect("journal parent")).expect("create journal parent"); + + write_pending(&journal, &pending("releases/new", None)).expect("write fresh journal"); + assert!( + !pending_release_has_runtime_rollback(&paths).expect("inspect fresh journal"), + "a fresh install has no prior runtime to restart" + ); + + write_pending( + &journal, + &pending("releases/new", Some("releases/previous")), + ) + .expect("write upgrade journal"); + assert!( + pending_release_has_runtime_rollback(&paths).expect("inspect upgrade journal"), + "an upgrade must retain prior runtime recovery" + ); + fs::remove_dir_all(root).expect("remove runtime rollback fixture"); +} + +#[test] +fn pending_journal_inspection_propagates_non_missing_filesystem_errors() { + let root = crate::test_support::fs::unique_temp_path("release-journal-read-error"); + fs::create_dir_all(&root).expect("create journal fixture"); + let journal = root.join("pending-install.json"); + fs::create_dir(&journal).expect("create invalid journal directory"); + + assert!( + read_pending(&journal).is_err(), + "an invalid journal object must not become an absent transaction" + ); + fs::remove_dir_all(root).expect("remove journal error fixture"); +} + +#[test] +fn entrypoint_preparation_rejects_special_objects_and_inspection_errors() { + let root = crate::test_support::fs::unique_temp_path("release-entrypoint-invalid"); + let paths = paths(&root); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::create_dir(paths.bin_dir.join("directory-entry")).expect("create invalid entrypoint"); + + let special_error = + plan_entrypoint_changes(&paths, &["directory-entry".to_string()], "test-generation") + .expect_err("directory entrypoint must fail closed"); + assert!(special_error + .to_string() + .contains("not a regular file or managed link")); + + let oversized_name = "x".repeat(4_096); + let inspection_error = plan_entrypoint_changes(&paths, &[oversized_name], "test-generation") + .expect_err("an entrypoint inspection error must not become a missing file"); + assert!(inspection_error.to_string().contains("inspect")); + fs::remove_dir_all(root).expect("remove invalid entrypoint fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/mod.rs b/crates/unixnotis-installer/src/actions/releases/tests/mod.rs new file mode 100644 index 000000000..cbfdc15fd --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/mod.rs @@ -0,0 +1,57 @@ +mod entrypoints; +mod health; +mod journal; +mod manifest; +mod recovery; +mod transaction; + +use std::path::Path; + +use anyhow::Result; + +use super::transaction::install_release_generation_transaction; +use crate::paths::InstallPaths; + +pub(super) fn install_release_generation( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, +{ + install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + || Ok(()), + ) +} + +pub(super) fn install_release_generation_with_reservation_check( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, + reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + reserved_check, + ) +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs b/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs new file mode 100644 index 000000000..a577163f1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::plan_entrypoint_changes; +use super::super::manifest::entrypoint_target; +use super::super::transaction::{ + pending_release_exists, rollback_pending_release, write_pending, PendingRelease, +}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +const LEGACY_BINARIES: [&str; 4] = [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "unixnotis-svg-renderer", +]; +const CREATED_BINARIES: [&str; 2] = ["unixnotis-css-validate", "noticenterctl"]; + +#[derive(Clone, Copy)] +struct CrashBoundary { + label: &'static str, + rollback_directory: bool, + moved: usize, + linked: usize, + current_switched: bool, +} + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +#[test] +fn every_entrypoint_crash_boundary_recovers_one_complete_legacy_layout() { + for boundary in crash_boundaries() { + let root = + crate::test_support::fs::unique_temp_path(&format!("release-crash-{}", boundary.label)); + let paths = paths(&root); + let pending = prepare_journaled_legacy_layout(&paths); + apply_crash_prefix(&paths, &pending, boundary); + + assert!( + rollback_pending_release(&paths).expect("recover crash boundary"), + "{} did not find its durable journal", + boundary.label + ); + assert_recovered_legacy_layout(&paths, boundary.label); + fs::remove_dir_all(root).expect("remove crash recovery fixture"); + } +} + +fn crash_boundaries() -> [CrashBoundary; 9] { + [ + CrashBoundary { + label: "journal-only", + rollback_directory: false, + moved: 0, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "rollback-directory-created", + rollback_directory: true, + moved: 0, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "first-legacy-moved", + rollback_directory: true, + moved: 1, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "half-legacy-moved", + rollback_directory: true, + moved: 2, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "all-legacy-moved", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "first-link-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: 1, + current_switched: false, + }, + CrashBoundary { + label: "half-links-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: usize::midpoint(LEGACY_BINARIES.len(), CREATED_BINARIES.len()), + current_switched: false, + }, + CrashBoundary { + label: "all-links-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: LEGACY_BINARIES.len() + CREATED_BINARIES.len(), + current_switched: false, + }, + CrashBoundary { + label: "current-switched-before-readiness", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: LEGACY_BINARIES.len() + CREATED_BINARIES.len(), + current_switched: true, + }, + ] +} + +fn assert_recovered_legacy_layout(paths: &InstallPaths, boundary: &str) { + for name in LEGACY_BINARIES { + assert_eq!( + fs::read_to_string(paths.bin_dir.join(name)).expect("read restored legacy binary"), + format!("legacy:{name}"), + "{boundary} did not restore {name}" + ); + } + for name in CREATED_BINARIES { + assert!( + fs::symlink_metadata(paths.bin_dir.join(name)).is_err(), + "{boundary} retained newly created entrypoint {name}" + ); + } + assert!( + fs::symlink_metadata(paths.installed_current_link().expect("current path")).is_err(), + "{boundary} retained the unready generation" + ); + assert!( + !pending_release_exists(paths).expect("inspect recovered journal"), + "{boundary} retained a completed recovery journal" + ); +} + +#[test] +fn recovery_fails_closed_when_legacy_live_and_backup_states_conflict() { + for conflict in ["both-copies", "neither-copy"] { + let root = + crate::test_support::fs::unique_temp_path(&format!("release-conflict-{conflict}")); + let paths = paths(&root); + let pending = prepare_journaled_legacy_layout(&paths); + let name = LEGACY_BINARIES[0]; + let rollback = rollback_bin_dir(&paths, &pending); + fs::create_dir_all(&rollback).expect("create rollback directory"); + match conflict { + "both-copies" => { + fs::write(rollback.join(name), "duplicate backup").expect("write duplicate backup"); + } + "neither-copy" => { + fs::remove_file(paths.bin_dir.join(name)).expect("remove legacy live copy"); + } + _ => unreachable!("the conflict table lists every case"), + } + + let error = rollback_pending_release(&paths) + .expect_err("ambiguous rollback state must fail closed"); + + assert!( + error.to_string().contains("binary rollback"), + "unexpected recovery error for {conflict}: {error:#}" + ); + assert!( + pending_release_exists(&paths).expect("retain failed recovery journal"), + "failed recovery must retain its journal" + ); + fs::remove_dir_all(root).expect("remove conflict recovery fixture"); + } +} + +fn prepare_journaled_legacy_layout(paths: &InstallPaths) -> PendingRelease { + fs::create_dir_all(&paths.bin_dir).expect("create legacy bin directory"); + for name in LEGACY_BINARIES { + fs::write(paths.bin_dir.join(name), format!("legacy:{name}")).expect("write legacy binary"); + } + fs::create_dir_all( + paths + .installed_release_root() + .expect("installed release root"), + ) + .expect("create release journal directory"); + let binaries = LEGACY_BINARIES + .into_iter() + .chain(CREATED_BINARIES) + .map(str::to_string) + .collect::>(); + let pending = plan_entrypoint_changes(paths, &binaries, "test-generation") + .expect("plan entrypoint changes"); + write_pending( + &paths + .installed_pending_manifest() + .expect("pending journal path"), + &pending, + ) + .expect("write durable pending journal"); + pending +} + +fn apply_crash_prefix(paths: &InstallPaths, pending: &PendingRelease, boundary: CrashBoundary) { + let rollback = rollback_bin_dir(paths, pending); + if boundary.rollback_directory { + fs::create_dir_all(&rollback).expect("create rollback directory"); + } + for name in LEGACY_BINARIES.into_iter().take(boundary.moved) { + fs::rename(paths.bin_dir.join(name), rollback.join(name)).expect("move legacy binary"); + } + + let expected = entrypoint_target(); + for name in LEGACY_BINARIES + .into_iter() + .chain(CREATED_BINARIES) + .take(boundary.linked) + { + symlink(expected.join(name), paths.bin_dir.join(name)).expect("create managed entrypoint"); + } + if boundary.current_switched { + symlink( + &pending.new_current, + paths.installed_current_link().expect("current path"), + ) + .expect("switch current generation"); + } +} + +fn rollback_bin_dir(paths: &InstallPaths, pending: &PendingRelease) -> PathBuf { + paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation) + .join("bin") +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs b/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs new file mode 100644 index 000000000..a18378e43 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs @@ -0,0 +1,504 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::super::manifest::build_manifest; +use super::super::transaction::{ + commit_pending_release, pending_release_exists, rollback_pending_release, + stage_release_with_copy, +}; +use super::{install_release_generation, install_release_generation_with_reservation_check}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +#[test] +fn successful_install_switches_every_entrypoint_to_one_generation() { + let root = crate::test_support::fs::unique_temp_path("release-transaction-success"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("payload:{binary}")); + } + let paths = paths(&root); + + let generation = install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("install release generation"); + + let current = std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("current release link"); + assert_eq!(current, Path::new("releases").join(&generation)); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read linked binary"), + format!("payload:{binary}") + ); + } + assert!(commit_pending_release(&paths).expect("commit pending release")); + std::fs::remove_dir_all(root).expect("remove release transaction fixture"); +} + +#[test] +fn failed_precommit_restores_all_legacy_entrypoints() { + let root = crate::test_support::fs::unique_temp_path("release-transaction-precommit"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy binary directory"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("new:{binary}")); + std::fs::write(paths.bin_dir.join(binary), format!("old:{binary}")) + .expect("write legacy binary"); + } + + let checks = std::cell::Cell::new(0usize); + let error = install_release_generation_with_reservation_check( + &paths, + &source, + &binaries, + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + if check == 0 { + Ok(()) + } else { + Err(anyhow::anyhow!("daemon restarted")) + } + }, + || Ok(()), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + if check == 1 { + Err(anyhow::anyhow!("daemon restarted")) + } else { + Ok(()) + } + }, + ) + .expect_err("failed precommit must roll back"); + + assert!(format!("{error:#}").contains("daemon restarted")); + assert_eq!(checks.get(), 2); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + format!("old:{binary}") + ); + } + assert!(!rollback_pending_release(&paths).expect("no pending rollback should remain")); + std::fs::remove_dir_all(root).expect("remove release rollback fixture"); +} + +#[test] +fn durable_journal_precedes_the_first_live_entrypoint_mutation() { + let root = crate::test_support::fs::unique_temp_path("release-journal-order"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy binary directory"); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "new generation"); + std::fs::write(paths.bin_dir.join(&binary), "legacy generation").expect("write legacy binary"); + let checks = std::cell::Cell::new(0usize); + + install_release_generation_with_reservation_check( + &paths, + &source, + std::slice::from_ref(&binary), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + let metadata = std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect live entrypoint at precommit"); + match check { + 0 => { + assert!(!pending_release_exists(&paths).expect("inspect initial journal")); + assert!(metadata.file_type().is_file()); + } + _ => panic!("unexpected precommit check {check}"), + } + Ok(()) + }, + || Ok(()), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + let metadata = std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect live entrypoint at reserved check"); + match check { + 1 => { + assert!(!pending_release_exists(&paths).expect("inspect initial journal")); + assert!(metadata.file_type().is_file()); + } + 2 => { + assert!(!pending_release_exists(&paths).expect("inspect pre-recovery journal")); + assert!( + metadata.file_type().is_file(), + "service check must run before pending recovery" + ); + } + 3 => { + assert!(pending_release_exists(&paths).expect("inspect durable journal")); + assert!( + metadata.file_type().is_file(), + "journal must be durable before the legacy entrypoint moves" + ); + } + 4 => { + assert!(pending_release_exists(&paths).expect("inspect activation journal")); + assert!( + metadata.file_type().is_symlink(), + "entrypoint publication must finish before the generation switch check" + ); + } + _ => panic!("unexpected reserved check {check}"), + } + Ok(()) + }, + ) + .expect("install journal ordering generation"); + + assert_eq!(checks.get(), 5); + assert!(commit_pending_release(&paths).expect("commit journal ordering generation")); + std::fs::remove_dir_all(root).expect("remove journal ordering fixture"); +} + +#[test] +fn activation_reservation_is_held_before_layout_and_until_current_switches() { + struct SwitchObserver { + current: std::path::PathBuf, + observed_switch: std::rc::Rc>, + } + + impl Drop for SwitchObserver { + fn drop(&mut self) { + self.observed_switch + .set(std::fs::read_link(&self.current).is_ok()); + } + } + + let root = crate::test_support::fs::unique_temp_path("release-reservation-order"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "reserved generation"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy entrypoint directory"); + std::fs::write(paths.bin_dir.join(&binary), "legacy generation") + .expect("write legacy entrypoint"); + let checks = std::cell::Cell::new(0usize); + let reservation_calls = std::cell::Cell::new(0usize); + let observed_switch = std::rc::Rc::new(std::cell::Cell::new(false)); + + install_release_generation_with_reservation_check( + &paths, + &source, + std::slice::from_ref(&binary), + || { + checks.set(checks.get().saturating_add(1)); + Ok(()) + }, + || { + assert_eq!( + checks.get(), + 1, + "reservation must follow the initial unowned-state check" + ); + assert!( + std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect entrypoint before reservation") + .file_type() + .is_file(), + "activation reservation must precede entrypoint mutation" + ); + assert!( + !pending_release_exists(&paths).expect("inspect journal before reservation"), + "activation reservation must precede journal publication" + ); + reservation_calls.set(reservation_calls.get().saturating_add(1)); + Ok(SwitchObserver { + current: paths.installed_current_link().expect("current link path"), + observed_switch: std::rc::Rc::clone(&observed_switch), + }) + }, + || Ok(()), + ) + .expect("activate generation under reservation"); + + assert_eq!(reservation_calls.get(), 1); + assert!( + observed_switch.get(), + "activation reservation must remain alive through the current-link switch" + ); + assert!(commit_pending_release(&paths).expect("commit reserved generation")); + std::fs::remove_dir_all(root).expect("remove reservation ordering fixture"); +} + +#[test] +fn failure_copying_any_binary_publishes_no_partial_generation() { + for failed_index in 0..3 { + let root = crate::test_support::fs::unique_temp_path(&format!( + "release-copy-failure-{failed_index}" + )); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let sources = ["first", "middle", "final"] + .into_iter() + .map(|name| { + let path = source.join(name); + write_test_binary(&path, name); + (name.to_string(), path) + }) + .collect::>(); + let manifest = build_manifest(&sources).expect("build test manifest"); + let paths = paths(&root); + let mut copied = 0usize; + + let error = stage_release_with_copy( + &paths, + "copy-failure", + &sources, + &manifest, + |source, destination| { + if copied == failed_index { + return Err(anyhow::anyhow!("injected copy failure")); + } + copied = copied.saturating_add(1); + unixnotis_core::filesystem::copy_file_atomic(source, destination) + .map_err(anyhow::Error::from) + }, + ) + .expect_err("injected copy failure must abort staging"); + + assert!(error.to_string().contains("stage release binary")); + let entries = std::fs::read_dir(paths.installed_releases_dir().expect("releases path")) + .expect("read releases directory") + .collect::, _>>() + .expect("collect releases directory"); + assert!( + entries.is_empty(), + "copy failure {failed_index} published partial release state" + ); + std::fs::remove_dir_all(root).expect("remove copy failure fixture"); + } +} + +#[test] +fn readiness_rollback_restores_the_complete_previous_generation() { + let root = crate::test_support::fs::unique_temp_path("release-readiness-rollback"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("old:{binary}")); + } + let paths = paths(&root); + let old_generation = + install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("install old generation"); + commit_pending_release(&paths).expect("commit old generation"); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("new:{binary}")); + } + install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("activate pending new generation"); + + assert!(rollback_pending_release(&paths).expect("roll back failed readiness")); + + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current release"), + Path::new("releases").join(old_generation) + ); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + format!("old:{binary}") + ); + } + std::fs::remove_dir_all(root).expect("remove readiness rollback fixture"); +} + +#[test] +fn recovery_finishes_when_current_was_restored_before_a_crash() { + let root = crate::test_support::fs::unique_temp_path("release-idempotent-current-rollback"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create idempotent rollback source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install previous generation"); + commit_pending_release(&paths).expect("commit previous generation"); + write_test_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate new generation"); + let previous_target = Path::new("releases").join(&old_generation); + unixnotis_core::filesystem::replace_symlink_atomic( + &paths.installed_current_link().expect("current path"), + &previous_target, + ) + .expect("simulate completed current-link rollback"); + + assert!(rollback_pending_release(&paths).expect("finish idempotent rollback")); + + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retained previous generation"), + previous_target + ); + assert!(!pending_release_exists(&paths).expect("journal removed after recovery")); + std::fs::remove_dir_all(root).expect("remove idempotent rollback fixture"); +} + +#[test] +fn readiness_rollback_refuses_a_previous_generation_that_changed_after_activation() { + let root = crate::test_support::fs::unique_temp_path("release-rollback-revalidation"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create rollback revalidation source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install previous generation"); + commit_pending_release(&paths).expect("commit previous generation"); + write_test_binary(&source.join(&binary), "new generation"); + let new_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate new generation"); + let old_binary = paths + .installed_releases_dir() + .expect("installed releases directory") + .join(old_generation) + .join("bin") + .join(&binary); + std::fs::write(old_binary, "corrupted old!").expect("corrupt previous generation"); + + let error = rollback_pending_release(&paths) + .expect_err("changed previous generation must not be reactivated"); + + assert!(error + .to_string() + .contains("verify previous release generation before rollback")); + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retain current generation"), + Path::new("releases").join(new_generation) + ); + assert!(pending_release_exists(&paths).expect("retain pending rollback journal")); + std::fs::remove_dir_all(root).expect("remove rollback revalidation fixture"); +} + +#[test] +fn successful_commits_retain_only_current_and_previous_verified_generations() { + let root = crate::test_support::fs::unique_temp_path("release-retention"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create retention source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + let mut generations = Vec::new(); + + for payload in ["generation one", "generation two", "generation three"] { + write_test_binary(&source.join(&binary), payload); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install retention generation"); + assert!(commit_pending_release(&paths).expect("commit retention generation")); + generations.push(generation); + } + + let releases = paths + .installed_releases_dir() + .expect("installed releases directory"); + assert!(!releases.join(&generations[0]).exists()); + assert!(releases.join(&generations[1]).exists()); + assert!(releases.join(&generations[2]).exists()); + std::fs::remove_dir_all(root).expect("remove retention fixture"); +} + +#[test] +fn readiness_commit_revalidates_the_generation_before_discarding_rollback() { + let root = crate::test_support::fs::unique_temp_path("release-commit-revalidation"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create revalidation source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "generation payload"); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install pending generation"); + let installed_binary = paths + .installed_releases_dir() + .expect("installed releases directory") + .join(generation) + .join("bin") + .join(&binary); + std::fs::write(&installed_binary, "changed payload!!") + .expect("corrupt pending generation after activation"); + + assert!(commit_pending_release(&paths).is_err()); + assert!(rollback_pending_release(&paths).expect("pending rollback remains recoverable")); + std::fs::remove_dir_all(root).expect("remove revalidation fixture"); +} + +fn write_test_binary(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write release test binary"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/transaction.rs b/crates/unixnotis-installer/src/actions/releases/transaction.rs new file mode 100644 index 000000000..73b833a6b --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/transaction.rs @@ -0,0 +1,448 @@ +//! Durable release staging, atomic activation, and rollback + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use unixnotis_core::filesystem::{ + copy_file_atomic, create_directory_all, read_regular_file_bounded, read_symlink, + remove_directory_tree, remove_regular_file, remove_symlink_if_target, + rename_directory_no_replace, replace_symlink_atomic, write_file_atomic, RemoveSymlinkOutcome, + RenameDirectoryOutcome, +}; + +use crate::managed_binaries::is_managed_binary_name; +use crate::paths::InstallPaths; + +use super::entrypoints::{ + apply_entrypoint_changes, plan_entrypoint_changes, rollback_entrypoint_changes, +}; +use super::manifest::{ + build_manifest, manifest_bytes, read_manifest, verify_release_directory, + InstalledReleaseManifest, INSTALLED_MANIFEST_FILE, +}; + +pub(in crate::actions::releases) const MAX_PENDING_MANIFEST_BYTES: u64 = 256 * 1024; +pub(in crate::actions::releases) const PENDING_RELEASE_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Deserialize, Serialize)] +pub(in crate::actions::releases) struct PendingRelease { + pub(in crate::actions::releases) schema_version: u32, + pub(in crate::actions::releases) generation: String, + pub(in crate::actions::releases) new_current: PathBuf, + pub(in crate::actions::releases) previous_current: Option, + pub(in crate::actions::releases) legacy_entrypoints: Vec, + pub(in crate::actions::releases) created_entrypoints: Vec, +} + +pub fn install_release_generation_transaction( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, + reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + install_release_generation_with_checks( + paths, + release_source, + binaries, + precommit, + reserve_activation, + reserved_check, + ) +} + +fn install_release_generation_with_checks( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + mut precommit: F, + mut reserve_activation: R, + mut reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + let sources = binaries + .iter() + .map(|name| (name.clone(), release_source.join(name))) + .collect::>(); + let manifest = build_manifest(&sources)?; + let generation = format!("{}-{}", manifest.package_version, manifest.build_id); + let release_dir = stage_release(paths, &generation, &sources, &manifest)?; + verify_release_directory(&release_dir, &manifest)?; + + // The first check runs before reserving the broker name, so it proves that no + // existing notification daemon owns the runtime boundary + precommit().context("verify daemon state before binary layout mutation")?; + + // Hold activation exclusion before recovery, journaling, or entrypoint mutation + // Later checks omit the broker owner because this reservation is expected + let _activation_reservation = + reserve_activation().context("reserve daemon activation before binary layout mutation")?; + reserved_check().context("verify selected service before binary layout mutation")?; + + commit_staged_release(paths, binaries, &generation, &mut reserved_check) +} + +fn commit_staged_release( + paths: &InstallPaths, + binaries: &[String], + generation: &str, + reserved_check: &mut C, +) -> Result +where + C: FnMut() -> Result<()>, +{ + // Recovery can change current and entrypoints, so prove the selected service is still stopped + if let Err(error) = reserved_check() { + return Err(error.context("verify selected service before pending-release recovery")); + } + rollback_pending_release(paths).context("recover incomplete prior binary installation")?; + let pending = plan_entrypoint_changes(paths, binaries, generation)?; + let pending_path = paths.installed_pending_manifest()?; + // Atomic publication synchronizes both the journal file and its parent directory + // No live entrypoint may change before this durable recovery authority exists + write_pending(&pending_path, &pending)?; + + // Planning and journal I/O may take time, so prove quiescence again at first mutation + if let Err(error) = reserved_check() { + return Err(rollback_with_context(paths, error)); + } + if let Err(error) = apply_entrypoint_changes(paths, &pending) { + return Err(rollback_with_context( + paths, + error.context("publish managed binary entrypoints"), + )); + } + + // Runtime state is sampled again immediately before the atomic generation switch + if let Err(error) = reserved_check() { + return Err(rollback_with_context(paths, error)); + } + if let Err(error) = + replace_symlink_atomic(&paths.installed_current_link()?, &pending.new_current) + { + return Err(rollback_with_context( + paths, + anyhow!(error).context("atomically switch installed release generation"), + )); + } + + // The journal covers every entrypoint mutation and the exact selected generation + Ok(generation.to_string()) +} + +fn stage_release( + paths: &InstallPaths, + generation: &str, + sources: &[(String, PathBuf)], + manifest: &InstalledReleaseManifest, +) -> Result { + stage_release_with_copy( + paths, + generation, + sources, + manifest, + |source, destination| copy_file_atomic(source, destination).map_err(anyhow::Error::from), + ) +} + +pub(in crate::actions::releases) fn stage_release_with_copy( + paths: &InstallPaths, + generation: &str, + sources: &[(String, PathBuf)], + manifest: &InstalledReleaseManifest, + mut copy_binary: F, +) -> Result +where + F: FnMut(&Path, &Path) -> Result<()>, +{ + let releases = paths.installed_releases_dir()?; + create_directory_all(&releases, 0o755).context("create installed releases directory")?; + let final_dir = releases.join(generation); + if final_dir.exists() { + verify_release_directory(&final_dir, manifest)?; + return Ok(final_dir); + } + + let staging = releases.join(format!(".staging-{generation}-{}", std::process::id())); + if staging.exists() { + remove_directory_tree(&staging).context("remove abandoned release staging directory")?; + } + create_directory_all(&staging.join("bin"), 0o755) + .context("create release staging directory")?; + let stage_result = (|| { + for (name, source) in sources { + copy_binary(source, &staging.join("bin").join(name)) + .with_context(|| format!("stage release binary {name}"))?; + } + write_file_atomic( + &staging.join(INSTALLED_MANIFEST_FILE), + &manifest_bytes(manifest)?, + 0o644, + ) + .context("write staged release manifest")?; + verify_release_directory(&staging, manifest)?; + match rename_directory_no_replace(&staging, &final_dir)? { + RenameDirectoryOutcome::Renamed => Ok(()), + RenameDirectoryOutcome::DestinationExists => { + remove_directory_tree(&staging)?; + verify_release_directory(&final_dir, manifest) + } + RenameDirectoryOutcome::SourceMissing => Err(anyhow!( + "release staging directory disappeared before publication" + )), + } + })(); + if stage_result.is_err() { + let _cleanup = remove_directory_tree(&staging); + } + stage_result?; + Ok(final_dir) +} + +pub fn rollback_pending_release(paths: &InstallPaths) -> Result { + let pending_path = paths.installed_pending_manifest()?; + let Some(pending) = read_pending(&pending_path)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + rollback_release_state(paths, &pending)?; + remove_regular_file(&pending_path).context("remove pending release manifest")?; + Ok(true) +} + +fn rollback_release_state(paths: &InstallPaths, pending: &PendingRelease) -> Result<()> { + let current = paths.installed_current_link()?; + let visible_current = read_symlink(¤t)?; + if visible_current.as_ref() == Some(&pending.new_current) { + if let Some(previous) = pending.previous_current.as_ref() { + // Rollback authority depends on the previous generation remaining byte-for-byte valid + verify_release_target(paths, previous) + .context("verify previous release generation before rollback")?; + replace_symlink_atomic(¤t, previous) + .context("restore prior release generation")?; + } else { + match remove_symlink_if_target(¤t, &pending.new_current)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => {} + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "current release changed during rollback to {}", + actual.display() + )); + } + } + } + } else { + let already_restored = pending.previous_current.as_ref().map_or_else( + || visible_current.is_none(), + |previous| visible_current.as_ref() == Some(previous), + ); + if !already_restored { + let actual = visible_current.map_or_else( + || "missing".to_string(), + |target| target.display().to_string(), + ); + return Err(anyhow!( + "current release changed during rollback to {actual}" + )); + } + if let Some(previous) = pending.previous_current.as_ref() { + // A crash may leave current restored while the journal still needs entrypoint cleanup + verify_release_target(paths, previous) + .context("verify already restored release generation during rollback")?; + } + } + rollback_entrypoint_changes(paths, pending)?; + Ok(()) +} + +pub fn commit_pending_release(paths: &InstallPaths) -> Result { + let pending_path = paths.installed_pending_manifest()?; + let Some(pending) = read_pending(&pending_path)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + if read_symlink(&paths.installed_current_link()?)?.as_ref() != Some(&pending.new_current) { + return Err(anyhow!("installed release changed before readiness commit")); + } + verify_release_target(paths, &pending.new_current) + .context("verify ready release generation before commit")?; + // Retention is still reversible because current and previous generations remain untouched + prune_release_generations(paths, &pending) + .context("prune superseded installed release generations")?; + remove_regular_file(&pending_path).context("remove committed pending release manifest")?; + let rollback_generation = paths.installed_rollback_root()?.join(&pending.generation); + if rollback_generation.exists() { + // Scratch cleanup follows the journal commit point and cannot make activation fail + let _cleanup = remove_directory_tree(&rollback_generation); + } + Ok(true) +} + +pub(in crate::actions::releases) fn verify_release_target( + paths: &InstallPaths, + target: &Path, +) -> Result { + if !is_managed_current_target(target) { + return Err(anyhow!( + "current release link points outside the managed releases directory" + )); + } + let release_dir = paths.installed_release_root()?.join(target); + let manifest = read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE))?; + let expected_generation = format!("{}-{}", manifest.package_version, manifest.build_id); + if target.file_name().and_then(|name| name.to_str()) != Some(expected_generation.as_str()) { + return Err(anyhow!( + "current release directory does not match its manifest generation" + )); + } + verify_release_directory(&release_dir, &manifest)?; + Ok(manifest) +} + +fn prune_release_generations(paths: &InstallPaths, pending: &PendingRelease) -> Result<()> { + let releases = paths.installed_releases_dir()?; + let retained = [ + pending.new_current.file_name(), + pending + .previous_current + .as_ref() + .and_then(|target| target.file_name()), + ] + .into_iter() + .flatten() + .collect::>(); + for entry in fs::read_dir(&releases).context("read installed release generations")? { + let entry = entry.context("read installed release generation entry")?; + let file_name = entry.file_name(); + if retained.contains(file_name.as_os_str()) { + continue; + } + let file_type = entry + .file_type() + .context("inspect installed release generation entry")?; + if !file_type.is_dir() { + return Err(anyhow!( + "installed releases directory contains an unmanaged object: {}", + entry.path().display() + )); + } + if file_name.to_string_lossy().starts_with(".staging-") { + remove_directory_tree(&entry.path()).context("remove abandoned release staging")?; + continue; + } + let manifest = read_manifest(&entry.path().join(INSTALLED_MANIFEST_FILE))?; + let expected_name = format!("{}-{}", manifest.package_version, manifest.build_id); + if file_name != std::ffi::OsStr::new(&expected_name) { + return Err(anyhow!( + "installed release directory name does not match its manifest" + )); + } + verify_release_directory(&entry.path(), &manifest)?; + remove_directory_tree(&entry.path()).context("remove superseded release generation")?; + } + Ok(()) +} + +pub fn pending_release_has_runtime_rollback(paths: &InstallPaths) -> Result { + let Some(pending) = read_pending(&paths.installed_pending_manifest()?)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + Ok(pending.previous_current.is_some() || !pending.legacy_entrypoints.is_empty()) +} + +pub fn pending_release_exists(paths: &InstallPaths) -> Result { + Ok(read_pending(&paths.installed_pending_manifest()?)?.is_some()) +} + +pub(in crate::actions::releases) fn write_pending( + path: &Path, + pending: &PendingRelease, +) -> Result<()> { + validate_pending_journal(pending)?; + let bytes = serde_json::to_vec_pretty(pending).context("serialize pending release")?; + write_file_atomic(path, &bytes, 0o600).context("write pending release manifest") +} + +pub(in crate::actions::releases) fn read_pending(path: &Path) -> Result> { + match read_regular_file_bounded(path, MAX_PENDING_MANIFEST_BYTES) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .context("parse pending release manifest"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("read pending release manifest"), + } +} + +pub(in crate::actions::releases) fn validate_pending_targets( + pending: &PendingRelease, +) -> Result<()> { + let expected_new_current = PathBuf::from("releases").join(&pending.generation); + if pending.new_current != expected_new_current + || !is_managed_current_target(&pending.new_current) + || pending + .previous_current + .as_ref() + .is_some_and(|target| !is_managed_current_target(target)) + { + return Err(anyhow!( + "pending release contains an unmanaged current-link target" + )); + } + let mut names = std::collections::HashSet::new(); + for name in pending + .legacy_entrypoints + .iter() + .chain(&pending.created_entrypoints) + { + if !is_managed_binary_name(name) || !names.insert(name) { + return Err(anyhow!( + "pending release contains an invalid or duplicate binary entrypoint" + )); + } + } + Ok(()) +} + +fn validate_pending_journal(pending: &PendingRelease) -> Result<()> { + if pending.schema_version != PENDING_RELEASE_SCHEMA_VERSION { + return Err(anyhow!( + "unsupported pending release schema {}", + pending.schema_version + )); + } + validate_pending_targets(pending) +} + +pub(in crate::actions::releases) fn is_managed_current_target(target: &Path) -> bool { + let mut components = target.components(); + matches!( + (components.next(), components.next(), components.next()), + ( + Some(std::path::Component::Normal(root)), + Some(std::path::Component::Normal(_generation)), + None + ) if root == "releases" + ) +} + +fn rollback_with_context(paths: &InstallPaths, error: anyhow::Error) -> anyhow::Error { + match rollback_pending_release(paths) { + Ok(_rolled_back) => error, + Err(rollback_error) => error.context(format!( + "binary release rollback also failed: {rollback_error:#}" + )), + } +} diff --git a/crates/unixnotis-installer/src/tests/release.rs b/crates/unixnotis-installer/src/tests/release.rs index 3c5057217..4b9814cae 100644 --- a/crates/unixnotis-installer/src/tests/release.rs +++ b/crates/unixnotis-installer/src/tests/release.rs @@ -97,7 +97,10 @@ fn release_status_display_line_reports_available_update() { state: ReleaseUpdateState::UpdateAvailable, }; - assert_eq!(status.display_line(), "v1.0.0 installed; v1.0.1 available"); + assert_eq!( + status.display_line_for(&status.current, "installed"), + "v1.0.0 installed; v1.0.1 available" + ); } #[test] @@ -125,11 +128,37 @@ fn release_status_display_line_reports_up_to_date_release() { }; assert_eq!( - status.display_line(), + status.display_line_for(&status.current, "installed"), "v1.0.0 installed; latest release is v1.0.0" ); } +#[test] +fn release_status_display_line_keeps_installation_role_explicit() { + let status = ReleaseStatus { + current: "v1.2.0".to_string(), + latest: Some("v1.2.0".to_string()), + state: ReleaseUpdateState::UpToDate, + }; + + assert_eq!( + status.display_line_for("v1.2.0", "binaries present"), + "v1.2.0 binaries present; latest release is v1.2.0" + ); + assert_eq!( + status.display_line_for("v1.2.0", "installer"), + "v1.2.0 installer; latest release is v1.2.0" + ); + assert_eq!( + status.update_state_for("v1.1.0"), + ReleaseUpdateState::UpdateAvailable + ); + assert_eq!( + status.update_state_for("v1.2.0"), + ReleaseUpdateState::UpToDate + ); +} + #[test] fn parse_version_tag_accepts_plain_and_prefixed_versions() { assert!(parse_version_tag("1.2.3").is_some()); From 42e73b4bb63d893cf45d60821246ee25ffd2f119 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:26:47 -0500 Subject: [PATCH 262/275] fix(installer): classify installation health for repair Distinguish healthy installs from missing and damaged installations. - replace binary existence checks with verified generation health - require all managed binaries to belong to one verified generation - include service artifact and manager inspection health in installed state - introduce NotInstalled, InstalledHealthy, and RepairRequired dispositions - expose verified installed version from generation metadata - label healthy installs as Reinstall and damaged installs as Repair - avoid presenting incomplete installations as clean fresh installs - add install-state and UI disposition regressions --- .../actions/{ => install}/install_state.rs | 115 ++++++++-- .../src/actions/install/mod.rs | 14 +- .../actions/install/tests/install_state.rs | 209 ++++++++++++++++++ crates/unixnotis-installer/src/actions/mod.rs | 19 +- .../unixnotis-installer/src/actions/state.rs | 2 +- .../src/actions/tests/install_state.rs | 88 -------- crates/unixnotis-installer/src/app/state.rs | 23 +- crates/unixnotis-installer/src/release.rs | 30 ++- .../src/ui/tests/welcome.rs | 21 +- crates/unixnotis-installer/src/ui/welcome.rs | 35 ++- 10 files changed, 418 insertions(+), 138 deletions(-) rename crates/unixnotis-installer/src/actions/{ => install}/install_state.rs (55%) create mode 100644 crates/unixnotis-installer/src/actions/install/tests/install_state.rs delete mode 100644 crates/unixnotis-installer/src/actions/tests/install_state.rs diff --git a/crates/unixnotis-installer/src/actions/install_state.rs b/crates/unixnotis-installer/src/actions/install/install_state.rs similarity index 55% rename from crates/unixnotis-installer/src/actions/install_state.rs rename to crates/unixnotis-installer/src/actions/install/install_state.rs index e577c3196..affa94fd9 100644 --- a/crates/unixnotis-installer/src/actions/install_state.rs +++ b/crates/unixnotis-installer/src/actions/install/install_state.rs @@ -5,8 +5,9 @@ use std::path::PathBuf; use crate::paths::InstallPaths; use crate::service_manager::ServiceArtifact; -use super::binaries::resolve_install_binaries_best_effort; -use super::conflicts::{detect_service_manager_conflict_state, ServiceManagerConflict}; +use super::super::binaries::resolve_install_binaries_best_effort; +use super::super::conflicts::{detect_service_manager_conflict_state, ServiceManagerConflict}; +use super::super::releases::{inspect_installed_generation, BinaryHealth}; #[derive(Clone)] pub(in crate::actions) struct BinaryState { @@ -14,8 +15,8 @@ pub(in crate::actions) struct BinaryState { pub(in crate::actions) name: String, // Concrete install path shown in logs when a binary is missing or present pub(in crate::actions) path: PathBuf, - // Existence is enough here because binary copying owns replacement safety later - pub(in crate::actions) exists: bool, + // Read-side health uses the same generation, link, type, size, and digest invariants as install + pub(in crate::actions) health: BinaryHealth, } #[derive(Clone)] @@ -39,20 +40,85 @@ pub struct InstallState { pub(in crate::actions) service_conflict_warnings: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstallationDisposition { + // No selected-manager artifact or managed binary entrypoint was found + NotInstalled, + // Every binary belongs to one verified generation and manager state is trustworthy + InstalledHealthy, + // A managed footprint exists but at least one required health invariant failed + RepairRequired, +} + +impl InstallationDisposition { + pub const fn label(self) -> &'static str { + match self { + Self::NotInstalled => "not installed", + Self::InstalledHealthy => "healthy", + Self::RepairRequired => "repair required", + } + } +} + impl InstallState { pub fn is_installed(&self) -> bool { - // Treat installed as binaries plus the service artifact; runtime status is separate - !self.binaries.is_empty() - && self.binaries.iter().all(|binary| binary.exists) + // Healthy means both filesystem integrity and manager inspection are trustworthy + self.healthy_generation().is_some() && self.service_artifact_exists + && self.service_enabled_error.is_none() + && self.service_active_error.is_none() + && self.service_conflicts.is_empty() } pub fn is_fully_installed(&self) -> bool { self.is_installed() && self.service_active } - pub const fn service_enabled(&self) -> bool { - self.service_enabled + pub fn disposition(&self) -> InstallationDisposition { + if self.is_installed() { + InstallationDisposition::InstalledHealthy + } else if self.has_installation_footprint() { + InstallationDisposition::RepairRequired + } else { + InstallationDisposition::NotInstalled + } + } + + pub fn installed_version(&self) -> Option<&str> { + self.healthy_generation().map(|(_, version)| version) + } + + fn has_installation_footprint(&self) -> bool { + self.service_artifact_exists + || self + .binaries + .iter() + .any(|binary| !matches!(binary.health, BinaryHealth::Missing)) + } + + fn healthy_generation(&self) -> Option<(&str, &str)> { + let BinaryHealth::Healthy { + generation, + package_version, + .. + } = &self.binaries.first()?.health + else { + return None; + }; + // A set of individually valid binaries is still invalid when generations differ + self.binaries + .iter() + .all(|binary| { + matches!( + &binary.health, + BinaryHealth::Healthy { + generation: candidate_generation, + package_version: candidate_version, + .. + } if candidate_generation == generation && candidate_version == package_version + ) + }) + .then_some((generation, package_version)) } } @@ -60,15 +126,12 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { // Keep install state aligned with installer binary discovery // Best-effort resolution keeps install state usable even if workspace metadata is broken let (binaries, warning) = resolve_install_binaries_best_effort(paths); - let binaries = binaries + let binaries = inspect_installed_generation(paths, &binaries) .into_iter() - .map(|name| { - let path = paths.bin_dir.join(&name); - BinaryState { - name, - exists: path.exists(), - path, - } + .map(|(name, health)| BinaryState { + path: paths.bin_dir.join(&name), + name, + health, }) .collect::>(); @@ -97,9 +160,21 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { }); // Active state still matters for the install summary shown in the UI let mut service_active_error = None; - let service_active = match paths.service.active_probe().evaluate() { - // Active probes can be plain exit status or stdout parsing, depending on backend - Ok(active) => active, + let service_active = match paths.service.active_probe().evaluate_state() { + Ok(crate::service_manager::contract::ServiceProbeState::Active) => true, + Ok( + crate::service_manager::contract::ServiceProbeState::Absent + | crate::service_manager::contract::ServiceProbeState::Inactive, + ) => false, + Ok(crate::service_manager::contract::ServiceProbeState::Unavailable) => { + service_active_error = Some("selected service manager is unavailable".to_string()); + false + } + Ok(crate::service_manager::contract::ServiceProbeState::Indeterminate) => { + service_active_error = + Some("selected service manager state is indeterminate".to_string()); + false + } Err(err) => { service_active_error = Some(err.to_string()); false diff --git a/crates/unixnotis-installer/src/actions/install/mod.rs b/crates/unixnotis-installer/src/actions/install/mod.rs index cd60647e2..19aca83ba 100644 --- a/crates/unixnotis-installer/src/actions/install/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/mod.rs @@ -2,13 +2,25 @@ // Binary copy and cleanup live apart from service management so filesystem writes stay focused mod binaries; +mod install_state; +mod installation_channel; +mod installer_lock; // Service artifact writes and startup behavior stay together because they share // service-manager state mod service; pub use binaries::{install_binaries, remove_binaries}; +pub use install_state::{check_install_state, InstallState, InstallationDisposition}; +pub(super) use installation_channel::reject_conflicting_installation_channel; +pub use installer_lock::InstallerLock; +pub use service::uninstall_service; pub use service::write_service_artifact; -pub use service::{enable_service, install_service, uninstall_service}; +pub use service::{enforce_service_readiness, rollback_failed_activation}; +pub use service::{ + install_service_under_reservation, prepare_service_start_under_reservation, + restart_previous_service, rollback_pending_under_activation_reservation, + start_service_and_verify, +}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/tests/install_state.rs b/crates/unixnotis-installer/src/actions/install/tests/install_state.rs new file mode 100644 index 000000000..c36510d77 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/install_state.rs @@ -0,0 +1,209 @@ +use super::{BinaryState, InstallState, InstallationDisposition}; +use crate::actions::releases::BinaryHealth; +use crate::service_manager::{ServiceArtifact, ServiceArtifactKind}; + +use super::service_artifacts_are_present; + +#[test] +fn empty_service_artifact_list_is_not_installed() { + // A backend with no artifacts has not proved ownership of anything on disk + assert!(!service_artifacts_are_present(&[])); +} + +#[test] +fn installation_disposition_labels_are_distinct_and_actionable() { + assert_eq!( + InstallationDisposition::NotInstalled.label(), + "not installed" + ); + assert_eq!(InstallationDisposition::InstalledHealthy.label(), "healthy"); + assert_eq!( + InstallationDisposition::RepairRequired.label(), + "repair required" + ); +} + +#[test] +fn missing_service_artifact_list_is_not_installed() { + let artifact = ServiceArtifact { + // Use a fixed missing path because this test only needs the safe-presence negative path + path: std::env::temp_dir().join("unixnotis-missing-service-artifact"), + kind: ServiceArtifactKind::File, + contents: Some(String::new()), + mode: None, + }; + + // Non-empty lists still need every artifact to match the expected safe shape + assert!(!service_artifacts_are_present(&[artifact])); +} + +#[test] +fn install_state_requires_non_empty_binary_list_all_binaries_and_service_artifact() { + let base = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + // Full install state needs at least one binary, every binary present, and a safe service artifact + assert!(base.is_installed()); + assert_eq!( + base.disposition(), + InstallationDisposition::InstalledHealthy + ); + assert_eq!(base.installed_version(), Some("1.2.0")); + + let mut no_binaries = base.clone(); + no_binaries.binaries.clear(); + assert!(!no_binaries.is_installed()); + assert_eq!( + no_binaries.disposition(), + InstallationDisposition::RepairRequired + ); + + let mut missing_binary = base.clone(); + missing_binary.binaries[0].health = BinaryHealth::Missing; + assert!(!missing_binary.is_installed()); + assert_eq!( + missing_binary.disposition(), + InstallationDisposition::RepairRequired + ); + + let mut missing_service = base; + missing_service.service_artifact_exists = false; + assert!(!missing_service.is_installed()); + assert_eq!( + missing_service.disposition(), + InstallationDisposition::RepairRequired + ); +} + +#[test] +fn install_state_with_no_binary_or_service_footprint_is_not_installed() { + let state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Missing, + }], + service_artifact_exists: false, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert_eq!(state.disposition(), InstallationDisposition::NotInstalled); +} + +#[test] +fn indeterminate_selected_manager_requires_repair_for_present_binaries() { + let state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: Some("manager state is indeterminate".to_string()), + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert!(!state.is_installed()); + assert_eq!(state.disposition(), InstallationDisposition::RepairRequired); +} + +#[test] +fn fully_installed_requires_running_service_and_enabled_accessor_tracks_field() { + let mut state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: true, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + // Enabled state and active state are separate; install summary should not conflate them + assert!(state.is_installed()); + assert!(state.service_enabled); + assert!(!state.is_fully_installed()); + + state.service_active = true; + + assert!(state.is_fully_installed()); +} + +#[test] +fn install_state_rejects_individually_healthy_binaries_from_different_generations() { + let binary = |name: &str, generation: &str| BinaryState { + name: name.to_string(), + path: std::env::temp_dir().join(name), + health: BinaryHealth::Healthy { + generation: generation.to_string(), + package_version: "1.2.0".to_string(), + digest: format!("digest-{name}"), + }, + }; + let state = InstallState { + binaries: vec![ + binary("unixnotis-daemon", "generation-a"), + binary("unixnotis-center", "generation-b"), + ], + service_artifact_exists: true, + service_enabled: true, + service_active: true, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert!( + !state.is_installed(), + "different release generations must never form one installed state" + ); + assert_eq!( + state.disposition(), + InstallationDisposition::RepairRequired, + "mixed release generations must be presented as a repair" + ); +} diff --git a/crates/unixnotis-installer/src/actions/mod.rs b/crates/unixnotis-installer/src/actions/mod.rs index 31f803179..53624fa7e 100644 --- a/crates/unixnotis-installer/src/actions/mod.rs +++ b/crates/unixnotis-installer/src/actions/mod.rs @@ -10,10 +10,9 @@ mod environment; mod format; mod hyprland; mod install; -mod install_state; -mod installation_channel; mod plan; mod process; +mod releases; mod state; pub use build::{ @@ -21,11 +20,13 @@ pub use build::{ BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome, }; pub use context::ActionContext; +pub use daemon::ensure_selected_service_inactive; +pub use daemon::DaemonActivationReservation; pub use format::{ daemon_has_displayable_status, daemon_status_is_warning, format_daemon_status, summarize_owner, }; -pub use install_state::{check_install_state, InstallState}; -pub use plan::{build_plan, run_step, steps_from_plan, StepKind}; +pub use plan::run_step_with_reservation; +pub use plan::{build_plan, steps_from_plan, StepKind}; pub use build::run_build; pub use config::backup::{list_backup_dirs_for_ui, restore_config}; @@ -33,7 +34,15 @@ pub use config::{ensure_config, remove_state, reset_config}; pub use daemon::stop_active_daemon; pub use environment::{ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment}; pub use install::{ - enable_service, install_binaries, install_service, remove_binaries, uninstall_service, + check_install_state, enforce_service_readiness, rollback_failed_activation, InstallState, + InstallationDisposition, InstallerLock, +}; +pub use install::{install_binaries, remove_binaries, uninstall_service}; +pub use install::{ + install_service_under_reservation, prepare_service_start_under_reservation, + restart_previous_service, rollback_pending_under_activation_reservation, + start_service_and_verify, }; pub use process::{log_line, run_command, run_command_without_stdout}; +pub use releases::{commit_pending_release, pending_release_exists}; pub use state::check_install_state_step; diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index 62348bdd6..163a83de1 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -28,7 +28,7 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { log_line(ctx, "Warning: no installable binaries discovered"); } for binary in &state.binaries { - let status = if binary.exists { "present" } else { "missing" }; + let status = binary.health.label(); log_line( ctx, format!( diff --git a/crates/unixnotis-installer/src/actions/tests/install_state.rs b/crates/unixnotis-installer/src/actions/tests/install_state.rs deleted file mode 100644 index 2ae38138e..000000000 --- a/crates/unixnotis-installer/src/actions/tests/install_state.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::path::PathBuf; - -use super::{BinaryState, InstallState}; -use crate::service_manager::{ServiceArtifact, ServiceArtifactKind}; - -use super::service_artifacts_are_present; - -#[test] -fn empty_service_artifact_list_is_not_installed() { - // A backend with no artifacts has not proved ownership of anything on disk - assert!(!service_artifacts_are_present(&[])); -} - -#[test] -fn missing_service_artifact_list_is_not_installed() { - let artifact = ServiceArtifact { - // Use a fixed missing path because this test only needs the safe-presence negative path - path: PathBuf::from("/tmp/unixnotis-missing-service-artifact"), - kind: ServiceArtifactKind::File, - contents: Some(String::new()), - mode: None, - }; - - // Non-empty lists still need every artifact to match the expected safe shape - assert!(!service_artifacts_are_present(&[artifact])); -} - -#[test] -fn install_state_requires_non_empty_binary_list_all_binaries_and_service_artifact() { - let base = InstallState { - binaries: vec![BinaryState { - name: "unixnotis-daemon".to_string(), - path: PathBuf::from("/tmp/unixnotis-daemon"), - exists: true, - }], - service_artifact_exists: true, - service_enabled: false, - service_active: false, - service_enabled_error: None, - service_active_error: None, - binary_warning: None, - service_conflicts: Vec::new(), - service_conflict_warnings: Vec::new(), - }; - - // Full install state needs at least one binary, every binary present, and a safe service artifact - assert!(base.is_installed()); - - let mut no_binaries = base.clone(); - no_binaries.binaries.clear(); - assert!(!no_binaries.is_installed()); - - let mut missing_binary = base.clone(); - missing_binary.binaries[0].exists = false; - assert!(!missing_binary.is_installed()); - - let mut missing_service = base; - missing_service.service_artifact_exists = false; - assert!(!missing_service.is_installed()); -} - -#[test] -fn fully_installed_requires_running_service_and_enabled_accessor_tracks_field() { - let mut state = InstallState { - binaries: vec![BinaryState { - name: "unixnotis-daemon".to_string(), - path: PathBuf::from("/tmp/unixnotis-daemon"), - exists: true, - }], - service_artifact_exists: true, - service_enabled: true, - service_active: false, - service_enabled_error: None, - service_active_error: None, - binary_warning: None, - service_conflicts: Vec::new(), - service_conflict_warnings: Vec::new(), - }; - - // Enabled state and active state are separate; install summary should not conflate them - assert!(state.is_installed()); - assert!(state.service_enabled()); - assert!(!state.is_fully_installed()); - - state.service_active = true; - - assert!(state.is_fully_installed()); -} diff --git a/crates/unixnotis-installer/src/app/state.rs b/crates/unixnotis-installer/src/app/state.rs index 96e68d73c..fce25469a 100644 --- a/crates/unixnotis-installer/src/app/state.rs +++ b/crates/unixnotis-installer/src/app/state.rs @@ -1,6 +1,6 @@ //! UI state and event handling for the installer TUI -use crate::actions::{check_install_state, InstallState}; +use crate::actions::{check_install_state, InstallState, InstallationDisposition}; use crate::actions::{BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome}; use crate::checks::Checks; use crate::detect::Detection; @@ -204,11 +204,17 @@ impl App { pub fn action_label(&self, mode: ActionMode) -> &'static str { match mode { ActionMode::Install => self.install_label(), - ActionMode::Reset => "Reset config", _ => mode.label(), } } + pub fn installation_disposition(&self) -> InstallationDisposition { + self.install_state.as_ref().map_or( + InstallationDisposition::NotInstalled, + InstallState::disposition, + ) + } + pub fn refresh_backups(&mut self) { // Refresh the list of available backup directories for restore self.restore_backups = crate::actions::list_backup_dirs_for_ui(); @@ -216,15 +222,10 @@ impl App { } fn install_label(&self) -> &'static str { - // Installed state is derived from filesystem presence, not runtime health - if self - .install_state - .as_ref() - .is_some_and(crate::actions::InstallState::is_installed) - { - "Reinstall" - } else { - "Install" + match self.installation_disposition() { + InstallationDisposition::NotInstalled => "Install", + InstallationDisposition::InstalledHealthy => "Reinstall", + InstallationDisposition::RepairRequired => "Repair", } } diff --git a/crates/unixnotis-installer/src/release.rs b/crates/unixnotis-installer/src/release.rs index d87b2e16d..d33bdc4be 100644 --- a/crates/unixnotis-installer/src/release.rs +++ b/crates/unixnotis-installer/src/release.rs @@ -61,18 +61,30 @@ impl ReleaseStatus { } } - pub fn display_line(&self) -> String { - // Keep the line compact because it sits in the installer status panel - match (self.state, self.latest.as_deref()) { - (ReleaseUpdateState::UpdateAvailable, Some(latest)) => { - format!("{} installed; {latest} available", self.current) + pub fn display_line_for(&self, version: &str, role: &str) -> String { + // The version role prevents installer-build and installed-binary state from being conflated + match self.latest.as_deref() { + Some(latest) + if self.update_state_for(version) == ReleaseUpdateState::UpdateAvailable => + { + format!("{version} {role}; {latest} available") } - (ReleaseUpdateState::UpToDate, Some(latest)) => { - format!("{} installed; latest release is {latest}", self.current) - } - _ => format!("{} installed; update check unavailable", self.current), + Some(latest) => format!("{version} {role}; latest release is {latest}"), + None => format!("{version} {role}; update check unavailable"), } } + + pub fn update_state_for(&self, version: &str) -> ReleaseUpdateState { + self.latest + .as_deref() + .map_or(ReleaseUpdateState::Unknown, |latest| { + if release_tag_is_newer(latest, version) { + ReleaseUpdateState::UpdateAvailable + } else { + ReleaseUpdateState::UpToDate + } + }) + } } fn current_version_tag() -> String { diff --git a/crates/unixnotis-installer/src/ui/tests/welcome.rs b/crates/unixnotis-installer/src/ui/tests/welcome.rs index 8941950ac..ab81d956a 100644 --- a/crates/unixnotis-installer/src/ui/tests/welcome.rs +++ b/crates/unixnotis-installer/src/ui/tests/welcome.rs @@ -1,3 +1,4 @@ +use crate::actions::InstallationDisposition; use crate::app::Screen; use crate::detect::OwnerInfo; use crate::release::{ReleaseStatus, ReleaseUpdateState}; @@ -7,6 +8,22 @@ use super::test_support::{ app_for_rendering, detected_daemon_with_status, render_app, render_app_buffer, style_for_text, }; +#[test] +fn installed_version_role_distinguishes_verified_and_repair_states() { + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::InstalledHealthy), + "installed" + ); + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::RepairRequired), + "binaries present" + ); + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::NotInstalled), + "binaries present" + ); +} + #[test] fn draw_welcome_renders_status_and_action_menu() { let app = app_for_rendering(Screen::Welcome); @@ -19,9 +36,10 @@ fn draw_welcome_renders_status_and_action_menu() { assert!(screen.contains("Actions")); assert!(screen.contains("Release")); assert!(screen.contains(&format!( - "Version: v{} installed", + "Version: v{} installer", env!("CARGO_PKG_VERSION") ))); + assert!(screen.contains("Install state: not installed")); assert!(screen.contains("Compatibility")); assert!(screen.contains("[ok]")); assert!(screen.contains("test - ok")); @@ -61,6 +79,7 @@ fn draw_welcome_hides_daemon_section_when_only_probe_errors_exist() { fn draw_welcome_shows_daemon_section_when_runtime_signal_exists() { let mut app = app_for_rendering(Screen::Welcome); app.detection.owner = Some(OwnerInfo { + unique_name: None, pid: Some(4242), comm: Some("dunst".to_string()), }); diff --git a/crates/unixnotis-installer/src/ui/welcome.rs b/crates/unixnotis-installer/src/ui/welcome.rs index 8d7c7c5b1..e0bf63b14 100644 --- a/crates/unixnotis-installer/src/ui/welcome.rs +++ b/crates/unixnotis-installer/src/ui/welcome.rs @@ -8,6 +8,7 @@ use super::header::draw_header; use super::widgets::truncate_to_width; use crate::actions::{ daemon_has_displayable_status, daemon_status_is_warning, format_daemon_status, summarize_owner, + InstallationDisposition, }; use crate::app::{App, MenuItem}; use crate::checks::{CheckItem, CheckState}; @@ -66,6 +67,20 @@ pub(super) fn draw_welcome(frame: &mut Frame<'_>, app: &App) { fn render_status(app: &App) -> Text<'static> { // Build a list of Lines that ratatui will render as a single Text block. // This is kept pure so rendering remains deterministic for any given App state. + let disposition = app.installation_disposition(); + let (version, version_role) = app + .install_state + .as_ref() + .and_then(crate::actions::InstallState::installed_version) + .map_or_else( + || (app.release_status.current.clone(), "installer"), + |installed_version| { + ( + format!("v{}", installed_version.trim_start_matches('v')), + installed_version_role(disposition), + ) + }, + ); let mut lines = vec![ // Section heading: release version and update status. Line::from(Span::styled( @@ -75,10 +90,17 @@ fn render_status(app: &App) -> Text<'static> { Line::from(vec![ Span::styled("Version: ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( - app.release_status.display_line(), - release_status_style(app.release_status.state), + app.release_status.display_line_for(&version, version_role), + release_status_style(app.release_status.update_state_for(&version)), ), ]), + Line::from(vec![ + Span::styled( + "Install state: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(disposition.label()), + ]), Line::from(""), // Section heading: core environment checks. Line::from(Span::styled( @@ -103,6 +125,15 @@ fn render_status(app: &App) -> Text<'static> { Text::from(lines) } +pub(super) const fn installed_version_role(disposition: InstallationDisposition) -> &'static str { + // Only a fully verified generation may use the unqualified installed wording + if matches!(disposition, InstallationDisposition::InstalledHealthy) { + "installed" + } else { + "binaries present" + } +} + fn render_daemon_section(app: &App, lines: &mut Vec>) { let visible_daemons = app .detection From 94557e645b9c653ddb6dc1def67fa9f7b290a4e2 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:27:14 -0500 Subject: [PATCH 263/275] fix(installer): harden installation channel detection and locking Fail closed when existing installation ownership cannot be established. - canonicalize systemd fragment and executable identity before classifying channels - reject mixed, package-owned, partial, and unrecognized installations - treat systemctl inspection failures as errors rather than absence - preserve persistent user masks and recover supported runtime masks safely - validate installed service artifacts by expected object shape - serialize mutating installer sessions with an owned runtime-directory lock - refuse symlink-redirection and foreign lock-file ownership - add channel-classification, path-identity, and installer-lock regressions --- .../{ => install}/installation_channel.rs | 110 +++++- .../src/actions/install/installer_lock.rs | 68 ++++ .../src/actions/install/service/lifecycle.rs | 2 +- .../tests/installation_channel.rs | 331 +++++++++++++++++- .../actions/install/tests/installer_lock.rs | 50 +++ 5 files changed, 527 insertions(+), 34 deletions(-) rename crates/unixnotis-installer/src/actions/{ => install}/installation_channel.rs (72%) create mode 100644 crates/unixnotis-installer/src/actions/install/installer_lock.rs rename crates/unixnotis-installer/src/actions/{ => install}/tests/installation_channel.rs (50%) create mode 100644 crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs diff --git a/crates/unixnotis-installer/src/actions/installation_channel.rs b/crates/unixnotis-installer/src/actions/install/installation_channel.rs similarity index 72% rename from crates/unixnotis-installer/src/actions/installation_channel.rs rename to crates/unixnotis-installer/src/actions/install/installation_channel.rs index d20fc4efe..6b9b9a927 100644 --- a/crates/unixnotis-installer/src/actions/installation_channel.rs +++ b/crates/unixnotis-installer/src/actions/install/installation_channel.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use super::{log_line, ActionContext}; +use super::super::{log_line, ActionContext}; const SYSTEM_UNIT_ROOT: &str = "/usr/lib/systemd/user"; const SYSTEM_BINARY_ROOT: &str = "/usr/bin"; @@ -35,7 +35,9 @@ enum ActiveUnitMetadata { }, } -pub(super) fn reject_conflicting_installation_channel(ctx: &mut ActionContext) -> Result<()> { +pub(in crate::actions) fn reject_conflicting_installation_channel( + ctx: &mut ActionContext, +) -> Result<()> { if !ctx.paths.service.is_systemd() { return Ok(()); } @@ -73,6 +75,15 @@ fn reject_channel(ctx: &mut ActionContext, fragment: &Path, executable: &Path) - ctx.paths.service.artifact_root(), &ctx.paths.bin_dir, ); + reject_classified_channel(ctx, channel, fragment, executable) +} + +fn reject_classified_channel( + ctx: &mut ActionContext, + channel: InstallationChannel, + fragment: &Path, + executable: &Path, +) -> Result<()> { match channel { InstallationChannel::HomeLocal => Ok(()), InstallationChannel::SystemPackage => { @@ -111,14 +122,25 @@ fn active_unit_metadata() -> Result { let output = command .output() .context("inspect active UnixNotis systemd unit")?; - if !output.status.success() { - return Ok(ActiveUnitMetadata::Absent); - } - if output.stdout.len() > MAX_SYSTEMCTL_OUTPUT_BYTES { + let text = validate_systemctl_output(output)?; + parse_active_unit_metadata(&text) +} + +fn validate_systemctl_output(output: std::process::Output) -> Result { + if output.stdout.len() > MAX_SYSTEMCTL_OUTPUT_BYTES + || output.stderr.len() > MAX_SYSTEMCTL_OUTPUT_BYTES + { bail!("systemctl unit metadata exceeded the safe output limit"); } - let text = String::from_utf8(output.stdout).context("systemctl unit metadata was not UTF-8")?; - parse_active_unit_metadata(&text) + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "failed to inspect UnixNotis systemd unit (status {}): {}", + output.status, + stderr.trim() + ); + } + String::from_utf8(output.stdout).context("systemctl unit metadata was not UTF-8") } fn parse_active_unit_metadata(text: &str) -> Result { @@ -203,8 +225,34 @@ fn classify_installation_channel( home_unit_root: &Path, home_binary_root: &Path, ) -> InstallationChannel { - let unit_channel = path_channel(fragment, home_unit_root, Path::new(SYSTEM_UNIT_ROOT)); - let binary_channel = path_channel(executable, home_binary_root, Path::new(SYSTEM_BINARY_ROOT)); + classify_installation_channel_at( + fragment, + executable, + home_unit_root, + home_binary_root, + Path::new(SYSTEM_UNIT_ROOT), + Path::new(SYSTEM_BINARY_ROOT), + ) +} + +fn classify_installation_channel_at( + fragment: &Path, + executable: &Path, + home_unit_root: &Path, + home_binary_root: &Path, + system_unit_root: &Path, + system_binary_root: &Path, +) -> InstallationChannel { + let unit_channel = path_channel(fragment, home_unit_root, system_unit_root); + let home_release_root = home_binary_root + .parent() + .map(|root| root.join("lib").join("unixnotis")); + let binary_channel = binary_path_channel( + executable, + home_binary_root, + home_release_root.as_deref(), + system_binary_root, + ); match (unit_channel, binary_channel) { (Some(InstallationChannel::HomeLocal), Some(InstallationChannel::HomeLocal)) => { InstallationChannel::HomeLocal @@ -217,14 +265,44 @@ fn classify_installation_channel( } } +fn binary_path_channel( + path: &Path, + home_binary_root: &Path, + home_release_root: Option<&Path>, + system_binary_root: &Path, +) -> Option { + let path = fs::canonicalize(path).ok()?; + if fs::canonicalize(home_binary_root) + .ok() + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); + } + if home_release_root + .and_then(|root| fs::canonicalize(root).ok()) + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); + } + fs::canonicalize(system_binary_root) + .ok() + .filter(|root| path.starts_with(root)) + .map(|_root| InstallationChannel::SystemPackage) +} + fn path_channel(path: &Path, home_root: &Path, system_root: &Path) -> Option { - if path.starts_with(home_root) { - Some(InstallationChannel::HomeLocal) - } else if path.starts_with(system_root) { - Some(InstallationChannel::SystemPackage) - } else { - None + // Resolve the object and both policy roots so lexical symlink placement has no authority + let path = fs::canonicalize(path).ok()?; + if fs::canonicalize(home_root) + .ok() + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); } + fs::canonicalize(system_root) + .ok() + .filter(|root| path.starts_with(root)) + .map(|_root| InstallationChannel::SystemPackage) } fn log_channel_conflict(ctx: &mut ActionContext, label: &str, fragment: &Path, executable: &Path) { diff --git a/crates/unixnotis-installer/src/actions/install/installer_lock.rs b/crates/unixnotis-installer/src/actions/install/installer_lock.rs new file mode 100644 index 000000000..c981835e8 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/installer_lock.rs @@ -0,0 +1,68 @@ +//! Process-wide installer action serialization + +use std::fs::{self, File}; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use rustix::fs::{flock, open, FlockOperation, Mode, OFlags}; +use rustix::process::geteuid; + +const INSTALLER_LOCK_FILE: &str = "unixnotis-installer.lock"; + +#[derive(Debug)] +pub struct InstallerLock { + // Retaining the descriptor retains the kernel lock for the complete action + _file: File, +} + +impl InstallerLock { + pub fn acquire_for_session() -> Result { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .ok_or_else(|| anyhow!("XDG_RUNTIME_DIR is required for installer serialization"))?; + let runtime_dir = fs::canonicalize(runtime_dir) + .context("resolve the session runtime directory for installer serialization")?; + let metadata = fs::metadata(&runtime_dir) + .context("inspect the session runtime directory for installer serialization")?; + if !owned_expected_object(metadata.is_dir(), metadata.uid(), geteuid().as_raw()) { + return Err(anyhow!( + "session runtime directory is not an owned directory" + )); + } + Self::acquire_at(&runtime_dir.join(INSTALLER_LOCK_FILE)) + } + + fn acquire_at(path: &Path) -> Result { + // NOFOLLOW prevents a lock-file link from redirecting the retained descriptor + let descriptor = open( + path, + OFlags::RDWR + .union(OFlags::CREATE) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::RUSR.union(Mode::WUSR), + ) + .context("open the installer action lock")?; + let file = File::from(descriptor); + let metadata = file + .metadata() + .context("inspect the installer action lock")?; + if !owned_expected_object(metadata.is_file(), metadata.uid(), geteuid().as_raw()) { + return Err(anyhow!( + "installer action lock is not an owned regular file" + )); + } + flock(&file, FlockOperation::NonBlockingLockExclusive) + .map_err(|error| anyhow!(error)) + .context("another UnixNotis installer action is already running")?; + Ok(Self { _file: file }) + } +} + +const fn owned_expected_object(expected_kind: bool, actual_uid: u32, effective_uid: u32) -> bool { + expected_kind && actual_uid == effective_uid +} + +#[cfg(test)] +#[path = "tests/installer_lock.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs index 088b13c66..2254223b6 100644 --- a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs @@ -19,7 +19,7 @@ fn service_start_mode(ctx: &ActionContext) -> ServiceStartMode { service_start_mode_from_enabled( ctx.install_state .as_ref() - .map(crate::actions::install_state::InstallState::service_enabled), + .map(|state| state.service_enabled), ) } diff --git a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs b/crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs similarity index 50% rename from crates/unixnotis-installer/src/actions/tests/installation_channel.rs rename to crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs index c89c3f358..15bbec361 100644 --- a/crates/unixnotis-installer/src/actions/tests/installation_channel.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs @@ -4,17 +4,19 @@ use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; use super::{ - active_unit_metadata, classify_installation_channel, installed_system_package_paths_at, - parse_active_unit_metadata, parse_exec_start_path, path_entry_exists, property_value, - reject_channel, reject_conflicting_installation_channel, ActiveUnitMetadata, - InstallationChannel, SYSTEM_BINARY_ROOT, SYSTEM_UNIT_ROOT, + active_unit_metadata, classify_installation_channel, classify_installation_channel_at, + installed_system_package_paths_at, parse_active_unit_metadata, parse_exec_start_path, + path_entry_exists, property_value, reject_channel, reject_classified_channel, + reject_conflicting_installation_channel, validate_systemctl_output, ActiveUnitMetadata, + InstallationChannel, MAX_SYSTEMCTL_OUTPUT_BYTES, SYSTEM_BINARY_ROOT, SYSTEM_UNIT_ROOT, }; use crate::actions::ActionContext; -use crate::app::events::UiMessage; +use crate::app::events::{UiMessage, WorkerEvent}; use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; +use std::os::unix::process::ExitStatusExt; struct TestHomeLayout { unit_root: PathBuf, @@ -48,10 +50,9 @@ fn test_context(root: &Path) -> (Detection, InstallPaths) { (detection, paths) } -fn action_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { +fn action_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { let (log_tx, _log_rx) = mpsc::sync_channel::(32); ActionContext { - detection, paths, install_state: None, log_tx, @@ -64,36 +65,100 @@ fn action_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> Acti #[test] fn matching_home_and_system_paths_select_one_installation_channel() { let home = test_home_layout("installation-channel-matching"); + let system = test_home_layout("installation-channel-matching-system"); + materialize_channel(&home); + materialize_channel(&system); assert_eq!( - classify_installation_channel( + classify_installation_channel_at( &home.unit_root.join("unixnotis-daemon.service"), &home.binary_root.join("unixnotis-daemon"), &home.unit_root, &home.binary_root, + &system.unit_root, + &system.binary_root, ), InstallationChannel::HomeLocal ); assert_eq!( - classify_installation_channel( - &Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"), - &Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"), + classify_installation_channel_at( + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), &home.unit_root, &home.binary_root, + &system.unit_root, + &system.binary_root, ), InstallationChannel::SystemPackage ); } +#[test] +fn one_existing_channel_does_not_require_the_other_policy_root_to_exist() { + let home = test_home_layout("installation-channel-one-root-home"); + let system = test_home_layout("installation-channel-one-root-system"); + materialize_channel(&system); + + assert_eq!( + classify_installation_channel_at( + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::SystemPackage + ); + + fs::remove_dir_all( + system + .unit_root + .ancestors() + .nth(4) + .expect("system fixture root"), + ) + .expect("remove system fixture"); + materialize_channel(&home); + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &home.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::HomeLocal + ); + fs::remove_dir_all( + home.unit_root + .ancestors() + .nth(4) + .expect("home fixture root"), + ) + .expect("remove home fixture"); +} + #[test] fn crossed_unit_and_binary_paths_are_always_mixed() { let home = test_home_layout("installation-channel-crossed"); + let system = test_home_layout("installation-channel-crossed-system"); + materialize_channel(&home); + materialize_channel(&system); let home_unit = home.unit_root.join("unixnotis-daemon.service"); let home_binary = home.binary_root.join("unixnotis-daemon"); - let system_unit = Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"); - let system_binary = Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"); + let system_unit = system.unit_root.join("unixnotis-daemon.service"); + let system_binary = system.binary_root.join("unixnotis-daemon"); for (unit, binary) in [(&home_unit, &system_binary), (&system_unit, &home_binary)] { assert_eq!( - classify_installation_channel(unit, binary, &home.unit_root, &home.binary_root,), + classify_installation_channel_at( + unit, + binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), InstallationChannel::Mixed ); } @@ -114,6 +179,109 @@ fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { ); } +#[test] +fn channel_classification_follows_cross_channel_symlink_targets() { + use std::os::unix::fs::symlink; + + let home = test_home_layout("installation-channel-link-home"); + let system = test_home_layout("installation-channel-link-system"); + materialize_channel(&home); + materialize_channel(&system); + let linked_binary = home.binary_root.join("linked-daemon"); + symlink(system.binary_root.join("unixnotis-daemon"), &linked_binary) + .expect("create home-to-system binary link"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &linked_binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Mixed + ); + + let linked_unit = system.unit_root.join("linked.service"); + symlink( + home.unit_root.join("unixnotis-daemon.service"), + &linked_unit, + ) + .expect("create system-to-home unit link"); + assert_eq!( + classify_installation_channel_at( + &linked_unit, + &system.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Mixed + ); +} + +#[test] +fn dangling_channel_link_is_unknown() { + use std::os::unix::fs::symlink; + + let home = test_home_layout("installation-channel-dangling-home"); + let system = test_home_layout("installation-channel-dangling-system"); + materialize_channel(&home); + materialize_channel(&system); + let dangling = home.binary_root.join("dangling-daemon"); + symlink(home.binary_root.join("missing-daemon"), &dangling).expect("create dangling link"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &dangling, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Unknown + ); +} + +#[test] +fn unrelated_object_under_the_local_prefix_is_not_a_managed_binary_channel() { + let home = test_home_layout("installation-channel-local-prefix-home"); + let system = test_home_layout("installation-channel-local-prefix-system"); + materialize_channel(&home); + materialize_channel(&system); + let local_root = home.binary_root.parent().expect("local root"); + let unrelated_root = local_root.join("share").join("unrelated"); + fs::create_dir_all(&unrelated_root).expect("create unrelated local directory"); + let unrelated_binary = unrelated_root.join("unixnotis-daemon"); + fs::write(&unrelated_binary, "unrelated binary").expect("write unrelated local binary"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &unrelated_binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Unknown + ); +} + +fn materialize_channel(layout: &TestHomeLayout) { + fs::create_dir_all(&layout.unit_root).expect("create channel unit root"); + fs::create_dir_all(&layout.binary_root).expect("create channel binary root"); + fs::write( + layout.unit_root.join("unixnotis-daemon.service"), + "[Service]\n", + ) + .expect("write channel unit"); + fs::write(layout.binary_root.join("unixnotis-daemon"), "binary").expect("write channel binary"); +} + #[test] fn systemd_exec_start_parser_reads_only_the_structured_path_field() { let home = test_home_layout("exec-start-parser"); @@ -284,6 +452,62 @@ fn systemctl_probe_returns_runtime_mask_metadata_without_dynamic_unit_paths() { fs::remove_dir_all(root).expect("remove fake systemctl fixture"); } +#[test] +fn systemctl_probe_failure_is_not_treated_as_an_absent_unit() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("systemctl-inspection-failure"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'user manager unavailable\\n' >&2\nexit 1\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = active_unit_metadata().expect_err("failed inspection must remain unknown"); + + assert!(error + .to_string() + .contains("failed to inspect UnixNotis systemd unit")); + assert!(error.to_string().contains("user manager unavailable")); + fs::remove_dir_all(root).expect("remove fake systemctl fixture"); +} + +#[test] +fn systemctl_metadata_budget_accepts_exact_stream_limits_and_rejects_oversize() { + assert_eq!(MAX_SYSTEMCTL_OUTPUT_BYTES, 32_768); + let output = |stdout: Vec, stderr: Vec, status| std::process::Output { + status: std::process::ExitStatus::from_raw(status), + stdout, + stderr, + }; + let exact = vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES]; + + assert_eq!( + validate_systemctl_output(output(exact.clone(), Vec::new(), 0)) + .expect("exact stdout budget") + .len(), + MAX_SYSTEMCTL_OUTPUT_BYTES + ); + let exact_stderr = validate_systemctl_output(output(Vec::new(), exact, 1)) + .expect_err("failed systemctl output remains an error"); + assert!(exact_stderr + .to_string() + .contains("failed to inspect UnixNotis systemd unit")); + assert!(validate_systemctl_output(output( + vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES + 1], + Vec::new(), + 0, + )) + .is_err()); + assert!(validate_systemctl_output(output( + Vec::new(), + vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES + 1], + 1, + )) + .is_err()); +} + #[test] fn installation_channel_guard_rejects_a_persistent_mask_through_the_real_action_boundary() { let _lock = crate::test_support::env::test_env_lock(); @@ -311,11 +535,20 @@ fn installation_channel_guard_rejects_a_persistent_mask_through_the_real_action_ #[test] fn conflict_dispatcher_rejects_system_package_paths() { let root = crate::test_support::fs::unique_temp_path("channel-dispatch-package"); - let (detection, paths) = test_context(&root); - let mut context = action_context(&detection, &paths); + let (_detection, paths) = test_context(&root); + let (log_tx, log_rx) = mpsc::sync_channel::(8); + let mut context = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; - let error = reject_channel( + let error = reject_classified_channel( &mut context, + InstallationChannel::SystemPackage, &Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"), &Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"), ) @@ -325,4 +558,68 @@ fn conflict_dispatcher_rejects_system_package_paths() { error.to_string(), "the system-package UnixNotis installation must be removed with its package manager before a home-local install" ); + let lines = log_rx + .try_iter() + .filter_map(|message| match message { + UiMessage::Worker(WorkerEvent::LogLine(line)) => Some(line), + _ => None, + }) + .collect::>(); + assert!(lines + .iter() + .any(|line| { line == "Error: system package UnixNotis installation channel" })); + assert!(lines.iter().any(|line| { + line == &format!( + "- unit: {}", + Path::new(SYSTEM_UNIT_ROOT) + .join("unixnotis-daemon.service") + .display() + ) + })); + assert!(lines.iter().any(|line| { + line == &format!( + "- executable: {}", + Path::new(SYSTEM_BINARY_ROOT) + .join("unixnotis-daemon") + .display() + ) + })); +} + +#[test] +fn resolved_channel_boundary_rejects_objects_outside_managed_roots() { + let root = crate::test_support::fs::unique_temp_path("channel-boundary-unknown"); + let home = test_home_layout("channel-boundary-unknown-home"); + let system = test_home_layout("channel-boundary-unknown-system"); + materialize_channel(&home); + materialize_channel(&system); + let (detection, paths) = test_context(&root); + let mut context = action_context(&detection, &paths); + + let error = reject_channel( + &mut context, + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), + ) + .expect_err("unrecognized resolved objects must reach the channel conflict policy"); + + assert!(error + .to_string() + .contains("unrecognized installation channel")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all( + home.unit_root + .ancestors() + .nth(4) + .expect("home fixture root"), + ) + .ok(); + fs::remove_dir_all( + system + .unit_root + .ancestors() + .nth(4) + .expect("system fixture root"), + ) + .ok(); } diff --git a/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs b/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs new file mode 100644 index 000000000..57f451730 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs @@ -0,0 +1,50 @@ +use std::os::unix::fs::symlink; + +use super::{owned_expected_object, InstallerLock}; + +#[test] +fn lock_ownership_requires_both_the_expected_shape_and_effective_user() { + assert!(owned_expected_object(true, 1_000, 1_000)); + assert!(!owned_expected_object(false, 1_000, 1_000)); + assert!(!owned_expected_object(true, 1_001, 1_000)); + assert!(!owned_expected_object(false, 1_001, 1_000)); +} + +#[test] +fn second_installer_cannot_acquire_the_same_action_lock() { + let root = test_root("contended"); + let lock_path = root.join("installer.lock"); + let first = InstallerLock::acquire_at(&lock_path).expect("first action lock"); + + let error = InstallerLock::acquire_at(&lock_path).expect_err("second action must be rejected"); + + assert!( + error + .to_string() + .contains("another UnixNotis installer action is already running"), + "unexpected contention error: {error:#}" + ); + drop(first); + InstallerLock::acquire_at(&lock_path).expect("released action lock"); + std::fs::remove_dir_all(root).expect("remove lock fixture"); +} + +#[test] +fn installer_lock_rejects_a_symlink_target() { + let root = test_root("symlink"); + let target = root.join("target"); + std::fs::write(&target, b"not a lock").expect("write symlink target"); + let lock_path = root.join("installer.lock"); + symlink(&target, &lock_path).expect("create lock symlink"); + + InstallerLock::acquire_at(&lock_path).expect_err("lock symlink must be rejected"); + + std::fs::remove_dir_all(root).expect("remove lock fixture"); +} + +fn test_root(label: &str) -> std::path::PathBuf { + let root = crate::test_support::fs::unique_temp_path(&format!("installer-lock-{label}")); + let _cleanup = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create lock fixture"); + root +} From 886b456c6df2d450d1dd31cf640d796eba8d95ab Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:28:25 -0500 Subject: [PATCH 264/275] fix(installer): guard daemon shutdown and service handoff Close the reinstall activation race across daemon shutdown, binary publication, and service preparation. - stop the selected daemon and wait for actual runtime quiescence - distinguish transient shutdown convergence from strict mutation checks - reserve both org.freedesktop.Notifications and com.unixnotis.Control - make the activation reservation a non-forgeable production capability - require the reservation for binary and service-artifact publication - retain the guard through manager refresh and pre-start preparation - recheck selected service state around destructive transaction boundaries - release activation exclusion only immediately before controlled startup - verify service readiness before committing the pending release - retain strict checks for direct service-manager starts - add reservation, quiescence, handoff, and reinstall-race tests --- .../daemon/state/tests/interaction_gates.rs | 44 ++ .../src/actions/build/tests/compile.rs | 3 +- .../src/actions/context.rs | 5 +- .../unixnotis-installer/src/actions/daemon.rs | 157 ------ .../src/actions/daemon/mod.rs | 17 + .../src/actions/daemon/name_reservation.rs | 100 ++++ .../src/actions/daemon/quiescence.rs | 127 +++++ .../src/actions/daemon/stop.rs | 215 ++++++++ .../actions/daemon/tests/name_reservation.rs | 97 ++++ .../src/actions/daemon/tests/quiescence.rs | 139 +++++ .../src/actions/daemon/tests/stop.rs | 249 +++++++++ .../src/actions/daemon/tests/support.rs | 67 +++ .../actions/environment/tests/shell_path.rs | 6 - .../src/actions/environment/tests/sync.rs | 6 - .../src/actions/format/tests/daemon_status.rs | 1 + .../actions/hyprland/tests/bootstrap_block.rs | 31 -- .../actions/hyprland/tests/symlink_safety.rs | 16 - .../src/actions/install/service/flow.rs | 158 +++++- .../src/actions/install/service/mod.rs | 9 +- .../src/actions/install/service/readiness.rs | 4 +- .../tests/service/backend_idempotence.rs | 3 +- .../tests/service/flow_failures/generation.rs | 233 ++++++++ .../tests/service/flow_failures/mod.rs | 1 + .../install/tests/service/flow_support.rs | 79 ++- .../install/tests/service/lifecycle.rs | 3 +- .../src/actions/install/tests/support.rs | 3 +- .../unixnotis-installer/src/actions/plan.rs | 26 +- .../unixnotis-installer/src/actions/state.rs | 8 +- .../src/actions/tests/daemon.rs | 225 -------- .../src/actions/tests/plan.rs | 63 ++- .../src/actions/tests/state.rs | 509 ++++++++++++++++-- crates/unixnotis-installer/src/detect.rs | 149 ++++- .../unixnotis-installer/src/tests/detect.rs | 203 ++++++- 33 files changed, 2439 insertions(+), 517 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs delete mode 100644 crates/unixnotis-installer/src/actions/daemon.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/mod.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/name_reservation.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/quiescence.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/stop.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/tests/stop.rs create mode 100644 crates/unixnotis-installer/src/actions/daemon/tests/support.rs create mode 100644 crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs delete mode 100644 crates/unixnotis-installer/src/actions/tests/daemon.rs diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs b/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs new file mode 100644 index 000000000..865243d4c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use super::{interaction_gate_index, InteractionGates, INTERACTION_GATE_SHARDS}; + +#[test] +fn every_protocol_id_maps_inside_the_fixed_interaction_shards() { + assert_eq!(interaction_gate_index(0), 0); + assert_eq!(interaction_gate_index(127), 127); + assert_eq!(interaction_gate_index(128), 0); + assert!(interaction_gate_index(u32::MAX) < INTERACTION_GATE_SHARDS); +} + +#[tokio::test] +async fn same_id_waits_for_the_existing_interaction_guard() { + let gates = Arc::new(InteractionGates::new()); + let first = gates.lock(42).await; + let waiting_gates = Arc::clone(&gates); + let waiting = tokio::spawn(async move { + let _second = waiting_gates.lock(42).await; + }); + + tokio::task::yield_now().await; + assert!( + !waiting.is_finished(), + "same-ID work must remain serialized" + ); + drop(first); + waiting.await.expect("waiting interaction task"); +} + +#[tokio::test] +async fn different_shards_can_progress_independently() { + let gates = Arc::new(InteractionGates::new()); + let _first = gates.lock(1).await; + let other_gates = Arc::clone(&gates); + let other = tokio::spawn(async move { + let _second = other_gates.lock(2).await; + }); + + tokio::time::timeout(std::time::Duration::from_millis(100), other) + .await + .expect("different shard should not wait") + .expect("different-shard interaction task"); +} diff --git a/crates/unixnotis-installer/src/actions/build/tests/compile.rs b/crates/unixnotis-installer/src/actions/build/tests/compile.rs index a3793d243..a8837da8a 100644 --- a/crates/unixnotis-installer/src/actions/build/tests/compile.rs +++ b/crates/unixnotis-installer/src/actions/build/tests/compile.rs @@ -75,10 +75,9 @@ fn run_build_rejects_release_archive_with_missing_bundled_binary() { let _ = fs::remove_dir_all(root); } -fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { +fn test_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { let (tx, _rx) = mpsc::sync_channel::(32); ActionContext { - detection, paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/context.rs b/crates/unixnotis-installer/src/actions/context.rs index 7986e40e0..502084ad3 100644 --- a/crates/unixnotis-installer/src/actions/context.rs +++ b/crates/unixnotis-installer/src/actions/context.rs @@ -5,15 +5,12 @@ use std::sync::atomic::AtomicBool; use std::sync::{mpsc::SyncSender, Arc}; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; -use super::install_state::InstallState; +use super::install::InstallState; pub struct ActionContext<'a> { - // Read-only compatibility snapshot collected before the action begins - pub detection: &'a Detection, // All filesystem and service-manager paths for the selected backend pub paths: &'a InstallPaths, // Cached install state keeps the progress view aligned with the selected action diff --git a/crates/unixnotis-installer/src/actions/daemon.rs b/crates/unixnotis-installer/src/actions/daemon.rs deleted file mode 100644 index 304e030b4..000000000 --- a/crates/unixnotis-installer/src/actions/daemon.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Stop and verify the currently running notification daemon - -use anyhow::{anyhow, Context, Result}; - -use super::{log_line, run_command, ActionContext}; -use crate::system_tools; - -mod process_handle; - -use process_handle::{ProcessHandle, ProcessState}; - -pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { - let Some(owner) = ctx.detection.owner.as_ref() else { - log_line(ctx, "No active notification daemon detected."); - return Ok(()); - }; - - let owner_pid = owner.pid; - let owner_comm = owner.comm.as_deref(); - // Prefer the bus-reported command name, but fall back to PID matching when comm is unavailable - let known = owner_comm - .and_then(|comm| { - ctx.detection - .daemons - .iter() - .find(|daemon| daemon.name == comm) - }) - .or_else(|| { - owner_pid.and_then(|pid| { - ctx.detection - .daemons - .iter() - .find(|daemon| daemon.running_pids.contains(&pid)) - }) - }); - - if let Some(daemon) = known { - if owner_comm.is_none() { - log_line( - ctx, - format!( - "Active owner detected without command name; matched pid to {}", - daemon.name - ), - ); - } - if daemon.systemd_active { - let is_unixnotis = daemon.name == "unixnotis-daemon"; - log_line(ctx, format!("Stopping systemd unit {}", daemon.unit)); - let (label, command) = if is_unixnotis { - // Reinstall can race with session hooks that start the daemon when the bus name drops - // The irreversible stop job keeps that start request from canceling the stop in flight - let spec = ctx.paths.service.stop_for_reinstall_command(); - (spec.label().to_string(), spec.to_command()?) - } else { - let mut command = system_tools::command("systemctl") - .context("failed to locate trusted systemctl")?; - command.args(["--user", "disable", "--now", daemon.unit.as_str()]); - ( - format!("systemctl --user disable --now {}", daemon.unit), - command, - ) - }; - if let Err(err) = run_command(ctx, &label, command, None) { - if is_systemd_unit_inactive(&daemon.unit)? { - // A canceled stop job can still leave the unit stopped, which satisfies reinstall - log_line( - ctx, - format!( - "Systemd unit {} is inactive after stop error; continuing.", - daemon.unit - ), - ); - return Ok(()); - } - return Err(err); - } - return Ok(()); - } - - if let Some(pid) = owner_pid { - log_line(ctx, format!("Stopping {} (pid {})", daemon.name, pid)); - // A stable process handle prevents a recycled PID from receiving the signal - let handle = match ProcessHandle::open(pid, &daemon.name)? { - ProcessState::Gone => { - log_line(ctx, format!("Process {pid} already stopped.")); - return Ok(()); - } - ProcessState::Running(handle) => handle, - }; - handle.terminate()?; - handle.wait_for_exit()?; - log_line(ctx, format!("Process {pid} stopped.")); - return Ok(()); - } - } - - unmanaged_owner_error(ctx, owner_comm, owner_pid) -} - -fn unmanaged_owner_error( - ctx: &mut ActionContext, - owner_comm: Option<&str>, - owner_pid: Option, -) -> Result<()> { - // Preserve the strongest broker identity available in the manual-stop instruction - let message = owner_comm.map_or_else( - || { - owner_pid.map_or_else( - || { - "Detected owner is not managed by a known unit; stop it manually before install." - .to_string() - }, - |pid| { - format!( - "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." - ) - }, - ) - }, - |comm| { - format!( - "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." - ) - }, - ); - log_line(ctx, message.clone()); - Err(anyhow!(message)) -} - -fn is_systemd_unit_inactive(unit: &str) -> Result { - // A failed stop command is only recoverable when systemd agrees the unit is no longer running - let output = system_tools::command("systemctl") - .context("failed to locate trusted systemctl")? - .args(["--user", "is-active", unit]) - .output() - .with_context(|| format!("failed to check systemd unit state for {unit}"))?; - let state = String::from_utf8_lossy(&output.stdout); - let state = state.trim(); - if state.is_empty() && !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!( - "failed to read systemd unit state for {unit}: {}", - stderr.trim() - )); - } - Ok(systemd_stop_error_is_satisfied_by_state(state)) -} - -fn systemd_stop_error_is_satisfied_by_state(state: &str) -> bool { - // Only known non-running states should turn a failed stop command into success - matches!(state.trim(), "inactive" | "failed" | "unknown") -} - -#[cfg(test)] -#[path = "tests/daemon.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/mod.rs b/crates/unixnotis-installer/src/actions/daemon/mod.rs new file mode 100644 index 000000000..20f0e7793 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/mod.rs @@ -0,0 +1,17 @@ +//! Notification-daemon lifecycle boundaries used by installer actions + +mod name_reservation; +mod process_handle; +mod quiescence; +mod stop; + +pub use name_reservation::DaemonActivationReservation; +pub use quiescence::{ + ensure_selected_service_inactive, wait_until_no_conflicting_live_daemon, + wait_until_selected_service_inactive, STOP_QUIESCENCE_TIMEOUT, +}; +pub use stop::stop_active_daemon; + +#[cfg(test)] +#[path = "tests/support.rs"] +mod test_support; diff --git a/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs b/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs new file mode 100644 index 000000000..4f21b16e1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs @@ -0,0 +1,100 @@ +//! Exclusive daemon-activation reservation for the release switch boundary + +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use zbus::fdo::{RequestNameFlags, RequestNameReply}; + +const RESERVATION_TIMEOUT: Duration = Duration::from_secs(2); + +pub struct DaemonActivationReservation { + backing: Box, +} + +trait ReservationBacking {} + +struct LiveReservationBacking { + // Keep the connection before the runtime so the names are released while + // the runtime that owns the connection is still alive + _connection: zbus::Connection, + _runtime: tokio::runtime::Runtime, +} + +impl ReservationBacking for LiveReservationBacking {} + +impl DaemonActivationReservation { + pub fn acquire() -> Result { + Self::acquire_names(&[ + unixnotis_core::NOTIFICATIONS_BUS_NAME, + unixnotis_core::CONTROL_BUS_NAME, + ]) + } + + fn acquire_names(names: &[&str]) -> Result { + if names.is_empty() { + bail!("daemon activation reservation requires at least one D-Bus name") + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create daemon-activation reservation runtime")?; + let address = format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ); + let connection = runtime.block_on(async { + let builder = zbus::connection::Builder::address(address.as_str()) + .context("prepare stable user-bus reservation connection")?; + let connection = tokio::time::timeout(RESERVATION_TIMEOUT, builder.build()) + .await + .context("daemon-activation reservation connection timed out")? + .context("connect to stable user bus for daemon-activation reservation")?; + for &name in names { + let reply = match tokio::time::timeout( + RESERVATION_TIMEOUT, + connection.request_name_with_flags(name, RequestNameFlags::DoNotQueue.into()), + ) + .await + .with_context(|| format!("D-Bus activation reservation for {name} timed out"))? + { + Ok(reply) => reply, + Err(zbus::Error::NameTaken) => { + bail!("D-Bus activation name {name} became owned before release activation") + } + Err(error) => { + return Err(error).with_context(|| { + format!("request D-Bus activation reservation for {name}") + }) + } + }; + match reply { + RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner => {} + RequestNameReply::InQueue | RequestNameReply::Exists => { + bail!("D-Bus activation name {name} became owned before release activation") + } + } + } + // Dropping this one connection releases every name if a later request failed + Ok(connection) + })?; + + Ok(Self { + backing: Box::new(LiveReservationBacking { + _connection: connection, + _runtime: runtime, + }), + }) + } +} + +impl Drop for DaemonActivationReservation { + fn drop(&mut self) { + // Keep the capability backing explicit while its boxed owner performs + // the normal connection-before-runtime drop sequence + let _ = &self.backing; + } +} + +#[cfg(test)] +#[path = "tests/name_reservation.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/quiescence.rs b/crates/unixnotis-installer/src/actions/daemon/quiescence.rs new file mode 100644 index 000000000..b821516ad --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/quiescence.rs @@ -0,0 +1,127 @@ +//! Bounded checks that prove the notification runtime is no longer active + +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; + +pub const STOP_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(5); +const STOP_QUIESCENCE_POLL_INTERVAL: Duration = Duration::from_millis(25); + +fn ensure_no_conflicting_live_daemon_until( + paths: &crate::paths::InstallPaths, + deadline: Instant, +) -> Result<()> { + // This check runs at the final generation-switch boundary, not from the UI snapshot + let owner = crate::detect::notification_owner_for_mutation_until(deadline) + .context("recheck notification ownership before binary activation")?; + if let Some(owner) = owner { + return Err(anyhow!( + "notification daemon appeared before binary activation (owner {owner}); retry installation" + )); + } + ensure_selected_service_inactive_until(paths, deadline) +} + +pub(in crate::actions) fn ensure_selected_service_inactive_until( + paths: &crate::paths::InstallPaths, + deadline: Instant, +) -> Result<()> { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "selected service probe deadline elapsed", + ) + .into()); + } + let state = paths + .service + .active_probe() + .evaluate_state_with_timeout(remaining) + .context("recheck selected service manager before binary activation")?; + match state { + crate::service_manager::contract::ServiceProbeState::Absent + | crate::service_manager::contract::ServiceProbeState::Inactive => Ok(()), + crate::service_manager::contract::ServiceProbeState::Active => Err(anyhow!( + "UnixNotis service became active again before binary activation" + )), + crate::service_manager::contract::ServiceProbeState::Unavailable => Err(anyhow!( + "selected service manager became unavailable before binary activation" + )), + crate::service_manager::contract::ServiceProbeState::Indeterminate => Err(anyhow!( + "selected service manager returned an indeterminate state before binary activation" + )), + } +} + +pub fn ensure_selected_service_inactive(paths: &crate::paths::InstallPaths) -> Result<()> { + let deadline = Instant::now() + .checked_add(crate::service_manager::contract::ServiceProbe::default_timeout()) + .ok_or_else(|| anyhow!("selected service check deadline exceeded the monotonic clock"))?; + ensure_selected_service_inactive_until(paths, deadline) +} + +pub fn wait_until_no_conflicting_live_daemon( + paths: &crate::paths::InstallPaths, + timeout: Duration, +) -> Result<()> { + wait_until_no_conflicting_live_daemon_with_probe( + timeout, + STOP_QUIESCENCE_POLL_INTERVAL, + |deadline| ensure_no_conflicting_live_daemon_until(paths, deadline), + ) +} + +pub fn wait_until_selected_service_inactive( + paths: &crate::paths::InstallPaths, + timeout: Duration, +) -> Result<()> { + // A held activation reservation makes broker ownership intentionally non-empty + wait_until_no_conflicting_live_daemon_with_probe( + timeout, + STOP_QUIESCENCE_POLL_INTERVAL, + |deadline| ensure_selected_service_inactive_until(paths, deadline), + ) +} + +fn wait_until_no_conflicting_live_daemon_with_probe( + timeout: Duration, + poll_interval: Duration, + mut probe: F, +) -> Result<()> +where + F: FnMut(Instant) -> Result<()>, +{ + let deadline = Instant::now() + .checked_add(timeout) + .ok_or_else(|| anyhow!("daemon quiescence deadline exceeded the monotonic clock"))?; + let poll_interval = poll_interval.max(Duration::from_millis(1)); + let max_attempts = timeout + .as_nanos() + .checked_div(poll_interval.as_nanos()) + .unwrap_or(0) + .saturating_add(1); + let max_attempts = usize::try_from(max_attempts).unwrap_or(usize::MAX); + let mut last_error = None; + for _attempt in 0..max_attempts { + match probe(deadline) { + Ok(()) => return Ok(()), + Err(error) => { + let now = Instant::now(); + last_error = Some(error); + let remaining = deadline.saturating_duration_since(now); + if remaining.is_zero() { + break; + } + // Bounded polling handles service-manager success before broker ownership disappears + std::thread::sleep(poll_interval.min(remaining)); + } + } + } + let error = last_error.ok_or_else(|| anyhow!("daemon quiescence probe did not run"))?; + Err(error).context("notification runtime did not become quiescent before rollback deadline") +} + +#[cfg(test)] +#[path = "tests/quiescence.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/stop.rs b/crates/unixnotis-installer/src/actions/daemon/stop.rs new file mode 100644 index 000000000..0e9266d96 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/stop.rs @@ -0,0 +1,215 @@ +//! Exact-owner shutdown for notification daemons discovered during installation + +use anyhow::{anyhow, Context, Result}; + +use super::process_handle::{ProcessHandle, ProcessState}; +use super::quiescence::{wait_until_no_conflicting_live_daemon, STOP_QUIESCENCE_TIMEOUT}; +use crate::actions::{log_line, run_command, ActionContext}; +use crate::system_tools; + +pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { + let detection = crate::detect::detect_for_mutation() + .context("refresh notification ownership immediately before stopping the daemon")?; + if let Some(expected_unique_name) = detection + .owner + .as_ref() + .and_then(|owner| owner.unique_name.as_deref()) + { + // Process metadata has authority only while the inspected broker owner remains current + crate::detect::ensure_owner_is_current(expected_unique_name) + .context("revalidate notification ownership immediately before stopping the daemon")?; + } + // A successful manager command is only the start of shutdown; broker and + // service state must converge before the next installer step is marked done + stop_active_daemon_with_quiescence(ctx, &detection, |paths| { + wait_until_no_conflicting_live_daemon(paths, STOP_QUIESCENCE_TIMEOUT) + }) +} + +fn stop_active_daemon_with_quiescence( + ctx: &mut ActionContext, + detection: &crate::detect::Detection, + wait_for_quiescence: Q, +) -> Result<()> +where + Q: FnOnce(&crate::paths::InstallPaths) -> Result<()>, +{ + let stop_result = stop_active_daemon_with_detection(ctx, detection); + let quiescence_result = wait_for_quiescence(ctx.paths); + + match (stop_result, quiescence_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(stop_error), Ok(())) => { + // Runtime truth wins when a service-manager command reports a stale failure + log_line( + ctx, + format!( + "Warning: stop command failed after runtime became quiescent ({stop_error:#})" + ), + ); + Ok(()) + } + (Ok(()), Err(state_error)) => Err(state_error).context( + "service manager reported a successful stop but notification runtime remains live", + ), + (Err(stop_error), Err(state_error)) => Err(state_error).context(format!( + "failed to stop notification daemon ({stop_error:#}); runtime remains live or indeterminate" + )), + } +} + +fn stop_active_daemon_with_detection( + ctx: &mut ActionContext, + detection: &crate::detect::Detection, +) -> Result<()> { + let Some(owner) = detection.owner.as_ref() else { + log_line(ctx, "No active notification daemon detected."); + return Ok(()); + }; + + let owner_pid = owner.pid; + let owner_comm = owner.comm.as_deref(); + // Prefer the bus-reported command name, but fall back to PID matching when comm is unavailable + let known = owner_comm + .and_then(|comm| detection.daemons.iter().find(|daemon| daemon.name == comm)) + .or_else(|| { + owner_pid.and_then(|pid| { + detection + .daemons + .iter() + .find(|daemon| daemon.running_pids.contains(&pid)) + }) + }); + + if let Some(daemon) = known { + if owner_comm.is_none() { + log_line( + ctx, + format!( + "Active owner detected without command name; matched pid to {}", + daemon.name + ), + ); + } + if daemon.systemd_active { + return stop_systemd_daemon(ctx, daemon); + } + + if let Some(pid) = owner_pid { + return stop_process_daemon(ctx, &daemon.name, pid); + } + } + + unmanaged_owner_error(ctx, owner_comm, owner_pid) +} + +fn stop_systemd_daemon( + ctx: &mut ActionContext, + daemon: &crate::detect::DetectedDaemon, +) -> Result<()> { + let is_unixnotis = daemon.name == "unixnotis-daemon"; + log_line(ctx, format!("Stopping systemd unit {}", daemon.unit)); + let (label, command) = if is_unixnotis { + // Reinstall can race with session hooks that start the daemon when the bus name drops + // The irreversible stop job keeps that start request from canceling the stop in flight + let spec = ctx.paths.service.stop_for_reinstall_command(); + (spec.label().to_string(), spec.to_command()?) + } else { + let mut command = + system_tools::command("systemctl").context("failed to locate trusted systemctl")?; + command.args(["--user", "disable", "--now", daemon.unit.as_str()]); + ( + format!("systemctl --user disable --now {}", daemon.unit), + command, + ) + }; + if let Err(error) = run_command(ctx, &label, command, None) { + if is_systemd_unit_inactive(&daemon.unit)? { + // A canceled stop job can still leave the unit stopped, which satisfies reinstall + log_line( + ctx, + format!( + "Systemd unit {} is inactive after stop error; continuing.", + daemon.unit + ), + ); + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +fn stop_process_daemon(ctx: &mut ActionContext, daemon_name: &str, pid: u32) -> Result<()> { + log_line(ctx, format!("Stopping {daemon_name} (pid {pid})")); + // A stable process handle prevents a recycled PID from receiving the signal + let handle = match ProcessHandle::open(pid, daemon_name)? { + ProcessState::Gone => { + log_line(ctx, format!("Process {pid} already stopped.")); + return Ok(()); + } + ProcessState::Running(handle) => handle, + }; + handle.terminate()?; + handle.wait_for_exit()?; + log_line(ctx, format!("Process {pid} stopped.")); + Ok(()) +} + +fn unmanaged_owner_error( + ctx: &mut ActionContext, + owner_comm: Option<&str>, + owner_pid: Option, +) -> Result<()> { + // Preserve the strongest broker identity available in the manual-stop instruction + let message = owner_comm.map_or_else( + || { + owner_pid.map_or_else( + || { + "Detected owner is not managed by a known unit; stop it manually before install." + .to_string() + }, + |pid| { + format!( + "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." + ) + }, + ) + }, + |comm| { + format!( + "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." + ) + }, + ); + log_line(ctx, message.clone()); + Err(anyhow!(message)) +} + +fn is_systemd_unit_inactive(unit: &str) -> Result { + // A failed stop command is only recoverable when systemd agrees the unit is no longer running + let output = system_tools::command("systemctl") + .context("failed to locate trusted systemctl")? + .args(["--user", "is-active", unit]) + .output() + .with_context(|| format!("failed to check systemd unit state for {unit}"))?; + let state = String::from_utf8_lossy(&output.stdout); + let state = state.trim(); + if state.is_empty() && !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "failed to read systemd unit state for {unit}: {}", + stderr.trim() + )); + } + Ok(systemd_stop_error_is_satisfied_by_state(state)) +} + +fn systemd_stop_error_is_satisfied_by_state(state: &str) -> bool { + // Only known non-running states should turn a failed stop command into success + matches!(state.trim(), "inactive" | "failed" | "unknown") +} + +#[cfg(test)] +#[path = "tests/stop.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs b/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs new file mode 100644 index 000000000..789928bed --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs @@ -0,0 +1,97 @@ +use super::DaemonActivationReservation; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +struct TestReservationBacking { + observer: Arc, +} + +impl super::ReservationBacking for TestReservationBacking {} + +impl Drop for TestReservationBacking { + fn drop(&mut self) { + self.observer.store(false, Ordering::Release); + } +} + +impl DaemonActivationReservation { + pub(crate) fn test_guard(observer: Arc) -> Self { + observer.store(true, Ordering::Release); + Self { + backing: Box::new(TestReservationBacking { observer }), + } + } +} + +fn acquire_name(name: &str) -> anyhow::Result { + DaemonActivationReservation::acquire_names(&[name]) +} + +#[test] +fn reservation_excludes_another_connection_until_the_guard_drops() { + let name = format!( + "io.github.unixnotis.InstallerReservation{}", + std::process::id() + ); + let first = + acquire_name(&name).expect("first connection should reserve the isolated test name"); + + let error = match acquire_name(&name) { + Ok(_unexpected) => panic!("a second connection acquired the reserved test name"), + Err(error) => error, + }; + + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected competing reservation error: {error:#}" + ); + drop(first); + acquire_name(&name).expect("the name should become available after the guard drops"); +} + +#[test] +fn reservation_blocks_both_activation_names_until_the_guard_drops() { + let suffix = std::process::id(); + let notifications = format!("io.github.unixnotis.InstallerNotificationsReservation{suffix}"); + let control = format!("io.github.unixnotis.InstallerControlReservation{suffix}"); + let first = DaemonActivationReservation::acquire_names(&[¬ifications, &control]) + .expect("one connection should reserve both activation names"); + + for name in [¬ifications, &control] { + let error = match acquire_name(name) { + Ok(_unexpected) => { + panic!("a second connection acquired a reserved activation name") + } + Err(error) => error, + }; + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected competing reservation error: {error:#}" + ); + } + + drop(first); + DaemonActivationReservation::acquire_names(&[¬ifications, &control]) + .expect("both names should become available after the guard drops"); +} + +#[test] +fn failed_second_name_request_releases_the_first_name() { + let suffix = std::process::id(); + let occupied = format!("io.github.unixnotis.OccupiedReservation{suffix}"); + let released = format!("io.github.unixnotis.ReleasedReservation{suffix}"); + let owner = + acquire_name(&occupied).expect("the competing connection should reserve the second name"); + + let error = match DaemonActivationReservation::acquire_names(&[&released, &occupied]) { + Ok(_unexpected) => panic!("a reservation succeeded after its second name was taken"), + Err(error) => error, + }; + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected partial reservation error: {error:#}" + ); + + acquire_name(&released).expect("the first name must be released when the second request fails"); + drop(owner); +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs b/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs new file mode 100644 index 000000000..3ed4a545c --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs @@ -0,0 +1,139 @@ +use crate::test_support::fs::write_executable; + +use super::super::test_support::{fake_daemon_tool_root, test_install_paths}; +use super::{ + ensure_selected_service_inactive_until, wait_until_no_conflicting_live_daemon, + wait_until_no_conflicting_live_daemon_with_probe, +}; + +fn one_shot_live_daemon_check(paths: &crate::paths::InstallPaths) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + .checked_add(crate::service_manager::contract::ServiceProbe::default_timeout()) + .ok_or_else(|| anyhow::anyhow!("daemon check deadline exceeded the monotonic clock"))?; + super::ensure_no_conflicting_live_daemon_until(paths, deadline) +} + +#[test] +fn daemon_quiescence_wait_retries_until_broker_and_manager_are_inactive() { + let attempts = std::cell::Cell::new(0usize); + + wait_until_no_conflicting_live_daemon_with_probe( + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + |_deadline| { + let attempt = attempts.get(); + attempts.set(attempt.saturating_add(1)); + if attempt < 2 { + Err(anyhow::anyhow!("runtime still live")) + } else { + Ok(()) + } + }, + ) + .expect("runtime should become quiescent after bounded retries"); + + assert_eq!(attempts.get(), 3); +} + +#[test] +fn daemon_quiescence_wait_preserves_indeterminate_state_at_timeout() { + let attempts = std::cell::Cell::new(0usize); + + let error = wait_until_no_conflicting_live_daemon_with_probe( + std::time::Duration::ZERO, + std::time::Duration::ZERO, + |_deadline| { + attempts.set(attempts.get().saturating_add(1)); + Err(anyhow::anyhow!("broker inspection failed")) + }, + ) + .expect_err("indeterminate runtime state must fail closed at the deadline"); + + assert_eq!(attempts.get(), 1); + assert!(error + .to_string() + .contains("notification runtime did not become quiescent")); +} + +#[test] +fn production_quiescence_wait_rejects_an_elapsed_deadline() { + let paths = test_install_paths(); + + wait_until_no_conflicting_live_daemon(&paths, std::time::Duration::ZERO) + .expect_err("an elapsed production deadline must fail closed"); +} + +#[test] +fn selected_service_recheck_rejects_an_active_manager() { + let root = fake_daemon_tool_root("active-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=loaded\\nActiveState=active\\n'\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + let error = ensure_selected_service_inactive_until(&paths, deadline) + .expect_err("an active selected service must block activation"); + + assert!(error.to_string().contains("became active again")); + std::fs::remove_dir_all(root).expect("remove active service fixture"); +} + +#[test] +fn selected_service_recheck_rejects_an_operational_probe_failure() { + let root = fake_daemon_tool_root("indeterminate-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'Failed to connect to bus\\n' >&2\nexit 1\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + let error = ensure_selected_service_inactive_until(&paths, deadline) + .expect_err("an indeterminate selected service state must block activation"); + + assert!(error.to_string().contains("indeterminate state")); + std::fs::remove_dir_all(root).expect("remove indeterminate service fixture"); +} + +#[test] +fn selected_service_recheck_accepts_an_absent_unit() { + let root = fake_daemon_tool_root("absent-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=not-found\nActiveState=inactive\n'\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + ensure_selected_service_inactive_until(&paths, deadline) + .expect("an absent selected service is safe before first binary activation"); + + std::fs::remove_dir_all(root).expect("remove absent service fixture"); +} + +#[test] +fn generation_precommit_rejects_a_daemon_that_reappeared_after_stop() { + let root = fake_daemon_tool_root("fresh-owner-before-commit"); + write_executable( + &root.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.100\"\\n' ;; *'status :1.100'*) printf 'Comm=unixnotis-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let paths = test_install_paths(); + + let error = one_shot_live_daemon_check(&paths) + .expect_err("a daemon appearing before activation must block the generation switch"); + + assert!( + error + .to_string() + .contains("notification daemon appeared before binary activation"), + "unexpected precommit owner error: {error:#}" + ); + std::fs::remove_dir_all(root).expect("remove precommit owner fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs b/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs new file mode 100644 index 000000000..8ada6afab --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs @@ -0,0 +1,249 @@ +use std::process::{Command, Stdio}; +use std::sync::mpsc; + +use crate::app::events::UiMessage; +use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; +use crate::test_support::fs::write_executable; + +use super::super::test_support::{ + action_context, fake_daemon_tool_root, known_daemon_detection, test_install_paths, +}; +use super::{ + is_systemd_unit_inactive, stop_active_daemon, stop_active_daemon_with_detection, + stop_active_daemon_with_quiescence, systemd_stop_error_is_satisfied_by_state, +}; + +#[test] +fn stop_active_daemon_errors_for_unmanaged_owner() { + let detection = Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: None, + comm: Some("unknown-daemon".to_string()), + }), + daemons: Vec::new(), + }; + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(4); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon_with_detection(&mut context, &detection) + .expect_err("unmanaged owner must block install"); + + assert!(error.to_string().contains("not managed by a known unit")); +} + +#[test] +fn stop_active_daemon_refreshes_ownership_after_the_initial_snapshot() { + let root = fake_daemon_tool_root("fresh-owner-before-stop"); + write_executable( + &root.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.99\"\\n' ;; *'status :1.99'*) printf 'Comm=appeared-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon(&mut context) + .expect_err("a daemon appearing after initial detection must block install"); + + assert!( + error.to_string().contains("not managed by a known unit"), + "unexpected fresh-owner error: {error:#}" + ); + std::fs::remove_dir_all(root).expect("remove fresh owner fixture"); +} + +#[test] +fn stop_active_daemon_terminates_the_exact_non_systemd_owner() { + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep daemon"); + wait_for_child_program(&mut child, "sleep"); + let detection = Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: Some(child.id()), + comm: Some("sleep".to_string()), + }), + daemons: vec![DetectedDaemon { + name: "sleep".to_string(), + unit: "sleep.service".to_string(), + systemd_active: false, + systemd_error: None, + running_pids: vec![child.id()], + is_owner: true, + }], + }; + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_detection(&mut context, &detection) + .expect("stable process stop should succeed"); + + let status = wait_for_child_exit(&mut child); + assert!(!status.success()); +} + +#[test] +fn stop_active_daemon_stops_unixnotis_without_disabling_its_unit() { + let root = fake_daemon_tool_root("unixnotis-reinstall-stop"); + let calls = root.join("systemctl-calls"); + write_executable( + &root.join("systemctl"), + &format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\n", calls.display()), + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_detection(&mut context, &detection) + .expect("reinstall stop should succeed"); + + let calls = std::fs::read_to_string(&calls).expect("systemctl calls"); + assert_eq!(calls.trim(), "--user stop unixnotis-daemon.service"); + let _cleanup = std::fs::remove_dir_all(root); +} + +#[test] +fn stop_does_not_report_success_when_quiescence_check_still_fails() { + let root = fake_daemon_tool_root("stop-quiescence-required"); + write_executable(&root.join("systemctl"), "#!/bin/sh\nexit 0\n"); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon_with_quiescence(&mut context, &detection, |_paths| { + Err(anyhow::anyhow!("runtime is still live")) + }) + .expect_err("successful stop command must not bypass a live runtime"); + + assert!(format!("{error:#}").contains("runtime is still live")); + std::fs::remove_dir_all(root).expect("remove stop quiescence fixture"); +} + +#[test] +fn stop_command_failure_is_accepted_only_after_runtime_quiescence() { + let root = fake_daemon_tool_root("stop-command-stale-failure"); + write_executable(&root.join("systemctl"), "#!/bin/sh\nexit 1\n"); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_quiescence(&mut context, &detection, |_paths| Ok(())) + .expect("a stale stop failure is safe after runtime quiescence"); + + std::fs::remove_dir_all(root).expect("remove stale stop fixture"); +} + +#[test] +fn systemd_stop_error_can_continue_when_unit_is_inactive() { + // A failed stop is acceptable only when systemd reports a non-running state + assert!(systemd_stop_error_is_satisfied_by_state("inactive")); +} + +#[test] +fn systemd_stop_error_can_continue_when_unit_is_failed() { + // Failed units no longer own the notification bus, so reinstall may continue + assert!(systemd_stop_error_is_satisfied_by_state("failed")); +} + +#[test] +fn systemd_stop_error_still_fails_when_unit_stays_active() { + assert!(!systemd_stop_error_is_satisfied_by_state("active")); +} + +#[test] +fn systemd_stop_error_still_fails_when_unit_is_transitioning() { + assert!(!systemd_stop_error_is_satisfied_by_state("deactivating")); +} + +#[test] +fn systemd_stop_error_still_fails_when_state_is_empty() { + // Empty output means the manager did not provide enough proof that stopping succeeded + assert!(!systemd_stop_error_is_satisfied_by_state("")); +} + +#[test] +fn systemd_stop_error_trims_state_output_before_matching() { + // systemctl prints a trailing newline in normal output + assert!(systemd_stop_error_is_satisfied_by_state(" inactive\n")); + assert!(systemd_stop_error_is_satisfied_by_state("\tunknown ")); +} + +#[test] +fn systemd_stop_error_rejects_unrecognized_non_running_words() { + // Only explicit systemd states should satisfy a failed stop + assert!(!systemd_stop_error_is_satisfied_by_state("dead")); + assert!(!systemd_stop_error_is_satisfied_by_state("stopped")); +} + +#[test] +fn is_systemd_unit_inactive_reads_trusted_systemctl_state() { + let _lock = crate::test_support::env::test_env_lock(); + let root = fake_daemon_tool_root("systemctl-state"); + let fake_bin = root.join("bin"); + std::fs::create_dir_all(&fake_bin).expect("fake bin"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$3\" in inactive.service) echo inactive; exit 3 ;; active.service) echo active; exit 0 ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert!(is_systemd_unit_inactive("inactive.service").expect("inactive state")); + assert!(!is_systemd_unit_inactive("active.service").expect("active state")); + let error = + is_systemd_unit_inactive("missing.service").expect_err("empty failed status is an error"); + assert!(error + .to_string() + .contains("failed to read systemd unit state")); + + let _cleanup = std::fs::remove_dir_all(root); +} + +fn wait_for_child_exit(child: &mut std::process::Child) -> std::process::ExitStatus { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().expect("inspect stopped sleep daemon") { + return status; + } + if std::time::Instant::now() >= deadline { + let _kill = child.kill(); + let _reaped = child.wait(); + panic!("daemon stop did not terminate the expected process before deadline"); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +fn wait_for_child_program(child: &mut std::process::Child, expected: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if crate::detect::read_cmdline_program(child.id()).as_deref() == Some(expected) { + return; + } + if std::time::Instant::now() >= deadline { + let _kill = child.kill(); + let _reaped = child.wait(); + panic!("child did not enter expected program {expected} before deadline"); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/support.rs b/crates/unixnotis-installer/src/actions/daemon/tests/support.rs new file mode 100644 index 000000000..6a346deff --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/support.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +pub(super) fn known_daemon_detection( + name: &str, + systemd_active: bool, + running_pids: Vec, +) -> Detection { + Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: Some(42), + comm: Some(name.to_string()), + }), + daemons: vec![DetectedDaemon { + name: name.to_string(), + unit: format!("{name}.service"), + systemd_active, + systemd_error: None, + running_pids, + is_owner: true, + }], + } +} + +pub(super) fn test_install_paths() -> InstallPaths { + InstallPaths { + repo_root: std::env::temp_dir(), + bin_dir: std::env::temp_dir(), + service: ServiceManager::systemd_user(std::env::temp_dir()), + } +} + +pub(super) fn action_context( + paths: &InstallPaths, + log_tx: mpsc::SyncSender, +) -> ActionContext<'_> { + ActionContext { + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +pub(super) fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-daemon-{label}-{}-{stamp}", + std::process::id() + )); + let _cleanup = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("fake daemon tool bin"); + root +} diff --git a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs index f57ccbfdb..a79cba947 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs @@ -12,7 +12,6 @@ use super::super::shell_path::{ }; use crate::actions::ActionContext; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -280,17 +279,12 @@ fn remove_shell_path_entry_removes_managed_block_from_selected_startup_files() { let _home = EnvGuard::set("HOME", &home); let _shell = EnvGuard::set("SHELL", "/bin/bash"); let (tx, rx) = mpsc::sync_channel::(16); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir, service: ServiceManager::systemd_user(home.join(".config/systemd/user")), }; let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/environment/tests/sync.rs b/crates/unixnotis-installer/src/actions/environment/tests/sync.rs index b769f7da9..6e32a7a73 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/sync.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/sync.rs @@ -4,7 +4,6 @@ use std::sync::{mpsc, Arc}; use crate::actions::{run_command_without_stdout, ActionContext}; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -14,17 +13,12 @@ fn env_sync_command_stdout_is_not_copied_into_logs() { // Command lookup reads PATH, so it must not race tests that replace process env let _lock = crate::test_support::env::test_env_lock(); let (tx, rx) = mpsc::sync_channel::(16); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: std::env::temp_dir(), bin_dir: std::env::temp_dir().join("bin"), service: ServiceManager::systemd_user(std::env::temp_dir().join("systemd")), }; let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs index 8882b02db..93981f9f9 100644 --- a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs +++ b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs @@ -5,6 +5,7 @@ use crate::detect::{DetectedDaemon, OwnerInfo}; fn summarize_owner_includes_comm_and_pid() { // Verifies formatted owner output includes both fields when available. let owner = OwnerInfo { + unique_name: None, pid: Some(4242), comm: Some("unixnotis-daemon".to_string()), }; diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs index 8f86fdf0b..c8875cfaf 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs @@ -2,7 +2,6 @@ use super::super::block::{ strip_hyprland_bootstrap_block, HYPR_BOOTSTRAP_END, HYPR_BOOTSTRAP_START, }; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use std::path::Path; @@ -13,14 +12,9 @@ use std::sync::{mpsc, Arc}; fn strip_hyprland_bootstrap_block_handles_malformed_block() { let _lock = crate::test_support::env::test_env_lock(); // Confirms malformed markers leave the original content intact for safe append - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -39,14 +33,9 @@ fn strip_hyprland_bootstrap_block_handles_malformed_block() { fn strip_hyprland_bootstrap_block_removes_managed_block() { let _lock = crate::test_support::env::test_env_lock(); // Ensures a well-formed block is removed and the remaining content is preserved - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -65,14 +54,9 @@ fn strip_hyprland_bootstrap_block_removes_managed_block() { #[test] fn strip_hyprland_bootstrap_block_removes_all_blocks() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -92,14 +76,9 @@ fn strip_hyprland_bootstrap_block_removes_all_blocks() { #[test] fn strip_hyprland_bootstrap_block_removes_comment_prefixes_without_residue() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -121,14 +100,9 @@ fn strip_hyprland_bootstrap_block_removes_comment_prefixes_without_residue() { #[test] fn strip_hyprland_bootstrap_block_matches_exact_hyprlang_marker_comments() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -150,14 +124,9 @@ fn strip_hyprland_bootstrap_block_matches_exact_hyprlang_marker_comments() { #[test] fn strip_hyprland_bootstrap_block_ignores_marker_text_inside_lua_strings() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs index 4e71b85bc..ed677f95a 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs @@ -2,7 +2,6 @@ use super::super::block::{HYPR_BOOTSTRAP_END, HYPR_BOOTSTRAP_START}; use super::super::{ensure_hyprland_autostart, remove_hyprland_autostart}; use crate::actions::ActionContext; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -28,10 +27,6 @@ fn hyprland_autostart_supports_config_symlink_to_regular_file_inside_home() { symlink(&target, &config_link).expect("config symlink"); let _home = EnvGuard::set("HOME", &home); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -39,7 +34,6 @@ fn hyprland_autostart_supports_config_symlink_to_regular_file_inside_home() { }; let (tx, rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -85,10 +79,6 @@ fn ensure_hyprland_autostart_rejects_config_symlink_outside_home() { symlink(&outside, &config_link).expect("config symlink"); let _home = EnvGuard::set("HOME", &home); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -96,7 +86,6 @@ fn ensure_hyprland_autostart_rejects_config_symlink_outside_home() { }; let (tx, rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -134,10 +123,6 @@ fn remove_hyprland_autostart_strips_managed_block_from_real_config() { ) .expect("hypr config"); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -145,7 +130,6 @@ fn remove_hyprland_autostart_strips_managed_block_from_real_config() { }; let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/install/service/flow.rs b/crates/unixnotis-installer/src/actions/install/service/flow.rs index 5c3394453..17c212d3f 100644 --- a/crates/unixnotis-installer/src/actions/install/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/service/flow.rs @@ -4,6 +4,7 @@ use std::sync::atomic::Ordering; use anyhow::{Context, Result}; +use crate::actions::DaemonActivationReservation; use crate::paths::format_with_home; use super::super::super::{ @@ -19,10 +20,21 @@ use super::artifacts::{ use super::lifecycle::{ remove_pre_start_artifacts, run_command_spec, run_service_start, warn_pre_start_artifacts_left, }; -use super::readiness::enforce_service_readiness; use super::refresh::refresh_service_artifacts; -pub fn install_service(ctx: &mut ActionContext) -> Result<()> { +pub(in crate::actions::install) fn install_service(ctx: &mut ActionContext) -> Result<()> { + install_service_impl(ctx) +} + +pub fn install_service_under_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + // The reservation is held by the worker while service artifacts are replaced + install_service(ctx) +} + +fn install_service_impl(ctx: &mut ActionContext) -> Result<()> { match write_service_artifacts(ctx)? { ServiceArtifactWrite::CreatedOrUpdated => { log_line( @@ -45,17 +57,7 @@ pub fn install_service(ctx: &mut ActionContext) -> Result<()> { Ok(()) } -pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { - enable_service_with_readiness(ctx, enforce_service_readiness) -} - -pub(in crate::actions::install) fn enable_service_with_readiness( - ctx: &mut ActionContext, - readiness: F, -) -> Result<()> -where - F: FnOnce(&mut ActionContext) -> Result<()>, -{ +pub(in crate::actions) fn prepare_service_start(ctx: &mut ActionContext) -> Result<()> { if ctx.service_reload_required.load(Ordering::Acquire) { // Refresh work can be a single reload command or a backend-owned database update refresh_service_artifacts(ctx)?; @@ -76,6 +78,21 @@ where return Err(err); } remove_pre_start_artifacts(ctx)?; + Ok(()) +} + +pub fn prepare_service_start_under_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + // Manager refresh and pre-start cleanup remain inside the activation exclusion + prepare_service_start(ctx) +} + +pub fn start_service_and_verify(ctx: &mut ActionContext, readiness: F) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ run_service_start(ctx)?; readiness(ctx)?; @@ -92,6 +109,121 @@ where Ok(()) } +pub fn rollback_failed_activation( + ctx: &mut ActionContext, + readiness: &F, + activation_error: anyhow::Error, +) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ + rollback_failed_activation_with_quiescence(ctx, readiness, activation_error, |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }) +} + +pub(in crate::actions::install) fn rollback_failed_activation_with_quiescence( + ctx: &mut ActionContext, + readiness: &F, + activation_error: anyhow::Error, + mut wait_for_quiescence: Q, +) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, + Q: FnMut(&crate::paths::InstallPaths) -> Result<()>, +{ + if !crate::actions::releases::pending_release_exists(ctx.paths)? { + return Err(activation_error); + } + let restart_previous = + crate::actions::releases::pending_release_has_runtime_rollback(ctx.paths)?; + // Disk generation must not move backward while the failed new daemon is still live + let stop = ctx.paths.service.stop_for_reinstall_command(); + let stop_result = run_command_spec(ctx, &stop); + let quiescence_result = wait_for_quiescence(ctx.paths); + match (stop_result, quiescence_result) { + (Ok(()), Ok(())) => {} + (Err(stop_error), Ok(())) => { + // Live state is authoritative when the manager command reports a stale failure + log_line( + ctx, + format!( + "Warning: rejected release stop command failed after runtime became quiescent ({stop_error:#})" + ), + ); + } + (Ok(()), Err(state_error)) => { + return Err(activation_error.context(format!( + "service manager reported a successful stop but the rejected runtime remains live: {state_error:#}" + ))); + } + (Err(stop_error), Err(state_error)) => { + return Err(activation_error.context(format!( + "failed to stop the rejected release before rollback: {stop_error:#}; runtime remains live or indeterminate: {state_error:#}" + ))); + } + } + // Current may move backward only after both broker and manager state prove quiescence + crate::actions::releases::rollback_pending_release(ctx.paths) + .context("roll back rejected binary release generation")?; + if restart_previous { + run_service_start(ctx).context("restart previous release generation")?; + readiness(ctx).context("previous release did not recover after rollback")?; + } + Err(activation_error) +} + +pub fn rollback_pending_under_activation_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result { + let restart_previous = + crate::actions::releases::pending_release_has_runtime_rollback(ctx.paths)?; + // Direct service-manager starts remain possible while the D-Bus names are reserved + let stop = ctx.paths.service.stop_for_reinstall_command(); + let stop_result = run_command_spec(ctx, &stop); + let service_quiescence_result = crate::actions::daemon::wait_until_selected_service_inactive( + ctx.paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ); + match (stop_result, service_quiescence_result) { + (Ok(()), Ok(())) => {} + (Err(stop_error), Ok(())) => { + log_line( + ctx, + format!( + "Warning: rejected release stop command failed after service became inactive ({stop_error:#})" + ), + ); + } + (Ok(()), Err(state_error)) => { + return Err( + state_error.context("service remained active after the guarded release failure") + ); + } + (Err(stop_error), Err(state_error)) => { + return Err(state_error.context(format!( + "failed to stop the rejected release while activation remained reserved ({stop_error:#})" + ))); + } + } + + crate::actions::releases::rollback_pending_release(ctx.paths) + .context("roll back rejected binary release generation while activation is reserved")?; + Ok(restart_previous) +} + +pub fn restart_previous_service(ctx: &mut ActionContext, readiness: &F) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ + run_service_start(ctx).context("restart previous release generation")?; + readiness(ctx).context("previous release did not recover after rollback") +} + pub fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { let artifacts = ctx.paths.service.install_artifacts(&ctx.paths.bin_dir); let artifact_exists = artifacts.iter().any(service_artifact_path_exists); diff --git a/crates/unixnotis-installer/src/actions/install/service/mod.rs b/crates/unixnotis-installer/src/actions/install/service/mod.rs index 8c7f3e36d..6991f31a0 100644 --- a/crates/unixnotis-installer/src/actions/install/service/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/service/mod.rs @@ -10,4 +10,11 @@ pub(in crate::actions::install) mod refresh; pub(in crate::actions::install) mod symlinks; pub use artifacts::write_service_artifact; -pub use flow::{enable_service, install_service, uninstall_service}; +pub use flow::install_service_under_reservation; +pub use flow::rollback_failed_activation; +pub use flow::uninstall_service; +pub use flow::{ + prepare_service_start_under_reservation, restart_previous_service, + rollback_pending_under_activation_reservation, start_service_and_verify, +}; +pub use readiness::enforce_service_readiness; diff --git a/crates/unixnotis-installer/src/actions/install/service/readiness.rs b/crates/unixnotis-installer/src/actions/install/service/readiness.rs index 9d4b767b5..77e7a7633 100644 --- a/crates/unixnotis-installer/src/actions/install/service/readiness.rs +++ b/crates/unixnotis-installer/src/actions/install/service/readiness.rs @@ -13,9 +13,7 @@ const INSTALL_READINESS_TIMEOUT: Duration = Duration::from_secs(20); const DBUS_METHOD_TIMEOUT: Duration = Duration::from_secs(2); const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(100); -pub(in crate::actions::install) fn enforce_service_readiness( - ctx: &mut ActionContext, -) -> Result<()> { +pub fn enforce_service_readiness(ctx: &mut ActionContext) -> Result<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs index 65fa67879..69f8ef361 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs @@ -7,7 +7,8 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::service_manager::ServiceManager; -use super::super::super::service::{install_service, uninstall_service}; +use super::super::super::service::flow::install_service; +use super::super::super::service::uninstall_service; use super::super::support::{test_context, test_root}; use super::flow_support::{flow_env, flow_paths, lock_env, write_fake_tools, FakeToolMode}; diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs new file mode 100644 index 000000000..85a70d968 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs @@ -0,0 +1,233 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::actions::install::service::flow::rollback_failed_activation_with_quiescence; +use crate::actions::releases::{commit_pending_release, pending_release_exists}; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::service_manager::ServiceManager; + +use super::super::super::support::test_context; +use super::super::flow_support::{ + enable_service_with_readiness_and_quiescence, flow_env, flow_paths, install_release_generation, + lock_env, service_flow_root, write_fake_tools, FakeToolMode, +}; + +#[test] +fn failed_new_generation_readiness_restores_and_rechecks_the_previous_runtime() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-readiness-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + let readiness_calls = AtomicUsize::new(0); + + let error = enable_service_with_readiness_and_quiescence( + &mut ctx, + |_ctx| { + if readiness_calls.fetch_add(1, Ordering::AcqRel) == 0 { + Err(anyhow::anyhow!("new generation failed readiness")) + } else { + Ok(()) + } + }, + |_paths| Ok(()), + ) + .expect_err("failed new generation must report activation failure after rollback"); + + assert!(error + .to_string() + .contains("new generation failed readiness")); + assert_eq!(readiness_calls.load(Ordering::Acquire), 2); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current link"), + std::path::Path::new("releases").join(old_generation) + ); + assert_eq!( + fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + "old generation" + ); + fs::remove_dir_all(root).expect("remove readiness rollback fixture"); +} + +#[test] +fn failure_after_binary_activation_restores_and_restarts_the_previous_runtime() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-later-step-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + let readiness_calls = AtomicUsize::new(0); + + let error = rollback_failed_activation_with_quiescence( + &mut ctx, + &|_ctx| { + readiness_calls.fetch_add(1, Ordering::AcqRel); + Ok(()) + }, + anyhow::anyhow!("service artifact installation failed"), + |_paths| Ok(()), + ) + .expect_err("a later step failure must remain an installation failure"); + + assert!(error + .to_string() + .contains("service artifact installation failed")); + assert_eq!(readiness_calls.load(Ordering::Acquire), 1); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current link"), + std::path::Path::new("releases").join(old_generation) + ); + assert_eq!( + fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + "old generation" + ); + fs::remove_dir_all(root).expect("remove later-step rollback fixture"); +} + +#[test] +fn successful_stop_result_cannot_roll_back_while_runtime_remains_live() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-live-runtime-blocks-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + let new_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + + let error = rollback_failed_activation_with_quiescence( + &mut ctx, + &|_ctx| Ok(()), + anyhow::anyhow!("new generation failed readiness"), + |_paths| Err(anyhow::anyhow!("notification owner is still live")), + ) + .expect_err("a live rejected runtime must block disk rollback"); + + assert!(error + .to_string() + .contains("service manager reported a successful stop")); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retain active generation"), + std::path::Path::new("releases").join(new_generation) + ); + assert!(pending_release_exists(&paths).expect("retain pending rollback journal")); + fs::remove_dir_all(root).expect("remove live runtime rollback fixture"); +} + +fn write_binary(path: &std::path::Path, contents: &str) { + fs::write(path, contents).expect("write release binary"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("set release binary mode"); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs index ed205c35b..135b51d86 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs @@ -1,3 +1,4 @@ +mod generation; mod runit; mod s6_uninstall; mod systemd_dinit; diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs index 81493a959..34abd5ec0 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs @@ -3,6 +3,8 @@ use std::fs; use std::path::Path; use std::sync::MutexGuard; +use anyhow::Context; + use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; @@ -10,8 +12,11 @@ use crate::service_manager::contract::command_routing::use_fake_command_bin; use crate::service_manager::ServiceManager; use crate::test_support::fs::write_executable; -use super::super::super::service::flow::enable_service_with_readiness; -use super::super::super::service::{install_service, uninstall_service}; +use super::super::super::service::flow::{ + install_service, prepare_service_start, rollback_failed_activation_with_quiescence, + start_service_and_verify, +}; +use super::super::super::service::uninstall_service; use super::super::support::{test_context, test_root}; pub(super) fn lock_env() -> MutexGuard<'static, ()> { @@ -19,6 +24,27 @@ pub(super) fn lock_env() -> MutexGuard<'static, ()> { crate::test_support::env::test_env_lock() } +pub(super) fn install_release_generation( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, +) -> anyhow::Result +where + F: FnMut() -> anyhow::Result<()>, + R: FnMut() -> anyhow::Result, +{ + crate::actions::releases::install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + || Ok(()), + ) +} + pub(super) struct EnvGuard { // Tests mutate process-wide env, so each guard owns one variable restoration key: &'static str, @@ -63,6 +89,55 @@ pub(super) enum FakeToolMode { RunitSv, } +pub(super) fn enable_service_with_readiness( + ctx: &mut crate::actions::ActionContext, + readiness: F, +) -> anyhow::Result<()> +where + F: Fn(&mut crate::actions::ActionContext) -> anyhow::Result<()>, +{ + enable_service_with_readiness_and_quiescence(ctx, readiness, |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }) +} + +pub(super) fn enable_service_with_readiness_and_quiescence( + ctx: &mut crate::actions::ActionContext, + readiness: F, + mut wait_for_quiescence: Q, +) -> anyhow::Result<()> +where + F: Fn(&mut crate::actions::ActionContext) -> anyhow::Result<()>, + Q: FnMut(&crate::paths::InstallPaths) -> anyhow::Result<()>, +{ + let result = (|| { + prepare_service_start(ctx)?; + start_service_and_verify(ctx, &readiness) + })(); + match result { + Ok(()) => { + crate::actions::releases::commit_pending_release(ctx.paths) + .context("commit ready binary release generation")?; + Ok(()) + } + Err(error) => { + if crate::actions::releases::pending_release_exists(ctx.paths)? { + rollback_failed_activation_with_quiescence( + ctx, + &readiness, + error, + &mut wait_for_quiescence, + ) + } else { + Err(error) + } + } + } +} + pub(super) fn run_install_and_enable(paths: &InstallPaths) -> anyhow::Result<()> { let detection = Detection { owner: None, diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs index 25b9e5a15..35fd328d5 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs @@ -8,8 +8,9 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::service_manager::ServiceManager; +use super::super::super::service::flow::install_service; use super::super::super::service::lifecycle::{service_start_mode_from_enabled, ServiceStartMode}; -use super::super::super::service::{install_service, uninstall_service, write_service_artifact}; +use super::super::super::service::{uninstall_service, write_service_artifact}; use super::super::support::{test_context, test_paths, test_root}; use super::expected_primary_artifact_contents; use super::flow_support::{flow_env, lock_env, write_fake_tools, FakeToolMode}; diff --git a/crates/unixnotis-installer/src/actions/install/tests/support.rs b/crates/unixnotis-installer/src/actions/install/tests/support.rs index 881609fcb..805790a4d 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/support.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/support.rs @@ -65,13 +65,12 @@ pub(super) fn write_fake_workspace(root: &std::path::Path, binaries: &[&str]) { } pub(super) fn test_context<'a>( - detection: &'a Detection, + _detection: &'a Detection, paths: &'a InstallPaths, action_mode: ActionMode, ) -> ActionContext<'a> { let (tx, _rx) = mpsc::sync_channel::(32); ActionContext { - detection, paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/plan.rs b/crates/unixnotis-installer/src/actions/plan.rs index f1fe947d5..b9648e8ba 100644 --- a/crates/unixnotis-installer/src/actions/plan.rs +++ b/crates/unixnotis-installer/src/actions/plan.rs @@ -3,14 +3,14 @@ //! Keeps the sequencing logic in one place so install, uninstall, and reset //! flows stay predictable -use anyhow::Result; +use anyhow::{bail, Context, Result}; use crate::model::{ActionMode, ActionStep, StepStatus}; use super::{ - check_install_state_step, enable_service, ensure_config, install_binaries, install_service, + check_install_state_step, ensure_config, install_binaries, install_service_under_reservation, remove_binaries, remove_state, reset_config, restore_config, run_build, stop_active_daemon, - uninstall_service, ActionContext, + uninstall_service, ActionContext, DaemonActivationReservation, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -65,7 +65,11 @@ pub fn steps_from_plan(plan: &[StepKind]) -> Vec { .collect() } -pub fn run_step(step: StepKind, ctx: &mut ActionContext) -> Result<()> { +pub fn run_step_with_reservation( + step: StepKind, + ctx: &mut ActionContext, + reservation: Option<&DaemonActivationReservation>, +) -> Result<()> { match step { StepKind::InstallCheck => check_install_state_step(ctx), StepKind::StopDaemon => stop_active_daemon(ctx), @@ -73,9 +77,17 @@ pub fn run_step(step: StepKind, ctx: &mut ActionContext) -> Result<()> { StepKind::EnsureConfig => ensure_config(ctx), StepKind::ResetConfig => reset_config(ctx), StepKind::RestoreConfig => restore_config(ctx), - StepKind::InstallBinaries => install_binaries(ctx), - StepKind::InstallService => install_service(ctx), - StepKind::EnableService => enable_service(ctx), + StepKind::InstallBinaries => install_binaries( + ctx, + reservation.context("binary installation requires daemon activation reservation")?, + ), + StepKind::InstallService => install_service_under_reservation( + ctx, + reservation.context("service installation requires daemon activation reservation")?, + ), + StepKind::EnableService => { + bail!("EnableService must use the install lifecycle handoff") + } StepKind::UninstallService => uninstall_service(ctx), StepKind::RemoveBinaries => remove_binaries(ctx), StepKind::RemoveState => remove_state(ctx), diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index 163a83de1..41e7effd2 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -8,8 +8,9 @@ use crate::model::ActionMode; use crate::paths::format_with_home; use crate::service_manager::ReadinessIssue; -use super::installation_channel::reject_conflicting_installation_channel; -use super::{context::ActionContext, install_state::check_install_state, log_line, InstallState}; +use super::conflicts::ServiceManagerConflictKind; +use super::install::{check_install_state, reject_conflicting_installation_channel}; +use super::{context::ActionContext, log_line, InstallState}; pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { // Use cached install state when available to keep the UI consistent with the plan @@ -38,6 +39,9 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { format_with_home(&binary.path) ), ); + if let super::releases::BinaryHealth::Unsafe(detail) = &binary.health { + log_line(ctx, format!(" inspection failure: {detail}")); + } } let service_artifact_status = if state.service_artifact_exists { diff --git a/crates/unixnotis-installer/src/actions/tests/daemon.rs b/crates/unixnotis-installer/src/actions/tests/daemon.rs deleted file mode 100644 index 7b71f68f3..000000000 --- a/crates/unixnotis-installer/src/actions/tests/daemon.rs +++ /dev/null @@ -1,225 +0,0 @@ -use std::process::{Command, Stdio}; -use std::sync::atomic::AtomicBool; -use std::sync::{mpsc, Arc}; - -use crate::actions::ActionContext; -use crate::app::events::UiMessage; -use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; -use crate::model::ActionMode; -use crate::paths::InstallPaths; -use crate::service_manager::ServiceManager; -use crate::test_support::fs::write_executable; - -use super::{ - is_systemd_unit_inactive, stop_active_daemon, systemd_stop_error_is_satisfied_by_state, -}; - -#[test] -fn stop_active_daemon_errors_for_unmanaged_owner() { - let detection = Detection { - owner: Some(crate::detect::OwnerInfo { - pid: None, - comm: Some("unknown-daemon".to_string()), - }), - daemons: Vec::new(), - }; - let paths = InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - }; - let (tx, _rx) = mpsc::sync_channel::(4); - let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let error = stop_active_daemon(&mut ctx).expect_err("unmanaged owner must block install"); - - assert!(error.to_string().contains("not managed by a known unit")); -} - -#[test] -fn stop_active_daemon_terminates_the_exact_non_systemd_owner() { - let sleep = unixnotis_core::util::trusted_system_program_path("sleep") - .expect("find sleep in a trusted system directory"); - let mut child = Command::new(sleep) - .arg("30") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn sleep daemon"); - let detection = Detection { - owner: Some(OwnerInfo { - pid: Some(child.id()), - comm: Some("sleep".to_string()), - }), - daemons: vec![DetectedDaemon { - name: "sleep".to_string(), - unit: "sleep.service".to_string(), - systemd_active: false, - systemd_error: None, - running_pids: vec![child.id()], - is_owner: true, - }], - }; - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("stable process stop should succeed"); - - let status = child.wait().expect("reap stopped sleep daemon"); - assert!(!status.success()); -} - -#[test] -fn stop_active_daemon_stops_unixnotis_without_disabling_its_unit() { - let root = fake_daemon_tool_root("unixnotis-reinstall-stop"); - let calls = root.join("systemctl-calls"); - write_executable( - &root.join("systemctl"), - &format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\n", calls.display()), - ); - let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("reinstall stop should succeed"); - - let calls = std::fs::read_to_string(&calls).expect("systemctl calls"); - assert_eq!(calls.trim(), "--user stop unixnotis-daemon.service"); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn systemd_stop_error_can_continue_when_unit_is_inactive() { - // A failed stop is acceptable only when systemd reports a non-running state - assert!(systemd_stop_error_is_satisfied_by_state("inactive")); -} - -#[test] -fn systemd_stop_error_can_continue_when_unit_is_failed() { - // Failed units no longer own the notification bus, so reinstall may continue - assert!(systemd_stop_error_is_satisfied_by_state("failed")); -} - -#[test] -fn systemd_stop_error_still_fails_when_unit_stays_active() { - assert!(!systemd_stop_error_is_satisfied_by_state("active")); -} - -#[test] -fn systemd_stop_error_still_fails_when_unit_is_transitioning() { - assert!(!systemd_stop_error_is_satisfied_by_state("deactivating")); -} - -#[test] -fn systemd_stop_error_still_fails_when_state_is_empty() { - // Empty output means the manager did not provide enough proof that stopping succeeded - assert!(!systemd_stop_error_is_satisfied_by_state("")); -} - -#[test] -fn systemd_stop_error_trims_state_output_before_matching() { - // systemctl prints a trailing newline in normal output - assert!(systemd_stop_error_is_satisfied_by_state(" inactive\n")); - assert!(systemd_stop_error_is_satisfied_by_state("\tunknown ")); -} - -#[test] -fn systemd_stop_error_rejects_unrecognized_non_running_words() { - // Only explicit systemd states should satisfy a failed stop - assert!(!systemd_stop_error_is_satisfied_by_state("dead")); - assert!(!systemd_stop_error_is_satisfied_by_state("stopped")); -} - -#[test] -fn is_systemd_unit_inactive_reads_trusted_systemctl_state() { - let _lock = crate::test_support::env::test_env_lock(); - let root = std::env::temp_dir().join(format!( - "unixnotis-daemon-systemctl-state-{}", - std::process::id() - )); - let fake_bin = root.join("bin"); - std::fs::create_dir_all(&fake_bin).expect("fake bin"); - write_executable( - &fake_bin.join("systemctl"), - "#!/bin/sh\ncase \"$3\" in inactive.service) echo inactive; exit 3 ;; active.service) echo active; exit 0 ;; *) exit 1 ;; esac\n", - ); - let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); - - assert!(is_systemd_unit_inactive("inactive.service").expect("inactive state")); - assert!(!is_systemd_unit_inactive("active.service").expect("active state")); - let error = - is_systemd_unit_inactive("missing.service").expect_err("empty failed status is an error"); - assert!(error - .to_string() - .contains("failed to read systemd unit state")); - - let _ = std::fs::remove_dir_all(root); -} - -fn known_daemon_detection(name: &str, systemd_active: bool, running_pids: Vec) -> Detection { - Detection { - owner: Some(OwnerInfo { - pid: Some(42), - comm: Some(name.to_string()), - }), - daemons: vec![DetectedDaemon { - name: name.to_string(), - unit: format!("{name}.service"), - systemd_active, - systemd_error: None, - running_pids, - is_owner: true, - }], - } -} - -fn test_install_paths() -> InstallPaths { - InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - } -} - -fn action_context<'a>( - detection: &'a Detection, - paths: &'a InstallPaths, - log_tx: mpsc::SyncSender, -) -> ActionContext<'a> { - ActionContext { - detection, - paths, - install_state: None, - log_tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - } -} - -fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-daemon-{label}-{}-{stamp}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("fake daemon tool bin"); - root -} diff --git a/crates/unixnotis-installer/src/actions/tests/plan.rs b/crates/unixnotis-installer/src/actions/tests/plan.rs index a597fda58..3b8de1679 100644 --- a/crates/unixnotis-installer/src/actions/tests/plan.rs +++ b/crates/unixnotis-installer/src/actions/tests/plan.rs @@ -1,6 +1,13 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; -use super::{build_plan, steps_from_plan, StepKind}; +use super::{build_plan, run_step_with_reservation, steps_from_plan, StepKind}; #[test] fn install_plan_stays_focused_on_build_and_install() { @@ -62,3 +69,57 @@ fn steps_from_plan_uses_user_visible_labels() { ] ); } + +#[test] +fn restore_step_dispatches_to_validation_and_rejects_a_missing_backup() { + let root = crate::test_support::fs::unique_temp_path("plan-restore-dispatch"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user(root.join("units")), + }; + let (log_tx, _log_rx) = mpsc::sync_channel::(4); + let mut ctx = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Reset, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = run_step_with_reservation(StepKind::RestoreConfig, &mut ctx, None) + .expect_err("restore dispatch must preserve missing-backup validation"); + + assert!(error.to_string().contains("no backup directory selected")); +} + +#[test] +fn binary_install_rejects_calls_without_the_worker_owned_activation_guard() { + let root = crate::test_support::fs::unique_temp_path("plan-guarded-binary-install"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user(root.join("units")), + }; + let (log_tx, _log_rx) = mpsc::sync_channel::(4); + let mut ctx = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = run_step_with_reservation(StepKind::InstallBinaries, &mut ctx, None) + .expect_err("binary publication must require the worker-owned activation guard"); + + assert!(error + .to_string() + .contains("binary installation requires daemon activation reservation")); + assert!( + !paths.bin_dir.exists(), + "guard rejection must not mutate binaries" + ); +} diff --git a/crates/unixnotis-installer/src/actions/tests/state.rs b/crates/unixnotis-installer/src/actions/tests/state.rs index fbba21a03..067e14920 100644 --- a/crates/unixnotis-installer/src/actions/tests/state.rs +++ b/crates/unixnotis-installer/src/actions/tests/state.rs @@ -6,7 +6,6 @@ use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::contract::command_routing::use_fake_command_bin; @@ -15,6 +14,7 @@ use crate::service_manager::{ServiceManager, MANAGED_DIRECTORY_MARKER_CONTENTS}; use crate::test_support::fs::write_executable; use super::{check_install_state, check_install_state_step, ActionContext}; +use crate::actions::conflicts::ServiceManagerConflictKind; #[test] fn dinit_artifact_backed_enablement_does_not_log_missing_enabled_command_error() { @@ -44,6 +44,43 @@ fn dinit_artifact_backed_enablement_does_not_log_missing_enabled_command_error() let _ = fs::remove_dir_all(root); } +#[test] +fn install_check_without_readiness_errors_logs_summary_and_succeeds() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("successful-install-check-summary"); + let _env = service_scan_env(&root); + let _fake_commands = fake_inactive_manager_commands(&root); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + let state = check_install_state(&paths); + let (log_tx, log_rx) = mpsc::sync_channel::(64); + let mut ctx = ActionContext { + paths: &paths, + install_state: Some(state), + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + check_install_state_step(&mut ctx).expect("a warning-only backend must pass install checks"); + + let logs = log_rx.try_iter().collect::>(); + assert!(logs.iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) if line == "- service enabled: no" + ))); + assert!(logs.iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) + if line == "Install will continue and update missing items." + ))); + fs::remove_dir_all(root).expect("remove successful install check fixture"); +} + #[test] fn install_state_rejects_foreign_runit_service_directory() { let _lock = crate::test_support::env::test_env_lock(); @@ -120,8 +157,12 @@ fn different_backend_artifacts_are_reported_as_install_conflict() { assert_eq!(state.service_conflicts.len(), 1); assert_eq!(state.service_conflicts[0].manager_label, "dinit --user"); - assert!(state.service_conflicts[0].installed); - assert!(!state.service_conflicts[0].active); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Installed)); + assert!(!state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Active)); let _ = fs::remove_dir_all(root); } @@ -159,13 +200,15 @@ fn same_backend_different_root_artifacts_are_reported_as_install_conflict() { state.service_conflicts[0].artifact_path, default_root.join("unixnotis-daemon") ); - assert!(state.service_conflicts[0].installed); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Installed)); let _ = fs::remove_dir_all(root); } #[test] -fn active_probe_errors_are_reported_as_conflict_warnings() { +fn active_probe_errors_are_fail_closed_as_indeterminate_conflicts() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("active-probe-warning"); let _env = service_scan_env(&root); @@ -176,10 +219,14 @@ fn active_probe_errors_are_reported_as_conflict_warnings() { // Non-executable command files force Command::status to return an io error fs::set_permissions(&systemctl, fs::Permissions::from_mode(0o644)) .expect("chmod non-executable systemctl"); - for command in ["sv", "s6-svstat"] { - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); let _fake_bin = use_fake_command_bin(&fake_bin); let paths = InstallPaths { repo_root: repo_root(), @@ -189,15 +236,351 @@ fn active_probe_errors_are_reported_as_conflict_warnings() { let state = check_install_state(&paths); - assert!(state.service_conflicts.is_empty()); - assert!(state - .service_conflict_warnings - .iter() - .any(|warning| warning.contains("could not check whether systemd --user is active"))); + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + assert!(state.service_conflicts[0].detail.as_deref().is_some_and( + |detail| detail.contains("could not establish whether systemd --user is reachable") + )); let _ = fs::remove_dir_all(root); } +#[test] +fn unavailable_alternate_managers_without_artifacts_do_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unavailable-empty-alternates"); + let _env = service_scan_env(&root); + let fake_bin = root.join("selected-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create selected manager tool directory"); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 1\n"); + // Strict test routing makes every omitted alternate manager program unavailable + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "missing irrelevant manager programs must not create ownership conflicts" + ); + fs::remove_dir_all(root).expect("remove unavailable alternate fixture"); +} + +#[test] +fn installed_sv_without_supervised_unixnotis_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("runit-tool-without-supervision"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'fail: %s: runsv not running\\n' \"$2\"\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unsupervised runit service must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove runit absence fixture"); +} + +#[test] +fn live_runit_service_without_artifacts_blocks_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("live-runit-without-artifacts"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\ncase \"$1\" in -V) exit 100 ;; status) printf 'run: %s: (pid 123) 2s\\n' \"$2\"; exit 0 ;; *) exit 100 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!( + state.service_conflicts[0].manager_label, + "runit user services" + ); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Active)); + fs::remove_dir_all(root).expect("remove live runit fixture"); +} + +#[test] +fn installed_s6_svstat_without_supervisor_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("s6-tool-without-supervision"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable(&fake_bin.join("s6-svstat"), "#!/bin/sh\nexit 1\n"); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "a missing s6 supervisor must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove s6 absence fixture"); +} + +#[test] +fn installed_dinitctl_without_loaded_unixnotis_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("dinit-tool-without-loaded-service"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\ncase \"$*\" in *' list') exit 0 ;; *' status '*) printf 'dinitctl: service not loaded.\\n' >&2; exit 1 ;; *) exit 1 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unloaded dinit service must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove dinit absence fixture"); +} + +#[test] +fn installed_systemctl_without_user_manager_does_not_block_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("systemctl-without-user-manager"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'Failed to connect to bus\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unreachable alternate systemd user manager owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove unavailable systemd fixture"); +} + +#[test] +fn offline_systemd_user_manager_without_artifacts_does_not_block_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("offline-systemd-manager"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'offline\\n'\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an explicitly offline alternate systemd manager owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove offline systemd fixture"); +} + +#[test] +fn unknown_systemd_manager_failure_blocks_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unknown-systemd-manager-failure"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'systemctl query failed unexpectedly\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + fs::remove_dir_all(root).expect("remove unknown systemd fixture"); +} + +#[test] +fn installed_dinitctl_without_user_daemon_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("dinitctl-without-user-daemon"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'dinit-client: connecting to socket failed\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unreachable alternate dinit daemon owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove unavailable dinit fixture"); +} + +#[test] +fn unknown_dinit_manager_failure_blocks_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unknown-dinit-manager-failure"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'dinitctl query failed unexpectedly\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "dinit --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + fs::remove_dir_all(root).expect("remove unknown dinit fixture"); +} + +#[test] +fn reachable_alternate_manager_with_ambiguous_service_state_blocks_installation() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("reachable-manager-ambiguous-state"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n'; exit 0 ;; *' show '*) printf 'ambiguous-state\\n'; exit 0 ;; *) exit 1 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert_eq!( + state.service_conflicts[0] + .kinds + .iter() + .filter(|kind| **kind == ServiceManagerConflictKind::Indeterminate) + .count(), + 1 + ); + fs::remove_dir_all(root).expect("remove ambiguous alternate fixture"); +} + +#[test] +fn partial_alternate_backend_artifacts_block_installation() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("partial-alternate-backend"); + let _env = service_scan_env(&root); + let fake_bin = root.join("selected-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create selected manager tool directory"); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 1\n"); + // The dinit artifact remains authoritative even when dinitctl is unavailable + let _fake_commands = use_fake_command_bin(&fake_bin); + let dinit_root = root.join("home").join(".config").join("dinit.d"); + fs::create_dir_all(&dinit_root).expect("create partial dinit root"); + let binary = root.join("bin").join("unixnotis-daemon"); + fs::write( + dinit_root.join("unixnotis-daemon"), + format!("type = process\ncommand = {}\n", binary.display()), + ) + .expect("write partial dinit service"); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::PartialInstall)); + assert_eq!( + state.service_conflicts[0].artifact_paths, + [dinit_root.join("unixnotis-daemon")] + ); + fs::remove_dir_all(root).expect("remove partial backend fixture"); +} + #[test] fn install_check_blocks_when_different_backend_is_active() { let _lock = crate::test_support::env::test_env_lock(); @@ -206,14 +589,21 @@ fn install_check_blocks_when_different_backend_is_active() { let fake_bin = root.join("fake-bin"); let fake_systemctl = fake_bin.join("systemctl"); fs::create_dir_all(&fake_bin).expect("fake bin"); - for command in ["dinitctl", "sv", "s6-svstat"] { - // Only systemd should look active; every other backend probe should stay inactive - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'Service: unixnotis-daemon\\n State: STOPPED\\n'\n", + ); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); write_executable( &fake_systemctl, - "#!/bin/sh\ncase \" $* \" in *\" is-active \"*) exit 0 ;; *) exit 1 ;; esac\n", + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n' ;; *) printf 'LoadState=loaded\\nActiveState=active\\n' ;; esac\n", ); let _fake_bin = use_fake_command_bin(&fake_bin); let paths = InstallPaths { @@ -221,13 +611,8 @@ fn install_check_blocks_when_different_backend_is_active() { bin_dir: root.join("bin"), service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), }; - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let (log_tx, log_rx) = mpsc::sync_channel::(16); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx, @@ -252,6 +637,42 @@ fn install_check_blocks_when_different_backend_is_active() { let _ = fs::remove_dir_all(root); } +#[test] +fn install_check_blocks_when_selected_manager_activity_is_indeterminate() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("selected-backend-indeterminate"); + let _env = service_scan_env(&root); + let _fake_commands = fake_inactive_manager_commands(&root); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + let mut state = check_install_state(&paths); + state.service_active_error = Some("selected active probe failed".to_string()); + state.service_conflicts.clear(); + let (log_tx, log_rx) = mpsc::sync_channel::(16); + let mut ctx = ActionContext { + paths: &paths, + install_state: Some(state), + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = check_install_state_step(&mut ctx) + .expect_err("an unknown selected-manager state must stop installation immediately"); + + assert!(error.to_string().contains("ownership is indeterminate")); + assert!(log_rx.try_iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) + if line.contains("service status check failed: selected active probe failed") + ))); + fs::remove_dir_all(root).expect("remove selected-manager fixture"); +} + fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -311,15 +732,43 @@ fn service_scan_env(root: &Path) -> Vec { fn fake_inactive_manager_commands(root: &Path) -> impl Drop { let fake_bin = root.join("fake-inactive-bin"); fs::create_dir_all(&fake_bin).expect("fake inactive bin"); - for command in ["systemctl", "dinitctl", "sv", "s6-svstat"] { - // Exit 1 models a healthy inactive service for every active-state probe style - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'Service: unixnotis-daemon\\n State: STOPPED\\n'\n", + ); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); // Active probes are command-backed, so route them away from the host managers use_fake_command_bin(&fake_bin) } +fn write_inactive_systemctl(fake_bin: &Path) { + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n'; exit 0 ;; *' show '*) printf 'LoadState=not-found\\nActiveState=inactive\\n'; exit 0 ;; *) exit 1 ;; esac\n", + ); +} + +fn systemd_paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + struct EnvGuard { key: &'static str, old: Option, diff --git a/crates/unixnotis-installer/src/detect.rs b/crates/unixnotis-installer/src/detect.rs index 87ef314f2..eb2782916 100644 --- a/crates/unixnotis-installer/src/detect.rs +++ b/crates/unixnotis-installer/src/detect.rs @@ -3,14 +3,21 @@ use std::fs; use std::io::ErrorKind; use std::path::Path; +use std::time::{Duration, Instant}; +use anyhow::{anyhow, Context, Result}; use rustix::process::geteuid; use serde_json::Value; use crate::system_tools; -#[derive(Clone)] +const MAX_BUSCTL_OUTPUT_BYTES: usize = 64 * 1024; +const DEFAULT_BUSCTL_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] pub struct OwnerInfo { + // Exact transport address is present for fail-closed mutation checks + pub unique_name: Option, pub pid: Option, pub comm: Option, } @@ -39,6 +46,13 @@ pub fn detect() -> Detection { Detection { owner, daemons } } +pub fn detect_for_mutation() -> Result { + // Destructive workflow gates keep broker errors distinct from an unowned bus name + let owner = read_busctl_owner_strict()?; + let daemons = detect_known_daemons(owner.as_ref()); + Ok(Detection { owner, daemons }) +} + pub fn parse_busctl_status(status: &str) -> Option { // Parses `busctl --user status` output and tolerates the indented key/value format let mut comm = None; @@ -77,7 +91,11 @@ pub fn parse_busctl_status(status: &str) -> Option { return None; } - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } pub fn parse_busctl_json(status: &str) -> Option { @@ -91,7 +109,11 @@ pub fn parse_busctl_json(status: &str) -> Option { return None; } - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } fn walk_busctl_json(value: &Value, comm: &mut Option, pid: &mut Option) { @@ -141,12 +163,16 @@ fn parse_pid_value(value: &Value) -> Option { } fn detect_owner() -> Option { - let OwnerInfo { pid, comm } = read_busctl_owner()?; + let OwnerInfo { pid, comm, .. } = read_busctl_owner()?; // Prefer the executable name derived from argv0; fall back to busctl and /proc data let comm = pid .and_then(read_cmdline_program) .or_else(|| comm.or_else(|| pid.and_then(read_comm))); - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } fn read_busctl_owner() -> Option { @@ -190,6 +216,89 @@ fn read_busctl_owner() -> Option { parse_busctl_status(&status) } +fn read_busctl_owner_strict() -> Result> { + let Some(unique_name) = read_busctl_unique_owner_strict()? else { + return Ok(None); + }; + + let mut owner = run_busctl(&["--user", "--json=short", "status", &unique_name]) + .and_then(|status| parse_busctl_json(&status)) + .or_else(|| { + run_busctl(&["--user", "status", &unique_name]) + .and_then(|status| parse_busctl_status(&status)) + }) + .unwrap_or(OwnerInfo { + unique_name: None, + pid: None, + comm: None, + }); + owner.unique_name = Some(unique_name); + owner.comm = owner + .pid + .and_then(read_cmdline_program) + .or_else(|| owner.comm.or_else(|| owner.pid.and_then(read_comm))); + Ok(Some(owner)) +} + +pub fn notification_owner_for_mutation_until(deadline: Instant) -> Result> { + // The final switch only needs the broker address, so it skips slower process discovery + read_busctl_unique_owner_strict_until(deadline) +} + +fn read_busctl_unique_owner_strict() -> Result> { + let deadline = Instant::now() + .checked_add(DEFAULT_BUSCTL_PROBE_TIMEOUT) + .ok_or_else(|| anyhow!("notification owner deadline exceeded the monotonic clock"))?; + read_busctl_unique_owner_strict_until(deadline) +} + +fn read_busctl_unique_owner_strict_until(deadline: Instant) -> Result> { + let has_owner = run_busctl_required_until( + &[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ], + deadline, + )?; + match has_owner.split_whitespace().collect::>().as_slice() { + ["b", "false"] => return Ok(None), + ["b", "true"] => {} + _ => return Err(anyhow!("busctl returned malformed NameHasOwner output")), + } + + let reply = run_busctl_required_until( + &[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ], + deadline, + )?; + let unique_name = parse_busctl_string_reply(&reply) + .ok_or_else(|| anyhow!("busctl returned malformed GetNameOwner output"))?; + Ok(Some(unique_name)) +} + +pub fn ensure_owner_is_current(expected_unique_name: &str) -> Result<()> { + let current = read_busctl_unique_owner_strict()?; + anyhow::ensure!( + current.as_deref() == Some(expected_unique_name), + "Notifications owner changed before the stop operation; refusing to act on stale process metadata" + ); + Ok(()) +} + fn parse_busctl_string_reply(reply: &str) -> Option { // Method-call string output is formatted as `s "value"` let (_, quoted) = reply.trim().split_once('"')?; @@ -209,6 +318,36 @@ fn run_busctl(args: &[&str]) -> Option { Some(String::from_utf8_lossy(&output.stdout).to_string()) } +fn run_busctl_required_until(args: &[&str], deadline: Instant) -> Result { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return Err(std::io::Error::new( + ErrorKind::TimedOut, + "notification owner probe deadline elapsed", + ) + .into()); + } + let mut command = system_tools::command("busctl").context("locate trusted busctl")?; + command.args(args); + let output = system_tools::output_bounded(&mut command, timeout, MAX_BUSCTL_OUTPUT_BYTES) + .context("query notification owner through busctl")?; + validate_busctl_output(output) +} + +fn validate_busctl_output(output: system_tools::BoundedOutput) -> Result { + if output.stdout_truncated || output.stderr_truncated { + return Err(anyhow!("busctl owner query exceeded the safe output limit")); + } + if !output.status.success() { + return Err(anyhow!( + "busctl owner query failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("busctl owner query output was not UTF-8") +} + fn detect_known_daemons(owner: Option<&OwnerInfo>) -> Vec { let owner_name = owner.and_then(|info| info.comm.as_deref()); KNOWN_DAEMONS diff --git a/crates/unixnotis-installer/src/tests/detect.rs b/crates/unixnotis-installer/src/tests/detect.rs index 84d1cd4f2..9cd4455a9 100644 --- a/crates/unixnotis-installer/src/tests/detect.rs +++ b/crates/unixnotis-installer/src/tests/detect.rs @@ -1,10 +1,13 @@ use crate::test_support::fs::write_executable; use std::fs; use std::io::{Error, ErrorKind}; +use std::os::unix::process::ExitStatusExt; use crate::detect::{ - parse_busctl_json, parse_busctl_status, parse_busctl_string_reply, read_cmdline_program, - read_comm, systemctl_spawn_error, KNOWN_DAEMONS, + ensure_owner_is_current, notification_owner_for_mutation_until, parse_busctl_json, + parse_busctl_status, parse_busctl_string_reply, read_busctl_owner_strict, read_cmdline_program, + read_comm, systemctl_spawn_error, validate_busctl_output, KNOWN_DAEMONS, + MAX_BUSCTL_OUTPUT_BYTES, }; #[test] @@ -155,6 +158,21 @@ fn parse_busctl_json_ignores_empty_comm_and_keeps_later_valid_value() { assert_eq!(owner.comm.as_deref(), Some("dunst")); } +#[test] +fn parse_busctl_json_keeps_the_first_valid_pid_and_command_identity() { + let output = r#" +{ + "first": { "PID": 111, "Comm": "first-owner" }, + "second": { "PID": 222, "Comm": "second-owner" } +} +"#; + + let owner = parse_busctl_json(output).expect("expected parsed owner info"); + + assert_eq!(owner.pid, Some(111)); + assert_eq!(owner.comm.as_deref(), Some("first-owner")); +} + #[test] fn parse_busctl_json_rejects_zero_and_out_of_range_pid_values() { let zero = parse_busctl_json(r#"{ "PID": 0 }"#); @@ -183,6 +201,141 @@ fn parse_busctl_string_reply_reads_unique_owner_name() { assert!(parse_busctl_string_reply("invalid").is_none()); } +#[test] +fn strict_owner_detection_keeps_broker_failure_distinct_from_unowned() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-error"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable(&fake_bin.join("busctl"), "#!/bin/sh\nexit 7\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = read_busctl_owner_strict().expect_err("broker failure must block mutation"); + + assert!( + error.to_string().contains("busctl owner query failed"), + "unexpected strict detection error: {error:#}" + ); + fs::remove_dir_all(root).expect("remove strict owner fixture"); +} + +#[test] +fn strict_owner_detection_accepts_only_explicit_unowned_reply() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-unowned"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\nprintf '%s\\n' 'b false'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert!( + read_busctl_owner_strict() + .expect("explicit unowned reply") + .is_none(), + "explicit false must be the only unowned state" + ); + fs::remove_dir_all(root).expect("remove strict unowned fixture"); +} + +#[test] +fn strict_owner_detection_retains_the_exact_unique_address() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-identity"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.77\"\\n' ;; *'status :1.77'*) printf 'Comm=unixnotis-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let owner = read_busctl_owner_strict() + .expect("strict owned query") + .expect("owned notification name"); + + assert_eq!(owner.unique_name.as_deref(), Some(":1.77")); + fs::remove_dir_all(root).expect("remove strict owner fixture"); +} + +#[test] +fn strict_owner_revalidation_rejects_a_different_current_address() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-handoff"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.88\"\\n' ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = ensure_owner_is_current(":1.77") + .expect_err("a new transport owner must invalidate inspected process metadata"); + + assert!(error.to_string().contains("owner changed")); + fs::remove_dir_all(root).expect("remove strict owner handoff fixture"); +} + +#[test] +fn strict_bus_output_budget_accepts_exact_limit_and_rejects_each_oversized_stream() { + assert_eq!(MAX_BUSCTL_OUTPUT_BYTES, 65_536); + let output = |stdout: Vec, stderr: Vec, stdout_truncated, stderr_truncated| { + crate::system_tools::BoundedOutput { + status: std::process::ExitStatus::from_raw(0), + stdout, + stderr, + stdout_truncated, + stderr_truncated, + } + }; + + let exact = vec![b'x'; MAX_BUSCTL_OUTPUT_BYTES]; + assert_eq!( + validate_busctl_output(output(exact.clone(), Vec::new(), false, false)) + .expect("exact output limit must remain valid") + .len(), + MAX_BUSCTL_OUTPUT_BYTES + ); + assert!( + validate_busctl_output(output(Vec::new(), exact, false, false)).is_ok(), + "exact stderr limit must remain valid" + ); + assert!(validate_busctl_output(output(Vec::new(), Vec::new(), true, false)).is_err()); + assert!(validate_busctl_output(output(Vec::new(), Vec::new(), false, true)).is_err()); +} + +#[test] +fn strict_owner_probe_kills_a_hung_busctl_at_the_shared_deadline() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-timeout"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable(&fake_bin.join("busctl"), "#!/bin/sh\nsleep 30\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let started = std::time::Instant::now(); + let deadline = started + std::time::Duration::from_millis(25); + + let error = notification_owner_for_mutation_until(deadline) + .expect_err("hung owner query must fail at the shared deadline"); + + assert!( + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::TimedOut) + }), + "timeout context must retain the operating-system timeout kind: {error:#}" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "hung busctl and helper processes must be killed promptly" + ); + fs::remove_dir_all(root).expect("remove strict owner timeout fixture"); +} + #[test] fn parse_busctl_json_returns_none_for_invalid_json() { let owner = parse_busctl_json("not json"); @@ -223,6 +376,45 @@ fn read_comm_returns_none_for_missing_process() { assert!(comm.is_none()); } +#[test] +fn read_comm_prefers_a_live_proc_identity_without_invoking_ps() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("read-comm-proc-first"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake tool directory"); + write_executable( + &fake_bin.join("ps"), + "#!/bin/sh\nprintf 'wrong-fallback\\n'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let expected = fs::read_to_string(format!("/proc/{}/comm", std::process::id())) + .expect("read current process comm") + .trim() + .to_string(); + + assert_eq!( + read_comm(std::process::id()).as_deref(), + Some(expected.as_str()) + ); + fs::remove_dir_all(root).expect("remove proc comm fixture"); +} + +#[test] +fn read_comm_uses_successful_ps_output_when_proc_identity_is_missing() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("read-comm-ps-fallback"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake tool directory"); + write_executable( + &fake_bin.join("ps"), + "#!/bin/sh\nprintf 'fallback-owner\\n'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert_eq!(read_comm(u32::MAX).as_deref(), Some("fallback-owner")); + fs::remove_dir_all(root).expect("remove ps comm fixture"); +} + #[test] fn missing_systemctl_does_not_emit_per_daemon_status_errors() { // Non-systemd installs can still use D-Bus and process detection without systemctl @@ -230,6 +422,13 @@ fn missing_systemctl_does_not_emit_per_daemon_status_errors() { assert!(systemctl_spawn_error(&err).is_none()); } +#[test] +fn unexpected_systemctl_spawn_errors_remain_visible() { + let err = Error::from(ErrorKind::PermissionDenied); + + assert!(systemctl_spawn_error(&err).is_some()); +} + #[test] fn detect_uses_bus_owner_systemd_status_and_pgrep_results() { let _lock = crate::test_support::env::test_env_lock(); From 559c2539087e5faff7b8e4b4d3f9d2d84541455d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 9 Aug 2026 16:28:37 -0500 Subject: [PATCH 265/275] fix(installer): fail closed when guarded recovery is uncertain Keep daemon activation inhibited when failed installation recovery cannot prove a safe runtime and disk state. - distinguish ordinary recovered failures from ActivationInhibited failures - retain the dual-name reservation when pending-journal inspection fails - retain activation exclusion when guarded rollback itself fails - treat contradictory in-memory and on-disk release state as catastrophic - keep the worker and installer lock alive while activation remains inhibited - expose a dedicated Manual recovery required progress state - prevent returning to the installer menu while the recovery guard is held - preserve concise status errors and complete anyhow chains in logs - test guard lifetime across install steps and catastrophic recovery branches --- crates/unixnotis-installer/src/app/events.rs | 15 +- .../unixnotis-installer/src/app/handlers.rs | 17 +- crates/unixnotis-installer/src/app/state.rs | 2 + .../src/app/tests/events.rs | 39 ++- .../src/app/tests/handlers.rs | 18 ++ .../src/app/tests/state.rs | 142 +++++++++- .../unixnotis-installer/src/app/workflow.rs | 258 ------------------ .../src/app/workflow/build_accel.rs | 71 +++++ .../src/app/workflow/controller.rs | 70 +++++ .../src/app/workflow/events.rs | 99 +++++++ .../src/app/workflow/mod.rs | 13 + .../src/app/workflow/recovery.rs | 155 +++++++++++ .../src/app/workflow/tests/build_accel.rs | 73 +++++ .../src/app/workflow/tests/controller.rs | 47 ++++ .../workflow.rs => workflow/tests/events.rs} | 68 +++-- .../src/app/workflow/tests/mod.rs | 6 + .../src/app/workflow/tests/recovery.rs | 188 +++++++++++++ .../src/app/workflow/tests/support.rs | 117 ++++++++ .../src/app/workflow/tests/worker.rs | 99 +++++++ .../src/app/workflow/worker.rs | 145 ++++++++++ crates/unixnotis-installer/src/ui/progress.rs | 25 +- .../src/ui/tests/progress.rs | 15 + 22 files changed, 1382 insertions(+), 300 deletions(-) delete mode 100644 crates/unixnotis-installer/src/app/workflow.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/build_accel.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/controller.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/events.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/mod.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/recovery.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/controller.rs rename crates/unixnotis-installer/src/app/{tests/workflow.rs => workflow/tests/events.rs} (68%) create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/mod.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/recovery.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/support.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/tests/worker.rs create mode 100644 crates/unixnotis-installer/src/app/workflow/worker.rs diff --git a/crates/unixnotis-installer/src/app/events.rs b/crates/unixnotis-installer/src/app/events.rs index 110a37991..7d071050f 100644 --- a/crates/unixnotis-installer/src/app/events.rs +++ b/crates/unixnotis-installer/src/app/events.rs @@ -19,7 +19,20 @@ pub enum UiMessage { pub enum WorkerEvent { StepStarted(usize), StepCompleted(usize), - StepFailed(usize, String), + StepFailed { + index: usize, + // The summary stays short enough for the progress header + summary: String, + // The complete anyhow chain stays in the bounded log view + detail: String, + }, + RecoveryRequired { + index: usize, + // The summary stays short enough for the progress header + summary: String, + // The complete anyhow chain stays in the bounded log view + detail: String, + }, LogLine(String), Finished, } diff --git a/crates/unixnotis-installer/src/app/handlers.rs b/crates/unixnotis-installer/src/app/handlers.rs index a166c9f6e..d6e8aa438 100644 --- a/crates/unixnotis-installer/src/app/handlers.rs +++ b/crates/unixnotis-installer/src/app/handlers.rs @@ -14,6 +14,7 @@ use crate::app::{App, MenuItem, ProgressState, Screen}; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::terminal::TerminalGuard; +use crate::ui; pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Option { match key.code { @@ -153,7 +154,17 @@ pub fn handle_confirm_key( return Ok(Some(ExitAction::RunTrial { repo_root })); } ActionMode::Install | ActionMode::Uninstall | ActionMode::Reset => { - start_action(app, terminal_guard, ui_tx, mode)?; + start_action( + app, + |app| { + terminal_guard + .terminal_mut() + .draw(|frame| ui::draw(frame, app))?; + Ok(()) + }, + ui_tx, + mode, + )?; } } @@ -167,6 +178,10 @@ pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Option { if matches!(app.progress_state, ProgressState::Running) { return None; } + if matches!(app.progress_state, ProgressState::RecoveryRequired) { + // The worker still owns the installer lock and activation names + return matches!(key.code, KeyCode::Char('q' | 'Q')).then_some(ExitAction::None); + } if let Some(ready_at) = app.progress_ready_at { if Instant::now() < ready_at { return None; diff --git a/crates/unixnotis-installer/src/app/state.rs b/crates/unixnotis-installer/src/app/state.rs index fce25469a..bd242e50d 100644 --- a/crates/unixnotis-installer/src/app/state.rs +++ b/crates/unixnotis-installer/src/app/state.rs @@ -21,6 +21,8 @@ pub enum ProgressState { Completed, // Action failed Failed, + // Recovery could not prove the disk/runtime state safe; the worker remains alive + RecoveryRequired, } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/app/tests/events.rs b/crates/unixnotis-installer/src/app/tests/events.rs index 1df78107e..ee1dc902e 100644 --- a/crates/unixnotis-installer/src/app/tests/events.rs +++ b/crates/unixnotis-installer/src/app/tests/events.rs @@ -28,18 +28,49 @@ fn ui_message_can_carry_release_status_update() { #[test] fn worker_event_failed_keeps_step_index_and_message() { - let event = WorkerEvent::StepFailed(3, "service start failed".to_string()); + let event = WorkerEvent::StepFailed { + index: 3, + summary: "service start failed".to_string(), + detail: "service start failed: bus unavailable".to_string(), + }; - // Failure events need both fields for progress rendering and final error text + // Failure events keep a short status summary and a full diagnostic chain match event { - WorkerEvent::StepFailed(index, message) => { + WorkerEvent::StepFailed { + index, + summary, + detail, + } => { assert_eq!(index, 3); - assert_eq!(message, "service start failed"); + assert_eq!(summary, "service start failed"); + assert_eq!(detail, "service start failed: bus unavailable"); } _ => panic!("expected failed event"), } } +#[test] +fn worker_event_recovery_required_keeps_detailed_failure_without_finished_event() { + let event = WorkerEvent::RecoveryRequired { + index: 2, + summary: "rollback state is unknown".to_string(), + detail: "rollback state is unknown: journal unreadable".to_string(), + }; + + match event { + WorkerEvent::RecoveryRequired { + index, + summary, + detail, + } => { + assert_eq!(index, 2); + assert_eq!(summary, "rollback state is unknown"); + assert_eq!(detail, "rollback state is unknown: journal unreadable"); + } + _ => panic!("expected recovery-required event"), + } +} + #[test] fn worker_log_line_keeps_original_text() { let event = WorkerEvent::LogLine("Installed service artifact".to_string()); diff --git a/crates/unixnotis-installer/src/app/tests/handlers.rs b/crates/unixnotis-installer/src/app/tests/handlers.rs index bd656f4a4..3a2e24423 100644 --- a/crates/unixnotis-installer/src/app/tests/handlers.rs +++ b/crates/unixnotis-installer/src/app/tests/handlers.rs @@ -172,6 +172,24 @@ fn progress_screen_quit_and_escape_work_after_action_finishes() { assert_eq!(app.screen, Screen::Welcome); } +#[test] +fn recovery_required_progress_allows_only_quit() { + let _lock = crate::test_support::env::test_env_lock(); + let mut app = App::new(None); + app.screen = Screen::Progress(ActionMode::Install); + app.progress_state = ProgressState::RecoveryRequired; + app.progress_ready_at = None; + + assert!(handle_progress_key(&mut app, key(KeyCode::Enter)).is_none()); + assert_eq!(app.screen, Screen::Progress(ActionMode::Install)); + assert!(handle_progress_key(&mut app, key(KeyCode::Esc)).is_none()); + assert_eq!(app.screen, Screen::Progress(ActionMode::Install)); + assert!(matches!( + handle_progress_key(&mut app, key(KeyCode::Char('q'))), + Some(ExitAction::None) + )); +} + #[test] fn progress_screen_respects_ready_delay_after_completion() { let _lock = crate::test_support::env::test_env_lock(); diff --git a/crates/unixnotis-installer/src/app/tests/state.rs b/crates/unixnotis-installer/src/app/tests/state.rs index 10d49c71a..3c16e87ea 100644 --- a/crates/unixnotis-installer/src/app/tests/state.rs +++ b/crates/unixnotis-installer/src/app/tests/state.rs @@ -1,8 +1,13 @@ use std::collections::VecDeque; use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::actions::{check_install_state, BuildAccelConfigStatus, BuildAccelDetection}; +use sha2::{Digest, Sha256}; + +use crate::actions::{ + check_install_state, BuildAccelConfigStatus, BuildAccelDetection, InstallationDisposition, +}; use crate::app::{App, BuildAccelMenuMode, BuildAccelState, MenuItem, ProgressState, Screen}; use crate::checks::{CheckItem, CheckState, Checks}; use crate::detect::Detection; @@ -31,6 +36,46 @@ fn selected_menu_clamps_out_of_range_index_to_last_item() { assert_eq!(app.selected_menu(), MenuItem::Quit); } +#[test] +fn refresh_reloads_environment_checks_instead_of_retaining_stale_state() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("app-refresh-checks"); + let _session = crate::test_support::env::EnvGuard::set("XDG_SESSION_TYPE", "x11"); + let _display = crate::test_support::env::EnvGuard::set("WAYLAND_DISPLAY", ""); + let _runtime = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", root.join("run")); + let _home = crate::test_support::env::EnvGuard::set("HOME", root.join("home")); + let _config = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", root.join("config")); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let mut app = app_with_build_accel(None); + + assert_eq!(app.checks.wayland.state, CheckState::Ok); + app.refresh(); + + assert_eq!(app.checks.wayland.state, CheckState::Fail); + fs::remove_dir_all(root).expect("remove refresh fixture"); +} + +#[test] +fn refresh_backups_replaces_stale_rows_and_resets_selection() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("app-refresh-backups"); + let config_home = root.join("config"); + let backup = config_home.join("unixnotis").join("Backup-2026-08-08"); + fs::create_dir_all(&backup).expect("backup fixture"); + let _config = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", &config_home); + let mut app = app_with_build_accel(None); + app.restore_backups = vec![root.join("stale-backup")]; + app.restore_menu_index = 9; + + app.refresh_backups(); + + assert_eq!(app.restore_backups, [backup]); + assert_eq!(app.restore_menu_index, 0); + fs::remove_dir_all(root).expect("remove backup refresh fixture"); +} + #[test] fn build_accel_menu_mode_returns_only_when_no_prompt_state_exists() { let app = app_with_build_accel(None); @@ -81,25 +126,75 @@ fn action_label_uses_install_wording_when_state_is_unknown() { } #[test] -fn action_label_uses_reinstall_when_expected_artifacts_are_present() { +fn action_label_distinguishes_healthy_install_from_missing_service_artifact() { + let _lock = crate::test_support::env::test_env_lock(); let root = test_root("app-reinstall-label"); let repo_root = root.join("repo"); let bin_dir = root.join("bin"); - let systemd_dir = root.join("systemd"); + let systemd_dir = root.join("config").join("systemd").join("user"); + let _home = crate::test_support::env::EnvGuard::set("HOME", root.join("home")); + let _user = crate::test_support::env::EnvGuard::set("USER", "unixnotis-test"); + let _config_home = + crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", root.join("config")); + let _runit = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_RUNIT_SERVICE_DIR", root.join("runit")); + let _svdir = crate::test_support::env::EnvGuard::set("SVDIR", root.join("runit")); + let _s6_data = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_S6_DATA_DIR", root.join("s6")); + let _s6_live = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_S6RC_LIVE_DIR", root.join("s6-live")); fs::create_dir_all(&repo_root).expect("repo dir"); fs::create_dir_all(&bin_dir).expect("bin dir"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in\n *is-enabled*) exit 0 ;;\nesac\nprintf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive'\nexit 0\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); - // Minimal workspace metadata keeps the install-state check focused on one - // binary while still using the real metadata parser + // A complete release inventory keeps the install-state check focused on one binary fs::write( - repo_root.join("Cargo.toml"), - r#" -[workspace.metadata.unixnotis.installer] -binaries = ["unixnotis-daemon"] -"#, + repo_root.join("unixnotis-release.json"), + r#"{"version":"test","binaries":["unixnotis-daemon"]}"#, ) - .expect("workspace metadata"); - fs::write(bin_dir.join("unixnotis-daemon"), "#!/bin/sh\n").expect("installed binary"); + .expect("release metadata"); + fs::create_dir_all(repo_root.join("bin")).expect("release source directory"); + fs::write(repo_root.join("bin/unixnotis-daemon"), "release source") + .expect("release source binary"); + let binary_contents = b"#!/bin/sh\n"; + let digest = format!("{:x}", Sha256::digest(binary_contents)); + let size = u64::try_from(binary_contents.len()).expect("test binary size fits u64"); + let build_id = release_build_id("test", "unixnotis-daemon", size, &digest); + let generation = format!("test-{build_id}"); + let release_root = root + .join("lib") + .join("unixnotis") + .join("releases") + .join(&generation); + let release_binary = release_root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(release_binary.parent().expect("release binary parent")) + .expect("release binary directory"); + fs::write(&release_binary, binary_contents).expect("installed release binary"); + fs::set_permissions(&release_binary, fs::Permissions::from_mode(0o755)) + .expect("installed release binary mode"); + fs::write( + release_root.join("manifest.json"), + format!( + "{{\"schema_version\":1,\"package_version\":\"test\",\"build_id\":\"{build_id}\",\"binaries\":{{\"unixnotis-daemon\":{{\"size\":{size},\"sha256\":\"{digest}\"}}}}}}" + ), + ) + .expect("installed release manifest"); + symlink( + std::path::Path::new("releases").join(generation), + root.join("lib/unixnotis/current"), + ) + .expect("current release link"); + symlink( + "../lib/unixnotis/current/bin/unixnotis-daemon", + bin_dir.join("unixnotis-daemon"), + ) + .expect("installed binary entrypoint"); let service = ServiceManager::systemd_user(systemd_dir); for artifact in service.artifacts(&bin_dir) { @@ -129,10 +224,33 @@ binaries = ["unixnotis-daemon"] // Installed binaries plus a safe service artifact should turn the primary // install action into a reinstall action in the TUI assert_eq!(app.action_label(ActionMode::Install), "Reinstall"); + assert_eq!( + app.installation_disposition(), + InstallationDisposition::InstalledHealthy + ); + + fs::remove_file(paths.service.primary_artifact_path()).expect("remove primary artifact"); + app.install_state = Some(check_install_state(&paths)); + + // Existing verified binaries with an incomplete service install need repair, not a fresh install + assert_eq!(app.action_label(ActionMode::Install), "Repair"); + assert_eq!( + app.installation_disposition(), + InstallationDisposition::RepairRequired + ); let _ = fs::remove_dir_all(root); } +fn release_build_id(package_version: &str, binary_name: &str, size: u64, digest: &str) -> String { + let mut release_digest = Sha256::new(); + release_digest.update(package_version.as_bytes()); + release_digest.update(binary_name.as_bytes()); + release_digest.update(size.to_le_bytes()); + release_digest.update(digest.as_bytes()); + format!("{:x}", release_digest.finalize()) +} + fn app_with_build_accel(detection: Option) -> App { App { checks: passing_checks(), diff --git a/crates/unixnotis-installer/src/app/workflow.rs b/crates/unixnotis-installer/src/app/workflow.rs deleted file mode 100644 index e6488e00d..000000000 --- a/crates/unixnotis-installer/src/app/workflow.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Action workflow, worker coordination, and state transitions for the installer - -use anyhow::Result; -use std::sync::atomic::AtomicBool; -use std::sync::mpsc; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use crate::actions::{ - build_plan, check_install_state, detect_build_accel, detect_build_accel_without_repo, run_step, - steps_from_plan, write_build_accel_config, ActionContext, BuildAccelOutcome, StepKind, -}; -use crate::app::events::{UiMessage, WorkerEvent}; -use crate::app::{App, ProgressState, Screen}; -use crate::model::{ActionMode, StepStatus}; -use crate::paths::InstallPaths; -use crate::terminal::TerminalGuard; -use crate::ui; - -pub fn start_action( - app: &mut App, - terminal_guard: &mut TerminalGuard, - ui_tx: &mpsc::SyncSender, - mode: ActionMode, -) -> Result<()> { - // Resolve paths once so every step in this action uses the same install target - let paths = InstallPaths::discover_with_service_manager(app.service_manager)?; - // Install state is only needed for install decisions like service start mode - let install_state = if mode == ActionMode::Install { - Some(check_install_state(&paths)) - } else { - None - }; - - let (plan, restore_backup) = match mode { - ActionMode::Reset => match &app.reset_action { - // Default reset uses the normal reset plan - crate::model::ResetAction::ResetDefaults => (build_plan(mode), None), - crate::model::ResetAction::RestoreBackup { path } => { - // Restore runs only the restore step and carries the chosen backup path - (vec![StepKind::RestoreConfig], Some(path.clone())) - } - }, - _ => (build_plan(mode), None), - }; - - // Reset visible progress state before the worker starts sending events - app.steps = steps_from_plan(&plan); - app.logs.clear(); - app.last_error = None; - app.progress_state = ProgressState::Running; - app.progress_ready_at = None; - app.screen = Screen::Progress(mode); - - terminal_guard - .terminal_mut() - .draw(|frame| ui::draw(frame, app))?; - - // Detection is cloned so the worker can run without borrowing UI state - let detection = app.detection.clone(); - let ui_tx = ui_tx.clone(); - thread::spawn(move || { - run_action_worker( - &plan, - mode, - &detection, - &paths, - install_state.as_ref(), - restore_backup.as_deref(), - &ui_tx, - ); - }); - - Ok(()) -} - -fn run_action_worker( - plan: &[StepKind], - mode: ActionMode, - detection: &crate::detect::Detection, - paths: &InstallPaths, - install_state: Option<&crate::actions::InstallState>, - restore_backup: Option<&std::path::Path>, - ui_tx: &mpsc::SyncSender, -) { - // Run plan steps on the worker thread and stream progress events to the UI - // The flag lives across steps so install can decide later whether reload is needed - let service_reload_required = Arc::new(AtomicBool::new(true)); - for (index, step) in plan.iter().enumerate() { - // Index maps to app.steps in the UI state - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepStarted(index))); - - // Build per-step context; clone install_state to avoid borrow issues - let result = { - let mut ctx = ActionContext { - detection, - paths, - install_state: install_state.cloned(), - log_tx: ui_tx.clone(), - action_mode: mode, - restore_backup: restore_backup.map(std::path::Path::to_path_buf), - service_reload_required: service_reload_required.clone(), - }; - run_step(*step, &mut ctx) - }; - - match result { - Ok(()) => { - // Successful steps advance the progress list in order - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepCompleted(index))); - } - Err(err) => { - // Stop the worker after the first failed step so later steps cannot compound damage - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepFailed( - index, - err.to_string(), - ))); - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); - return; - } - } - } - - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); -} - -pub fn apply_worker_event(app: &mut App, event: WorkerEvent) { - match event { - WorkerEvent::StepStarted(index) => { - // Missing indices are ignored because UI state may have reset after worker start - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Running; - } - } - WorkerEvent::StepCompleted(index) => { - // Step completion is best-effort because the worker is decoupled from UI state - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Done; - } - } - WorkerEvent::StepFailed(index, err) => { - // Preserve the error message for the progress screen - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Failed; - } - app.last_error = Some(err.clone()); - append_log(app, format!("Error: {err}")); - app.progress_state = ProgressState::Failed; - app.progress_ready_at = Some(std::time::Instant::now() + Duration::from_millis(400)); - } - WorkerEvent::LogLine(line) => { - // Worker logs are bounded by append_log - append_log(app, line); - } - WorkerEvent::Finished => { - // Finished should not overwrite a failed progress state - if matches!(app.progress_state, ProgressState::Running) { - app.progress_state = ProgressState::Completed; - app.progress_ready_at = - Some(std::time::Instant::now() + Duration::from_millis(400)); - } - } - } -} - -fn append_log(app: &mut App, line: String) { - // Bound log memory usage by trimming old entries - const MAX_LINES: usize = 200; - - app.logs.push_back(line); - - if app.logs.len() > MAX_LINES { - // VecDeque allows O(1) removal from the front - while app.logs.len() > MAX_LINES { - app.logs.pop_front(); - } - } -} - -pub fn reset_to_menu(app: &mut App) { - // Return every transient menu and progress field to the welcome state - app.screen = Screen::Welcome; - app.last_error = None; - app.logs.clear(); - app.steps.clear(); - app.progress_state = ProgressState::Idle; - app.progress_ready_at = None; - app.build_accel = None; - app.build_accel_menu_index = 0; - app.reset_menu_index = 0; - app.reset_action = crate::model::ResetAction::ResetDefaults; - app.restore_backups.clear(); - app.restore_menu_index = 0; - app.refresh(); -} - -pub fn prepare_build_accel_prompt(app: &mut App) { - // Snapshot detection so the prompt remains stable while the user decides - let detection = match InstallPaths::discover_with_service_manager(app.service_manager) { - Ok(paths) => detect_build_accel(&paths.repo_root), - Err(err) => detect_build_accel_without_repo(err.to_string()), - }; - app.build_accel = Some(crate::app::BuildAccelState { - detection, - outcome: None, - }); - app.build_accel_menu_index = 0; -} - -fn apply_build_accel_setup(app: &mut App) { - // Writes per-repository Cargo config only when explicitly requested - let Some(state) = app.build_accel.as_mut() else { - return; - }; - let paths = match InstallPaths::discover_with_service_manager(app.service_manager) { - Ok(paths) => paths, - Err(err) => { - state.outcome = Some(BuildAccelOutcome::Failed(err.to_string())); - return; - } - }; - let outcome = write_build_accel_config(&paths.repo_root, &state.detection); - state.outcome = Some(outcome); - // Keep selection on the only available action once a result is shown - app.build_accel_menu_index = 0; - // Refresh detection so config state is reflected in the prompt immediately - state.detection = detect_build_accel(&paths.repo_root); -} - -pub fn handle_build_accel_enter(app: &mut App) { - match app.build_accel_menu_mode() { - crate::app::BuildAccelMenuMode::ReturnOnly => { - // Completed prompt returns directly to the main menu - reset_to_menu(app); - } - crate::app::BuildAccelMenuMode::EnableOrSkip => { - // First entry enables acceleration, second entry skips it - if app.build_accel_menu_index == 0 { - apply_build_accel_setup(app); - } else { - reset_to_menu(app); - } - } - crate::app::BuildAccelMenuMode::Reinstall => { - // Reinstall mode keeps return first and setup second - if app.build_accel_menu_index == 0 { - reset_to_menu(app); - } else { - apply_build_accel_setup(app); - } - } - } -} - -#[cfg(test)] -#[path = "tests/workflow.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/app/workflow/build_accel.rs b/crates/unixnotis-installer/src/app/workflow/build_accel.rs new file mode 100644 index 000000000..0a4bf3266 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/build_accel.rs @@ -0,0 +1,71 @@ +//! Build acceleration prompt state and repository-local setup + +use crate::actions::{ + detect_build_accel, detect_build_accel_without_repo, write_build_accel_config, + BuildAccelOutcome, +}; +use crate::app::{App, BuildAccelMenuMode, BuildAccelState}; +use crate::paths::InstallPaths; + +pub fn prepare_build_accel_prompt(app: &mut App) { + // Snapshot detection so the prompt remains stable while the user decides + let detection = match InstallPaths::discover_with_service_manager(app.service_manager) { + Ok(paths) => detect_build_accel(&paths.repo_root), + Err(err) => detect_build_accel_without_repo(err.to_string()), + }; + app.build_accel = Some(BuildAccelState { + detection, + outcome: None, + }); + app.build_accel_menu_index = 0; +} + +fn apply_build_accel_setup(app: &mut App) { + // Writes per-repository Cargo config only when explicitly requested + let Some(state) = app.build_accel.as_mut() else { + return; + }; + let paths = match InstallPaths::discover_with_service_manager(app.service_manager) { + Ok(paths) => paths, + Err(err) => { + state.outcome = Some(BuildAccelOutcome::Failed(err.to_string())); + return; + } + }; + let outcome = write_build_accel_config(&paths.repo_root, &state.detection); + state.outcome = Some(outcome); + // Keep selection on the only available action once a result is shown + app.build_accel_menu_index = 0; + // Refresh detection so config state is reflected in the prompt immediately + state.detection = detect_build_accel(&paths.repo_root); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildAccelEnterAction { + ReturnToMenu, + ApplySetup, +} + +pub const fn build_accel_enter_action( + mode: BuildAccelMenuMode, + selected_index: usize, +) -> BuildAccelEnterAction { + // One pure mapping keeps menu order separate from filesystem side effects + match mode { + BuildAccelMenuMode::EnableOrSkip if selected_index == 0 => { + BuildAccelEnterAction::ApplySetup + } + BuildAccelMenuMode::Reinstall if selected_index != 0 => BuildAccelEnterAction::ApplySetup, + BuildAccelMenuMode::ReturnOnly + | BuildAccelMenuMode::EnableOrSkip + | BuildAccelMenuMode::Reinstall => BuildAccelEnterAction::ReturnToMenu, + } +} + +pub fn handle_build_accel_enter(app: &mut App) { + let action = build_accel_enter_action(app.build_accel_menu_mode(), app.build_accel_menu_index); + match action { + BuildAccelEnterAction::ReturnToMenu => super::reset_to_menu(app), + BuildAccelEnterAction::ApplySetup => apply_build_accel_setup(app), + } +} diff --git a/crates/unixnotis-installer/src/app/workflow/controller.rs b/crates/unixnotis-installer/src/app/workflow/controller.rs new file mode 100644 index 000000000..1f47c1ae1 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/controller.rs @@ -0,0 +1,70 @@ +//! Top-level action setup and worker launch + +use anyhow::Result; +use std::sync::mpsc; +use std::thread; + +use crate::actions::{build_plan, check_install_state, steps_from_plan, InstallerLock, StepKind}; +use crate::app::events::UiMessage; +use crate::app::workflow::worker::{action_requires_install_state, run_action_worker}; +use crate::app::{App, ProgressState, Screen}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; + +pub fn start_action( + app: &mut App, + draw_action: F, + ui_tx: &mpsc::SyncSender, + mode: ActionMode, +) -> Result<()> +where + F: FnOnce(&App) -> Result<()>, +{ + // Resolve paths once so every step in this action uses the same install target + let paths = InstallPaths::discover_with_service_manager(app.service_manager)?; + // The retained descriptor serializes every mutating step across installer processes + let installer_lock = InstallerLock::acquire_for_session()?; + // Install state is only needed for install decisions like service start mode + let install_state = if action_requires_install_state(mode) { + Some(check_install_state(&paths)) + } else { + None + }; + + let (plan, restore_backup) = match mode { + ActionMode::Reset => match &app.reset_action { + // Default reset uses the normal reset plan + crate::model::ResetAction::ResetDefaults => (build_plan(mode), None), + crate::model::ResetAction::RestoreBackup { path } => { + // Restore runs only the restore step and carries the chosen backup path + (vec![StepKind::RestoreConfig], Some(path.clone())) + } + }, + _ => (build_plan(mode), None), + }; + + // Reset visible progress state before the worker starts sending events + app.steps = steps_from_plan(&plan); + app.logs.clear(); + app.last_error = None; + app.progress_state = ProgressState::Running; + app.progress_ready_at = None; + app.screen = Screen::Progress(mode); + + draw_action(app)?; + + let ui_tx = ui_tx.clone(); + thread::spawn(move || { + let _installer_lock = installer_lock; + run_action_worker( + &plan, + mode, + &paths, + install_state.as_ref(), + restore_backup.as_deref(), + &ui_tx, + ); + }); + + Ok(()) +} diff --git a/crates/unixnotis-installer/src/app/workflow/events.rs b/crates/unixnotis-installer/src/app/workflow/events.rs new file mode 100644 index 000000000..6781fa801 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/events.rs @@ -0,0 +1,99 @@ +//! UI-facing workflow state transitions + +use std::time::Duration; + +use crate::app::events::WorkerEvent; +use crate::app::{App, ProgressState, Screen}; +use crate::model::StepStatus; + +pub fn apply_worker_event(app: &mut App, event: WorkerEvent) { + match event { + WorkerEvent::StepStarted(index) => { + // Missing indices are ignored because UI state may have reset after worker start + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Running; + } + } + WorkerEvent::StepCompleted(index) => { + // Step completion is best-effort because the worker is decoupled from UI state + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Done; + } + } + WorkerEvent::StepFailed { + index, + summary, + detail, + } => { + // Preserve the error message for the progress screen + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Failed; + } + app.last_error = Some(summary); + // Keep the compact summary in the status panel and the complete anyhow chain in logs + append_log(app, format!("Error: {detail}")); + app.progress_state = ProgressState::Failed; + app.progress_ready_at = Some(std::time::Instant::now() + Duration::from_millis(400)); + } + WorkerEvent::RecoveryRequired { + index, + summary, + detail, + } => { + // A recovery-required worker is still alive and still owns activation + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Failed; + } + app.last_error = Some(summary); + append_log(app, format!("Error: {detail}")); + append_log( + app, + "CRITICAL: daemon activation remains inhibited because safe rollback could not be proven." + .to_string(), + ); + app.progress_state = ProgressState::RecoveryRequired; + app.progress_ready_at = None; + } + WorkerEvent::LogLine(line) => { + // Worker logs are bounded by append_log + append_log(app, line); + } + WorkerEvent::Finished => { + // Finished should not overwrite a failed progress state + if matches!(app.progress_state, ProgressState::Running) { + app.progress_state = ProgressState::Completed; + app.progress_ready_at = + Some(std::time::Instant::now() + Duration::from_millis(400)); + } + } + } +} + +fn append_log(app: &mut App, line: String) { + // Bound log memory usage by trimming old entries + const MAX_LINES: usize = 200; + + app.logs.push_back(line); + + // Each call adds one row, so at most one old row needs removal + if app.logs.len() > MAX_LINES { + let _oldest = app.logs.pop_front(); + } +} + +pub fn reset_to_menu(app: &mut App) { + // Return every transient menu and progress field to the welcome state + app.screen = Screen::Welcome; + app.last_error = None; + app.logs.clear(); + app.steps.clear(); + app.progress_state = ProgressState::Idle; + app.progress_ready_at = None; + app.build_accel = None; + app.build_accel_menu_index = 0; + app.reset_menu_index = 0; + app.reset_action = crate::model::ResetAction::ResetDefaults; + app.restore_backups.clear(); + app.restore_menu_index = 0; + app.refresh(); +} diff --git a/crates/unixnotis-installer/src/app/workflow/mod.rs b/crates/unixnotis-installer/src/app/workflow/mod.rs new file mode 100644 index 000000000..0367d8893 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/mod.rs @@ -0,0 +1,13 @@ +//! Installer workflow modules + +mod build_accel; +mod controller; +mod events; +mod recovery; +mod worker; + +pub(super) use build_accel::{handle_build_accel_enter, prepare_build_accel_prompt}; +pub(super) use controller::start_action; +pub(super) use events::{apply_worker_event, reset_to_menu}; +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-installer/src/app/workflow/recovery.rs b/crates/unixnotis-installer/src/app/workflow/recovery.rs new file mode 100644 index 000000000..c4afa27e6 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/recovery.rs @@ -0,0 +1,155 @@ +//! Failure classification and guarded rollback + +use anyhow::Result; +use std::sync::mpsc; +use std::thread; + +use crate::actions::{ + pending_release_exists, restart_previous_service, rollback_failed_activation, + rollback_pending_under_activation_reservation, ActionContext, DaemonActivationReservation, +}; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::workflow::worker::InstallLifecycle; + +pub enum InstallFailureRecovery { + Recovered(anyhow::Error), + ActivationInhibited(anyhow::Error), +} + +pub fn send_worker_failure(ui_tx: &mpsc::SyncSender, index: usize, err: &anyhow::Error) { + let summary = err.to_string(); + let detail = format!("{err:#}"); + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepFailed { + index, + summary, + detail, + })); +} + +pub fn send_recovery_required( + ui_tx: &mpsc::SyncSender, + index: usize, + err: &anyhow::Error, +) { + let summary = err.to_string(); + let detail = format!("{err:#}"); + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::RecoveryRequired { + index, + summary, + detail, + })); +} + +#[expect( + clippy::needless_pass_by_value, + reason = "the owned lifecycle must remain alive while this thread is parked" +)] +pub fn hold_activation_inhibition(lifecycle: InstallLifecycle) -> ! { + debug_assert!( + lifecycle.activation.is_some(), + "catastrophic recovery must retain the activation reservation" + ); + + // Keeping this stack frame alive keeps both the reservation and installer lock alive + loop { + thread::park(); + } +} + +pub fn recover_install_failure( + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, + activation_error: anyhow::Error, +) -> InstallFailureRecovery { + if lifecycle.activation.is_none() { + if lifecycle.release_pending { + return match rollback_failed_activation( + ctx, + &crate::actions::enforce_service_readiness, + activation_error, + ) { + Ok(()) => InstallFailureRecovery::Recovered(anyhow::anyhow!( + "failed install unexpectedly completed generation rollback without an error" + )), + Err(error) => InstallFailureRecovery::Recovered(error), + }; + } + + return InstallFailureRecovery::Recovered(activation_error); + } + + recover_guarded_failure_with_hooks( + ctx, + lifecycle, + activation_error, + pending_release_exists(ctx.paths), + rollback_pending_under_activation_reservation, + |ctx| restart_previous_service(ctx, &crate::actions::enforce_service_readiness), + ) +} + +pub fn recover_guarded_failure_with_hooks( + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, + activation_error: anyhow::Error, + pending: Result, + guarded_rollback: F, + restart_previous: R, +) -> InstallFailureRecovery +where + F: FnOnce(&mut ActionContext, &DaemonActivationReservation) -> Result, + R: FnOnce(&mut ActionContext) -> Result<()>, +{ + let pending = match pending { + Ok(value) => value, + Err(error) => { + return InstallFailureRecovery::ActivationInhibited(activation_error.context( + format!( + "could not determine pending release state; daemon activation remains inhibited: {error:#}" + ), + )); + } + }; + + if !pending { + if lifecycle.release_pending { + return InstallFailureRecovery::ActivationInhibited(activation_error.context( + "release state is inconsistent: worker expected a pending release but the recovery journal is missing; daemon activation remains inhibited", + )); + } + + lifecycle.activation.take(); + return InstallFailureRecovery::Recovered(activation_error); + } + + let rollback_result = { + let Some(reservation) = lifecycle.activation.as_ref() else { + return InstallFailureRecovery::ActivationInhibited( + activation_error + .context("activation reservation disappeared before guarded rollback"), + ); + }; + guarded_rollback(ctx, reservation) + }; + + match rollback_result { + Ok(restart) => { + lifecycle.activation.take(); + + if restart { + if let Err(error) = restart_previous(ctx) { + return InstallFailureRecovery::Recovered(activation_error.context(format!( + "previous generation failed after rollback: {error:#}" + ))); + } + } + + InstallFailureRecovery::Recovered(activation_error) + } + Err(rollback_error) => InstallFailureRecovery::ActivationInhibited(rollback_error.context( + format!( + "guarded rollback failed after the original install error: {activation_error:#}; daemon activation remains inhibited" + ), + )), + } +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs b/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs new file mode 100644 index 000000000..b7a601209 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs @@ -0,0 +1,73 @@ +use super::super::build_accel::{ + build_accel_enter_action, handle_build_accel_enter, BuildAccelEnterAction, +}; +use crate::actions::{BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome}; +use crate::app::{App, BuildAccelMenuMode, BuildAccelState, Screen}; + +#[test] +fn build_accel_enter_selection_maps_each_menu_mode_to_one_action() { + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::ReturnOnly, 0), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::EnableOrSkip, 0), + BuildAccelEnterAction::ApplySetup + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::EnableOrSkip, 1), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::Reinstall, 0), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::Reinstall, 1), + BuildAccelEnterAction::ApplySetup + ); +} + +#[test] +fn build_accel_enable_action_writes_repo_local_setup_and_records_the_outcome() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("workflow-build-accel-setup"); + let repo = root.join("repo"); + let home = root.join("home"); + let config_home = home.join(".config"); + std::fs::create_dir_all(&repo).expect("create build acceleration repo"); + std::fs::create_dir_all(&home).expect("create build acceleration home"); + std::fs::write( + repo.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/unixnotis-daemon\", \"crates/unixnotis-core\"]\n", + ) + .expect("write build acceleration workspace identity"); + let _repo_env = crate::test_support::env::EnvGuard::set("UNIXNOTIS_REPO_ROOT", &repo); + let _home_env = crate::test_support::env::EnvGuard::set("HOME", &home); + let _config_env = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", &config_home); + let _manager_env = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_SERVICE_MANAGER", "systemd"); + let mut app = App::new(None); + app.screen = Screen::BuildAccel; + app.build_accel = Some(BuildAccelState { + detection: BuildAccelDetection { + sccache_installed: true, + mold_installed: false, + config_status: BuildAccelConfigStatus::Missing, + }, + outcome: None, + }); + app.build_accel_menu_index = 0; + + handle_build_accel_enter(&mut app); + + assert!(matches!( + app.build_accel + .as_ref() + .and_then(|state| state.outcome.as_ref()), + Some(BuildAccelOutcome::Written { .. }) + )); + assert!(repo.join(".cargo/config.toml").is_file()); + assert_eq!(app.build_accel_menu_index, 0); + std::fs::remove_dir_all(root).expect("remove build acceleration workflow fixture"); +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/controller.rs b/crates/unixnotis-installer/src/app/workflow/tests/controller.rs new file mode 100644 index 000000000..9c1a6e57f --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/controller.rs @@ -0,0 +1,47 @@ +use std::sync::mpsc; + +use super::super::controller::start_action; +use super::super::worker::action_requires_install_state; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::{App, Screen}; +use crate::model::ActionMode; + +#[test] +fn start_action_prepares_and_draws_the_test_workflow_before_worker_completion() { + let _lock = crate::test_support::env::test_env_lock(); + let runtime = crate::test_support::fs::unique_temp_path("start-action-runtime"); + std::fs::create_dir_all(&runtime).expect("create action runtime directory"); + let _runtime_env = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", &runtime); + let mut app = App::new(None); + let mut draws = 0_u8; + let (tx, rx) = mpsc::sync_channel(8); + + start_action( + &mut app, + |_| { + draws = draws.saturating_add(1); + Ok(()) + }, + &tx, + ActionMode::Test, + ) + .expect("start empty test action"); + + assert_eq!(draws, 1); + assert_eq!(app.screen, Screen::Progress(ActionMode::Test)); + assert_eq!(app.progress_state, crate::app::ProgressState::Running); + assert!(matches!( + rx.recv_timeout(std::time::Duration::from_secs(1)) + .expect("worker completion event"), + UiMessage::Worker(WorkerEvent::Finished) + )); + std::fs::remove_dir_all(runtime).expect("remove action runtime directory"); +} + +#[test] +fn only_install_actions_capture_the_pre_action_install_state() { + assert!(action_requires_install_state(ActionMode::Install)); + assert!(!action_requires_install_state(ActionMode::Test)); + assert!(!action_requires_install_state(ActionMode::Reset)); + assert!(!action_requires_install_state(ActionMode::Uninstall)); +} diff --git a/crates/unixnotis-installer/src/app/tests/workflow.rs b/crates/unixnotis-installer/src/app/workflow/tests/events.rs similarity index 68% rename from crates/unixnotis-installer/src/app/tests/workflow.rs rename to crates/unixnotis-installer/src/app/workflow/tests/events.rs index 26e7bcac2..b23b0e18f 100644 --- a/crates/unixnotis-installer/src/app/tests/workflow.rs +++ b/crates/unixnotis-installer/src/app/workflow/tests/events.rs @@ -1,24 +1,9 @@ use crate::app::events::WorkerEvent; -use crate::app::workflow::{apply_worker_event, reset_to_menu}; -use crate::app::{App, BuildAccelState, ProgressState, Screen}; +use crate::app::{BuildAccelState, ProgressState, Screen}; use crate::model::{ActionStep, ResetAction, StepStatus}; -fn app_with_steps() -> App { - let _lock = crate::test_support::env::test_env_lock(); - let mut app = App::new(None); - app.steps = vec![ - ActionStep { - name: "first", - status: StepStatus::Pending, - }, - ActionStep { - name: "second", - status: StepStatus::Pending, - }, - ]; - app.progress_state = ProgressState::Running; - app -} +use super::super::events::{apply_worker_event, reset_to_menu}; +use super::support::app_with_steps; #[test] fn worker_step_events_update_only_existing_steps() { @@ -38,15 +23,53 @@ fn worker_step_events_update_only_existing_steps() { fn worker_failure_marks_step_logs_error_and_blocks_finished_from_success() { let mut app = app_with_steps(); - apply_worker_event(&mut app, WorkerEvent::StepFailed(1, "boom".to_string())); + apply_worker_event( + &mut app, + WorkerEvent::StepFailed { + index: 1, + summary: "boom".to_string(), + detail: "boom: nested cause".to_string(), + }, + ); apply_worker_event(&mut app, WorkerEvent::Finished); // Finished must not erase the failure state produced by the worker assert_eq!(app.steps[1].status, StepStatus::Failed); assert_eq!(app.progress_state, ProgressState::Failed); assert_eq!(app.last_error.as_deref(), Some("boom")); - assert_eq!(app.logs.back().map(String::as_str), Some("Error: boom")); + assert_eq!( + app.logs.back().map(String::as_str), + Some("Error: boom: nested cause") + ); assert!(app.progress_ready_at.is_some()); + assert!(app + .progress_ready_at + .is_some_and(|deadline| deadline > std::time::Instant::now())); +} + +#[test] +fn recovery_required_event_keeps_the_worker_state_inhibited() { + let mut app = app_with_steps(); + + apply_worker_event( + &mut app, + WorkerEvent::RecoveryRequired { + index: 1, + summary: "rollback failed".to_string(), + detail: "rollback failed: service state unknown".to_string(), + }, + ); + apply_worker_event(&mut app, WorkerEvent::Finished); + + // A catastrophic worker intentionally does not finish, so Finished cannot turn this into success + assert_eq!(app.steps[1].status, StepStatus::Failed); + assert_eq!(app.progress_state, ProgressState::RecoveryRequired); + assert_eq!(app.last_error.as_deref(), Some("rollback failed")); + assert_eq!( + app.logs.back().map(String::as_str), + Some("CRITICAL: daemon activation remains inhibited because safe rollback could not be proven.") + ); + assert!(app.progress_ready_at.is_none()); } #[test] @@ -58,6 +81,9 @@ fn worker_finished_marks_running_action_completed() { // Successful workers delay navigation briefly so users can read completion state assert_eq!(app.progress_state, ProgressState::Completed); assert!(app.progress_ready_at.is_some()); + assert!(app + .progress_ready_at + .is_some_and(|deadline| deadline > std::time::Instant::now())); } #[test] @@ -77,7 +103,7 @@ fn worker_logs_keep_recent_two_hundred_entries() { #[test] fn reset_to_menu_clears_transient_action_state() { let _lock = crate::test_support::env::test_env_lock(); - let mut app = App::new(None); + let mut app = crate::app::App::new(None); app.steps = vec![ActionStep { name: "first", status: StepStatus::Running, diff --git a/crates/unixnotis-installer/src/app/workflow/tests/mod.rs b/crates/unixnotis-installer/src/app/workflow/tests/mod.rs new file mode 100644 index 000000000..060ba57de --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/mod.rs @@ -0,0 +1,6 @@ +mod build_accel; +mod controller; +mod events; +mod recovery; +mod support; +mod worker; diff --git a/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs b/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs new file mode 100644 index 000000000..a447f50b8 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs @@ -0,0 +1,188 @@ +use anyhow::anyhow; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use super::super::recovery::{ + recover_guarded_failure_with_hooks, recover_install_failure, InstallFailureRecovery, +}; +use super::support::{guarded_lifecycle, recovery_context, recovery_paths}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkerFailureAction { + Return, + HoldActivation, +} + +const fn worker_failure_action(recovery: &InstallFailureRecovery) -> WorkerFailureAction { + match recovery { + InstallFailureRecovery::Recovered(_) => WorkerFailureAction::Return, + InstallFailureRecovery::ActivationInhibited(_) => WorkerFailureAction::HoldActivation, + } +} + +#[test] +fn pending_journal_inspection_failure_keeps_activation_inhibited() { + let root = crate::test_support::fs::unique_temp_path("workflow-pending-inspection-failure"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Err(anyhow!("journal unreadable")), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn guarded_rollback_failure_keeps_activation_inhibited() { + let root = crate::test_support::fs::unique_temp_path("workflow-rollback-failure"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(true), + |_ctx, _reservation| Err(anyhow!("rollback failed")), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn successful_guarded_rollback_releases_before_previous_restart() { + let root = crate::test_support::fs::unique_temp_path("workflow-rollback-success"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + let restart_saw_released = Arc::clone(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(true), + |_ctx, _reservation| Ok(true), + move |_ctx| { + assert!(!restart_saw_released.load(Ordering::Acquire)); + Ok(()) + }, + ); + + assert!(matches!(recovery, InstallFailureRecovery::Recovered(_))); + assert!(lifecycle.activation.is_none()); + assert!(!alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn missing_pending_journal_without_memory_mutation_is_an_ordinary_failure() { + let root = crate::test_support::fs::unique_temp_path("workflow-no-pending"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("staging failed"), + Ok(false), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!(recovery, InstallFailureRecovery::Recovered(_))); + assert!(lifecycle.activation.is_none()); + assert!(!alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn missing_pending_journal_with_memory_mutation_is_catastrophic() { + let root = crate::test_support::fs::unique_temp_path("workflow-pending-contradiction"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + lifecycle.release_pending = true; + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(false), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn catastrophic_recovery_selects_hold_action_without_blocking_the_test() { + let recovered = InstallFailureRecovery::Recovered(anyhow!("ordinary failure")); + let inhibited = InstallFailureRecovery::ActivationInhibited(anyhow!("unsafe to release")); + + assert_eq!( + worker_failure_action(&recovered), + WorkerFailureAction::Return + ); + assert_eq!( + worker_failure_action(&inhibited), + WorkerFailureAction::HoldActivation + ); +} + +#[test] +fn worker_recovery_keeps_the_real_guard_when_pending_inspection_fails() { + let root = crate::test_support::fs::unique_temp_path("workflow-real-pending-error"); + let paths = recovery_paths(&root); + let pending_path = paths + .installed_pending_manifest() + .expect("pending manifest path"); + std::fs::create_dir_all(&pending_path).expect("make unreadable pending manifest object"); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_install_failure(&mut ctx, &mut lifecycle, anyhow!("install failed")); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/support.rs b/crates/unixnotis-installer/src/app/workflow/tests/support.rs new file mode 100644 index 000000000..b5747860d --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/support.rs @@ -0,0 +1,117 @@ +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::sync::Arc; + +use super::super::worker::InstallLifecycle; +use crate::actions::DaemonActivationReservation; +use crate::app::{App, ProgressState}; +use crate::model::{ActionStep, StepStatus}; +use anyhow::{Context, Result}; + +pub(super) fn app_with_steps() -> App { + let _lock = crate::test_support::env::test_env_lock(); + let mut app = App::new(None); + app.steps = vec![ + ActionStep { + name: "first", + status: StepStatus::Pending, + }, + ActionStep { + name: "second", + status: StepStatus::Pending, + }, + ]; + app.progress_state = ProgressState::Running; + app +} + +pub(super) fn recovery_context( + paths: &crate::paths::InstallPaths, +) -> crate::actions::ActionContext<'_> { + let (tx, _rx) = mpsc::sync_channel(8); + crate::actions::ActionContext { + paths, + install_state: None, + log_tx: tx, + action_mode: crate::model::ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +pub(super) fn recovery_paths(root: &std::path::Path) -> crate::paths::InstallPaths { + std::fs::create_dir_all(root).expect("create recovery fixture"); + crate::paths::InstallPaths { + repo_root: root.to_path_buf(), + bin_dir: root.join("home").join(".local").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +pub(super) fn guarded_lifecycle(alive: &Arc) -> InstallLifecycle { + InstallLifecycle { + activation: Some(crate::actions::DaemonActivationReservation::test_guard( + Arc::clone(alive), + )), + release_pending: false, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the test seam names each lifecycle boundary explicitly" +)] +pub(super) fn run_install_lifecycle_with_hooks< + Stop, + Acquire, + Check, + Binary, + Service, + Prepare, + Start, +>( + lifecycle: &mut InstallLifecycle, + stop: Stop, + acquire: Acquire, + mut check_after_guard: Check, + install_binaries: Binary, + install_service: Service, + prepare_service: Prepare, + start: Start, +) -> Result<()> +where + Stop: FnOnce() -> Result<()>, + Acquire: FnOnce() -> Result, + Check: FnMut(&DaemonActivationReservation) -> Result<()>, + Binary: FnOnce(&DaemonActivationReservation) -> Result<()>, + Service: FnOnce(&DaemonActivationReservation) -> Result<()>, + Prepare: FnOnce(&DaemonActivationReservation) -> Result<()>, + Start: FnOnce() -> Result<()>, +{ + stop()?; + lifecycle.activation = Some(acquire()?); + + let reservation = lifecycle + .activation + .as_ref() + .context("test lifecycle lost activation reservation")?; + check_after_guard(reservation)?; + install_binaries(reservation)?; + install_service(reservation)?; + prepare_service(reservation)?; + check_after_guard(reservation)?; + + // The controlled start is the only point where the names may be released + drop( + lifecycle + .activation + .take() + .context("test lifecycle missing activation handoff")?, + ); + start() +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/worker.rs b/crates/unixnotis-installer/src/app/workflow/tests/worker.rs new file mode 100644 index 000000000..b0f269950 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/worker.rs @@ -0,0 +1,99 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; + +use crate::actions::StepKind; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::model::ActionMode; + +use super::super::worker::{ + release_pending_after_completed_step, run_action_worker, InstallLifecycle, +}; +use super::support::run_install_lifecycle_with_hooks; + +#[test] +fn empty_worker_plan_still_reports_completion() { + let root = crate::test_support::fs::unique_temp_path("empty-worker-plan"); + let paths = crate::paths::InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + let (tx, rx) = mpsc::sync_channel(4); + + run_action_worker(&[], ActionMode::Install, &paths, None, None, &tx); + + assert!(matches!( + rx.recv_timeout(std::time::Duration::from_secs(1)) + .expect("worker completion event"), + UiMessage::Worker(WorkerEvent::Finished) + )); +} + +#[test] +fn worker_owned_guard_spans_install_steps_and_drops_before_controlled_start() { + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = InstallLifecycle::new(); + + run_install_lifecycle_with_hooks( + &mut lifecycle, + || Ok(()), + || { + Ok(crate::actions::DaemonActivationReservation::test_guard( + Arc::clone(&alive), + )) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + || { + assert!(!alive.load(Ordering::Acquire)); + Ok(()) + }, + ) + .expect("complete guarded install lifecycle"); + + assert!(lifecycle.activation.is_none()); +} + +#[test] +fn release_rollback_state_starts_at_binary_activation_and_ends_after_readiness() { + assert!(release_pending_after_completed_step( + false, + StepKind::InstallBinaries + )); + assert!(!release_pending_after_completed_step( + true, + StepKind::EnableService + )); + assert!(release_pending_after_completed_step( + true, + StepKind::InstallService + )); + assert!(!release_pending_after_completed_step( + false, + StepKind::EnsureConfig + )); +} diff --git a/crates/unixnotis-installer/src/app/workflow/worker.rs b/crates/unixnotis-installer/src/app/workflow/worker.rs new file mode 100644 index 000000000..93fd5689f --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/worker.rs @@ -0,0 +1,145 @@ +//! Worker execution and guarded installation lifecycle + +use anyhow::{Context, Result}; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::sync::Arc; + +use crate::actions::{ + commit_pending_release, ensure_selected_service_inactive, run_step_with_reservation, + start_service_and_verify, stop_active_daemon, ActionContext, DaemonActivationReservation, + StepKind, +}; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::workflow::recovery::{ + hold_activation_inhibition, recover_install_failure, send_recovery_required, + send_worker_failure, InstallFailureRecovery, +}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; + +pub const fn action_requires_install_state(mode: ActionMode) -> bool { + matches!(mode, ActionMode::Install) +} + +pub struct InstallLifecycle { + // This guard lives across StopDaemon, binary publication, and service preparation + pub(super) activation: Option, + // A committed binary step leaves a reversible pending generation behind + pub(super) release_pending: bool, +} + +impl InstallLifecycle { + pub(super) const fn new() -> Self { + Self { + activation: None, + release_pending: false, + } + } +} + +pub fn run_action_worker( + plan: &[StepKind], + mode: ActionMode, + paths: &InstallPaths, + install_state: Option<&crate::actions::InstallState>, + restore_backup: Option<&std::path::Path>, + ui_tx: &mpsc::SyncSender, +) { + // Run plan steps on the worker thread and stream progress events to the UI + // The flag lives across steps so install can decide later whether reload is needed + let service_reload_required = Arc::new(AtomicBool::new(true)); + let mut lifecycle = InstallLifecycle::new(); + for (index, step) in plan.iter().enumerate() { + // Index maps to app.steps in the UI state + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepStarted(index))); + + // Build per-step context; clone install_state to avoid borrow issues + let mut ctx = ActionContext { + paths, + install_state: install_state.cloned(), + log_tx: ui_tx.clone(), + action_mode: mode, + restore_backup: restore_backup.map(std::path::Path::to_path_buf), + service_reload_required: service_reload_required.clone(), + }; + let result = if mode == ActionMode::Install { + run_install_step(*step, &mut ctx, &mut lifecycle) + } else { + run_step_with_reservation(*step, &mut ctx, None) + }; + + match result { + Ok(()) => { + lifecycle.release_pending = + release_pending_after_completed_step(lifecycle.release_pending, *step); + // Successful steps advance the progress list in order + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepCompleted(index))); + } + Err(err) => match recover_install_failure(&mut ctx, &mut lifecycle, err) { + InstallFailureRecovery::Recovered(err) => { + send_worker_failure(ui_tx, index, &err); + // Stop the worker after the first failed step so later steps cannot compound damage + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); + return; + } + InstallFailureRecovery::ActivationInhibited(err) => { + send_recovery_required(ui_tx, index, &err); + // The worker and its installer lock remain alive while this guard is held + hold_activation_inhibition(lifecycle); + } + }, + } + } + + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); +} + +pub fn run_install_step( + step: StepKind, + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, +) -> Result<()> { + match step { + StepKind::StopDaemon => { + stop_active_daemon(ctx)?; + let reservation = DaemonActivationReservation::acquire() + .context("reserve daemon activation after shutdown")?; + ensure_selected_service_inactive(ctx.paths) + .context("recheck selected service after activation reservation")?; + lifecycle.activation = Some(reservation); + Ok(()) + } + StepKind::EnableService => { + { + let reservation = lifecycle + .activation + .as_ref() + .context("service start requires daemon activation reservation")?; + crate::actions::prepare_service_start_under_reservation(ctx, reservation)?; + ensure_selected_service_inactive(ctx.paths) + .context("verify service remains inactive after artifact refresh")?; + } + + // The next operation is the intentional handoff to the new daemon + let reservation = lifecycle + .activation + .take() + .context("missing activation reservation before controlled service start")?; + drop(reservation); + start_service_and_verify(ctx, crate::actions::enforce_service_readiness)?; + commit_pending_release(ctx.paths).context("commit ready binary release generation")?; + Ok(()) + } + _ => run_step_with_reservation(step, ctx, lifecycle.activation.as_ref()), + } +} + +pub(super) const fn release_pending_after_completed_step(current: bool, step: StepKind) -> bool { + match step { + // Binary activation stays reversible until the matching service passes readiness + StepKind::InstallBinaries => true, + StepKind::EnableService => false, + _ => current, + } +} diff --git a/crates/unixnotis-installer/src/ui/progress.rs b/crates/unixnotis-installer/src/ui/progress.rs index 8fd970fa2..0471adad1 100644 --- a/crates/unixnotis-installer/src/ui/progress.rs +++ b/crates/unixnotis-installer/src/ui/progress.rs @@ -16,14 +16,21 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) ProgressState::Running => ("In progress", Color::Yellow), ProgressState::Completed => ("Completed", Color::Green), ProgressState::Failed => ("Failed", Color::Red), + ProgressState::RecoveryRequired => ("Manual recovery required", Color::Red), ProgressState::Idle => ("Pending", Color::Gray), }; + let status_height = if matches!(app.progress_state, ProgressState::RecoveryRequired) { + 8 + } else { + 6 + }; + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Length(6), + Constraint::Length(status_height), Constraint::Min(8), Constraint::Length(3), ]) @@ -46,13 +53,24 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) .add_modifier(Modifier::BOLD), ))]; if let Some(err) = &app.last_error { - if matches!(app.progress_state, ProgressState::Failed) { + if matches!( + app.progress_state, + ProgressState::Failed | ProgressState::RecoveryRequired + ) { let summary = summarize_error(err); status_lines.push(Line::from(vec![ Span::styled("Error: ", Style::default().fg(Color::Red)), Span::raw(summary), ])); - status_lines.push(Line::from("See logs for full output.")); + if matches!(app.progress_state, ProgressState::RecoveryRequired) { + status_lines.push(Line::from( + "UnixNotis activation remains inhibited while this installer is running.", + )); + status_lines.push(Line::from("Do not start another UnixNotis instance.")); + status_lines.push(Line::from("See logs for the complete failure chain.")); + } else { + status_lines.push(Line::from("See logs for full output.")); + } } } @@ -93,6 +111,7 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) "Enter = back to menu Q = quit" } } + ProgressState::RecoveryRequired => "Q = quit", ProgressState::Idle => "", }; let footer = Paragraph::new(footer_text) diff --git a/crates/unixnotis-installer/src/ui/tests/progress.rs b/crates/unixnotis-installer/src/ui/tests/progress.rs index 7c60cc4ad..7c695d4fc 100644 --- a/crates/unixnotis-installer/src/ui/tests/progress.rs +++ b/crates/unixnotis-installer/src/ui/tests/progress.rs @@ -63,3 +63,18 @@ fn draw_progress_running_state_uses_running_footer_without_error_summary() { assert!(screen.contains("Running...")); assert!(!screen.contains("Error:")); } + +#[test] +fn draw_progress_recovery_required_warns_that_activation_remains_inhibited() { + let mut app = app_for_rendering(Screen::Progress(ActionMode::Install)); + app.progress_state = ProgressState::RecoveryRequired; + app.last_error = Some("rollback state is unknown".to_string()); + + let screen = render_app(&app); + + assert!(screen.contains("Install - Manual recovery required")); + assert!(screen.contains("UnixNotis activation remains inhibited")); + assert!(screen.contains("Do not start another UnixNotis instance")); + assert!(screen.contains("Q = quit")); + assert!(!screen.contains("Enter = back to menu")); +} From 2ab173b525d789e7ee74f8ff0e641b7977b41891 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 10 Aug 2026 15:03:59 -0500 Subject: [PATCH 266/275] fix(notifications): bind default activation to sender ownership Allow an advertised default notification action only when the callback owner is bound to a concrete sender process lifetime. Keep application identity independent from callback ownership. Arbitrary action buttons and inline reply remain denied for owner-bound unresolved senders. Require stable sender name, PID, process start time, and UID evidence. Credential failures, incomplete evidence, conflicts, and relay fallback paths continue to fail closed. Add resolver, sender-evidence, and action-target regression coverage. --- .../unixnotis-core/src/model/interaction.rs | 8 ++ .../src/model/tests/interaction.rs | 16 ++++ .../identity/resolver/candidates.rs | 41 ++++++---- .../identity/resolver/resolution.rs | 12 ++- .../resolver/tests/candidates/claims.rs | 24 ++++++ .../identity/resolver/tests/resolution.rs | 80 ++++++++++++++++++- .../daemon/notifications/identity/sender.rs | 17 ++++ .../notifications/identity/tests/sender.rs | 21 +++++ .../src/store/tests/runtime/action_target.rs | 34 ++++++++ 9 files changed, 237 insertions(+), 16 deletions(-) diff --git a/crates/unixnotis-core/src/model/interaction.rs b/crates/unixnotis-core/src/model/interaction.rs index 272d63f62..652f8c137 100644 --- a/crates/unixnotis-core/src/model/interaction.rs +++ b/crates/unixnotis-core/src/model/interaction.rs @@ -47,6 +47,14 @@ impl InteractionPolicies { inline_reply: InlineReplyPolicy::Deny, }; + /// A strongly owner-bound sender may expose only the advertised default action + /// This does not authenticate application branding or grant richer controls + pub const OWNER_BOUND_DEFAULT: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Deny, + inline_reply: InlineReplyPolicy::Deny, + }; + /// Brokered and user-local associations require confirmation for every action pub const CONFIRM_ACTIONS: Self = Self { default_activation: ApplicationActionPolicy::Confirm, diff --git a/crates/unixnotis-core/src/model/tests/interaction.rs b/crates/unixnotis-core/src/model/tests/interaction.rs index feaa24b92..f1600d774 100644 --- a/crates/unixnotis-core/src/model/tests/interaction.rs +++ b/crates/unixnotis-core/src/model/tests/interaction.rs @@ -43,6 +43,22 @@ fn native_compatibility_keeps_default_activation_without_richer_authority() { ); } +#[test] +fn owner_bound_default_grants_only_default_activation() { + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.default_activation, + ApplicationActionPolicy::Allow + ); + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.action_buttons, + ApplicationActionPolicy::Deny + ); + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.inline_reply, + InlineReplyPolicy::Deny + ); +} + #[test] fn confirmation_and_denial_matrices_never_allow_inline_text() { for policies in [ diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs index b168eacc6..c0c8ee903 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; -use unixnotis_core::{AttributionReason, AttributionStatus, NotificationAttribution}; +use unixnotis_core::{ + AttributionReason, AttributionStatus, InteractionPolicies, NotificationAttribution, +}; use super::super::desktop_index::{ normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, LaunchFailure, @@ -13,7 +15,8 @@ use super::diagnostics::{launch_failure_label, with_diagnostics}; use super::evidence::{candidate_proves_conflict, lineage_association, sender_claim_relation}; use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; use super::resolution::{ - conflict_from_candidate, policy_resolution, recognized_resolution, sender_claim_group_key, + conflict_from_candidate, owner_bound_default_interactions, policy_resolution, + recognized_resolution, sender_claim_group_key, }; use super::{AppClaim, AttributionResolution}; @@ -141,12 +144,14 @@ fn resolve_matching_candidate( sender, candidate, "Sender belongs to a separate installed package without a positive application association", + InteractionPolicies::DENY, ), SenderClaimRelation::UnknownExecutable => unresolved_claim_resolution( claim, sender, candidate, "No positive sender association with the claimed application was established", + owner_bound_default_interactions(sender), ), SenderClaimRelation::DifferentVerifiedApplication => { conflict_from_candidate(claim, sender, index, candidate.record, failure) @@ -158,6 +163,7 @@ fn resolve_matching_candidate( sender, candidate, "The relay executable could not be revalidated", + InteractionPolicies::DENY, ) }), } @@ -185,18 +191,23 @@ fn unresolved_claim_resolution( sender: &SenderMetadata, candidate: &CandidateVerification<'_>, detail: &str, + interactions: InteractionPolicies, ) -> AttributionResolution { let detail = sender.sender_executable.as_deref().map_or_else( || detail.to_string(), |path| format!("{detail}; source {path}"), ); with_diagnostics( - policy_resolution(NotificationAttribution::unresolved( - claim.reported_name, - AttributionReason::NoDesktopCandidate, - &detail, - sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), - )), + policy_resolution({ + let mut attribution = NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::NoDesktopCandidate, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + ); + attribution.interactions = interactions; + attribution + }), claim, sender, Some(candidate.record), @@ -250,13 +261,15 @@ fn unresolved_candidate_resolution( || "No reliable desktop application candidate was found".to_string(), |path| format!("No desktop application matched source {path}"), ); + let mut attribution = NotificationAttribution::unresolved( + claim.reported_name, + reason, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + ); + attribution.interactions = owner_bound_default_interactions(sender); with_diagnostics( - policy_resolution(NotificationAttribution::unresolved( - claim.reported_name, - reason, - &detail, - sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), - )), + policy_resolution(attribution), claim, sender, None, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs index 020d5be13..9315368b9 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -15,6 +15,16 @@ use super::diagnostics::{launch_failure_label, with_diagnostics}; use super::model::VerifiedDesktopRecord; use super::{AppClaim, AttributionResolution}; +pub(super) const fn owner_bound_default_interactions( + sender: &SenderMetadata, +) -> InteractionPolicies { + if sender.has_stable_callback_owner() { + InteractionPolicies::OWNER_BOUND_DEFAULT + } else { + InteractionPolicies::DENY + } +} + pub(in crate::daemon) fn unknown_reply_denied( claim: AppClaim<'_>, sender: &SenderMetadata, @@ -185,7 +195,7 @@ pub(super) fn recognized_resolution( canonical_id, &canonical.badge_icon, assurance, - InteractionPolicies::DENY, + owner_bound_default_interactions(sender), attribution_reason_for_failure(failure), &source, group_key, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs index bd49519d3..ba8192994 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs @@ -41,6 +41,30 @@ fn mismatched_desktop_hint_does_not_become_claim_evidence() { ); } +#[test] +fn stable_unresolved_sender_keeps_only_the_protocol_default_action() { + let mut metadata = sender("/usr/bin/example", identity(100, 1_000, 0)); + metadata.sender_pid = Some(42); + metadata.sender_start_time = Some(4_200); + metadata.sender_uid = Some(1_000); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Example Application", + desktop_entry: None, + }, + &metadata, + &DesktopIdentityIndex::default(), + &[], + &[], + ); + + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} + #[test] fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { let executable = identity(102, 1_020, 0); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs index 5dd3012bb..99168ab4a 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -1,7 +1,8 @@ //! Attribution construction and grouping tests use super::super::resolution::{ - resolution_for_record, sender_claim_group_key, unknown_reply_denied, + owner_bound_default_interactions, recognized_resolution, resolution_for_record, + sender_claim_group_key, unknown_reply_denied, }; use super::*; use crate::daemon::notifications::identity::sender::SenderMetadataStatus; @@ -123,3 +124,80 @@ fn sender_credential_timeout_is_preserved_in_diagnostics() { .diagnostic_detail .contains("credential lookup timed out")); } + +#[test] +fn stable_callback_owner_gets_only_default_activation_authority() { + let mut metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + metadata.sender_pid = Some(42); + metadata.sender_start_time = Some(4_200); + metadata.sender_uid = Some(1_000); + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} + +#[test] +fn incomplete_callback_owner_gets_no_interaction_authority() { + let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::DENY + ); +} + +#[test] +fn credential_timeout_never_gets_owner_bound_default_authority() { + let metadata = SenderMetadata { + sender_name: Some(":1.43".to_string()), + sender_pid: Some(43), + sender_start_time: Some(4_300), + sender_uid: Some(1_000), + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + }; + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::DENY + ); +} + +#[test] +fn recognized_candidate_with_a_stable_owner_exposes_only_default_activation() { + let record = system_record( + "org.example.Application", + "Example Application", + "/usr/bin/example", + identity(107, 1_070, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.Application") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let mut metadata = sender("/usr/bin/example", identity(107, 1_070, 0)); + metadata.sender_pid = Some(43); + metadata.sender_start_time = Some(4_300); + metadata.sender_uid = Some(1_000); + + let resolution = recognized_resolution( + AppClaim { + reported_name: "Example Application", + desktop_entry: None, + }, + &metadata, + record, + &index, + LaunchFailure::ExecutableMismatch, + "generic stable-owner fixture", + ); + + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs index 79869921f..9ddad94dd 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -80,6 +80,23 @@ pub(in crate::daemon) struct SenderMetadata { pub(in crate::daemon::notifications) status: SenderMetadataStatus, } +impl SenderMetadata { + /// A callback can be returned only to one concrete process lifetime + /// This is delivery evidence, not application identity evidence + pub(in crate::daemon::notifications) const fn has_stable_callback_owner(&self) -> bool { + self.sender_name.is_some() + && self.sender_pid.is_some() + && self.sender_start_time.is_some() + && self.sender_uid.is_some() + && !matches!( + self.status, + SenderMetadataStatus::MissingSenderName + | SenderMetadataStatus::CredentialLookupFailed + | SenderMetadataStatus::CredentialLookupTimedOut + ) + } +} + fn metadata_with_status( sender_name: Option, status: SenderMetadataStatus, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs index 867034314..93a9ba046 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -91,6 +91,27 @@ fn status_metadata_preserves_sender_name_and_failure_status() { assert!(metadata.sender_uid.is_none()); } +#[test] +fn stable_callback_owner_requires_a_complete_process_lifetime_binding() { + let stable = SenderMetadata { + sender_name: Some(":1.42".to_string()), + sender_pid: Some(42), + sender_start_time: Some(420), + sender_uid: Some(1_000), + status: SenderMetadataStatus::ProcessEvidenceUnavailable, + ..SenderMetadata::default() + }; + assert!(stable.has_stable_callback_owner()); + + let mut missing_process = stable.clone(); + missing_process.sender_start_time = None; + assert!(!missing_process.has_stable_callback_owner()); + + let mut failed_lookup = stable; + failed_lookup.status = SenderMetadataStatus::CredentialLookupTimedOut; + assert!(!failed_lookup.has_stable_callback_owner()); +} + #[cfg(target_os = "linux")] #[test] fn parse_process_start_time_handles_spaces_in_comm() { diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs index 0c0adc6aa..5de57ddd8 100644 --- a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -93,6 +93,40 @@ fn active_action_target_denies_every_unverified_sender_class() { } } +#[test] +fn owner_bound_unresolved_sender_allows_only_the_advertised_default_action() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("owner-bound default"); + notification.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + notification.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + notification.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "delete".to_string(), + label: "Delete".to_string(), + }, + ]; + let key = store.insert(notification, 0).active_notification().key(); + + assert!(store + .active_action_target_generation(key, "default", false) + .is_some()); + assert!(store + .active_action_target_generation(key, "made-up-action", false) + .is_none()); + assert!(store + .active_action_target_generation(key, "delete", true) + .is_none()); +} + #[test] fn native_association_allows_default_but_requires_confirmation_for_buttons() { let mut store = make_store_with_limits(12, 20); From bc8cbe1d24da967d33d2c6e441e57856d9bc2faa Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 10 Aug 2026 15:04:19 -0500 Subject: [PATCH 267/275] fix(notifications): separate branding from visual authority Preserve bounded desktop-entry metadata as presentation-only application branding without treating caller claims as identity evidence. Separate wire conversation visuals, application-provided visuals, and content media so choosing a presentation slot cannot grant filesystem or interaction authority. Keep local visual paths gated on positive attribution while allowing bounded wire avatar pixels to retain communication semantics. Normalize desktop identifiers used for local presentation lookup and keep conflict and relay trust states authoritative over decorative branding. Clarify unresolved presentation as "App identity could not be verified" and add shared generic visual and trust regression coverage. --- .../unixnotis-core/src/model/image/hints.rs | 23 ++- .../unixnotis-core/src/model/image/model.rs | 4 + .../src/model/image/tests/hints.rs | 55 +++++++ .../src/model/image/tests/projection.rs | 1 + .../src/model/tests/notification.rs | 1 + .../identity/desktop_index/model.rs | 4 +- .../identity/desktop_index/names.rs | 8 +- .../notifications/ingress/payload/mod.rs | 4 +- .../ingress/payload/tests/mod.rs | 1 + .../ingress/payload/tests/visuals.rs | 29 ++++ .../notifications/ingress/payload/visuals.rs | 52 +++++-- .../src/daemon/notifications/server/flow.rs | 38 +++-- .../daemon/notifications/server/tests/flow.rs | 14 +- .../notifications/server/tests/ingress.rs | 144 +++++++++++++++++- .../src/ui/icons/tests/content.rs | 1 + crates/unixnotis-ui/src/presentation/build.rs | 3 +- .../src/presentation/tests/presentation.rs | 113 +++++++++++++- .../src/presentation/tests/visual_contract.rs | 140 ++++++++++++++++- crates/unixnotis-ui/src/presentation/types.rs | 6 + 19 files changed, 599 insertions(+), 42 deletions(-) diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index b186b6efb..a45e2dab9 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -23,6 +23,11 @@ impl NotificationImage { Self { badge_icon: String::new(), claimed_theme_icon: Self::sanitize_theme_icon_hint(app_icon), + claimed_desktop_id: hints + .get("desktop-entry") + .and_then(|value| value.try_clone().ok()) + .and_then(|value| String::try_from(value).ok()) + .map_or_else(String::new, |value| Self::sanitize_desktop_id_hint(&value)), sender_visual_role: super::NotificationVisualRole::None, sender_visual: ImageData::default(), content_image: image_data.unwrap_or_default(), @@ -34,10 +39,20 @@ impl NotificationImage { if value.is_empty() || value.len() > 128 || value.starts_with('.') - || value.contains('/') - || value.contains('\\') - || value.contains(':') - || value.chars().any(char::is_whitespace) + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return String::new(); + } + value.to_string() + } + + fn sanitize_desktop_id_hint(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.len() > 128 + || value.starts_with('.') || !value.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') }) diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index b9806cbf7..818515c5b 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -39,6 +39,10 @@ pub struct NotificationImage { /// Sender-supplied theme name retained only as a decorative lookup hint #[serde(default)] pub claimed_theme_icon: String, + /// Sender-supplied desktop id retained only for bounded decorative lookup + /// This value is never attribution evidence or an authorization input + #[serde(default)] + pub claimed_desktop_id: String, /// Safely decoded sender-provided visual pub sender_visual_role: NotificationVisualRole, pub sender_visual: ImageData, diff --git a/crates/unixnotis-core/src/model/image/tests/hints.rs b/crates/unixnotis-core/src/model/image/tests/hints.rs index f8beee8c7..16fa7e37c 100644 --- a/crates/unixnotis-core/src/model/image/tests/hints.rs +++ b/crates/unixnotis-core/src/model/image/tests/hints.rs @@ -44,6 +44,61 @@ fn app_icon_theme_names_are_retained_only_as_bounded_lookup_hints() { } } +#[test] +fn desktop_entry_is_retained_only_as_a_bounded_branding_hint() { + let mut hints = HashMap::new(); + hints.insert( + "desktop-entry".to_string(), + string_value("Example.Chat.desktop"), + ); + let image = NotificationImage::from_hints("App", "", &hints); + assert_eq!(image.claimed_desktop_id, "Example.Chat.desktop"); + + for value in [ + "/tmp/example.desktop", + "file:///tmp/example", + "bad id", + ".hidden", + ] { + let mut hints = HashMap::new(); + hints.insert("desktop-entry".to_string(), string_value(value)); + let image = NotificationImage::from_hints("App", "", &hints); + assert!( + image.claimed_desktop_id.is_empty(), + "unsafe desktop hint: {value}" + ); + } + + for value in [ + "example/chat", + "example\\chat", + "example:chat", + "example chat", + "example@chat", + ] { + let mut hints = HashMap::new(); + hints.insert("desktop-entry".to_string(), string_value(value)); + let image = NotificationImage::from_hints("App", "", &hints); + assert!( + image.claimed_desktop_id.is_empty(), + "unsafe desktop hint: {value}" + ); + } + + let mut hints = HashMap::new(); + hints.insert("desktop-entry".to_string(), string_value(&"a".repeat(128))); + assert_eq!( + NotificationImage::from_hints("App", "", &hints) + .claimed_desktop_id + .len(), + 128 + ); + hints.insert("desktop-entry".to_string(), string_value(&"a".repeat(129))); + assert!(NotificationImage::from_hints("App", "", &hints) + .claimed_desktop_id + .is_empty()); +} + #[test] fn parse_image_data_rejects_wrong_structure() { let wrong = Structure::from((1_i32, 1_i32)); diff --git a/crates/unixnotis-core/src/model/image/tests/projection.rs b/crates/unixnotis-core/src/model/image/tests/projection.rs index 8ff76ee1f..5b71ab86e 100644 --- a/crates/unixnotis-core/src/model/image/tests/projection.rs +++ b/crates/unixnotis-core/src/model/image/tests/projection.rs @@ -4,6 +4,7 @@ fn image() -> NotificationImage { NotificationImage { badge_icon: "mail".to_string(), claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), sender_visual_role: NotificationVisualRole::ConversationAvatar, sender_visual: ImageData { width: 1, diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 7d75e6a88..18bc1911d 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -71,6 +71,7 @@ fn image_with_raw_bytes() -> NotificationImage { sender_visual: ImageData::default(), badge_icon: "mail".to_string(), claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs index b611963ec..a71d5474e 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -167,7 +167,9 @@ impl DesktopIdentityIndex { &self, desktop_id: &str, ) -> bool { - self.communication_desktop_ids.contains(desktop_id) && self.by_id.contains_key(desktop_id) + // Wire hints commonly carry mixed case or a trailing .desktop suffix + let normalized = super::names::normalize_desktop_id(desktop_id); + self.communication_desktop_ids.contains(&normalized) && self.by_id.contains_key(&normalized) } } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs index d0ac49f42..134d93569 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs @@ -4,11 +4,11 @@ use unicode_security::skeleton; pub(in crate::daemon::notifications::identity) fn normalize_desktop_id(value: &str) -> String { // Desktop hints commonly include an optional suffix and mixed case - value - .trim() + let normalized = value.trim().to_ascii_lowercase(); + normalized .strip_suffix(".desktop") - .unwrap_or_else(|| value.trim()) - .to_ascii_lowercase() + .unwrap_or(&normalized) + .to_string() } pub(in crate::daemon::notifications::identity) fn normalize_name(value: &str) -> String { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs index b058092a5..55330daab 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -8,8 +8,8 @@ pub(in crate::daemon::notifications) use build::{build_notification, Notificatio pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; pub(in crate::daemon::notifications) use visuals::{ materialize_sender_visual, may_materialize_content_image, sender_visual_path_allowed, - sender_visual_role, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, - MAX_STORED_CONTENT_DIMENSION, + sender_visual_role, wire_image_role, SenderVisualRole, WireImageRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs index d3cfa00fe..38cb91312 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -15,6 +15,7 @@ pub(super) use super::visuals::{ sender_visual_path_allowed, MAX_SENDER_VISUAL_BYTES, }; pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; +pub(super) use super::visuals::{wire_image_role, WireImageRole}; pub(super) use unixnotis_core::{ ApplicationActionPolicy, AttributionReason, IdentityAssurance, InteractionPolicies, diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs index 52bc3b6c3..1c49eb221 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -29,6 +29,15 @@ fn associated_sender_role_accepts_inline_reply_and_message_categories() { ), SenderVisualRole::ConversationAvatar ); + assert_eq!( + wire_image_role( + &attribution, + &index, + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + ), + WireImageRole::ConversationAvatar + ); let mut hints = HashMap::new(); hints.insert( @@ -94,6 +103,26 @@ fn associated_noncommunication_path_is_a_small_application_visual() { )); } +#[test] +fn trusted_conversation_avatar_path_remains_allowed() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.Chat:sender".to_string(), + ); + + assert!(sender_visual_path_allowed( + SenderVisualRole::ConversationAvatar, + &attribution, + )); +} + #[test] fn portal_communication_keeps_wire_avatar_role_without_allowing_host_path_access() { let attribution = unixnotis_core::NotificationAttribution::associated( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs index 1becaa727..0a4bdfccf 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -36,6 +36,12 @@ pub(in crate::daemon::notifications) enum SenderVisualRole { ApplicationProvidedIcon, } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum WireImageRole { + ContentImage, + ConversationAvatar, +} + pub(in crate::daemon::notifications) const fn may_materialize_application_icon( attribution: &NotificationAttribution, ) -> bool { @@ -48,19 +54,18 @@ pub(in crate::daemon::notifications) const fn may_materialize_content_image( attribution.may_materialize_content_image() } -pub(in crate::daemon::notifications) fn sender_visual_role( +pub(in crate::daemon::notifications) fn wire_image_role( attribution: &NotificationAttribution, index: &DesktopIdentityIndex, hints: &HashMap, actions: &[String], - app_icon: &str, -) -> SenderVisualRole { +) -> WireImageRole { // Communication metadata selects a presentation slot without authenticating the application if actions .chunks_exact(2) .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) { - return SenderVisualRole::ConversationAvatar; + return WireImageRole::ConversationAvatar; } // Category hints remain presentation input, not identity proof @@ -76,10 +81,33 @@ pub(in crate::daemon::notifications) fn sender_visual_role( }); // Desktop categories cover clients that omit the optional wire category let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); - if explicit_metadata || desktop_metadata { - SenderVisualRole::ConversationAvatar - } else if may_materialize_application_icon(attribution) && local_avatar_path(app_icon).is_some() - { + // A claimed desktop entry is presentation metadata only; it never proves identity + let claimed_desktop_metadata = hints + .get("desktop-entry") + .and_then(owned_to_string) + .is_some_and(|desktop_id| index.desktop_id_has_communication_role(&desktop_id)); + if explicit_metadata || desktop_metadata || claimed_desktop_metadata { + WireImageRole::ConversationAvatar + } else { + WireImageRole::ContentImage + } +} + +pub(in crate::daemon::notifications) fn sender_visual_role( + attribution: &NotificationAttribution, + index: &DesktopIdentityIndex, + hints: &HashMap, + actions: &[String], + app_icon: &str, +) -> SenderVisualRole { + // Wire pixels and local application artwork use separate authorization decisions + if matches!( + wire_image_role(attribution, index, hints, actions), + WireImageRole::ConversationAvatar + ) { + return SenderVisualRole::ConversationAvatar; + } + if may_materialize_application_icon(attribution) && local_avatar_path(app_icon).is_some() { SenderVisualRole::ApplicationProvidedIcon } else { SenderVisualRole::None @@ -90,8 +118,12 @@ pub(in crate::daemon::notifications) const fn sender_visual_path_allowed( role: SenderVisualRole, attribution: &NotificationAttribution, ) -> bool { - // Local paths remain identity-gated even when wire pixels may be shown as presentation data - !matches!(role, SenderVisualRole::None) && may_materialize_application_icon(attribution) + // Local paths remain forbidden for unresolved, conflicting, and relay senders + // A positively associated sender may use a path for either visual presentation role + matches!( + role, + SenderVisualRole::ConversationAvatar | SenderVisualRole::ApplicationProvidedIcon + ) && may_materialize_application_icon(attribution) } pub(in crate::daemon::notifications) fn materialize_sender_visual( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index be7ff5e6b..3a588bb0b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -13,8 +13,8 @@ use crate::daemon::notifications::identity::{ }; use crate::daemon::notifications::ingress::payload::{ build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, - sender_visual_role, NotificationInput, SenderVisualRole, CONVERSATION_AVATAR_TIMEOUT, - MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, + sender_visual_role, wire_image_role, NotificationInput, SenderVisualRole, WireImageRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; use crate::daemon::{to_fdo_error, NotificationSignalMode}; use crate::store::{CommitDisposition, InsertOutcome, SuppressedNotification}; @@ -128,10 +128,7 @@ impl NotificationServer { sender } else { warn!("notification sender credentials timed out and failed closed"); - SenderMetadata { - status: SenderMetadataStatus::CredentialLookupTimedOut, - ..SenderMetadata::default() - } + timed_out_sender_metadata() } } @@ -155,6 +152,12 @@ impl NotificationServer { ), ) .await; + let wire_image_role = wire_image_role( + &resolution.attribution, + &desktop_identity_index, + &input.hints, + &input.actions, + ); let sender_visual_role = sender_visual_role( &resolution.attribution, &desktop_identity_index, @@ -171,10 +174,15 @@ impl NotificationServer { let materialized_content = materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; let (image_data, wire_sender_visual) = normalize_wire_image_for_role( - sender_visual_role, + wire_image_role, input.wire_image_data, materialized_content, ); + let stored_sender_visual_role = if wire_sender_visual.is_some() { + SenderVisualRole::ConversationAvatar + } else { + sender_visual_role + }; if matches!( resolution.attribution.status, unixnotis_core::AttributionStatus::Conflict @@ -211,7 +219,7 @@ impl NotificationServer { image_data, sender_visual_data: wire_sender_visual, sender_visual, - sender_visual_role, + sender_visual_role: stored_sender_visual_role, sender, attribution: resolution.attribution, attribution_diagnostics: resolution.diagnostics, @@ -363,19 +371,19 @@ impl NotificationServer { } fn normalize_wire_image_for_role( - role: SenderVisualRole, + role: WireImageRole, wire_image_data: Option, materialized_content: Option, ) -> (Option, Option) { match role { - SenderVisualRole::ConversationAvatar => { + WireImageRole::ConversationAvatar => { // Communication artwork becomes a small sender visual before model storage let sender_visual = wire_image_data .and_then(|image| image.into_storage_image(MAX_STORED_AVATAR_DIMENSION)); (materialized_content, sender_visual) } // Non-communication artwork uses the larger content-image storage bound - SenderVisualRole::ApplicationProvidedIcon | SenderVisualRole::None => { + WireImageRole::ContentImage => { let content_image = wire_image_data .and_then(|image| image.into_storage_image(MAX_STORED_CONTENT_DIMENSION)) .or(materialized_content); @@ -418,6 +426,14 @@ async fn materialize_content_visual( .flatten() } +fn timed_out_sender_metadata() -> SenderMetadata { + // Timeout status prevents incomplete credentials from being treated as identity evidence + SenderMetadata { + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + } +} + #[cfg(test)] #[path = "tests/flow.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs index 74280053f..8751e4557 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -15,9 +15,9 @@ use zbus::message::{Header, Type}; use zbus::zvariant::{OwnedValue, Value}; use zbus::{Connection, MatchRule, Message, MessageStream}; -use crate::daemon::notifications::identity::SenderMetadata; +use crate::daemon::notifications::identity::{SenderMetadata, SenderMetadataStatus}; use crate::daemon::notifications::ingress::payload::{ - build_notification, NotificationInput, SenderVisualRole, + build_notification, NotificationInput, SenderVisualRole, WireImageRole, }; use crate::daemon::{DaemonState, NotificationServer}; use crate::expire::ExpirationScheduler; @@ -66,6 +66,14 @@ impl NotificationServer { } } +#[test] +fn timed_out_sender_metadata_remains_explicitly_untrusted() { + assert_eq!( + super::timed_out_sender_metadata().status, + SenderMetadataStatus::CredentialLookupTimedOut + ); +} + fn notification_with_id(id: u32) -> Arc { Arc::new(Notification { id, @@ -276,7 +284,7 @@ fn conversation_avatar_wire_image_is_stored_with_the_avatar_role_and_bound() { .expect("320x320 communication image should pass wire validation"); // The communication role must send the wire image down the sender-visual branch let (content_image, sender_visual_data) = super::normalize_wire_image_for_role( - SenderVisualRole::ConversationAvatar, + WireImageRole::ConversationAvatar, Some(wire_image), None, ); diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs index 9d188d958..b7f5b1c74 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::os::fd::AsFd; +use std::sync::Arc; use std::time::Duration; use zbus::zvariant::{OwnedValue, SerializeValue, Structure, Value}; @@ -12,7 +13,7 @@ use super::{ use crate::daemon::{NotificationServer, NOTIFICATIONS_OBJECT_PATH}; use crate::expire::ExpirationScheduler; use crate::store::test_support::make_notification_with_sender; -use crate::test_support::daemon_state_for_test; +use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; // Four-megabyte D-Bus fixtures need headroom when the full test binary runs in parallel @@ -220,6 +221,133 @@ async fn supported_wire_hints_keep_text_boolean_and_both_urgency_types() { assert_eq!(second.urgency, 1); } +#[tokio::test] +async fn claimed_communication_desktop_entry_keeps_wire_avatar_untrusted() { + let (state, client) = notification_ingress().await; + let root = TempRoot::new("claimed-communication-avatar"); + let applications = root.path().join("applications"); + std::fs::create_dir_all(&applications).expect("create desktop application fixture root"); + std::fs::write( + applications.join("org.example.Chat.desktop"), + "[Desktop Entry]\nType=Application\nName=Example Chat\nCategories=Network;InstantMessaging;\nExec=/usr/bin/true\n", + ) + .expect("write communication desktop fixture"); + let index = { + let _environment_lock = env_lock(); + let _data_home = EnvVarGuard::set("XDG_DATA_HOME", root.path()); + let _data_dirs = EnvVarGuard::set("XDG_DATA_DIRS", root.path()); + crate::daemon::DesktopIdentityIndex::build_snapshot().index + }; + assert!(index.desktop_id_has_communication_role("ORG.EXAMPLE.CHAT.DESKTOP")); + state.desktop_identity_index.store(Arc::new(index)); + + let hints = HashMap::from([ + ( + "desktop-entry".to_string(), + OwnedValue::try_from(Value::from("ORG.EXAMPLE.CHAT.DESKTOP")) + .expect("desktop-entry hint"), + ), + ( + "image-data".to_string(), + owned_rgba_pixel([220, 20, 20, 255]), + ), + ]); + let untrusted_icon = root + .path() + .join("untrusted-application-icon.png") + .to_string_lossy() + .into_owned(); + let id = send_notification_with_hints( + &state, + &client, + "Unrelated sender claim", + &untrusted_icon, + hints, + ) + .await + .expect("claimed communication notification should be accepted") + .body() + .deserialize::() + .expect("notification id"); + + let notification = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(!notification.image.sender_visual.data.is_empty()); + assert!(notification.image.content_image.data.is_empty()); + assert_eq!( + notification.image.claimed_desktop_id, + "ORG.EXAMPLE.CHAT.DESKTOP" + ); + assert!(!notification.attribution.may_materialize_application_icon()); + assert_ne!( + notification.attribution.assurance, + unixnotis_core::IdentityAssurance::Authenticated + ); +} + +#[tokio::test] +async fn claimed_noncommunication_desktop_entry_keeps_wire_image_as_content() { + let (state, client) = notification_ingress().await; + let root = TempRoot::new("claimed-content-image"); + let applications = root.path().join("applications"); + std::fs::create_dir_all(&applications).expect("create desktop application fixture root"); + std::fs::write( + applications.join("example-viewer.desktop"), + "[Desktop Entry]\nType=Application\nName=Example Viewer\nCategories=Graphics;Viewer;\nExec=/usr/bin/true\n", + ) + .expect("write noncommunication desktop fixture"); + let index = { + let _environment_lock = env_lock(); + let _data_home = EnvVarGuard::set("XDG_DATA_HOME", root.path()); + let _data_dirs = EnvVarGuard::set("XDG_DATA_DIRS", root.path()); + crate::daemon::DesktopIdentityIndex::build_snapshot().index + }; + state.desktop_identity_index.store(Arc::new(index)); + + let hints = HashMap::from([ + ( + "desktop-entry".to_string(), + OwnedValue::try_from(Value::from("example-viewer.desktop")) + .expect("desktop-entry hint"), + ), + ( + "image-data".to_string(), + owned_rgba_pixel([20, 40, 220, 255]), + ), + ]); + let id = send_notification_with_hints(&state, &client, "Unrelated viewer claim", "", hints) + .await + .expect("claimed noncommunication notification should be accepted") + .body() + .deserialize::() + .expect("notification id"); + + let notification = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); + assert!(notification.image.sender_visual.data.is_empty()); + assert!(!notification.image.content_image.data.is_empty()); + assert_eq!( + notification.image.claimed_desktop_id, + "example-viewer.desktop" + ); +} + #[tokio::test] async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order() { let (state, client) = notification_ingress().await; @@ -491,6 +619,16 @@ async fn send_owned_hints_notification( state: &crate::daemon::DaemonState, client: &Connection, hints: HashMap, +) -> zbus::Result { + send_notification_with_hints(state, client, "app", "", hints).await +} + +async fn send_notification_with_hints( + state: &crate::daemon::DaemonState, + client: &Connection, + app_name: &str, + app_icon: &str, + hints: HashMap, ) -> zbus::Result { let destination = state .connection() @@ -498,9 +636,9 @@ async fn send_owned_hints_notification( .expect("daemon unique name") .clone(); let payload = ( - "app", + app_name, 0_u32, - "", + app_icon, "summary", "body", Vec::::new(), diff --git a/crates/unixnotis-popups/src/ui/icons/tests/content.rs b/crates/unixnotis-popups/src/ui/icons/tests/content.rs index f4828fd40..685ba4106 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/content.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/content.rs @@ -2,6 +2,7 @@ use super::*; fn image_data(channels: i32, rowstride: i32, data: Vec) -> NotificationImage { NotificationImage { + claimed_desktop_id: String::new(), content_image: ImageData { width: 2, height: 1, diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs index 2487d8f7a..5196ed369 100644 --- a/crates/unixnotis-ui/src/presentation/build.rs +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -178,7 +178,7 @@ fn identity_presentation( let claim = visible_claim(&claimed_name); ( claim.unwrap_or("Unknown application").to_string(), - claim.map(|_| "Identity could not be verified".to_string()), + claim.map(|_| "App identity could not be verified".to_string()), ) } }; @@ -333,6 +333,7 @@ const fn visual_presentation(notification: &NotificationView) -> VisualPresentat } else { match notification.image.sender_visual_role { unixnotis_core::NotificationVisualRole::ConversationAvatar => { + // Bounded sender pixels are conversation presentation, not application identity SenderVisualPresentation::ConversationAvatar } unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon => { diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs index 2b615cd37..6605e406d 100644 --- a/crates/unixnotis-ui/src/presentation/tests/presentation.rs +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -287,7 +287,54 @@ fn unknown_claim_is_primary_but_remains_unverified() { assert_eq!(presentation.identity.primary_label, "Local helper"); assert_eq!( presentation.identity.secondary_claim.as_deref(), - Some("Identity could not be verified") + Some("App identity could not be verified") + ); + assert_ne!(presentation.trust.short_label.as_deref(), Some("Local app")); + assert_eq!( + presentation.trust.short_label.as_deref(), + Some("Unverified") + ); +} + +#[test] +fn local_process_ownership_does_not_equal_local_application_identity() { + let mut unresolved = notification(); + unresolved.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application association unavailable", + "unknown:example".to_string(), + ); + unresolved.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + + let unresolved_presentation = NotificationPresentation::from_view_at(&unresolved, 1_000); + assert_eq!(unresolved_presentation.trust.level, TrustLevel::Unresolved); + assert_eq!( + unresolved_presentation.trust.short_label.as_deref(), + Some("Unverified") + ); + + let mut associated = notification(); + associated.attribution = NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Application", + "example-application", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "generic user association fixture", + "associated:user:example".to_string(), + ); + + let associated_presentation = NotificationPresentation::from_view_at(&associated, 1_000); + assert_eq!( + associated_presentation.trust.level, + TrustLevel::UserAssociated + ); + assert_eq!( + associated_presentation.trust.short_label.as_deref(), + Some("Local app") ); } @@ -351,6 +398,70 @@ fn unresolved_claim_has_no_application_actions_or_reply() { assert!(presentation.actions.overflow.is_empty()); } +#[test] +fn owner_bound_unresolved_sender_exposes_only_the_advertised_default_action() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + view.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "delete".to_string(), + label: "Delete".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.iter().any(|action| { + action.key == "default" + && action.label == "Open" + && action.policy == unixnotis_core::ApplicationActionPolicy::Allow + })); + assert!(!presentation + .actions + .primary + .iter() + .any(|action| action.key == "delete")); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); +} + +#[test] +fn owner_bound_blank_default_uses_card_activation_without_a_redundant_button() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + view.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + view.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.is_empty()); +} + #[test] fn communication_layout_is_preserved_for_unverified_sender() { let mut view = notification(); diff --git a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs index a24485e08..27a0b2df7 100644 --- a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs +++ b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs @@ -1,6 +1,11 @@ -use unixnotis_core::{ImageData, NotificationVisualRole}; +use unixnotis_core::{ + AttributionReason, IdentityAssurance, ImageData, InteractionPolicies, NotificationAttribution, + NotificationVisualRole, +}; -use super::super::{NotificationKind, NotificationPresentation, SenderVisualPresentation}; +use super::super::{ + NotificationKind, NotificationPresentation, SenderVisualPresentation, TrustLevel, +}; use super::support::notification; #[test] @@ -94,3 +99,134 @@ fn shared_notification_visual_contract_covers_client_surface_matrix() { ); } } + +#[test] +fn conversation_pixels_keep_avatar_role_across_trust_states() { + for (name, attribution) in conversation_attribution_cases() { + let mut view = notification(); + view.attribution = attribution; + view.image.sender_visual_role = NotificationVisualRole::ConversationAvatar; + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + // Trust is carried by the separate trust presentation, never by the image role + assert_eq!( + presentation.visuals.sender, + SenderVisualPresentation::ConversationAvatar, + "case={name}" + ); + } +} + +#[test] +fn trust_state_only_controls_semantic_badge_precedence() { + let semantic_first = [TrustLevel::Conflict, TrustLevel::Relay]; + let branding_first = [ + TrustLevel::Verified, + TrustLevel::SystemAssociated, + TrustLevel::PortalAssociated, + TrustLevel::UserAssociated, + TrustLevel::Unresolved, + ]; + + for level in semantic_first { + assert!(level.semantic_badge_is_authoritative()); + } + for level in branding_first { + assert!(!level.semantic_badge_is_authoritative()); + } +} + +fn conversation_attribution_cases() -> [(&'static str, NotificationAttribution); 7] { + [ + ( + "authenticated", + NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "verified:example".to_string(), + ), + ), + ( + "system-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "system association", + "associated:system:example".to_string(), + ), + ), + ( + "user-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "user association", + "associated:user:example".to_string(), + ), + ), + ( + "portal-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal association", + "associated:portal:example".to_string(), + ), + ), + ( + "unresolved", + NotificationAttribution::unresolved( + "Example", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example".to_string(), + ), + ), + ( + "conflict", + NotificationAttribution::conflict( + "Example", + "org.example.App", + AttributionReason::ExecutableMismatch, + "sender executable differs", + "conflict:example".to_string(), + ), + ), + ( + "relay", + NotificationAttribution::relay( + "Example", + "forwarded notification", + "relay:example".to_string(), + ), + ), + ] +} diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs index 3773997fc..1d5b19777 100644 --- a/crates/unixnotis-ui/src/presentation/types.rs +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -55,6 +55,12 @@ impl TrustLevel { Self::Relay => "relay", } } + + /// Returns whether the semantic trust badge must remain the leading icon + #[must_use] + pub const fn semantic_badge_is_authoritative(self) -> bool { + matches!(self, Self::Conflict | Self::Relay) + } } /// Controlled badge source selected from daemon-owned identity evidence From 0379c8e1465cfb09f4a8d85593aedb22aeaafb82 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 10 Aug 2026 15:09:25 -0500 Subject: [PATCH 268/275] fix(popups): restore application and sender visual hierarchy Restore compact application identity in the popup header and keep conversation avatars beside message content. Prevent fixed application and avatar slots from inheriting horizontal expansion so short messages retain correct spacing. Resolve presentation-only application branding without allowing it to override conflict or relay trust states. Include claimed desktop branding and trust-dependent candidate order in icon cache identity so attribution transitions cannot reuse stale icons. Keep content thumbnails independent from sender avatars and application branding. Add generic popup visual-matrix, layout, cache, trust, malformed-image, and short-message regression coverage. --- crates/unixnotis-core/assets/popup.css | 58 +- .../unixnotis-core/src/embedded/tests/css.rs | 4 +- .../src/ui/entry/builders/common.rs | 100 +++- .../src/ui/entry/builders/layout.rs | 38 +- .../src/ui/entry/builders/mod.rs | 7 +- .../src/ui/entry/builders/tests/common.rs | 77 ++- .../src/ui/entry/builders/tests/layout.rs | 52 +- .../src/ui/entry/builders/tests/thumbnail.rs | 19 + .../ui/entry/builders/tests/visual_matrix.rs | 559 ++++++++++++++++++ .../unixnotis-popups/src/ui/icons/resolver.rs | 54 +- crates/unixnotis-popups/src/ui/icons/state.rs | 5 +- .../src/ui/icons/tests/resolver/candidates.rs | 54 ++ .../src/ui/icons/tests/state.rs | 11 + crates/unixnotis-popups/src/ui/state/model.rs | 4 + .../src/ui/state/tests/constructor.rs | 4 +- .../src/ui/state/tests/mutation.rs | 7 +- 16 files changed, 937 insertions(+), 116 deletions(-) create mode 100644 crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 519b9d595..6c6156553 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -105,7 +105,9 @@ } .unixnotis-popup-identity-row, -.unixnotis-popup-message { +.unixnotis-popup-message, +.unixnotis-popup-header-row, +.unixnotis-popup-message-row { min-width: 0; } @@ -160,38 +162,25 @@ color: inherit; } -/* Avatar: neutral rounded tile that quietly holds the app icon */ -.unixnotis-identity-avatar { - min-width: 46px; - min-height: 46px; - border-radius: 13px; - background: alpha(#ffffff, 0.09); - border: 1px solid alpha(#ffffff, 0.13); +/* Application branding stays compact so the message remains the visual focus */ +.unixnotis-popup-application-icon-slot { + min-width: 24px; + min-height: 24px; + border-radius: 6px; + background: transparent; + border: none; color: alpha(#ffffff, 0.95); - box-shadow: - inset 0 1px 0 alpha(#ffffff, 0.10), - 0 2px 6px -3px alpha(#000000, 0.50); } -.unixnotis-identity-avatar.recognized { - border: 1px solid alpha(#ffffff, 0.12); -} - -.unixnotis-identity-avatar.relay { - background: alpha(#fbbf24, 0.09); - border: 1px solid alpha(#fbbf24, 0.24); +.unixnotis-popup-application-icon-slot.relay { color: alpha(#fde68a, 0.90); } -.unixnotis-identity-avatar.unresolved { - background: alpha(#ffffff, 0.06); - border: 1px solid alpha(#ffffff, 0.08); +.unixnotis-popup-application-icon-slot.unresolved { color: alpha(#ffffff, 0.84); } -.unixnotis-identity-avatar.conflict { - background: alpha(#fb7185, 0.12); - border: 1px solid alpha(#fb7185, 0.32); +.unixnotis-popup-application-icon-slot.conflict { color: #fecdd3; } @@ -226,6 +215,25 @@ box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04); } +/* Conversation avatars share the message lane and carry no app-identity chrome */ +.unixnotis-popup-conversation-avatar-slot { + min-width: 46px; + min-height: 46px; + border-radius: 50%; + background: transparent; + border: none; + box-shadow: none; +} + +.unixnotis-popup-conversation-avatar-slot .unixnotis-popup-conversation-avatar { + min-width: 46px; + min-height: 46px; + border-radius: 50%; + background: transparent; + border: none; + box-shadow: none; +} + .unixnotis-popup-card.recognized, .unixnotis-popup-card.unresolved { border-color: alpha(#ffffff, 0.09); @@ -350,7 +358,7 @@ color: @unixnotis-critical-text; } -.unixnotis-popup-card.critical .unixnotis-identity-avatar, +.unixnotis-popup-card.critical .unixnotis-popup-application-icon-slot, .unixnotis-popup-card.critical .unixnotis-popup-icon { color: @unixnotis-critical-icon; } diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index 375e6d973..edae6ec41 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -193,7 +193,9 @@ fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { // Default popups must not restore the old raw provenance body row assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); - assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-identity-avatar")); + assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-popup-application-icon-slot")); + assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-popup-conversation-avatar-slot")); + assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-identity-avatar")); assert!(DEFAULT_POPUP_CSS.contains("min-width: 46px")); assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs index 75d95191a..2ab555220 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -24,45 +24,81 @@ const MIN_CONFIRM_INTERVAL_MS: u64 = 350; // Armed state expires after this long and the button goes back to normal const MAX_CONFIRM_TIMEOUT_MS: u64 = 5000; -pub(super) struct IdentityAvatar { +pub(super) struct VisualSlot { pub(super) widget: gtk::Box, } -pub(super) fn build_identity_avatar( +pub(super) fn build_application_identity( state: &mut UiState, notification: &NotificationView, view: &PopupEntryViewModel, size: i32, -) -> IdentityAvatar { - let has_conversation_avatar = - view.visuals.sender == SenderVisualPresentation::ConversationAvatar; - let icon_size = if has_conversation_avatar { - size +) -> VisualSlot { + // Application branding and trust are separate presentation decisions + let icon_size = size.clamp(18, 22); + let icon = if view.trust.level.semantic_badge_is_authoritative() { + // A warning state never falls back to caller-provided branding + build_semantic_badge(view.badge, icon_size) } else { - (size - 14).max(18) - }; - let icon = UiState::build_conversation_avatar_widget(notification, icon_size) - .or_else(|| build_semantic_badge(view.badge, icon_size)) - .or_else(|| state.build_app_icon_widget(notification, icon_size)) - .unwrap_or_else(|| gtk::Image::from_icon_name("application-x-executable-symbolic")); + state + .build_app_icon_widget(notification, icon_size) + .or_else(|| build_semantic_badge(view.badge, icon_size)) + } + .unwrap_or_else(|| gtk::Image::from_icon_name("application-x-executable-symbolic")); + build_identity_slot(icon, view, size, icon_size, true) +} + +pub(super) fn build_conversation_avatar( + notification: &NotificationView, + view: &PopupEntryViewModel, + size: i32, +) -> Option { + // Bounded in-memory pixels are a conversation visual, even when trust is unresolved + if view.visuals.sender != SenderVisualPresentation::ConversationAvatar { + return None; + } + + let icon = UiState::build_conversation_avatar_widget(notification, size)?; + Some(build_identity_slot(icon, view, size, size, false)) +} + +fn build_identity_slot( + icon: gtk::Image, + view: &PopupEntryViewModel, + size: i32, + icon_size: i32, + is_application: bool, +) -> VisualSlot { icon.set_pixel_size(icon_size); icon.set_size_request(icon_size, icon_size); icon.set_valign(Align::Center); icon.set_halign(Align::Center); - // Expansion centers the glyph optically inside the fixed avatar allocation + + // The child can expand inside its fixed visual allocation for centering icon.set_hexpand(true); icon.set_vexpand(true); icon.set_accessible_role(gtk::AccessibleRole::Presentation); - icon.add_css_class("unixnotis-popup-icon"); - - let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); - avatar.set_size_request(size, size); - avatar.set_halign(Align::Start); - avatar.set_valign(Align::Start); - avatar.add_css_class("unixnotis-identity-avatar"); - avatar.add_css_class(view.trust.level.css_class()); - avatar.append(&icon); - IdentityAvatar { widget: avatar } + if is_application { + icon.add_css_class("unixnotis-popup-icon"); + } + let slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); + slot.set_size_request(size, size); + slot.set_halign(Align::Start); + slot.set_valign(Align::Start); + + // Stop child expansion from making the adjacent message column drift + slot.set_hexpand(false); + slot.set_vexpand(false); + + if is_application { + // A compact header icon must not become a second message card + slot.add_css_class("unixnotis-popup-application-icon-slot"); + slot.add_css_class(view.trust.level.css_class()); + } else { + slot.add_css_class("unixnotis-popup-conversation-avatar-slot"); + } + slot.append(&icon); + VisualSlot { widget: slot } } pub(super) struct IdentityHeader { @@ -71,11 +107,15 @@ pub(super) struct IdentityHeader { } pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeader { - let identity = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); identity.add_css_class("unixnotis-popup-identity-row"); identity.set_hexpand(true); identity.set_halign(Align::Fill); + // The application name and trust chip share one compact header line + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); + identity_top.set_hexpand(true); + let app = gtk::Label::new(Some(&view.app_label)); app.set_xalign(0.0); app.set_hexpand(true); @@ -87,10 +127,16 @@ pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeade // Raw paths remain available on demand without entering normal card content app.set_tooltip_text(Some(details)); } - identity.append(&app); + identity_top.append(&app); if let Some(chip) = build_trust_chip(&view.trust) { - identity.append(&chip); + identity_top.append(&chip); + } + identity.append(&identity_top); + + // Attribution context belongs under the application header, not the message body + if let Some(claim) = build_secondary_claim(view) { + identity.append(&claim); } let trailing = gtk::Box::new(gtk::Orientation::Vertical, 2); diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs index f157978a2..d5759fcb4 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -4,14 +4,15 @@ use gtk::prelude::*; use unixnotis_core::NotificationView; use super::common::{ - build_body_label, build_identity_avatar, build_identity_header, build_reply_note, - build_secondary_claim, build_title_label, + build_application_identity, build_body_label, build_conversation_avatar, build_identity_header, + build_reply_note, build_title_label, }; use super::{append_thumbnail, RenderedPopup}; use crate::ui::entry::presentation::PopupEntryViewModel; use crate::ui::UiState; -const POPUP_IDENTITY_SIZE: i32 = 34; +const POPUP_APPLICATION_ICON_SIZE: i32 = 24; +const POPUP_CONVERSATION_AVATAR_SIZE: i32 = 46; pub(super) struct PopupLayout { pub(super) css_class: &'static str, @@ -35,19 +36,22 @@ pub(super) fn build_popup_grid( let accessible_label = popup_accessible_label(view); grid.update_property(&[gtk::accessible::Property::Label(&accessible_label)]); - let avatar = build_identity_avatar(state, notification, view, POPUP_IDENTITY_SIZE); - grid.attach(&avatar.widget, 0, 0, 1, 2); - + // The header row owns application identity and trust context independently + let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header_row.add_css_class("unixnotis-popup-header-row"); + header_row.set_hexpand(true); + let application_identity = + build_application_identity(state, notification, view, POPUP_APPLICATION_ICON_SIZE); let header = build_identity_header(view); - grid.attach(&header.identity, 1, 0, 1, 1); - grid.attach(&header.trailing, 2, 0, 1, 1); + header_row.append(&application_identity.widget); + header_row.append(&header.identity); + header_row.append(&header.trailing); + grid.attach(&header_row, 0, 0, 3, 1); + // Message content is a separate row so the avatar never sizes the app header let message = gtk::Box::new(gtk::Orientation::Vertical, 2); message.add_css_class("unixnotis-popup-message"); message.set_hexpand(true); - if let Some(claim) = build_secondary_claim(view) { - message.append(&claim); - } if let Some(title) = build_title_label(view) { message.append(&title); } @@ -60,7 +64,17 @@ pub(super) fn build_popup_grid( message.append(¬e); } } - grid.attach(&message, 1, 1, 2, 1); + let message_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + message_row.add_css_class("unixnotis-popup-message-row"); + message_row.set_hexpand(true); + // Conversation pixels belong beside the message, not below its body as a thumbnail + if let Some(conversation_avatar) = + build_conversation_avatar(notification, view, POPUP_CONVERSATION_AVATAR_SIZE) + { + message_row.append(&conversation_avatar.widget); + } + message_row.append(&message); + grid.attach(&message_row, 0, 1, 3, 1); RenderedPopup { widget: grid, diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs index 7f23e42b3..ba4f69338 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -46,15 +46,14 @@ pub(super) fn append_thumbnail( if !should_append_thumbnail(view) { return false; } - let image = UiState::build_content_image_widget(notification); - let Some(image) = image else { + let Some(image) = UiState::build_content_image_widget(notification) else { return false; }; if image.paintable().is_none() { return false; } - // Only genuine message media belongs below the body in the content lane + // Content pixels remain in the dedicated message-media lane image.set_halign(gtk::Align::Start); image.add_css_class("unixnotis-popup-content-image"); content.append(&image); @@ -62,7 +61,7 @@ pub(super) fn append_thumbnail( } const fn should_append_thumbnail(view: &PopupEntryViewModel) -> bool { - // Sender and application visuals are identity-lane data, never message attachments + // Only genuine message/media content belongs below the body matches!(view.thumbnail, super::presentation::ThumbnailKind::Content) } diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs index 69bf8b296..52e654234 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -1,11 +1,11 @@ use super::{ - build_action_row, build_body_label, build_close_button, build_identity_avatar, - build_identity_header, build_reply_note, build_secondary_claim, build_title_label, - build_urgency_badge, + build_action_row, build_application_identity, build_body_label, build_close_button, + build_conversation_avatar, build_identity_header, build_reply_note, build_secondary_claim, + build_title_label, build_urgency_badge, }; use gtk::prelude::*; use unixnotis_core::{ - Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, + Action, AttributionReason, ImageData, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, NotificationView, }; @@ -78,7 +78,9 @@ fn reply_note_exists_only_when_the_policy_explanation_is_needed() { #[gtk::test] fn close_button_and_identity_header_keep_their_interaction_contracts() { let close = build_close_button(); - let header = build_identity_header(&view_model()); + let mut view = view_model(); + view.secondary_claim = Some("App identity could not be verified".to_string()); + let header = build_identity_header(&view); assert!(close.has_css_class("unixnotis-popup-close")); assert_eq!( @@ -86,6 +88,8 @@ fn close_button_and_identity_header_keep_their_interaction_contracts() { Some("Dismiss notification") ); assert!(header.identity.hexpands()); + assert_eq!(header.trailing.width_request(), 42); + assert_eq!(header.trailing.height_request(), -1); assert_eq!(header.trailing.margin_end(), 30); assert_eq!(header.trailing.orientation(), gtk::Orientation::Vertical); assert!(header @@ -96,6 +100,10 @@ fn close_button_and_identity_header_keep_their_interaction_contracts() { .trailing .last_child() .is_some_and(|child| { child.has_css_class(unixnotis_core::hooks::urgency::BADGE) })); + assert!(header + .identity + .last_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-secondary-claim"))); } #[gtk::test] @@ -299,7 +307,7 @@ fn empty_action_model_does_not_build_an_action_row() { } #[gtk::test] -fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { +fn application_identity_scales_the_symbolic_glyph_inside_its_fixed_slot() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupAvatarSizing") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -319,7 +327,7 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { ); let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); - let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); + let avatar = build_application_identity(&mut state, ¬ification, &view, 36); let icon = avatar .widget .first_child() @@ -334,7 +342,7 @@ fn identity_avatar_scales_the_symbolic_glyph_inside_its_fixed_slot() { } #[gtk::test] -fn communication_identity_avatar_prefers_materialized_conversation_image() { +fn conversation_avatar_renders_from_bounded_message_pixels() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupConversationAvatar") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -345,7 +353,7 @@ fn communication_identity_avatar_prefers_materialized_conversation_image() { let root = std::env::temp_dir().join("unixnotis-popup-conversation-avatar"); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); - let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let _state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); let mut notification = notification(); notification.inline_reply.available = true; notification.image.sender_visual_role = @@ -361,7 +369,8 @@ fn communication_identity_avatar_prefers_materialized_conversation_image() { }; let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); - let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); + let avatar = build_conversation_avatar(¬ification, &view, 36) + .expect("conversation avatar should be available"); let icon = avatar .widget .first_child() @@ -372,6 +381,52 @@ fn communication_identity_avatar_prefers_materialized_conversation_image() { assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); } +#[gtk::test] +fn conversation_avatar_aspect_ratios_keep_the_fixed_lead_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConversationAspectRatios") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register aspect-ratio application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conversation-aspect-ratios"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let _state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + + for (width, height, rowstride, data) in [ + (1, 1, 4, vec![1, 2, 3, 255]), + (1, 3, 4, [1, 2, 3, 255].repeat(3)), + (3, 1, 12, [1, 2, 3, 255].repeat(3)), + ] { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data, + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let avatar = build_conversation_avatar(¬ification, &view, 36) + .expect("valid bounded avatar should build"); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar slot should contain the image"); + + assert_eq!(avatar.widget.width_request(), 36); + assert_eq!(avatar.widget.height_request(), 36); + assert_eq!(icon.pixel_size(), 36); + } +} + #[gtk::test] fn decorative_application_visual_does_not_replace_the_identity_badge() { let app = gtk::Application::builder() @@ -399,7 +454,7 @@ fn decorative_application_visual_does_not_replace_the_identity_badge() { }; let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); - let avatar = build_identity_avatar(&mut state, ¬ification, &view, 36); + let avatar = build_application_identity(&mut state, ¬ification, &view, 36); let icon = avatar .widget .first_child() diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs index 884471104..a57cad256 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -43,7 +43,7 @@ fn conflict_accessible_name_includes_trust_claim_and_body() { } #[gtk::test] -fn conversation_avatar_occupies_left_grid_column_across_message_rows() { +fn popup_separates_application_identity_from_conversation_avatar() { let app = gtk::Application::builder() .application_id("org.unixnotis.PopupAvatarGrid") .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) @@ -69,25 +69,41 @@ fn conversation_avatar_occupies_left_grid_column_across_message_rows() { }, ); - let avatar = rendered + let header_row = rendered .widget .child_at(0, 0) .and_downcast::() - .expect("left grid cell should contain the identity avatar"); - let avatar_second_row = rendered + .expect("header row"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("header row should contain the application identity"); + let message_row = rendered .widget .child_at(0, 1) - .expect("avatar should span the message row"); - let icon = avatar + .and_downcast::() + .expect("message row"); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation avatar"); + let application_icon = application_identity .first_child() .and_downcast::() - .expect("avatar slot should contain one image"); + .expect("application identity slot should contain one image"); + let conversation_icon = conversation_avatar + .first_child() + .and_downcast::() + .expect("conversation slot should contain one image"); - assert_eq!(avatar_second_row, avatar.upcast::()); - assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); - assert!(rendered.widget.child_at(1, 1).is_some()); + assert!(!application_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(conversation_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert!(message_row.last_child().is_some()); + assert!(header_row.has_css_class("unixnotis-popup-header-row")); + assert!(message_row.has_css_class("unixnotis-popup-message-row")); } - fn view_model() -> PopupEntryViewModel { PopupEntryViewModel { kind: PopupKind::Communication, @@ -120,11 +136,14 @@ fn conversation_notification() -> NotificationView { id: 7, generation: 1, app_name: "Example Chat".to_string(), - attribution: unixnotis_core::NotificationAttribution::unresolved( + attribution: unixnotis_core::NotificationAttribution::verified( "Example Chat", - unixnotis_core::AttributionReason::MissingSenderEvidence, - "no sender evidence", - "claim:example-chat".to_string(), + "Example Chat", + "org.example.Chat", + "example-chat", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "verified test fixture", + "verified:example-chat".to_string(), ), summary: "PV2 Rivera in Tel Aviv 2026".to_string(), body: "10 eps I heard ts tuff asf".to_string(), @@ -163,3 +182,6 @@ fn theme_paths(root: &std::path::Path) -> ThemePaths { media_css: root.join("media.css"), } } + +#[path = "visual_matrix.rs"] +mod visual_matrix; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs index 237eed3cd..7707431bb 100644 --- a/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs @@ -55,6 +55,17 @@ fn append_thumbnail_adds_only_genuine_content_image() { assert!(image.has_css_class("unixnotis-popup-content-image")); } +#[gtk::test] +fn append_thumbnail_rejects_conversation_avatar_without_content_image() { + let notification = notification_with_conversation_pixels(); + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ConversationAvatar; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(!append_thumbnail(¬ification, &view, &content)); + assert!(content.first_child().is_none()); +} + fn view_model() -> PopupEntryViewModel { PopupEntryViewModel { kind: PopupKind::Communication, @@ -108,6 +119,14 @@ fn notification() -> NotificationView { } } +fn notification_with_conversation_pixels() -> NotificationView { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = pixel(); + notification +} + fn pixel() -> unixnotis_core::ImageData { unixnotis_core::ImageData { width: 1, diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs new file mode 100644 index 000000000..eb6c0e4ba --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs @@ -0,0 +1,559 @@ +//! Generic popup visual-role matrix + +use super::{build_popup_grid, conversation_notification, theme_paths, PopupLayout}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{ + AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationAttribution, +}; +use unixnotis_ui::css::CssManager; + +#[gtk::test] +fn unresolved_conversation_avatar_stays_in_the_message_lead_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupUnresolvedConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register unresolved conversation avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-unresolved-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + // A claimed desktop id may brand the header without changing the unverified state + notification.image.claimed_desktop_id = "folder".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + assert_eq!( + view.trust.level, + unixnotis_ui::presentation::TrustLevel::Unresolved + ); + assert_eq!( + view.visuals.sender, + unixnotis_ui::presentation::SenderVisualPresentation::ConversationAvatar + ); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: true, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("header row should exist"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("header row should contain the application identity"); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation avatar"); + let application_icon = application_identity + .first_child() + .and_downcast::() + .expect("identity slot should contain one image"); + let conversation_icon = conversation_avatar + .first_child() + .and_downcast::() + .expect("conversation slot should contain one image"); + + assert!(!application_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(application_icon.paintable().is_some()); + assert!(conversation_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(!rendered.has_image); + assert!(header_row.has_css_class("unixnotis-popup-header-row")); + assert!(message_row.has_css_class("unixnotis-popup-message-row")); +} + +#[gtk::test] +fn trust_state_does_not_change_conversation_avatar_geometry() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupTrustGeometry") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register trust geometry application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-trust-geometry"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + + for (name, attribution) in trust_attributions() { + let mut notification = conversation_notification(); + notification.attribution = attribution; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .unwrap_or_else(|| panic!("missing application header for {name}")); + let application_identity = header_row + .first_child() + .and_downcast::() + .unwrap_or_else(|| panic!("missing application identity for {name}")); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .unwrap_or_else(|| panic!("missing message row for {name}")); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .unwrap_or_else(|| panic!("missing conversation avatar for {name}")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(!application_identity.has_css_class("unixnotis-identity-avatar")); + assert!(conversation_avatar.has_css_class("unixnotis-popup-conversation-avatar-slot")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert_eq!(conversation_avatar.width_request(), 46); + assert_eq!(conversation_avatar.height_request(), 46); + assert!(!conversation_avatar.compute_expand(gtk::Orientation::Horizontal)); + assert!(!conversation_avatar.compute_expand(gtk::Orientation::Vertical)); + assert!(message_row.last_child().is_some()); + } +} + +#[gtk::test] +fn fixed_visual_slots_do_not_consume_short_message_width() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupFixedVisualSlots") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register fixed visual slot application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-fixed-visual-slots"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.summary = "A".to_string(); + notification.body = "B".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("header row should exist"); + let application_slot = header_row + .first_child() + .and_downcast::() + .expect("header should contain the application visual slot"); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let conversation_slot = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation visual slot"); + let message_column = message_row + .last_child() + .and_downcast::() + .expect("message row should contain the message column"); + + assert_eq!(application_slot.width_request(), 24); + assert_eq!(conversation_slot.width_request(), 46); + assert!(!application_slot.compute_expand(gtk::Orientation::Horizontal)); + assert!(!conversation_slot.compute_expand(gtk::Orientation::Horizontal)); + assert!(message_column.compute_expand(gtk::Orientation::Horizontal)); +} + +#[gtk::test] +fn conflict_popup_keeps_warning_badge_ahead_of_claimed_branding() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConflictBranding") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register conflict branding application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conflict-branding"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "identity conflict", + "conflict:example-chat".to_string(), + ); + notification.image.claimed_theme_icon = "folder".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("conflict header row"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("conflict header identity slot"); + let icon = application_identity + .first_child() + .and_downcast::() + .expect("conflict header icon"); + + assert_eq!( + icon.icon_name().as_deref(), + Some("unixnotis-shield-warning-symbolic") + ); + assert_eq!( + view.trust.level, + unixnotis_ui::presentation::TrustLevel::Conflict + ); +} + +#[gtk::test] +fn conversation_avatar_and_content_image_use_separate_popup_lanes() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupVisualLanes") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register visual lane application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-visual-lanes"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.content_image = unixnotis_core::ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let avatar = message_row + .first_child() + .and_downcast::() + .expect("conversation avatar should stay beside the message"); + assert!(avatar + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar"))); + + let message = message_row + .last_child() + .and_downcast::() + .expect("message column should exist"); + let content_image = message + .last_child() + .and_downcast::() + .expect("content media should remain below the message"); + assert!(content_image.has_css_class("unixnotis-popup-content-image")); + assert!(rendered.has_image); +} + +#[gtk::test] +fn invalid_conversation_pixels_remove_the_popup_avatar_cell() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupInvalidConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register invalid avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-invalid-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.sender_visual = unixnotis_core::ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..unixnotis_core::ImageData::default() + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should remain when avatar decoding fails"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); + assert!(!rendered.has_image); +} + +#[gtk::test] +fn ordinary_notifications_do_not_gain_a_second_avatar_row() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupOrdinaryNotification") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register ordinary notification application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-ordinary-notification"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.category.clear(); + notification.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + notification.image.sender_visual = unixnotis_core::ImageData::default(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-utility-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("ordinary notifications still have one message row"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); +} + +#[gtk::test] +fn content_only_popup_keeps_media_below_message_without_avatar_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupContentOnly") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register content-only application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-content-only"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + notification.image.sender_visual = unixnotis_core::ImageData::default(); + notification.image.content_image = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![8, 9, 10, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-media-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("content-only notifications still have one message row"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); + let message = message_row + .last_child() + .and_downcast::() + .expect("message column should exist"); + let content = message + .last_child() + .and_downcast::() + .expect("content image should remain in the message column"); + assert!(content.has_css_class("unixnotis-popup-content-image")); + assert!(rendered.has_image); +} + +fn trust_attributions() -> [(&'static str, NotificationAttribution); 7] { + [ + ( + "authenticated", + NotificationAttribution::verified( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + AttributionReason::ExactSystemExecutable, + "authenticated fixture", + "verified:example-chat".to_string(), + ), + ), + ( + "system-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "system fixture", + "associated:system:example-chat".to_string(), + ), + ), + ( + "user-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "user fixture", + "associated:user:example-chat".to_string(), + ), + ), + ( + "portal-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal fixture", + "associated:portal:example-chat".to_string(), + ), + ), + ( + "unresolved", + NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "unresolved fixture", + "unknown:example-chat".to_string(), + ), + ), + ( + "conflict", + NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "conflict fixture", + "conflict:example-chat".to_string(), + ), + ), + ( + "relay", + NotificationAttribution::relay( + "Example Chat", + "relay fixture", + "relay:example-chat".to_string(), + ), + ), + ] +} diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index cca2ad637..c9b03fe13 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use gio::prelude::FileExt; use gtk::gdk; use gtk::{IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::NotificationView; +use unixnotis_core::{AttributionStatus, NotificationView}; pub(in crate::ui) fn file_path_from_hint(path: &str) -> Option { // Accept raw absolute paths and file:// URIs, decoding percent escapes when present. @@ -57,31 +57,57 @@ pub(in crate::ui) fn resolve_icon_paintable_with_scale( pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> Vec { // Candidate lists stay small, so ordered linear deduplication avoids a hash allocation - let mut candidates = Vec::with_capacity(7); + let mut candidates = Vec::with_capacity(12); + if notification.attribution.status == AttributionStatus::Unresolved { + push_claimed_icon_candidates(&mut candidates, notification); + push_attributed_icon_candidates(&mut candidates, notification); + } else { + push_attributed_icon_candidates(&mut candidates, notification); + push_claimed_icon_candidates(&mut candidates, notification); + } + candidates +} + +fn push_attributed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { let badge_icon = notification.attribution.badge_icon.as_str(); if !badge_icon.is_empty() { - push_candidate(&mut candidates, badge_icon); + push_candidate(candidates, badge_icon); if let Some(stripped) = badge_icon.strip_suffix(".desktop") { - push_candidate(&mut candidates, stripped); + push_candidate(candidates, stripped); } let lowercase = badge_icon.to_lowercase(); - push_candidate(&mut candidates, &lowercase); + push_candidate(candidates, &lowercase); } + let desktop_id = notification.attribution.desktop_id.as_str(); if !desktop_id.is_empty() { - // Desktop ids are daemon-associated metadata and safe badge lookup candidates - push_candidate(&mut candidates, desktop_id); + push_candidate(candidates, desktop_id); + if let Some(stripped) = desktop_id.strip_suffix(".desktop") { + push_candidate(candidates, stripped); + } let lowercase = desktop_id.to_lowercase(); - push_candidate(&mut candidates, &lowercase); + push_candidate(candidates, &lowercase); } +} + +fn push_claimed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { + // Claimed names only select presentation candidates; they never prove identity + let claimed_desktop_id = notification.image.claimed_desktop_id.as_str(); + if is_safe_theme_name(claimed_desktop_id) { + push_candidate(candidates, claimed_desktop_id); + if let Some(stripped) = claimed_desktop_id.strip_suffix(".desktop") { + push_candidate(candidates, stripped); + } + let lowercase = claimed_desktop_id.to_lowercase(); + push_candidate(candidates, &lowercase); + } + let claimed_theme_icon = notification.image.claimed_theme_icon.as_str(); if is_safe_theme_name(claimed_theme_icon) { - // Sender input is only a bounded theme lookup hint, never identity evidence - push_candidate(&mut candidates, claimed_theme_icon); + push_candidate(candidates, claimed_theme_icon); let lowercase = claimed_theme_icon.to_lowercase(); - push_candidate(&mut candidates, &lowercase); + push_candidate(candidates, &lowercase); } - candidates } fn push_candidate(candidates: &mut Vec, candidate: &str) { @@ -95,10 +121,6 @@ fn is_safe_theme_name(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && !value.starts_with('.') - && !value.contains('/') - && !value.contains('\\') - && !value.contains(':') - && !value.chars().any(char::is_whitespace) && value.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') }) diff --git a/crates/unixnotis-popups/src/ui/icons/state.rs b/crates/unixnotis-popups/src/ui/icons/state.rs index f9a79d18e..118273794 100644 --- a/crates/unixnotis-popups/src/ui/icons/state.rs +++ b/crates/unixnotis-popups/src/ui/icons/state.rs @@ -65,12 +65,15 @@ impl UiState { size: i32, ) -> Option { self.refresh_icon_sources_if_needed(); - // Caller image hints are content, so the header resolves only authenticated badge inputs + // Authenticated inputs and bounded presentation hints share lookup, not trust authority let cache_key = IconResolutionKey { app_name: notification.app_name.clone(), badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, }; if let Some(cached) = self.icon_cache.get(&cache_key) { if let Some(icon_name) = cached.resolved.as_deref() { diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index 5494bd029..42d371019 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -28,6 +28,7 @@ fn collect_icon_candidates_includes_a_distinct_desktop_id() { vec![ "trusted-badge", "org.demo.App.desktop", + "org.demo.App", "org.demo.app.desktop", ] ); @@ -70,3 +71,56 @@ fn collect_icon_candidates_keeps_a_bounded_unresolved_theme_hint_decorative() { assert!(candidates.iter().any(|value| value == "trusted-brand")); assert!(candidates.iter().all(|value| !value.contains('/'))); } + +#[test] +fn claimed_desktop_id_is_a_presentation_only_icon_candidate() { + let mut notification = notification("Example Chat", ""); + notification.image.claimed_desktop_id = "example-chat.desktop".to_string(); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat.desktop")); + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + +#[test] +fn unresolved_claimed_branding_precedes_the_generic_daemon_badge() { + let mut input = notification("Example Application", "application-x-executable-symbolic"); + input.image.claimed_desktop_id = "org.example.App.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.App.desktop") + ); +} + +#[test] +fn associated_branding_still_precedes_presentation_claims() { + let mut input = notification("Example Application", "org.example.associated"); + input.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Associated", + "org.example.associated", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated fixture", + "associated:system-app:org.example.Associated".to_string(), + ); + input.image.claimed_desktop_id = "org.example.Claimed.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.associated") + ); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/state.rs b/crates/unixnotis-popups/src/ui/icons/tests/state.rs index 24d232282..141aeb57f 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/state.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/state.rs @@ -105,8 +105,14 @@ fn icon_resolution_key_includes_all_candidate_inputs() { let mut second = first.clone(); second.attribution.desktop_id = "org.example.Second.desktop".to_string(); second.image.claimed_theme_icon = "second-theme".to_string(); + let mut third = first.clone(); + third.image.claimed_desktop_id = "org.example.Third.desktop".to_string(); + let mut fourth = first.clone(); + fourth.attribution.status = unixnotis_core::AttributionStatus::Recognized; assert_ne!(icon_cache_key(&first), icon_cache_key(&second)); + assert_ne!(icon_cache_key(&first), icon_cache_key(&third)); + assert_ne!(icon_cache_key(&first), icon_cache_key(&fourth)); } #[gtk::test] @@ -195,6 +201,9 @@ fn icon_cache_key(notification: &unixnotis_core::NotificationView) -> IconResolu badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, } } @@ -204,6 +213,8 @@ fn test_cache_key(name: &str) -> IconResolutionKey { badge_icon: String::new(), desktop_id: String::new(), claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), + claimed_candidates_first: true, } } diff --git a/crates/unixnotis-popups/src/ui/state/model.rs b/crates/unixnotis-popups/src/ui/state/model.rs index 9848c0170..cfd08a419 100644 --- a/crates/unixnotis-popups/src/ui/state/model.rs +++ b/crates/unixnotis-popups/src/ui/state/model.rs @@ -61,6 +61,10 @@ pub(in crate::ui) struct IconResolutionKey { pub(in crate::ui) badge_icon: String, pub(in crate::ui) desktop_id: String, pub(in crate::ui) claimed_theme_icon: String, + pub(in crate::ui) claimed_desktop_id: String, + // Unresolved identities search claimed presentation before daemon branding + // A trust transition must not reuse a result chosen under the opposite order + pub(in crate::ui) claimed_candidates_first: bool, } pub(in crate::ui) struct IconCacheEntry { diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 84c8d5602..b468cdc67 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -254,7 +254,7 @@ fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { )); assert!(visible_descendant_has_text( root.upcast_ref(), - "Identity could not be verified" + "App identity could not be verified" )); } @@ -374,7 +374,7 @@ fn notify_send_claim_uses_one_command_line_avatar_without_app_branding() { "App label: Example Chat" )); assert_eq!( - visible_descendant_class_count(root.upcast_ref(), "unixnotis-identity-avatar"), + visible_descendant_class_count(root.upcast_ref(), "unixnotis-popup-application-icon-slot",), 1 ); assert!(!visible_descendant_has_class( diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs index 6f81212b2..2c969f6c6 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -259,6 +259,9 @@ fn identical_update_rebuilds_a_row_after_icon_source_invalidation() { badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, }; assert!(state .icon_cache @@ -326,9 +329,9 @@ fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { gtk::AccessibleRole::Group ); assert_eq!( - descendant_class_count(root.upcast_ref(), "unixnotis-identity-avatar"), + descendant_class_count(root.upcast_ref(), "unixnotis-popup-application-icon-slot",), 1, - "one provenance-controlled avatar must own application identity" + "one compact icon must own application identity" ); assert!(descendant_has_text( root.upcast_ref(), From 4b2d41f66a5105fdc118382fd81be89f42420090 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 10 Aug 2026 15:09:38 -0500 Subject: [PATCH 269/275] fix(center): harden notification identity rendering lifecycle Restore application branding and conversation-avatar presentation in notification and group rows while keeping trust state visually separate. Invalidate asynchronous icon ownership whenever rows are cleared, rebound, grouped, or unbound so late decode completion cannot resurrect stale branding. Include every branding and trust input in row icon signatures and clear recycled conflict, relay, recognized, unresolved, tooltip, and warning state when a group loses its sample notification. Keep semantic conflict and relay badges authoritative over presentation branding. Add generic row-recycling, group-lifecycle, icon-cache, trust-transition, thumbnail, and visual-matrix regression coverage. --- crates/unixnotis-center/src/ui/icons/cache.rs | 12 + .../unixnotis-center/src/ui/icons/resolver.rs | 41 +- .../src/ui/icons/tests/cache.rs | 15 +- .../src/ui/icons/tests/resolver.rs | 67 +++ .../src/ui/icons/tests/theme.rs | 91 ++++ crates/unixnotis-center/src/ui/icons/theme.rs | 72 ++- .../src/ui/notifications/row/group.rs | 41 +- .../notifications/row/notification/state.rs | 10 +- .../row/notification/update/row.rs | 83 ++-- .../row/notification/update/tests/mod.rs | 1 + .../row/notification/update/tests/state.rs | 58 ++- .../notification/update/tests/thumbnail.rs | 1 + .../update/tests/visual_matrix.rs | 432 ++++++++++++++++++ .../src/ui/notifications/row/tests/group.rs | 50 +- .../src/ui/notifications/view/build.rs | 7 +- .../ui/notifications/view/tests/widgets.rs | 46 +- .../src/ui/notifications/view/widgets.rs | 10 +- 17 files changed, 961 insertions(+), 76 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index e229090e8..4a593713b 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -103,6 +103,18 @@ pub(super) fn image_key_matches(image: >k::Image, key: &IconKey) -> bool { }) } +pub(super) fn clear_image_key(image: >k::Image) { + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| { + let Some(current) = weak.upgrade() else { + return false; + }; + current != *image + }); + }); +} + #[cfg(test)] #[path = "tests/cache.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index b725ba637..b2077883a 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -5,10 +5,12 @@ use std::collections::HashMap; use std::rc::Rc; use gtk::glib; +use gtk::prelude::WidgetExt; use unixnotis_core::NotificationView; use unixnotis_ui::icons::DesktopIconIndex; +use unixnotis_ui::presentation::{apply_semantic_badge, BadgePresentation, TrustLevel}; -use super::cache::{IconCache, IconKey}; +use super::cache::{clear_image_key, IconCache, IconKey}; use super::decode::{IconUpdate, IconWorker}; use super::missing::MissingIconCache; @@ -58,6 +60,43 @@ impl IconResolver { self.inner.apply_badge(image, notification, size, scale); } + pub fn clear_identity_badge(&self, image: >k::Image) { + // Invalidate old async work before clearing the recycled GTK image + clear_image_key(image); + image.clear(); + image.set_visible(false); + } + + /// Applies application branding without letting contradictory trust evidence disappear + pub fn apply_identity_badge( + &self, + image: >k::Image, + notification: &NotificationView, + badge: BadgePresentation, + trust: TrustLevel, + size: i32, + scale: i32, + ) { + // Recycled rows may retain both a paintable and a hidden visibility state + self.clear_identity_badge(image); + + if trust.semantic_badge_is_authoritative() { + // Conflict and relay states keep their semantic warning icon in front + if apply_semantic_badge(image, badge, size) { + // A semantic warning is visible even when this widget was recycled + image.set_visible(true); + } + return; + } + + // Recognized and unresolved branding is presentation-only and may be resolved first + self.apply_badge(image, notification, size, scale); + if !image.get_visible() && apply_semantic_badge(image, badge, size) { + // Resolver misses and pending work leave the image hidden + image.set_visible(true); + } + } + pub fn apply_sender_visual(&self, image: >k::Image, notification: &NotificationView) { self.inner.apply_sender_visual(image, notification); } diff --git a/crates/unixnotis-center/src/ui/icons/tests/cache.rs b/crates/unixnotis-center/src/ui/icons/tests/cache.rs index 9f383cebc..182433404 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/cache.rs @@ -1,4 +1,4 @@ -use super::{icon_key_for_path, image_key_matches, set_image_key, IconKey}; +use super::{clear_image_key, icon_key_for_path, image_key_matches, set_image_key, IconKey}; fn hash_image_data(data: &[u8]) -> [u8; 32] { *blake3::hash(data).as_bytes() @@ -25,6 +25,19 @@ fn image_key_matches_only_the_stored_icon_request() { assert!(!image_key_matches(&image, &different)); } +#[gtk::test] +fn cleared_image_key_cannot_accept_a_stale_decode_completion() { + let image = gtk::Image::new(); + let key = key("org.example.Old"); + + set_image_key(&image, key.clone()); + assert!(image_key_matches(&image, &key)); + + clear_image_key(&image); + + assert!(!image_key_matches(&image, &key)); +} + #[gtk::test] fn image_keys_do_not_survive_the_image_object() { let stored = key("network-wireless"); diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolver.rs b/crates/unixnotis-center/src/ui/icons/tests/resolver.rs index b40419d91..d73f4f8c8 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolver.rs @@ -1,6 +1,73 @@ use super::ICON_UPDATE_QUEUE_CAPACITY; +use gtk::prelude::*; +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{BadgePresentation, TrustLevel}; + +use super::super::cache::{image_key_matches, set_image_key, IconKey}; #[test] fn icon_update_queue_capacity_remains_bounded() { assert_eq!(ICON_UPDATE_QUEUE_CAPACITY, 256); } + +#[gtk::test] +fn identity_badge_restores_semantic_fallback_visibility_on_a_recycled_image() { + let resolver = super::IconResolver::new(); + let mut notification = NotificationView { + id: 1, + generation: 1, + app_name: "Example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: String::new(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: unixnotis_core::NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + // Remove every branding candidate so the semantic fallback path is exercised + notification.attribution.badge_icon.clear(); + let image = gtk::Image::from_icon_name("folder"); + image.set_visible(false); + + resolver.apply_identity_badge( + &image, + ¬ification, + BadgePresentation::UnknownApplication, + TrustLevel::Unresolved, + 20, + 1, + ); + + assert!(image.get_visible()); + assert_eq!( + image.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); +} + +#[gtk::test] +fn clearing_identity_badge_invalidates_pending_icon_ownership() { + let resolver = super::IconResolver::new(); + let image = gtk::Image::new(); + let old_key = IconKey::Name { + name: "org.example.Old".to_string(), + size: 20, + scale: 1, + }; + + set_image_key(&image, old_key.clone()); + assert!(image_key_matches(&image, &old_key)); + + resolver.clear_identity_badge(&image); + + assert!(!image_key_matches(&image, &old_key)); + assert!(image.paintable().is_none()); + assert!(!image.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 9f33d1ab5..a7c3cabf2 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -103,6 +103,97 @@ fn unresolved_notifications_keep_only_bounded_decorative_theme_hints() { assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); } +#[test] +fn claimed_desktop_id_is_a_bounded_decorative_theme_hint() { + let notification = notification_view( + "Unknown", + unixnotis_core::NotificationAttribution::default(), + NotificationImage { + claimed_desktop_id: "example-chat.desktop".to_string(), + ..NotificationImage::default() + }, + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat.desktop")); + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + +#[test] +fn unresolved_claimed_branding_precedes_the_generic_daemon_badge() { + let notification = notification_view( + "Example Application", + unixnotis_core::NotificationAttribution::default(), + NotificationImage { + claimed_desktop_id: "org.example.App.desktop".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.App.desktop") + ); +} + +#[test] +fn associated_branding_still_precedes_presentation_claims() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Associated", + "org.example.associated", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated fixture", + "associated:system-app:org.example.Associated".to_string(), + ); + let notification = notification_view( + "Example Application", + attribution, + NotificationImage { + claimed_desktop_id: "org.example.Claimed.desktop".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.associated") + ); +} + +#[test] +fn icon_candidates_remove_duplicate_presentation_hints() { + let notification = notification_view( + "Example", + unixnotis_core::NotificationAttribution { + badge_icon: "folder".to_string(), + desktop_id: "folder".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + NotificationImage { + claimed_theme_icon: "folder".to_string(), + claimed_desktop_id: "folder".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + let mut unique = candidates.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(candidates.len(), unique.len()); +} + #[test] fn expand_rgb_to_rgba_appends_alpha() { let data = ImageData { diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index bc9bf493f..18b88efe4 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -9,7 +9,7 @@ use gio::prelude::FileExt; use gtk::gdk; use gtk::prelude::*; use gtk::{IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::{ImageData, NotificationImage, NotificationView}; +use unixnotis_core::{AttributionStatus, ImageData, NotificationImage, NotificationView}; pub(super) enum IconSource { Paintable(IconPaintable), @@ -78,24 +78,18 @@ fn resolve_icon_paintable(name: &str, size: i32, scale: i32) -> Option Vec { - let mut candidates = Vec::new(); - if !notification.attribution.badge_icon.is_empty() { - candidates.push(notification.attribution.badge_icon.clone()); - if let Some(stripped) = notification.attribution.badge_icon.strip_suffix(".desktop") { - candidates.push(stripped.to_string()); - } - candidates.push(notification.attribution.badge_icon.to_lowercase()); - } - if !notification.attribution.desktop_id.is_empty() { - // Desktop ids are daemon-associated metadata and safe badge lookup candidates - candidates.push(notification.attribution.desktop_id.clone()); - candidates.push(notification.attribution.desktop_id.to_lowercase()); - } - if is_safe_theme_name(¬ification.image.claimed_theme_icon) { - // The daemon has bounded this value and rejected path-like input - candidates.push(notification.image.claimed_theme_icon.clone()); - candidates.push(notification.image.claimed_theme_icon.to_lowercase()); + let mut candidates = Vec::with_capacity(12); + + // Presentation claims come first only when attribution is unresolved + // This keeps a generic daemon badge from hiding a useful bounded app hint + if notification.attribution.status == AttributionStatus::Unresolved { + push_claimed_icon_candidates(&mut candidates, notification); + push_attributed_icon_candidates(&mut candidates, notification); + } else { + push_attributed_icon_candidates(&mut candidates, notification); + push_claimed_icon_candidates(&mut candidates, notification); } + let mut seen = HashSet::new(); candidates .into_iter() @@ -103,14 +97,48 @@ pub(super) fn collect_icon_candidates(notification: &NotificationView) -> Vec, notification: &NotificationView) { + let badge_icon = notification.attribution.badge_icon.as_str(); + if !badge_icon.is_empty() { + candidates.push(badge_icon.to_string()); + if let Some(stripped) = badge_icon.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(badge_icon.to_lowercase()); + } + + let desktop_id = notification.attribution.desktop_id.as_str(); + if !desktop_id.is_empty() { + candidates.push(desktop_id.to_string()); + if let Some(stripped) = desktop_id.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(desktop_id.to_lowercase()); + } +} + +fn push_claimed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { + // A desktop-entry hint stays decorative and never changes attribution + let claimed_desktop_id = notification.image.claimed_desktop_id.as_str(); + if is_safe_theme_name(claimed_desktop_id) { + candidates.push(claimed_desktop_id.to_string()); + if let Some(stripped) = claimed_desktop_id.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(claimed_desktop_id.to_lowercase()); + } + + let claimed_theme_icon = notification.image.claimed_theme_icon.as_str(); + if is_safe_theme_name(claimed_theme_icon) { + candidates.push(claimed_theme_icon.to_string()); + candidates.push(claimed_theme_icon.to_lowercase()); + } +} + fn is_safe_theme_name(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && !value.starts_with('.') - && !value.contains('/') - && !value.contains('\\') - && !value.contains(':') - && !value.chars().any(char::is_whitespace) && value.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') }) diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 4502668a1..f425c6c74 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -10,7 +10,7 @@ use gtk::pango; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{css::hooks, util}; -use unixnotis_ui::presentation::{apply_semantic_badge, NotificationPresentation, TrustLevel}; +use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use crate::control::UiEvent; @@ -251,18 +251,20 @@ pub(in crate::ui::notifications) fn update_group_row( | TrustLevel::UserAssociated ), ); - if apply_semantic_badge(&group.icon, presentation.identity.badge, GROUP_ICON_SIZE) { - group.icon.set_visible(true); - } else { - let scale = root.scale_factor(); - // Verified groups keep authenticated application art from the shared resolver - icon_resolver.apply_badge(&group.icon, notification.as_ref(), GROUP_ICON_SIZE, scale); - } + let scale = root.scale_factor(); + icon_resolver.apply_identity_badge( + &group.icon, + notification.as_ref(), + presentation.identity.badge, + presentation.trust.level, + GROUP_ICON_SIZE, + scale, + ); set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); } else { - set_widget_visible_if_changed(&group.avatar, false); - set_widget_visible_if_changed(&group.icon, false); + clear_group_identity(group, icon_resolver); + clear_group_trust_state(group, root); set_class_state(root, hooks::group_row::NO_ICON, true); set_class_state(root, hooks::group_row::HAS_ICON, false); } @@ -270,6 +272,25 @@ pub(in crate::ui::notifications) fn update_group_row( root.queue_resize(); } +pub(in crate::ui::notifications) fn clear_group_identity( + group: &GroupRowWidgets, + icon_resolver: &IconResolver, +) { + // Recycled group rows must revoke ownership of pending async icon work + // Hiding the widget alone is insufficient because a late decode shows it again + icon_resolver.clear_identity_badge(&group.icon); + set_widget_visible_if_changed(&group.avatar, false); +} + +fn clear_group_trust_state(group: &GroupRowWidgets, root: >k::Box) { + // An empty model sample carries no trust evidence from the previous recycled row + group.title.set_tooltip_text(None); + set_class_state(root, "unixnotis-attribution-warning", false); + for class_name in ["verified", "recognized", "unresolved", "conflict", "relay"] { + set_class_state(root, class_name, false); + } +} + fn group_accessible_label( display_name: &str, trust_label: &str, diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index bfd241872..2ab6320e8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use unixnotis_core::{NotificationKey, NotificationView}; use unixnotis_ui::presentation::default_activation::DefaultActionBinding; -use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation}; +use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation, TrustLevel}; use super::reply::InlineReplyWidgets; @@ -95,10 +95,13 @@ pub(super) struct OptionalLabelState<'a> { #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::ui::notifications) struct IconSignature { - // Header badges depend only on daemon-associated attribution inputs + // Every field that can change the chosen header icon belongs in this key badge_icon: String, desktop_id: String, + claimed_theme_icon: String, + claimed_desktop_id: String, presentation: BadgePresentation, + trust: TrustLevel, } impl IconSignature { @@ -111,7 +114,10 @@ impl IconSignature { Self { badge_icon: notification.attribution.badge_icon.clone(), desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), presentation: presentation.identity.badge, + trust: presentation.trust.level, } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs index 7881c113f..6bd13c5ac 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -4,7 +4,7 @@ use gtk::prelude::*; use tokio::sync::mpsc; use unixnotis_core::hooks; use unixnotis_ui::presentation::{ - apply_semantic_badge, default_activation::DefaultActionTarget, NotificationPresentation, + default_activation::DefaultActionTarget, NotificationPresentation, }; use crate::control::UiCommand; @@ -15,10 +15,13 @@ use super::super::state::{IconSignature, NotificationRowWidgets}; use super::actions::{update_actions, visible_action_count_from}; use super::labels::update_notification_text; use super::metadata::update_metadata_labels; -use super::thumbnail::{panel_lead_visual, PanelLeadVisual}; +use super::thumbnail::{has_content_thumbnail, panel_lead_visual, PanelLeadVisual}; use super::visual::{apply_visual_state, set_widget_visible_if_changed}; -pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRowWidgets) { +pub(in crate::ui::notifications) fn clear_notification_row( + row: &NotificationRowWidgets, + icon_resolver: &IconResolver, +) { // Clear every visible lane before a recycled row can be painted again row.default_activation.set_target(None); row.notify_key.set(unixnotis_core::NotificationKey { @@ -56,7 +59,7 @@ pub(in crate::ui::notifications) fn clear_notification_row(row: &NotificationRow ] { widget.set_visible(false); } - row.icon.clear(); + icon_resolver.clear_identity_badge(&row.icon); row.thumbnail.clear(); for label in [ &row.app_label, @@ -95,7 +98,7 @@ pub(in crate::ui::notifications) fn update_notification_row( .set_reduced_motion(data.presentation.reduced_motion); // Model changes may briefly update a recycled row without notification data let Some(notification_snapshot) = data.notification.as_ref() else { - clear_notification_row(row); + clear_notification_row(row, icon_resolver); return; }; let notification = notification_snapshot.as_ref(); @@ -125,16 +128,6 @@ pub(in crate::ui::notifications) fn update_notification_row( data.presentation.show_avatar, data.presentation.show_thumbnail, ); - let has_thumbnail = lead_visual != PanelLeadVisual::None; - - apply_visual_state( - row, - data, - notification, - &presentation, - has_actions, - has_thumbnail, - ); update_notification_text( row, &presentation.identity.primary_label, @@ -185,15 +178,19 @@ pub(in crate::ui::notifications) fn update_notification_row( let next_sig = IconSignature::from_presentation(notification, &presentation); let mut sig_guard = row.icon_sig.borrow_mut(); if show_identity && sig_guard.as_ref() != Some(&next_sig) { - if apply_semantic_badge(&row.icon, presentation.identity.badge, 20) { - row.icon.set_visible(true); - } else { - let scale = row.card.scale_factor(); - // Verified rows keep authenticated application art from the shared resolver - icon_resolver.apply_badge(&row.icon, notification, 20, scale); - } + let scale = row.card.scale_factor(); + icon_resolver.apply_identity_badge( + &row.icon, + notification, + presentation.identity.badge, + presentation.trust.level, + 20, + scale, + ); *sig_guard = Some(next_sig); } else if !show_identity { + // Grouped rows do not own the application icon anymore + icon_resolver.clear_identity_badge(&row.icon); *sig_guard = None; } set_widget_visible_if_changed(&row.icon, show_identity); @@ -207,7 +204,8 @@ pub(in crate::ui::notifications) fn update_notification_row( .remove_css_class(hooks::panel_card::CONTENT_IMAGE); row.thumbnail .remove_css_class(hooks::panel_card::SENDER_VISUAL); - match lead_visual { + let mut actual_visual = lead_visual; + match actual_visual { PanelLeadVisual::ConversationAvatar => { icon_resolver.apply_sender_visual(&row.thumbnail, notification); } @@ -223,11 +221,42 @@ pub(in crate::ui::notifications) fn update_notification_row( } PanelLeadVisual::None => {} } - // A role alone cannot make an empty or malformed image paintable - set_widget_visible_if_changed( - &row.thumbnail, - has_thumbnail && row.thumbnail.paintable().is_some(), + + // A malformed preferred avatar must not hide independently valid content media + if actual_visual == PanelLeadVisual::ConversationAvatar + && row.thumbnail.paintable().is_none() + && data.presentation.show_thumbnail + && has_content_thumbnail(&presentation) + { + row.thumbnail.clear(); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); + row.thumbnail + .add_css_class(hooks::panel_card::CONTENT_IMAGE); + icon_resolver.apply_content_visual(&row.thumbnail, notification); + if row.thumbnail.paintable().is_some() { + actual_visual = PanelLeadVisual::ContentImage; + } else { + // Invalid content stays out of the lane instead of reserving a blank slot + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + actual_visual = PanelLeadVisual::None; + } + } + + // A semantic role is not enough to reserve a slot; the bounded texture must exist + let has_thumbnail = + actual_visual != PanelLeadVisual::None && row.thumbnail.paintable().is_some(); + apply_visual_state( + row, + data, + notification, + &presentation, + has_actions, + has_thumbnail, ); + // Keep malformed or empty rasters out of the visible lead lane + set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); set_widget_visible_if_changed(&row.card_plate, true); set_widget_visible_if_changed(&row.card, true); // Recycled rows can change natural height when text, media, or stack depth changes diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs index c0e54cba4..a8f4c33a3 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -5,6 +5,7 @@ mod labels; mod metadata; mod state; mod thumbnail; +mod visual_matrix; pub(super) use super::actions::clamp_action_label_text; pub(super) use super::labels::optional_label_state; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs index eb0c70cc5..a4e8429ea 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -39,6 +39,60 @@ fn icon_signature_changes_when_trust_presentation_changes() { ); } +#[test] +fn claimed_application_branding_changes_the_icon_signature() { + let mut first = sample_notification(); + first.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + first.image.claimed_desktop_id = "org.example.First.desktop".to_string(); + + let mut second = first.clone(); + second.image.claimed_desktop_id = "org.example.Second.desktop".to_string(); + + assert_ne!(icon_signature(&first), icon_signature(&second)); +} + +#[gtk::test] +fn claimed_application_branding_refreshes_a_recycled_row_icon_signature() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + let mut first = sample_notification(); + first.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + first.image.claimed_desktop_id = "org.example.First.desktop".to_string(); + let mut first_data = row_data(Rc::new(first), RowFlags::default()); + first_data.app_header_present = false; + + update_notification_row(&row, &first_data, &resolver, &command_tx); + let first_signature = row.icon_sig.borrow().clone(); + assert!(first_signature.is_some()); + + let mut second = sample_notification(); + second.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + second.image.claimed_desktop_id = "org.example.Second.desktop".to_string(); + let mut second_data = row_data(Rc::new(second), RowFlags::default()); + second_data.app_header_present = false; + + update_notification_row(&row, &second_data, &resolver, &command_tx); + + assert_ne!(*row.icon_sig.borrow(), first_signature); +} + #[gtk::test] fn close_control_ignores_unbound_rows_and_keeps_the_bound_generation() { let (_root, row, mut command_rx) = notification_row_with_receiver(); @@ -71,7 +125,7 @@ fn clearing_a_recycled_row_removes_old_content_and_controls() { assert_eq!(row.summary_label.text().as_str(), "summary"); assert!(row.card.get_visible()); - clear_notification_row(&row); + clear_notification_row(&row, &IconResolver::new()); assert!(row.summary_label.text().is_empty()); assert!(row.body_label.text().is_empty()); @@ -110,7 +164,7 @@ fn rebinding_after_clear_restores_wrapper_and_actions() { let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); update_notification_row(&row, &first, &IconResolver::new(), &command_tx); - clear_notification_row(&row); + clear_notification_row(&row, &IconResolver::new()); update_notification_row(&row, &second, &IconResolver::new(), &command_tx); assert!(row.card_plate.get_visible()); diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs index 06ab68147..6ec21ee5c 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -276,6 +276,7 @@ fn historical_empty_avatar_role_does_not_create_a_blank_lead_slot() { assert!(!row.thumbnail.get_visible()); assert!(row.thumbnail.paintable().is_none()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs new file mode 100644 index 000000000..d468d4f8b --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs @@ -0,0 +1,432 @@ +//! Generic popup/panel visual-role matrix for reusable notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, ImageData}; + +use crate::ui::icons::IconResolver; + +use super::super::super::test_support::{ + notification_row, row_data, sample_notification, RowFlags, +}; +use super::super::thumbnail::{panel_lead_visual, PanelLeadVisual}; +use super::super::update_notification_row; +use unixnotis_ui::presentation::NotificationPresentation; + +#[test] +fn unresolved_conversation_avatar_follows_avatar_setting() { + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + let presentation = NotificationPresentation::from_view(¬ification); + + assert_eq!( + panel_lead_visual(&presentation, true, false), + PanelLeadVisual::ConversationAvatar + ); + assert_eq!( + panel_lead_visual(&presentation, false, true), + PanelLeadVisual::None + ); +} + +#[gtk::test] +fn panel_conversation_avatar_obeys_avatar_setting_without_thumbnail_fallback() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![8, 9, 10, 255], + }; + let notification = Rc::new(notification); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + let enabled = row_data( + Rc::clone(¬ification), + RowFlags { + show_avatar: true, + show_thumbnail: false, + ..Default::default() + }, + ); + update_notification_row(&row, &enabled, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + + let disabled = row_data( + notification, + RowFlags { + show_avatar: false, + show_thumbnail: true, + ..Default::default() + }, + ); + update_notification_row(&row, &disabled, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn grouped_rows_keep_trust_chip_in_the_shared_application_header() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.trust_chip.get_visible()); +} + +#[gtk::test] +fn identity_signature_is_set_for_owned_headers_and_cleared_for_grouped_rows() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let mut standalone = row_data(notification.clone(), RowFlags::default()); + standalone.app_header_present = false; + let grouped = row_data(notification, RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &standalone, &IconResolver::new(), &command_tx); + assert!(row.icon_sig.borrow().is_some()); + + update_notification_row(&row, &grouped, &IconResolver::new(), &command_tx); + assert!(row.icon_sig.borrow().is_none()); +} + +#[gtk::test] +fn grouped_rebind_invalidates_previous_identity_icon_request() { + let (_root, row) = notification_row(); + let resolver = IconResolver::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic fixture", + "unknown:example".to_string(), + ); + notification.image.claimed_desktop_id = "org.example.Async.desktop".to_string(); + let notification = Rc::new(notification); + + let mut standalone = row_data(Rc::clone(¬ification), RowFlags::default()); + standalone.app_header_present = false; + let mut grouped = row_data(notification, RowFlags::default()); + grouped.app_header_present = true; + + update_notification_row(&row, &standalone, &resolver, &command_tx); + update_notification_row(&row, &grouped, &resolver, &command_tx); + + assert!(row.icon_sig.borrow().is_none()); + assert!(row.icon.paintable().is_none()); + assert!(!row.icon.get_visible()); +} + +#[gtk::test] +fn malformed_conversation_avatar_does_not_reserve_a_panel_lead_slot() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + // The role is present, but the raster has no valid dimensions or pixels + notification.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn conversation_avatar_wins_panel_lead_slot_when_content_is_also_present() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn content_only_notification_stays_in_the_content_lead_lane() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn malformed_conversation_avatar_falls_back_to_valid_content_media() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_avatar: true, + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + + // Disabling content thumbnails must not turn the malformed avatar into a fallback lane + let mut hidden_content = sample_notification(); + hidden_content.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + hidden_content.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + hidden_content.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + }; + let hidden_data = row_data( + Rc::new(hidden_content), + RowFlags { + show_avatar: true, + show_thumbnail: false, + ..Default::default() + }, + ); + update_notification_row(&row, &hidden_data, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); +} + +#[gtk::test] +fn rapid_avatar_replacement_clears_previous_paintable() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + let mut first = sample_notification(); + first.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + first.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + update_notification_row( + &row, + &row_data(Rc::new(first), RowFlags::default()), + &resolver, + &command_tx, + ); + let first_pixels = paintable_rgba(&row.thumbnail).expect("first avatar pixels"); + + let mut second = sample_notification(); + second.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + second.image.sender_visual = avatar_pixel([0, 0, 255, 255]); + update_notification_row( + &row, + &row_data(Rc::new(second), RowFlags::default()), + &resolver, + &command_tx, + ); + let second_pixels = paintable_rgba(&row.thumbnail).expect("replacement avatar pixels"); + assert_ne!(first_pixels, second_pixels); + + let mut empty = sample_notification(); + empty.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + empty.image.sender_visual = ImageData::default(); + update_notification_row( + &row, + &row_data(Rc::new(empty), RowFlags::default()), + &resolver, + &command_tx, + ); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); +} + +#[gtk::test] +fn burst_rebinding_does_not_retain_another_notification_visual() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + for index in 0..40 { + let mut notification = sample_notification(); + let use_avatar = index % 2 == 0; + let use_content = index % 3 == 0; + if use_avatar { + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([index as u8, 2, 3, 255]); + } + if use_content { + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [4, index as u8, 6, 255].repeat(4), + }; + } + let expected_visual = use_avatar || use_content; + let show_thumbnail = use_content; + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail, + ..Default::default() + }, + ); + + update_notification_row(&row, &data, &resolver, &command_tx); + + assert_eq!(row.thumbnail.get_visible(), expected_visual); + assert_eq!(row.thumbnail.paintable().is_some(), expected_visual); + } +} + +fn avatar_pixel(pixel: [u8; 4]) -> ImageData { + ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: pixel.to_vec(), + } +} + +fn paintable_rgba(image: >k::Image) -> Option> { + let texture = image.paintable()?.downcast::().ok()?; + let width = usize::try_from(texture.width()).ok()?; + let height = usize::try_from(texture.height()).ok()?; + let stride = width.checked_mul(4)?; + let mut pixels = vec![0; stride.checked_mul(height)?]; + gtk::gdk::prelude::TextureExtManual::download(&texture, &mut pixels, stride); + Some(pixels) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index d4218d3f6..ceb24960e 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -134,6 +134,9 @@ fn update_group_row_falls_back_to_group_key_without_sample() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); + let resolver = IconResolver::new(); + let sample = RowData::group_header(Rc::from("terminal"), 2, false, notification("Terminal")); + update_group_row(&widgets, &root, &sample, &resolver); let data = RowData { kind: RowKind::GroupHeader, group_key: Rc::from("terminal"), @@ -142,13 +145,50 @@ fn update_group_row_falls_back_to_group_key_without_sample() { ..RowData::default() }; - update_group_row(&widgets, &root, &data, &IconResolver::new()); + update_group_row(&widgets, &root, &data, &resolver); assert_eq!(widgets.title.text().as_str(), "terminal"); + assert!(widgets.icon.paintable().is_none()); assert!(!widgets.icon.get_visible()); assert!(root.has_css_class("unixnotis-group-row-no-icon")); } +#[gtk::test] +fn missing_group_sample_clears_recycled_conflict_presentation() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let resolver = IconResolver::new(); + let mut conflicting = notification("Unknown application").as_ref().clone(); + conflicting.attribution = unixnotis_core::NotificationAttribution::conflict( + "Example Claim", + "org.example.Application", + unixnotis_core::AttributionReason::ExecutableMismatch, + "conflicting process evidence", + "conflict:example".to_string(), + ); + let conflict = + RowData::group_header(Rc::from("conflict:example"), 2, false, Rc::new(conflicting)); + update_group_row(&widgets, &root, &conflict, &resolver); + assert!(root.has_css_class("unixnotis-attribution-warning")); + assert!(root.has_css_class("conflict")); + assert!(widgets.title.tooltip_text().is_some()); + + let empty = RowData { + kind: RowKind::GroupHeader, + group_key: Rc::from("empty:example"), + count: 1, + notification: None, + ..RowData::default() + }; + update_group_row(&widgets, &root, &empty, &resolver); + + assert!(!root.has_css_class("unixnotis-attribution-warning")); + assert!(!root.has_css_class("conflict")); + assert!(!root.has_css_class("relay")); + assert!(widgets.title.tooltip_text().is_none()); +} + #[gtk::test] fn update_group_row_keeps_conflict_warning_out_of_the_title() { support::init_gtk(); @@ -221,7 +261,7 @@ fn recognized_group_keeps_application_icon_separate_from_trust_chip() { } #[gtk::test] -fn unresolved_group_uses_neutral_icon_despite_claimed_application_branding() { +fn unresolved_group_keeps_unverified_claimed_branding_separate_from_trust() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); @@ -232,7 +272,8 @@ fn unresolved_group_uses_neutral_icon_despite_claimed_application_branding() { "no sender evidence", "claim:example-chat".to_string(), ); - unresolved.image.claimed_theme_icon = "example-chat".to_string(); + // A claimed desktop id is presentation-only and does not authenticate the sender + unresolved.image.claimed_desktop_id = "folder".to_string(); let data = RowData::group_header( Rc::from("claim:example-chat"), 2, @@ -242,10 +283,11 @@ fn unresolved_group_uses_neutral_icon_despite_claimed_application_branding() { update_group_row(&widgets, &root, &data, &IconResolver::new()); - assert_eq!( + assert_ne!( widgets.icon.icon_name().as_deref(), Some("unixnotis-app-unknown-symbolic") ); + assert!(widgets.icon.paintable().is_some()); assert_eq!(widgets.trust_chip.text().as_str(), "Unverified"); assert!(widgets.trust_chip.get_visible()); } diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index d0025d7e5..6b4dad286 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -67,7 +67,7 @@ impl NotificationList { let command_tx_clone = command_tx; let event_tx_clone = event_tx; - let icon_resolver_clone = icon_resolver; + let icon_resolver_for_bind = icon_resolver.clone(); factory.connect_bind(move |_, item| { let Some(gtk_item) = item.downcast_ref::() else { return; @@ -83,15 +83,16 @@ impl NotificationList { event_tx_clone.clone(), ); - bind_row(widgets, &row_item, &data, icon_resolver_clone.clone()); + bind_row(widgets, &row_item, &data, icon_resolver_for_bind.clone()); }); + let icon_resolver_for_unbind = icon_resolver; factory.connect_unbind(move |_, item| { let Some(gtk_item) = item.downcast_ref::() else { return; }; if let Some(widgets) = get_row_widgets(gtk_item) { - widgets.unbind(); + widgets.unbind(&icon_resolver_for_unbind); } // Keep RowWidgets attached so GTK can recycle rows without rebuilding // the widget tree on every scroll. Kind mismatches are handled in diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index eaf2977c0..29135edc3 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -30,6 +30,21 @@ fn contains_label_text(root: >k::Widget, text: &str) -> bool { false } +fn find_image_with_class(root: >k::Widget, class_name: &str) -> Option { + if root.has_css_class(class_name) { + return root.clone().downcast::().ok(); + } + + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(image) = find_image_with_class(&widget, class_name) { + return Some(image); + } + child = widget.next_sibling(); + } + None +} + #[gtk::test] fn set_and_get_row_widgets_round_trips_cached_bundle() { support::init_gtk(); @@ -164,7 +179,7 @@ fn unbind_disconnects_row_item_update_handler() { "summary 1" )); - widgets.unbind(); + widgets.unbind(&IconResolver::new()); let changed = Rc::new(support::notification(2, "Terminal")); item.update(RowData::notification( Rc::from("terminal"), @@ -185,3 +200,32 @@ fn unbind_disconnects_row_item_update_handler() { "summary 2" )); } + +#[gtk::test] +fn unbind_clears_group_identity_before_async_work_can_repaint_it() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let widgets = Rc::new(RowWidgets::new(RowKind::GroupHeader, command_tx, event_tx)); + let notification = Rc::new(support::notification(1, "Example Application")); + let item = RowItem::new(RowData::group_header( + Rc::from("example:application"), + 2, + false, + notification, + )); + let resolver = Rc::new(IconResolver::new()); + + bind_row(widgets.clone(), &item, &item.data(), resolver.clone()); + + let icon = find_image_with_class( + &widgets.root.clone().upcast::(), + unixnotis_core::hooks::group_row::ICON, + ) + .expect("group identity icon should exist"); + assert!(icon.get_visible()); + + widgets.unbind(&resolver); + + assert!(!icon.get_visible()); + assert!(icon.paintable().is_none()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 186492c43..f454e36f3 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -14,7 +14,7 @@ use tracing::debug; use crate::control::{UiCommand, UiEvent}; use super::item::{RowData, RowItem, RowKind}; -use super::row::group::{build_group_row, update_group_row, GroupRowWidgets}; +use super::row::group::{build_group_row, clear_group_identity, update_group_row, GroupRowWidgets}; use super::row::notification::{ build_notification_row, clear_notification_row, update_notification_row, NotificationRowWidgets, }; @@ -92,10 +92,14 @@ impl RowWidgets { } } - pub(super) fn unbind(&self) { + pub(super) fn unbind(&self, icon_resolver: &IconResolver) { self.disconnect(); + if let Some(group) = &self.group { + // Unbind is a real ownership boundary even when no empty model update arrives + clear_group_identity(group, icon_resolver); + } if let Some(notification) = &self.notification { - clear_notification_row(notification); + clear_notification_row(notification, icon_resolver); } } From 1cbfedddd69d672d7bd438d82113b20b3684fa3e Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 10 Aug 2026 15:09:43 -0500 Subject: [PATCH 270/275] fix(cli): sanitize human-readable diagnostic output Treat human-readable CLI diagnostics as terminal trust boundaries. Sanitize and bound every free-form attribution field rendered by explain-notification so notification metadata cannot inject lines, terminal controls, bidi controls, or unbounded output. Apply the existing doctor text sanitizer to every free-form field in human-readable doctor reports while preserving the structured JSON schema unchanged. Keep empty sanitized diagnostic values rendered as "none" and add deterministic terminal-injection regression coverage. --- .../noticenterctl/src/doctor/report/render.rs | 39 ++++++++++----- .../src/doctor/report/tests/render.rs | 48 ++++++++++++++++++- .../noticenterctl/src/output/diagnostics.rs | 25 ++++++---- .../src/output/tests/diagnostics.rs | 46 ++++++++++++++++++ 4 files changed, 136 insertions(+), 22 deletions(-) diff --git a/crates/noticenterctl/src/doctor/report/render.rs b/crates/noticenterctl/src/doctor/report/render.rs index 840170dc9..59e9517e2 100644 --- a/crates/noticenterctl/src/doctor/report/render.rs +++ b/crates/noticenterctl/src/doctor/report/render.rs @@ -3,6 +3,7 @@ use anyhow::Result; use super::model::{DoctorLogResult, DoctorReport}; +use super::text::safe_doctor_text; pub(super) fn render_json(report: &DoctorReport) -> Result { // Pretty JSON remains readable as an issue attachment while preserving the schema @@ -10,23 +11,30 @@ pub(super) fn render_json(report: &DoctorReport) -> Result { } pub(super) fn render_human(report: &DoctorReport) -> String { + // Human output is a terminal boundary for paths, errors, config keys, and logs + // Keep every free-form model value bounded and on one physical line // The heading includes both software and report schema versions let mut lines = vec![format!( "UnixNotis doctor {} (schema {})", - report.unixnotis_version, report.schema_version + safe_doctor_text(&report.unixnotis_version), + report.schema_version )]; // Input order is retained so related checks stay grouped predictably for check in &report.checks { lines.push(String::new()); - lines.push(check.label.to_uppercase()); - lines.push(format!("[{}] {}", check.severity.label(), check.summary)); + lines.push(safe_doctor_text(&check.label).to_uppercase()); + lines.push(format!( + "[{}] {}", + check.severity.label(), + safe_doctor_text(&check.summary) + )); // Optional context stays on plain lines for easy terminal copying if let Some(details) = &check.details { - lines.push(details.clone()); + lines.push(safe_doctor_text(details)); } if let Some(hint) = &check.hint { - lines.push(format!("Hint: {hint}")); + lines.push(format!("Hint: {}", safe_doctor_text(hint))); } } @@ -34,16 +42,20 @@ pub(super) fn render_human(report: &DoctorReport) -> String { lines.push(String::new()); lines.push("CONFIGURATION DIAGNOSTICS".to_string()); for diagnostic in &report.config_diagnostics { - lines.push(format!("[{:?}] {}", diagnostic.kind, diagnostic.message)); + lines.push(format!( + "[{:?}] {}", + diagnostic.kind, + safe_doctor_text(&diagnostic.message) + )); lines.push(format!("Code: {}", diagnostic.code)); if let Some(path) = &diagnostic.path { - lines.push(format!("Key: {path}")); + lines.push(format!("Key: {}", safe_doctor_text(path))); } if let Some(original) = &diagnostic.original { - lines.push(format!("Original: {original}")); + lines.push(format!("Original: {}", safe_doctor_text(original))); } if let Some(effective) = &diagnostic.effective { - lines.push(format!("Effective: {effective}")); + lines.push(format!("Effective: {}", safe_doctor_text(effective))); } } } @@ -63,14 +75,17 @@ pub(super) fn render_human(report: &DoctorReport) -> String { lines.push(format!("Source: {source:?}")); lines.push(format!("Limits: {line_limit} lines, {byte_limit} bytes")); lines.push(format!("Truncated: {truncated}")); - lines.extend(logs.iter().map(|line| format!(" {line}"))); + lines.extend( + logs.iter() + .map(|line| format!(" {}", safe_doctor_text(line))), + ); } DoctorLogResult::Unavailable { reason, hint, .. } => { // Unavailable sources explain the limitation without pretending collection failed lines.push("Persistent logs: unavailable".to_string()); - lines.push(reason.clone()); + lines.push(safe_doctor_text(reason)); if let Some(hint) = hint { - lines.push(format!("Hint: {hint}")); + lines.push(format!("Hint: {}", safe_doctor_text(hint))); } } } diff --git a/crates/noticenterctl/src/doctor/report/tests/render.rs b/crates/noticenterctl/src/doctor/report/tests/render.rs index 3f2769fa5..3ee830772 100644 --- a/crates/noticenterctl/src/doctor/report/tests/render.rs +++ b/crates/noticenterctl/src/doctor/report/tests/render.rs @@ -46,7 +46,7 @@ fn human_output_omits_machine_data_that_duplicates_curated_details() { let rendered = render_human(&report); - assert!(rendered.contains("Manager: systemd\nState: active")); + assert!(rendered.contains("Manager: systemd State: active")); assert!(!rendered.contains("manager: systemd")); assert!(!rendered.contains("active: true")); } @@ -80,6 +80,52 @@ fn human_output_renders_typed_configuration_diagnostics() { assert!(rendered.contains("Effective: 100")); } +#[test] +fn human_output_sanitizes_every_free_form_terminal_field() { + let report = DoctorReport::new( + vec![DoctorCheck::new( + "example", + "Example\nFORGED_CHECK_HEADING", + DoctorSeverity::Warning, + "Unsafe\u{1b}[31m summary", + ) + .details("detail\nFORGED_DETAIL_LINE") + .hint("hint\u{202e}spoof")], + vec![ConfigDiagnostic { + code: "config.unknown-key", + kind: ConfigDiagnosticKind::Warning, + path: Some("example\nFORGED_CONFIG_FIELD".to_string()), + message: "Unknown\u{1b}[31m configuration key".to_string(), + original: Some("before\nFORGED_ORIGINAL_FIELD".to_string()), + effective: Some("after\u{202e}spoof".to_string()), + }], + DoctorLogResult::Unavailable { + source: DoctorLogSource::Manual, + reason: "unavailable\nFORGED_LOG_FIELD".to_string(), + hint: Some("log hint\u{1b}[31mred".to_string()), + }, + ); + + let rendered = render_human(&report); + + assert!(!rendered.contains('\u{1b}')); + assert!(!rendered.contains('\u{202e}')); + for forged_line in [ + "\nFORGED_CHECK_HEADING", + "\nFORGED_DETAIL_LINE", + "\nFORGED_CONFIG_FIELD", + "\nFORGED_ORIGINAL_FIELD", + "\nFORGED_LOG_FIELD", + ] { + assert!( + !rendered.contains(forged_line), + "free-form report values must not create terminal lines" + ); + } + assert!(rendered.contains("detail FORGED_DETAIL_LINE")); + assert!(rendered.contains("Key: example FORGED_CONFIG_FIELD")); +} + #[test] fn json_output_is_valid_and_versioned() { let report = DoctorReport::new( diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs index d33767d8f..110097fc9 100644 --- a/crates/noticenterctl/src/output/diagnostics.rs +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -4,7 +4,7 @@ use std::fmt::Write; use anyhow::Result; use unixnotis_core::{ - ApplicationActionPolicy, CommandLineQualityView, IdentityAssurance, InlineReplyPolicy, + util, ApplicationActionPolicy, CommandLineQualityView, IdentityAssurance, InlineReplyPolicy, LaunchAuthorityView, LaunchVerificationView, NotificationDiagnosticsView, PopupAdmissionView, PopupDeliveryStage, RecordTrust, }; @@ -15,6 +15,9 @@ pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Res write_stdout(&format_notification_diagnostics(view)?) } +// Diagnostic wire values include sender-controlled notification metadata +// Every free-form string passes through the terminal sanitizer here +// Enum labels and numeric fields cannot carry free-form terminal text fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result { let diagnostics = &view.attribution; let mut output = String::new(); @@ -22,22 +25,22 @@ fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result writeln!( output, "Application claim: {}", - value_or_none(&diagnostics.claimed_name) + diagnostic_value(&diagnostics.claimed_name) )?; writeln!( output, "Claimed desktop entry: {}", - value_or_none(&diagnostics.claimed_desktop_entry) + diagnostic_value(&diagnostics.claimed_desktop_entry) )?; writeln!( output, "Sender executable: {}", - value_or_none(&diagnostics.sender_executable) + diagnostic_value(&diagnostics.sender_executable) )?; writeln!( output, "Matched desktop ID: {}", - value_or_none(&diagnostics.matched_desktop_id) + diagnostic_value(&diagnostics.matched_desktop_id) )?; writeln!( output, @@ -62,7 +65,7 @@ fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result writeln!( output, "Launch detail: {}", - value_or_none(&diagnostics.reason) + diagnostic_value(&diagnostics.reason) )?; writeln!( output, @@ -166,9 +169,13 @@ const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { } } -fn value_or_none(value: &str) -> &str { - if value.trim().is_empty() { - "none" +fn diagnostic_value(value: &str) -> String { + // Attribution diagnostics may contain sender-controlled metadata + // Keep each field bounded and single-line before it reaches the terminal + let value = util::sanitize_log_value(value, util::diagnostic_log_limit()); + + if value.is_empty() { + "none".to_string() } else { value } diff --git a/crates/noticenterctl/src/output/tests/diagnostics.rs b/crates/noticenterctl/src/output/tests/diagnostics.rs index 7b5248394..14a9ccaf8 100644 --- a/crates/noticenterctl/src/output/tests/diagnostics.rs +++ b/crates/noticenterctl/src/output/tests/diagnostics.rs @@ -25,3 +25,49 @@ fn diagnostics_keep_launch_verification_distinct_from_attribution_status() { "diagnostics must expose every independent interaction policy" ); } + +#[test] +fn diagnostics_sanitize_sender_controlled_terminal_text() { + let mut view = unixnotis_core::NotificationDiagnosticsView::default(); + + view.attribution.claimed_name = + "Example App\nFORGED_DIAGNOSTIC_LINE:\u{1b}[31mred\u{1b}[0m".to_string(); + view.attribution.claimed_desktop_entry = + "org.example.App.desktop\nFORGED_DESKTOP_LINE".to_string(); + view.attribution.sender_executable = + "/tmp/example\nFORGED_EXECUTABLE_LINE:\u{1b}[2J".to_string(); + view.attribution.matched_desktop_id = + "org.example.Match.desktop\nFORGED_MATCH_LINE".to_string(); + view.attribution.reason = "ambiguous\nFORGED_REASON_LINE:\u{202e}spoof".to_string(); + + let output = format_notification_diagnostics(&view).expect("diagnostics should render"); + + assert!( + !output.contains('\u{1b}'), + "terminal escape characters must not survive diagnostic rendering" + ); + assert!(!output.contains('\u{202e}')); + for forged_line in [ + "\nFORGED_DIAGNOSTIC_LINE:", + "\nFORGED_DESKTOP_LINE", + "\nFORGED_EXECUTABLE_LINE:", + "\nFORGED_MATCH_LINE", + "\nFORGED_REASON_LINE:", + ] { + assert!( + !output.contains(forged_line), + "diagnostic values must not inject terminal lines" + ); + } + assert!( + output.contains("Application claim: Example App FORGED_DIAGNOSTIC_LINE:"), + "sanitized diagnostic content should remain useful to the operator" + ); + assert!( + output.contains("Claimed desktop entry: org.example.App.desktop FORGED_DESKTOP_LINE"), + "sanitized desktop-entry content should remain inspectable" + ); + assert!(output.contains("Sender executable: /tmp/example FORGED_EXECUTABLE_LINE:")); + assert!(output.contains("Matched desktop ID: org.example.Match.desktop FORGED_MATCH_LINE")); + assert!(output.contains("Launch detail: ambiguous FORGED_REASON_LINE:spoof")); +} From ab3b55e84dc4365f910786c866e69579da793082 Mon Sep 17 00:00:00 2001 From: locainin Date: Tue, 11 Aug 2026 01:19:14 -0500 Subject: [PATCH 271/275] fix(security): harden trial executable authorization Remove ~/.local/bin as an independent executable trust root in trial mode. Require privileged UnixNotis control and renderer processes to resolve to the known trial build/install tree, including the expected debug/release siblings, instead of trusting a same-UID executable by writable launcher pathname alone. Preserve temporary ~/.local/bin/noticenterctl symlinks when they resolve to the genuine trial binary, while rejecting copied or renamed noticenterctl, center, popup, and daemon executables. Keep Linux process-handle and executable fingerprint verification intact and add regression coverage for forged local-bin component names and trial-shim compatibility. --- .../src/daemon/auth/executable_trust/paths.rs | 19 +--- .../auth/executable_trust/tests/paths.rs | 99 +++++++++++-------- .../src/daemon/auth/tests/authorization.rs | 98 ++++++++++-------- crates/unixnotis-installer/src/trial/shim.rs | 18 +--- .../src/trial/tests/shim.rs | 90 ++++++++++++++++- 5 files changed, 209 insertions(+), 115 deletions(-) diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 5701fc06e..7a3e77a90 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -63,10 +63,10 @@ pub(in crate::daemon) fn is_trusted_control_executable_path_relaxed_in_dir( return false; } - // Keep trust scoped to known local build/install locations in trial mode + // Writable launcher and shim paths are convenience locations, never trust roots + // Trial mode still binds authorization to the actual executable in the known tree trusted_path_matches_executable(trusted_dir, executable, path) || trusted_profile_sibling_matches_executable(trusted_dir, executable, path) - || trusted_local_bin_matches_executable(executable, path) } pub(in crate::daemon) fn trusted_path_matches_executable( @@ -97,21 +97,6 @@ pub(in crate::daemon) fn trusted_profile_sibling_matches_executable( .any(|candidate| canonicalize_best_effort(&candidate) == observed) } -pub(in crate::daemon) fn trusted_local_bin_matches_executable( - executable: &str, - observed: &Path, -) -> bool { - // Installed keybinds usually point to ~/.local/bin during trial sessions - let Some(home) = std::env::var_os("HOME") else { - return false; - }; - let candidate = PathBuf::from(home) - .join(".local") - .join("bin") - .join(executable); - canonicalize_best_effort(&candidate) == observed -} - pub(in crate::daemon::auth) fn trusted_control_directory() -> Option { // The daemon trusts binaries installed next to the running daemon executable let current_exe = std::env::current_exe().ok()?; diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs index 987b9c0c9..2bcd64b6f 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs @@ -1,10 +1,9 @@ use super::super::paths::{ canonicalize_best_effort, is_trusted_control_executable_path_relaxed_in_dir, - trusted_local_bin_matches_executable, trusted_path_matches_executable, - trusted_profile_sibling_matches_executable, + trusted_path_matches_executable, trusted_profile_sibling_matches_executable, }; use crate::daemon::auth::support::write_executable; -use crate::test_support::{env_lock, EnvVarGuard, TempRoot}; +use crate::test_support::TempRoot; #[test] fn trusted_path_match_requires_exact_canonical_sibling() { @@ -54,46 +53,6 @@ fn trusted_profile_sibling_requires_debug_or_release_target_root() { )); } -#[test] -fn trusted_local_bin_uses_home_local_bin_exactly() { - let _guard = env_lock(); - let home = TempRoot::new("auth-home"); - let local_ctl = home.join(".local/bin/noticenterctl"); - let wrong_name = home.join(".local/bin/untrusted"); - let outside = home.join("bin/noticenterctl"); - write_executable(&local_ctl); - write_executable(&wrong_name); - write_executable(&outside); - let _home = EnvVarGuard::set("HOME", home.path()); - - assert!(trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&local_ctl) - )); - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&outside) - )); - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&wrong_name) - )); -} - -#[test] -fn trusted_local_bin_requires_home() { - let _guard = env_lock(); - let root = TempRoot::new("auth-no-home"); - let local_ctl = root.join(".local/bin/noticenterctl"); - write_executable(&local_ctl); - let _home = EnvVarGuard::remove("HOME"); - - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&local_ctl) - )); -} - #[test] fn relaxed_path_check_accepts_safe_trusted_sibling() { let root = TempRoot::new("auth-relaxed-sibling"); @@ -106,6 +65,60 @@ fn relaxed_path_check_accepts_safe_trusted_sibling() { )); } +#[test] +fn relaxed_path_check_rejects_arbitrary_local_bin_components() { + // Writable launcher paths are not executable trust roots in trial mode + let root = TempRoot::new("auth-local-bin-components"); + let trusted_dir = root.join("target/debug"); + let local_bin = root.join(".local/bin"); + std::fs::create_dir_all(&trusted_dir).expect("trusted directory"); + std::fs::create_dir_all(&local_bin).expect("local bin"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let forged = local_bin.join(executable); + write_executable(&forged); + + assert!(!is_trusted_control_executable_path_relaxed_in_dir( + &forged, + &trusted_dir, + )); + } +} + +#[cfg(unix)] +#[test] +fn relaxed_path_check_accepts_local_bin_symlink_to_trial_binary() { + // PATH convenience remains supported when the symlink resolves into the + // known trial build tree rather than to an arbitrary local-bin executable + let root = TempRoot::new("auth-local-bin-symlink"); + let trusted_dir = root.join("target/debug"); + let local_bin = root.join(".local/bin"); + std::fs::create_dir_all(&trusted_dir).expect("trusted directory"); + std::fs::create_dir_all(&local_bin).expect("local bin"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let target = trusted_dir.join(executable); + let shim = local_bin.join(executable); + write_executable(&target); + std::os::unix::fs::symlink(&target, &shim).expect("trial symlink"); + + assert!(is_trusted_control_executable_path_relaxed_in_dir( + &canonicalize_best_effort(&shim), + &trusted_dir, + )); + } +} + #[test] fn relaxed_path_check_accepts_safe_profile_sibling() { let target = TempRoot::new("auth-relaxed-profile"); diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 27fb32b6b..496cdcaf3 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -14,7 +14,7 @@ use super::credentials::CallerCredentials; use super::executable_trust::paths::canonicalize_best_effort; use super::policy::TRUSTED_INTERACTION_EXECUTABLES; use super::support::write_executable; -use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; +use crate::test_support::{daemon_state_for_test, TempRoot}; fn open_test_executable(path: &std::path::Path) -> OwnedFd { File::open(path).expect("open test executable").into() @@ -90,27 +90,23 @@ fn control_uid_error_is_none_only_for_matching_uid() { } #[test] -fn control_executable_error_requires_present_allowed_trusted_binary() { - let _guard = env_lock(); - let home = TempRoot::new("auth-executable-error"); - let trusted = home.join(".local/bin/noticenterctl"); - let untrusted_name = home.join(".local/bin/unknown"); - write_executable(&trusted); +fn control_executable_error_rejects_missing_or_untrusted_binary() { + let root = TempRoot::new("auth-executable-error"); + let untrusted_name = root.join(".local/bin/noticenterctl"); write_executable(&untrusted_name); - let _home = EnvVarGuard::set("HOME", home.path()); - let trusted = canonicalize_best_effort(&trusted); let untrusted_name = canonicalize_best_effort(&untrusted_name); + let untrusted_fd = open_test_executable(&untrusted_name); - let trusted_fd = open_test_executable(&trusted); - + // An allowed executable name still fails when its file object is outside + // the trusted build or install tree assert!(control_executable_error( - Some(&trusted), - Some(&trusted_fd), + Some(&untrusted_name), + Some(&untrusted_fd), &["noticenterctl"], true, &HashMap::new(), ) - .is_none()); + .is_some()); assert!(control_executable_error::( None, None::<&OwnedFd>, @@ -119,17 +115,9 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { &HashMap::new(), ) .is_some()); - assert!(control_executable_error( - Some(&trusted), - Some(&trusted_fd), - &["unixnotis-center"], - true, - &HashMap::new(), - ) - .is_some()); assert!(control_executable_error( Some(&untrusted_name), - Some(&trusted_fd), + Some(&untrusted_fd), &["unknown"], true, &HashMap::new(), @@ -138,31 +126,34 @@ fn control_executable_error_requires_present_allowed_trusted_binary() { } #[test] -fn interaction_executable_policy_excludes_noninteractive_control_clients() { - let _guard = env_lock(); - let home = TempRoot::new("auth-interaction-executable"); - let center = home.join(".local/bin/unixnotis-center"); - let popups = home.join(".local/bin/unixnotis-popups"); - let cli = home.join(".local/bin/noticenterctl"); - write_executable(¢er); - write_executable(&popups); - write_executable(&cli); - let _home = EnvVarGuard::set("HOME", home.path()); - - for trusted_ui in [¢er, &popups] { - let trusted_fd = open_test_executable(trusted_ui); +fn interaction_executable_policy_rejects_untrusted_components() { + let root = TempRoot::new("auth-interaction-executable"); + for executable in ["unixnotis-center", "unixnotis-popups"] { + let path = root.join(".local/bin").join(executable); + write_executable(&path); + let path = canonicalize_best_effort(&path); + let fd = open_test_executable(&path); + + // Renderer names do not create trust for arbitrary local-bin files assert!(control_executable_error::( - Some(&canonicalize_best_effort(trusted_ui)), - Some(&trusted_fd), + Some(&path), + Some(&fd), &TRUSTED_INTERACTION_EXECUTABLES, true, &HashMap::new(), ) - .is_none()); + .is_some()); } + + let cli = root.join(".local/bin/noticenterctl"); + write_executable(&cli); + let cli = canonicalize_best_effort(&cli); let cli_fd = open_test_executable(&cli); + + // The CLI is not an interactive renderer, even when its name is allowed + // by another control policy assert!(control_executable_error::( - Some(&canonicalize_best_effort(&cli)), + Some(&cli), Some(&cli_fd), &TRUSTED_INTERACTION_EXECUTABLES, true, @@ -171,6 +162,33 @@ fn interaction_executable_policy_excludes_noninteractive_control_clients() { .is_some()); } +#[test] +fn trial_control_authorization_rejects_all_arbitrary_local_bin_components() { + // Every privileged component name still requires a trusted-tree executable + let root = TempRoot::new("auth-local-bin-components"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let forged = root.join(".local/bin").join(executable); + write_executable(&forged); + let forged_path = canonicalize_best_effort(&forged); + let forged_fd = open_test_executable(&forged_path); + + assert!(control_executable_error( + Some(&forged_path), + Some(&forged_fd), + &[executable], + true, + &HashMap::new(), + ) + .is_some()); + } +} + #[cfg(target_os = "linux")] #[test] fn linux_authorization_rejects_credentials_without_a_stable_process_handle() { diff --git a/crates/unixnotis-installer/src/trial/shim.rs b/crates/unixnotis-installer/src/trial/shim.rs index 790ba6923..80050e18d 100644 --- a/crates/unixnotis-installer/src/trial/shim.rs +++ b/crates/unixnotis-installer/src/trial/shim.rs @@ -42,7 +42,8 @@ pub(super) fn ensure_trial_control_access(ctl_bin: &Path) -> Result Date: Wed, 12 Aug 2026 18:00:03 -0500 Subject: [PATCH 272/275] test(center): use a deterministic icon fixture --- .../unixnotis-center/src/ui/notifications/row/tests/group.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index ceb24960e..6c97fb1cc 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -273,7 +273,8 @@ fn unresolved_group_keeps_unverified_claimed_branding_separate_from_trust() { "claim:example-chat".to_string(), ); // A claimed desktop id is presentation-only and does not authenticate the sender - unresolved.image.claimed_desktop_id = "folder".to_string(); + // A symbolic fixture keeps this attribution test independent from raster-worker timing + unresolved.image.claimed_desktop_id = "application-x-executable-symbolic".to_string(); let data = RowData::group_header( Rc::from("claim:example-chat"), 2, From 04c77b9194fa29e394f1bd0556e271840c3c6f5e Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 12 Aug 2026 18:00:10 -0500 Subject: [PATCH 273/275] chore(release): prepare v1.3.0 --- Cargo.lock | 192 +++++++++++++++++++++++++++++++++++++++-------------- Cargo.toml | 4 +- README.md | 2 +- 3 files changed, 146 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77c984859..23e5e93b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -291,9 +300,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" @@ -337,6 +346,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -361,7 +376,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -553,6 +568,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -574,7 +595,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -1241,7 +1262,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -1380,7 +1401,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "gdk4", "glib", "glib-sys", @@ -1469,6 +1490,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1820,13 +1846,19 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.7.4", @@ -1838,7 +1870,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -1876,11 +1908,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -1979,7 +2011,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -1998,7 +2030,7 @@ dependencies = [ [[package]] name = "noticenterctl" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "blake3", @@ -2027,7 +2059,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2115,6 +2147,39 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.21.5" @@ -2310,7 +2375,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -2406,7 +2471,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.11.1", + "bitflags 2.13.1", "num-traits", "rand 0.9.5", "rand_chacha 0.9.0", @@ -2526,31 +2591,35 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.0", "itertools", "kasuari", "lru", + "palette", + "serde", "strum", "thiserror 2.0.18", "unicode-segmentation", @@ -2560,9 +2629,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -2572,19 +2641,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -2592,17 +2672,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.0", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", @@ -2615,7 +2696,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2624,7 +2705,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2703,7 +2784,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -2990,18 +3071,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -3095,6 +3176,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3124,7 +3218,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3608,7 +3702,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unixnotis-center" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "async-channel", @@ -3643,7 +3737,7 @@ dependencies = [ [[package]] name = "unixnotis-core" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "blake3", @@ -3668,7 +3762,7 @@ dependencies = [ [[package]] name = "unixnotis-daemon" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "arc-swap", @@ -3699,7 +3793,7 @@ dependencies = [ [[package]] name = "unixnotis-installer" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "crossterm", @@ -3720,7 +3814,7 @@ dependencies = [ [[package]] name = "unixnotis-popups" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "async-channel", @@ -3744,7 +3838,7 @@ dependencies = [ [[package]] name = "unixnotis-ui" -version = "1.2.0" +version = "1.3.0" dependencies = [ "glib-build-tools", "gtk4", @@ -3957,7 +4051,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4370,7 +4464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3654477eb..878d04219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.2.0" +version = "1.3.0" edition = "2021" license = "MIT" @@ -52,7 +52,7 @@ gtk4-layer-shell = "0.7.1" indexmap = "2" libc = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "tiff", "webp", "ico"] } -ratatui = "0.30.0" +ratatui = "0.30.2" proptest = "1.11.0" crossterm = "0.29" data-url = "0.3" diff --git a/README.md b/README.md index ea3759a0f..6698aa755 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ reports when a newer GitHub release is available. Maintainers can build a local release archive manually: ```sh -scripts/package-release.sh v1.2.0 +scripts/package-release.sh v1.3.0 ``` ## Development From e9010cceb51ebb61493dc2d81d3d240fde260fe9 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 12 Aug 2026 18:12:58 -0500 Subject: [PATCH 274/275] ci: provide stable bus fixtures for container tests --- .github/workflows/ci.yml | 25 ++++++++++++++++++- .../contract/tests/artifact.rs | 4 +++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791546384..62b24bab8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,30 @@ jobs: run: | set -euo pipefail xvfb-run -a dbus-run-session -- \ - cargo test --workspace --all-targets --all-features + bash -c ' + set -euo pipefail + user_bus_dir="/run/user/$(id -u)" + stable_bus="${user_bus_dir}/bus" + session_bus="${DBUS_SESSION_BUS_ADDRESS#unix:path=}" + session_bus="${session_bus%%,guid=*}" + case "$session_bus" in + /*) ;; + *) + echo "dbus-run-session did not provide a filesystem bus address" >&2 + exit 1 + ;; + esac + mkdir -p "$user_bus_dir" + chmod 0700 "$user_bus_dir" + if [[ ! -e "$stable_bus" && ! -L "$stable_bus" ]]; then + ln -s -- "$session_bus" "$stable_bus" + cleanup_stable_bus() { + rm -f -- "$stable_bus" + } + trap cleanup_stable_bus EXIT + fi + cargo test --workspace --all-targets --all-features + ' - name: Run dependency audit run: cargo audit --deny warnings diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs index 4424d6012..c3cc903f9 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs @@ -224,6 +224,10 @@ fn artifact_inspection_propagates_non_missing_path_errors() { #[test] fn managed_marker_inspection_propagates_permission_errors() { + // Root bypasses directory mode bits, so this boundary cannot be observed in root CI + if rustix::process::getuid().as_raw() == 0 { + return; + } let root = test_root("managed-marker-inspection-error"); let service_dir = root.join("service"); fs::create_dir_all(&service_dir).expect("create managed service directory"); From 99d6263b29992037c255b72412b335fb92de5839 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 12 Aug 2026 18:52:56 -0500 Subject: [PATCH 275/275] ci: trust release checkout in container --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33f5a3c5d..da4a17389 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,9 @@ jobs: with: fetch-depth: 0 + - name: Trust checked-out repository + run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" + - name: Verify release source commit env: RELEASE_TAG: ${{ inputs.tag }}